bom-pi 0.0.0-stage → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/apps/README.md +7 -0
- package/apps/web/README.md +21 -0
- package/apps/web/pocket-adapter.mjs +323 -0
- package/apps/web/public/LICENSE.pocket +21 -0
- package/apps/web/public/activity-fold.js +28 -0
- package/apps/web/public/agent-now.js +37 -0
- package/apps/web/public/agent-tile.js +79 -0
- package/apps/web/public/app.js +975 -0
- package/apps/web/public/apple-touch-icon.png +0 -0
- package/apps/web/public/artifact-bridge.js +687 -0
- package/apps/web/public/avatar.js +87 -0
- package/apps/web/public/back.js +554 -0
- package/apps/web/public/box-keys.js +216 -0
- package/apps/web/public/browser-panel.js +1097 -0
- package/apps/web/public/browser.js +68 -0
- package/apps/web/public/calls.js +139 -0
- package/apps/web/public/chat.js +919 -0
- package/apps/web/public/clock.js +34 -0
- package/apps/web/public/commands.js +578 -0
- package/apps/web/public/composer.js +2068 -0
- package/apps/web/public/diff-parse.js +672 -0
- package/apps/web/public/diff.js +1646 -0
- package/apps/web/public/dock.js +299 -0
- package/apps/web/public/extension-ui.js +2734 -0
- package/apps/web/public/files-panel.js +775 -0
- package/apps/web/public/files.js +368 -0
- package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
- package/apps/web/public/fonts/Geist.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
- package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
- package/apps/web/public/fonts/SOURCES.md +69 -0
- package/apps/web/public/gestures.js +610 -0
- package/apps/web/public/highlight.js +480 -0
- package/apps/web/public/history.js +70 -0
- package/apps/web/public/home.js +169 -0
- package/apps/web/public/icon-192.png +0 -0
- package/apps/web/public/icon-512.png +0 -0
- package/apps/web/public/icon-maskable-512.png +0 -0
- package/apps/web/public/icon.svg +16 -0
- package/apps/web/public/index.html +71 -0
- package/apps/web/public/launcher.js +1293 -0
- package/apps/web/public/linked-sessions.js +55 -0
- package/apps/web/public/live.js +39 -0
- package/apps/web/public/manifest.webmanifest +32 -0
- package/apps/web/public/model-refresh.js +73 -0
- package/apps/web/public/notify.js +251 -0
- package/apps/web/public/peeks.js +920 -0
- package/apps/web/public/pip-attachments.js +182 -0
- package/apps/web/public/pip-window.js +141 -0
- package/apps/web/public/pip.js +1217 -0
- package/apps/web/public/quote-reply.js +109 -0
- package/apps/web/public/quote-text.js +31 -0
- package/apps/web/public/rich.js +403 -0
- package/apps/web/public/run-frame.js +106 -0
- package/apps/web/public/safe-mode.js +71 -0
- package/apps/web/public/seen.js +169 -0
- package/apps/web/public/session-tree.js +117 -0
- package/apps/web/public/sessions.js +2695 -0
- package/apps/web/public/share.js +126 -0
- package/apps/web/public/sheets/appearance.js +820 -0
- package/apps/web/public/sheets/branch.js +353 -0
- package/apps/web/public/sheets/extensions.js +818 -0
- package/apps/web/public/sheets/file.js +318 -0
- package/apps/web/public/sheets/find.js +250 -0
- package/apps/web/public/sheets/folder.js +680 -0
- package/apps/web/public/sheets/gallery.js +2340 -0
- package/apps/web/public/sheets/hooks.js +350 -0
- package/apps/web/public/sheets/memory.js +531 -0
- package/apps/web/public/sheets/menu.js +580 -0
- package/apps/web/public/sheets/message.js +220 -0
- package/apps/web/public/sheets/model.js +412 -0
- package/apps/web/public/sheets/people.js +414 -0
- package/apps/web/public/sheets/pi-sessions.js +264 -0
- package/apps/web/public/sheets/providers.js +525 -0
- package/apps/web/public/sheets/running.js +130 -0
- package/apps/web/public/sheets/schedules.js +71 -0
- package/apps/web/public/sheets/session.js +249 -0
- package/apps/web/public/sheets/shortcuts.js +101 -0
- package/apps/web/public/sheets/spend.js +150 -0
- package/apps/web/public/sheets/text.js +53 -0
- package/apps/web/public/sheets/viewers.js +363 -0
- package/apps/web/public/sheets/worktree.js +50 -0
- package/apps/web/public/sheets.js +410 -0
- package/apps/web/public/signin.js +57 -0
- package/apps/web/public/slots.js +208 -0
- package/apps/web/public/store.js +1760 -0
- package/apps/web/public/style.css +18625 -0
- package/apps/web/public/subagents-board.js +879 -0
- package/apps/web/public/subagents.js +273 -0
- package/apps/web/public/sw.js +169 -0
- package/apps/web/public/taps.js +26 -0
- package/apps/web/public/theme-surface.d.ts +4 -0
- package/apps/web/public/theme-surface.js +266 -0
- package/apps/web/public/theme.js +845 -0
- package/apps/web/public/themes/lantern/colors.toml +25 -0
- package/apps/web/public/themes/lantern/direction.css +60 -0
- package/apps/web/public/themes/lantern/frame.css +254 -0
- package/apps/web/public/themes/lantern/theme.css +10 -0
- package/apps/web/public/themes/lantern/theme.json +7 -0
- package/apps/web/public/themes/pocket/theme.css +189 -0
- package/apps/web/public/themes/pocket/theme.json +6 -0
- package/apps/web/public/themes.js +556 -0
- package/apps/web/public/transcript.js +3348 -0
- package/apps/web/public/trust.js +295 -0
- package/apps/web/public/turns.js +507 -0
- package/apps/web/public/ui.js +1910 -0
- package/apps/web/public/voice.js +345 -0
- package/apps/web/server.mjs +774 -0
- package/package.json +43 -4
- package/packages/cli/src/bom-pi.mjs +452 -0
- package/packages/cli/templates/blank.factory.json +7 -0
- package/packages/cli/templates/dev.factory.json +11 -0
- package/packages/cli/templates/docs.factory.json +12 -0
- package/packages/cli/templates/ops.factory.json +14 -0
- package/packages/cli/templates/triage.factory.json +10 -0
- package/packages/core/src/daemon.mjs +2047 -0
- package/packages/core/src/limit-gate.mjs +192 -0
- package/packages/core/src/rpc-client.mjs +66 -0
- package/systemd/README.md +11 -0
|
@@ -0,0 +1,1760 @@
|
|
|
1
|
+
// App state, the server connection, and API calls. Components read `store.state` and re-render on `store.subscribe`.
|
|
2
|
+
import { onRoute, pushRoute } from "./back.js";
|
|
3
|
+
import { sessionForest, unfolded } from "./session-tree.js";
|
|
4
|
+
import { liveMessage } from "./live.js";
|
|
5
|
+
import { fitClock, moment } from "./clock.js";
|
|
6
|
+
|
|
7
|
+
/** A random id. crypto.randomUUID only exists on https and localhost pages; plain-http network addresses lack it. */
|
|
8
|
+
export function uid() {
|
|
9
|
+
if (typeof crypto.randomUUID === "function") {
|
|
10
|
+
return crypto.randomUUID();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
14
|
+
|
|
15
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
16
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
17
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
18
|
+
|
|
19
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How many transcript rows a session shows when opened; "Show earlier messages" adds this many more. */
|
|
23
|
+
export const TRANSCRIPT_ROWS = 60;
|
|
24
|
+
|
|
25
|
+
const TAB_KEY = "pocket.tab";
|
|
26
|
+
const UNREAD_KEY = "pocket.unread";
|
|
27
|
+
|
|
28
|
+
/** Sessions this browser has marked unread, like the pinned list beside it (`pocket.unread`). */
|
|
29
|
+
function unreadOf() {
|
|
30
|
+
try {
|
|
31
|
+
const ids = JSON.parse(localStorage.getItem(UNREAD_KEY) ?? "[]");
|
|
32
|
+
|
|
33
|
+
return Array.isArray(ids) ? ids : [];
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** How far this browser has read a session's chat: the time of the newest message seen, in `pocket.chatRead.<id>`. */
|
|
40
|
+
const CHAT_READ_PREFIX = "pocket.chatRead.";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every session's read time, by id, from local storage. Read once at load and kept in the store (`chatReads`): the list
|
|
44
|
+
* asks for each row as it draws, and storage is slow to ask.
|
|
45
|
+
*/
|
|
46
|
+
function readChatReads() {
|
|
47
|
+
const reads = {};
|
|
48
|
+
|
|
49
|
+
for (let index = 0; index < localStorage.length; index++) {
|
|
50
|
+
const key = localStorage.key(index);
|
|
51
|
+
const at = key?.startsWith(CHAT_READ_PREFIX) ? Number(localStorage.getItem(key)) : 0;
|
|
52
|
+
|
|
53
|
+
if (at > 0) {
|
|
54
|
+
reads[key.slice(CHAT_READ_PREFIX.length)] = at;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return reads;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const firstReads = readChatReads();
|
|
62
|
+
|
|
63
|
+
const TAB = sessionStorage.getItem(TAB_KEY) ?? uid();
|
|
64
|
+
|
|
65
|
+
sessionStorage.setItem(TAB_KEY, TAB);
|
|
66
|
+
|
|
67
|
+
const emptyView = () => ({
|
|
68
|
+
conversation: null,
|
|
69
|
+
entries: new Map(),
|
|
70
|
+
order: [],
|
|
71
|
+
live: { busy: false },
|
|
72
|
+
inbox: [],
|
|
73
|
+
agent: null,
|
|
74
|
+
stats: { cost: 0 },
|
|
75
|
+
clients: 0,
|
|
76
|
+
viewers: [],
|
|
77
|
+
approvals: [],
|
|
78
|
+
artifacts: [],
|
|
79
|
+
subagents: [],
|
|
80
|
+
authors: {},
|
|
81
|
+
reactions: {},
|
|
82
|
+
pins: [],
|
|
83
|
+
turns: { on: false, asks: [] },
|
|
84
|
+
decisions: {},
|
|
85
|
+
plan: { on: false },
|
|
86
|
+
schedules: [],
|
|
87
|
+
goal: null,
|
|
88
|
+
watch: null,
|
|
89
|
+
branch: null,
|
|
90
|
+
subagentsHeld: null,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/** Where a tab keeps the extension panel it shows (`extPanel`), as `{ module, id }`. */
|
|
94
|
+
export const EXT_PANEL_KEY = "pocket.extPanel";
|
|
95
|
+
|
|
96
|
+
function readExtPanel() {
|
|
97
|
+
try {
|
|
98
|
+
const kept = JSON.parse(sessionStorage.getItem(EXT_PANEL_KEY));
|
|
99
|
+
|
|
100
|
+
return typeof kept?.module === "string" && typeof kept.id === "string"
|
|
101
|
+
? { module: kept.module, id: kept.id }
|
|
102
|
+
: null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Where this browser keeps the sessions whose linked sessions are folded away (`toggleFoldedLinked`). */
|
|
109
|
+
const FOLDED_LINKED_KEY = "pocket.foldedLinked";
|
|
110
|
+
|
|
111
|
+
export const store = {
|
|
112
|
+
state: {
|
|
113
|
+
me: undefined, // undefined: unknown, null: signed out
|
|
114
|
+
users: [],
|
|
115
|
+
models: [],
|
|
116
|
+
/** This tab's shared model refresh state and last result (`model-refresh.js`). */
|
|
117
|
+
modelRefresh: { loading: false, result: null, error: null },
|
|
118
|
+
guard: null,
|
|
119
|
+
server: null,
|
|
120
|
+
sessions: [],
|
|
121
|
+
/** This person's favorite folders, in shortcut order, sent by the server to every signed-in device. */
|
|
122
|
+
folderFavorites: [],
|
|
123
|
+
/** False until the server's first session list arrives: an empty list until then means "not here yet". */
|
|
124
|
+
sessionsLoaded: false,
|
|
125
|
+
/** Sessions on their way into the archive (true) or out of it (false), by id, until the list catches up (`sessions.js`). */
|
|
126
|
+
moving: {},
|
|
127
|
+
/** Sessions marked unread in this browser: an id each, until opened again. */
|
|
128
|
+
unread: unreadOf(),
|
|
129
|
+
conversationId: routeConversation(),
|
|
130
|
+
/** Sessions whose linked sessions are folded away in the list: this browser's choice (`toggleFoldedLinked`). */
|
|
131
|
+
foldedLinked: readFoldedLinked(),
|
|
132
|
+
view: emptyView(),
|
|
133
|
+
/** How far this browser has read each session's chat, by id: what `pocket.chatRead.<id>` keeps. */
|
|
134
|
+
chatReads: firstReads,
|
|
135
|
+
...peopleFor(routeConversation(), firstReads),
|
|
136
|
+
history: null,
|
|
137
|
+
/** The id of the oldest transcript entry shown, or null for the newest `TRANSCRIPT_ROWS` rows. */
|
|
138
|
+
transcriptFrom: null,
|
|
139
|
+
missing: null,
|
|
140
|
+
connection: "connecting",
|
|
141
|
+
notices: [],
|
|
142
|
+
sheet: null,
|
|
143
|
+
drawer: false,
|
|
144
|
+
auth: null,
|
|
145
|
+
/** A transcript message the next chat message discusses: `{ entryId, text }`. */
|
|
146
|
+
chatQuote: null,
|
|
147
|
+
replyQuote: null,
|
|
148
|
+
/** Text to put into the message box to Pi: `{ text, n }`, picked up by the composer. */
|
|
149
|
+
composerInsert: null,
|
|
150
|
+
/** Pi's prompt templates for a conversation's folder: `{ conversationId, at, list }`. */
|
|
151
|
+
templates: null,
|
|
152
|
+
/** Whether Pi trusts a conversation's project, for its own skills (`trust.js`): `{ conversationId, cwd, info }`. */
|
|
153
|
+
trust: null,
|
|
154
|
+
/** The conversation whose subagents bar shows a row for each subagent (`subagents.js`); null for none. */
|
|
155
|
+
subagentsOpen: null,
|
|
156
|
+
/** The subagents board shows in the conversation's place (`subagents-board.js`). */
|
|
157
|
+
board: false,
|
|
158
|
+
/** Every subagent in every session, while the board shows; null until the server's first list arrives. */
|
|
159
|
+
boardAgents: null,
|
|
160
|
+
/** An extension's page in the conversation's place, as the board shows (`extension-ui.js`): `{ module, id }`. */
|
|
161
|
+
page: null,
|
|
162
|
+
/**
|
|
163
|
+
* An extension's panel beside the conversation, where the Browser and Files panels dock (`extension-ui.js`):
|
|
164
|
+
* `{ module, id }`. Kept per tab, like the browser's.
|
|
165
|
+
*/
|
|
166
|
+
extPanel: readExtPanel(),
|
|
167
|
+
/** Counts changes to what extensions' browser modules add (`extension-ui.js`), so what shows them draws again. */
|
|
168
|
+
extensionsRev: 0,
|
|
169
|
+
/** Counts the lists of files for @ mentions that arrived; the lists themselves are kept in `files.js`. */
|
|
170
|
+
filesLoaded: 0,
|
|
171
|
+
/** `!` commands this tab started that have no entry yet: `{ taskId, command, conversationId, at }`. */
|
|
172
|
+
pendingShells: [],
|
|
173
|
+
/** The session's browser page as the server last told: address, title, loading, size (`browser.js`). */
|
|
174
|
+
browser: null,
|
|
175
|
+
/** The Browser panel shows; kept per tab, so the reload after a live edit keeps it. */
|
|
176
|
+
browserOpen: sessionStorage.getItem("pocket.browser") === "1",
|
|
177
|
+
/** The Files tile shows (`files-panel.js`); kept per tab, like the browser's. */
|
|
178
|
+
filesOpen: sessionStorage.getItem("pocket.files") === "1",
|
|
179
|
+
/** The Files tile's tab: `files` or `changes`. */
|
|
180
|
+
filesTab: sessionStorage.getItem("pocket.filesTab") === "changes" ? "changes" : "files",
|
|
181
|
+
/** The last file asked to open in the Files tile: `{ path, line, n }`, `n` telling requests apart. */
|
|
182
|
+
filesTarget: null,
|
|
183
|
+
/** When someone last asked for the Files tile, and for which tab: `{ tab, at }`. */
|
|
184
|
+
filesAsk: null,
|
|
185
|
+
/** The People panel shows beside the conversation on wide screens; kept per tab, like the browser's. */
|
|
186
|
+
peopleOpen: sessionStorage.getItem("pocket.people") === "1",
|
|
187
|
+
/** The last request to show the People panel: which tab and chat message, and a count that tells requests apart. */
|
|
188
|
+
peopleAsk: { tab: null, highlight: null, n: 0 },
|
|
189
|
+
/** This person's PiP, as the server last told (`{ id, busy, endedAt }`); null when they have none yet. */
|
|
190
|
+
pip: null,
|
|
191
|
+
/** The PiP panel shows; kept per tab, like the browser's, so the reload after a live edit keeps it. */
|
|
192
|
+
pipOpen: sessionStorage.getItem("pocket.pip") === "1",
|
|
193
|
+
/** PiP's conversation, while the panel shows. */
|
|
194
|
+
pipView: emptyView(),
|
|
195
|
+
},
|
|
196
|
+
listeners: new Set(),
|
|
197
|
+
/**
|
|
198
|
+
* Change the state: by a patch, or by what a function of the state returns. The function runs first, so a change
|
|
199
|
+
* it makes on the way (a notice, say) is kept, not overwritten by the state as it was before.
|
|
200
|
+
*/
|
|
201
|
+
set(patch) {
|
|
202
|
+
const change = typeof patch === "function" ? patch(this.state) : patch;
|
|
203
|
+
|
|
204
|
+
this.state = { ...this.state, ...change };
|
|
205
|
+
|
|
206
|
+
for (const listener of this.listeners) {
|
|
207
|
+
listener(this.state);
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
/**
|
|
211
|
+
* Change the state without drawing anything again: for what the last draw already showed, so a draw for it would
|
|
212
|
+
* change nothing on screen. Kept in the same state object, which `apartFromViews` compares, so the next change draws
|
|
213
|
+
* as if this one was always there.
|
|
214
|
+
*/
|
|
215
|
+
note(patch) {
|
|
216
|
+
Object.assign(this.state, patch);
|
|
217
|
+
},
|
|
218
|
+
subscribe(listener) {
|
|
219
|
+
this.listeners.add(listener);
|
|
220
|
+
|
|
221
|
+
return () => this.listeners.delete(listener);
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// ─── Marked-unread sessions (this browser) ──────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
/** The marked-unread ids as a set, made again only when the list changes: rows ask for each session as they draw. */
|
|
228
|
+
let unreadSet = { list: null, set: new Set() };
|
|
229
|
+
|
|
230
|
+
export function isUnread(id) {
|
|
231
|
+
const list = store.state.unread;
|
|
232
|
+
|
|
233
|
+
if (unreadSet.list !== list) {
|
|
234
|
+
unreadSet = { list, set: new Set(list) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return unreadSet.set.has(id);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Mark a session unread, or clear it again, in this browser only. Opening the session clears it (`opened`). */
|
|
241
|
+
export function setUnread(id, unread) {
|
|
242
|
+
const next = unread
|
|
243
|
+
? [...new Set([...store.state.unread, id])]
|
|
244
|
+
: store.state.unread.filter((each) => each !== id);
|
|
245
|
+
|
|
246
|
+
localStorage.setItem(UNREAD_KEY, JSON.stringify(next));
|
|
247
|
+
store.set({ unread: next });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Opening a session is reading it, however it opens (a tap, back, a link, a notification): its mark goes. */
|
|
251
|
+
function opened(id) {
|
|
252
|
+
if (id !== null && isUnread(id)) {
|
|
253
|
+
setUnread(id, false);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
opened(store.state.conversationId);
|
|
258
|
+
|
|
259
|
+
function routeConversation() {
|
|
260
|
+
const match = /^\/s\/(\d+)/.exec(location.pathname);
|
|
261
|
+
|
|
262
|
+
return match ? Number(match[1]) : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── People: presence, typing, and the side chat ───────────────────────────────────
|
|
266
|
+
|
|
267
|
+
const CHAT_LIMIT = 500;
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Fresh people state for a conversation: no one known yet, and how far this browser has read its chat. A function
|
|
271
|
+
* declaration, not a const: the store's initial state calls it before this line runs, with `reads` given.
|
|
272
|
+
*/
|
|
273
|
+
function peopleFor(id, reads = store.state.chatReads) {
|
|
274
|
+
return {
|
|
275
|
+
chat: [],
|
|
276
|
+
presence: [],
|
|
277
|
+
notes: null,
|
|
278
|
+
chatQuote: null,
|
|
279
|
+
replyQuote: null,
|
|
280
|
+
chatRead: id === null ? 0 : (reads[id] ?? 0),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Another tab of this browser read a chat (or the storage was cleared): the list's marks follow.
|
|
285
|
+
addEventListener("storage", (event) => {
|
|
286
|
+
if (event.key === null || event.key.startsWith(CHAT_READ_PREFIX)) {
|
|
287
|
+
setChanged({ chatReads: kept(store.state.chatReads, readChatReads()) });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
/** Someone else chatted in a session after this browser last read its chat. */
|
|
292
|
+
export function sessionUnread(session, state = store.state) {
|
|
293
|
+
const read =
|
|
294
|
+
session.id === state.conversationId ? state.chatRead : (state.chatReads[session.id] ?? 0);
|
|
295
|
+
|
|
296
|
+
return session.chatAt !== undefined && session.chatBy !== state.me?.id && session.chatAt > read;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Sessions in "workspace" order, as Alt+1…9 and the rail number them: pinned ones first (in the order they were
|
|
301
|
+
* pinned), then the rest, newest first, each followed by the linked sessions under it, as the list shows them
|
|
302
|
+
* (`session-tree.js`). Archived sessions are left out.
|
|
303
|
+
*/
|
|
304
|
+
export function workspaceOrder(state = store.state) {
|
|
305
|
+
const { sessions, pinned: pins, foldedLinked, conversationId } = state;
|
|
306
|
+
|
|
307
|
+
// The list, the rail, the home screen, and peek tiles all ask as they draw: the order is made once for each list.
|
|
308
|
+
if (
|
|
309
|
+
ordered.sessions === sessions &&
|
|
310
|
+
ordered.pinned === pins &&
|
|
311
|
+
ordered.folded === foldedLinked &&
|
|
312
|
+
ordered.open === conversationId
|
|
313
|
+
) {
|
|
314
|
+
return ordered.order;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Linked sessions folded away are out of the order too: the numbers and Alt+arrows follow what shows.
|
|
318
|
+
const order = orderOf(state, foldsNow(state));
|
|
319
|
+
|
|
320
|
+
ordered = { sessions, pinned: pins, folded: foldedLinked, open: conversationId, order };
|
|
321
|
+
|
|
322
|
+
return order;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Every session that is not archived, in workspace order, folded ones too: for finding a session (the launcher), where a
|
|
327
|
+
* fold must not hide one that waits.
|
|
328
|
+
*/
|
|
329
|
+
export function sessionOrder(state = store.state) {
|
|
330
|
+
const { sessions, pinned: pins } = state;
|
|
331
|
+
|
|
332
|
+
if (orderedAll.sessions !== sessions || orderedAll.pinned !== pins) {
|
|
333
|
+
orderedAll = { sessions, pinned: pins, order: orderOf(state, new Set()) };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return orderedAll.order;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Sessions in workspace order, with the linked sessions under `folds` left out. */
|
|
340
|
+
function orderOf(state, folds) {
|
|
341
|
+
const { sessions, pinned: pins } = state;
|
|
342
|
+
const active = sessions.filter((session) => !session.archived);
|
|
343
|
+
const { roots, rows } = sessionForest(active, { pinned: pins });
|
|
344
|
+
const family = (root) => unfolded(rows.get(root.id), folds).map((row) => row.session);
|
|
345
|
+
const byId = new Map(roots.map((root) => [root.id, root]));
|
|
346
|
+
const pinned = pins.map((id) => byId.get(id)).filter(Boolean);
|
|
347
|
+
const first = new Set(pinned);
|
|
348
|
+
|
|
349
|
+
// Shared by every caller until the next list: frozen, so none changes it for the others.
|
|
350
|
+
return Object.freeze([
|
|
351
|
+
...pinned.flatMap(family),
|
|
352
|
+
...roots.filter((root) => !first.has(root)).flatMap(family),
|
|
353
|
+
]);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The last workspace order, and the lists it was made from. */
|
|
357
|
+
let ordered = { sessions: null, pinned: null, folded: null, open: null, order: [] };
|
|
358
|
+
/** The last order of every session, folded ones too. */
|
|
359
|
+
let orderedAll = { sessions: null, pinned: null, order: [] };
|
|
360
|
+
|
|
361
|
+
function readFoldedLinked() {
|
|
362
|
+
try {
|
|
363
|
+
const kept = JSON.parse(localStorage.getItem(FOLDED_LINKED_KEY) ?? "[]");
|
|
364
|
+
|
|
365
|
+
return Array.isArray(kept) ? kept.filter((id) => Number.isSafeInteger(id)) : [];
|
|
366
|
+
} catch {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The folds that apply now: a session whose linked sessions are folded, but not one above the open session, which
|
|
373
|
+
* always shows.
|
|
374
|
+
*/
|
|
375
|
+
export function foldsNow(state = store.state) {
|
|
376
|
+
const folds = new Set(state.foldedLinked);
|
|
377
|
+
const byId = new Map(state.sessions.map((session) => [session.id, session]));
|
|
378
|
+
const seen = new Set();
|
|
379
|
+
|
|
380
|
+
for (
|
|
381
|
+
let at = byId.get(state.conversationId)?.linkedFrom?.id;
|
|
382
|
+
at !== undefined && !seen.has(at);
|
|
383
|
+
at = byId.get(at)?.linkedFrom?.id
|
|
384
|
+
) {
|
|
385
|
+
seen.add(at);
|
|
386
|
+
folds.delete(at);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return folds;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Fold a session's linked sessions away in the list, or show them again; kept for this browser, its other tabs too. */
|
|
393
|
+
export function toggleFoldedLinked(id) {
|
|
394
|
+
const known = new Set(store.state.sessions.map((session) => session.id));
|
|
395
|
+
// Sessions that are gone are let go of as the choice is saved.
|
|
396
|
+
const next = store.state.foldedLinked.filter((each) => each !== id && known.has(each));
|
|
397
|
+
|
|
398
|
+
if (!store.state.foldedLinked.includes(id)) {
|
|
399
|
+
next.push(id);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
localStorage.setItem(FOLDED_LINKED_KEY, JSON.stringify(next));
|
|
404
|
+
} catch {
|
|
405
|
+
// Storage refused (a private window): the fold holds until the page reloads.
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
store.set({ foldedLinked: next });
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Another tab of this browser folded or unfolded: this one follows.
|
|
412
|
+
addEventListener("storage", (event) => {
|
|
413
|
+
if (event.key === FOLDED_LINKED_KEY) {
|
|
414
|
+
store.set({ foldedLinked: readFoldedLinked() });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
/** Server features the web app may use. `collab` 2 adds roles, take turns, reactions, pins, notes, mentions, push. */
|
|
419
|
+
export const collab = () => (store.state.server?.collab ?? 0) >= 2;
|
|
420
|
+
/** Viewers read and chat; they never steer Pi. */
|
|
421
|
+
export const canSteer = () => store.state.me?.role !== "viewer";
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Whether this person may turn take turns on or off here, as the server decides: anyone who steers while it is off; the
|
|
425
|
+
* driver, the owner, or anyone when nobody drives or the driver has left, while it is on.
|
|
426
|
+
*/
|
|
427
|
+
export function canToggleTurns() {
|
|
428
|
+
const { me, presence, view } = store.state;
|
|
429
|
+
const turns = view.turns;
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
canSteer() &&
|
|
433
|
+
(!turns?.on ||
|
|
434
|
+
turns.driver === me?.id ||
|
|
435
|
+
me?.role === "owner" ||
|
|
436
|
+
!turns.driver ||
|
|
437
|
+
!presence.some((person) => person.id === turns.driver))
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** People invited to one session cannot start sessions or invite others. */
|
|
442
|
+
export const scoped = () => Array.isArray(store.state.me?.sessions);
|
|
443
|
+
|
|
444
|
+
/** The notices that announce chat messages, here or a mention elsewhere, by id: their session's. Reading its chat takes them down. */
|
|
445
|
+
const chatNotices = new Map();
|
|
446
|
+
|
|
447
|
+
// Read from storage: another tab's read counts before its event arrives.
|
|
448
|
+
export function chatReadStored(id) {
|
|
449
|
+
return Number(localStorage.getItem(CHAT_READ_PREFIX + id)) || 0;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function setChatReadAt(id, at) {
|
|
453
|
+
if (at > 0) {
|
|
454
|
+
localStorage.setItem(CHAT_READ_PREFIX + id, String(at));
|
|
455
|
+
} else {
|
|
456
|
+
localStorage.removeItem(CHAT_READ_PREFIX + id);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
store.set((state) => {
|
|
460
|
+
const chatReads = { ...state.chatReads };
|
|
461
|
+
|
|
462
|
+
if (at > 0) {
|
|
463
|
+
chatReads[id] = at;
|
|
464
|
+
} else {
|
|
465
|
+
delete chatReads[id];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return { chatReads, ...(id === state.conversationId ? { chatRead: at } : {}) };
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Everything in the chat counts as read: called while the chat is open. */
|
|
473
|
+
export function markChatRead() {
|
|
474
|
+
const { chat, chatRead, conversationId } = store.state;
|
|
475
|
+
|
|
476
|
+
for (const [id, conversation] of chatNotices) {
|
|
477
|
+
if (conversation === conversationId) {
|
|
478
|
+
dismiss(id);
|
|
479
|
+
chatNotices.delete(id);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const last = chat.at(-1)?.at ?? 0;
|
|
484
|
+
|
|
485
|
+
if (last <= chatRead || conversationId === null) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
setChatReadAt(conversationId, last);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function applyChat(data) {
|
|
493
|
+
const state = store.state;
|
|
494
|
+
|
|
495
|
+
if (data.conversationId !== state.conversationId) {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const known = new Set(data.full ? [] : state.chat.map((message) => message.id));
|
|
500
|
+
const added = data.messages.filter((message) => !known.has(message.id));
|
|
501
|
+
|
|
502
|
+
store.set({ chat: (data.full ? data.messages : [...state.chat, ...added]).slice(-CHAT_LIMIT) });
|
|
503
|
+
|
|
504
|
+
// The People panel stays open for long: on its Pinned or Notes tab, new messages still count and show a notice.
|
|
505
|
+
if (chatLogs > 0) {
|
|
506
|
+
markChatRead();
|
|
507
|
+
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// A full list comes on connect: only live messages from others pop up.
|
|
512
|
+
const fresh = data.full
|
|
513
|
+
? []
|
|
514
|
+
: added.filter((message) => message.userId !== state.me?.id && message.kind !== "event");
|
|
515
|
+
|
|
516
|
+
if (fresh.length === 0) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const open = () => openSheet({ type: "chat" });
|
|
521
|
+
const announce = (message) =>
|
|
522
|
+
chatNotices.set(notify("info", message, open), store.state.conversationId);
|
|
523
|
+
|
|
524
|
+
if (fresh.length > 1) {
|
|
525
|
+
return announce(`${fresh.length} new chat messages`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const [message] = fresh;
|
|
529
|
+
const name = state.users.find((user) => user.id === message.userId)?.name ?? message.name;
|
|
530
|
+
const text = message.text.replace(/\s+/g, " ");
|
|
531
|
+
const mentioned = message.mentions?.includes(state.me?.id);
|
|
532
|
+
|
|
533
|
+
announce(
|
|
534
|
+
`${name}${mentioned ? " mentioned you" : ""}: ${text.length > 120 ? `${text.slice(0, 119)}…` : text}`,
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let typingSent = { where: null, at: 0, conversationId: null };
|
|
539
|
+
|
|
540
|
+
/** Tell others this person is typing ("chat" or "pi") or stopped (null). Renewed at most every few seconds. */
|
|
541
|
+
export function typing(where) {
|
|
542
|
+
const id = store.state.conversationId;
|
|
543
|
+
|
|
544
|
+
if (!store.state.server?.chat || id === null) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const now = Date.now();
|
|
549
|
+
const same = typingSent.conversationId === id && typingSent.where === where;
|
|
550
|
+
|
|
551
|
+
if (same && (where === null || now - typingSent.at < 3000)) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
typingSent = { where, at: now, conversationId: id };
|
|
556
|
+
api(`c/${id}/typing`, { where }).catch(() => {});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
class ApiError extends Error {
|
|
560
|
+
constructor(status, message) {
|
|
561
|
+
super(message);
|
|
562
|
+
this.status = status;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Call the server's API: a GET without `body`, a POST with one, or `options.method`; `options.signal` stops it. */
|
|
567
|
+
export async function api(path, body, options = {}) {
|
|
568
|
+
const init = {
|
|
569
|
+
method: body === undefined && !options.method ? "GET" : (options.method ?? "POST"),
|
|
570
|
+
headers: { "X-Pocket": "1" },
|
|
571
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
if (body !== undefined) {
|
|
575
|
+
if (body instanceof Blob) {
|
|
576
|
+
init.body = body;
|
|
577
|
+
init.headers["content-type"] = body.type || "application/octet-stream";
|
|
578
|
+
} else {
|
|
579
|
+
init.body = JSON.stringify(body);
|
|
580
|
+
init.headers["content-type"] = "application/json";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const response = await fetch(`/api/${path}`, init);
|
|
585
|
+
const text = await response.text();
|
|
586
|
+
let data;
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
data = text ? JSON.parse(text) : {};
|
|
590
|
+
} catch {
|
|
591
|
+
data = { error: text };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (!response.ok) {
|
|
595
|
+
throw new ApiError(response.status, data.error ?? `HTTP ${response.status}`);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return data;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let nextNotice = 1;
|
|
602
|
+
/** Each notice's countdown, by id: what is left of it, and its timer while it runs. */
|
|
603
|
+
const countdowns = new Map();
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Show a short notice, and return its id. With `action`, tapping it runs the action as well as dismissing it. It stays longer when it
|
|
607
|
+
* offers something (an Undo) or says what went wrong, and its countdown holds while a pointer or the focus is on it.
|
|
608
|
+
*/
|
|
609
|
+
export function notify(level, message, action) {
|
|
610
|
+
const id = nextNotice++;
|
|
611
|
+
const life = level === "error" ? 9000 : action ? 7000 : 4500;
|
|
612
|
+
|
|
613
|
+
store.set((state) => ({
|
|
614
|
+
notices: [...state.notices, { id, level, message, action, life }].slice(-4),
|
|
615
|
+
}));
|
|
616
|
+
countdowns.set(id, {
|
|
617
|
+
left: life,
|
|
618
|
+
since: Date.now(),
|
|
619
|
+
timer: setTimeout(() => dismiss(id), life),
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
return id;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Hold a notice's countdown (`held`), or let it go on with what was left. */
|
|
626
|
+
export function holdNotice(id, held) {
|
|
627
|
+
const countdown = countdowns.get(id);
|
|
628
|
+
|
|
629
|
+
if (!countdown || held === (countdown.timer === null)) {
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (held) {
|
|
634
|
+
clearTimeout(countdown.timer);
|
|
635
|
+
countdown.timer = null;
|
|
636
|
+
countdown.left -= Date.now() - countdown.since;
|
|
637
|
+
} else {
|
|
638
|
+
countdown.since = Date.now();
|
|
639
|
+
countdown.timer = setTimeout(() => dismiss(id), Math.max(countdown.left, 1000));
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function dismiss(id) {
|
|
644
|
+
clearTimeout(countdowns.get(id)?.timer);
|
|
645
|
+
countdowns.delete(id);
|
|
646
|
+
chatNotices.delete(id);
|
|
647
|
+
store.set((state) => ({ notices: state.notices.filter((notice) => notice.id !== id) }));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Run an action and show its error, if any, as a notice. */
|
|
651
|
+
export async function attempt(action) {
|
|
652
|
+
try {
|
|
653
|
+
return await action();
|
|
654
|
+
} catch (error) {
|
|
655
|
+
notify("error", error.message ?? String(error));
|
|
656
|
+
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** End this browser's sign-in, and go to the sign-in page. */
|
|
662
|
+
export function signOut() {
|
|
663
|
+
return attempt(async () => {
|
|
664
|
+
await api("logout", {});
|
|
665
|
+
location.href = "/";
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let source;
|
|
670
|
+
|
|
671
|
+
function applyView(data) {
|
|
672
|
+
store.set((state) => ({ view: mergeView(state.view, data), missing: null }));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** PiP's conversation, for its panel (`pip.js`): the same view, from a view of its own beside the session's. */
|
|
676
|
+
function applyPipView(data) {
|
|
677
|
+
store.set((state) => ({ pipView: mergeView(state.pipView, data) }));
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* A view after an update from the server: the full view, or what changed since the last one. A full view of the same
|
|
682
|
+
* conversation, as after a reconnect, keeps the entries loaded before (`loadEarlier`): it brings only the newest.
|
|
683
|
+
*/
|
|
684
|
+
function mergeView(current, data) {
|
|
685
|
+
const same = current.conversation?.id === data.conversation.id;
|
|
686
|
+
const base = data.full || !same ? emptyView() : current;
|
|
687
|
+
|
|
688
|
+
// Most updates while Pi writes bring no entries: the same Map then, so what is built from it (the transcript's rows)
|
|
689
|
+
// is not built again for each of them.
|
|
690
|
+
const unchanged = !data.full && same && data.entries.length === 0 && !data.order;
|
|
691
|
+
const entries = unchanged
|
|
692
|
+
? current.entries
|
|
693
|
+
: new Map(data.full && same ? current.entries : base.entries);
|
|
694
|
+
|
|
695
|
+
// A light one held from before (the tab left before the whole one came) goes: past the newest rows, the server
|
|
696
|
+
// counts it as sent, so only `loadEarlier` brings it whole, and it asks for what is missing.
|
|
697
|
+
if (data.full && same) {
|
|
698
|
+
for (const [id, entry] of entries) {
|
|
699
|
+
if (entry.partial) {
|
|
700
|
+
entries.delete(id);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
for (const entry of data.entries) {
|
|
706
|
+
// A light one (`partial`) does not replace the whole one this tab has, as after a reconnect: it is sent whole
|
|
707
|
+
// again right after.
|
|
708
|
+
const had = entries.get(entry.id);
|
|
709
|
+
|
|
710
|
+
if (entry.partial && had !== undefined && !had.partial) {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
entries.set(entry.id, entry);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const order = data.order ?? base.order;
|
|
718
|
+
|
|
719
|
+
if (data.order) {
|
|
720
|
+
const keep = new Set(data.order);
|
|
721
|
+
|
|
722
|
+
for (const id of entries.keys()) {
|
|
723
|
+
if (!keep.has(id)) {
|
|
724
|
+
entries.delete(id);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return {
|
|
730
|
+
...base,
|
|
731
|
+
conversation: data.conversation,
|
|
732
|
+
entries,
|
|
733
|
+
order,
|
|
734
|
+
live: mergeLive(base.live, data.live),
|
|
735
|
+
agent: data.agent,
|
|
736
|
+
stats: data.stats,
|
|
737
|
+
clients: data.clients,
|
|
738
|
+
viewers: data.viewers,
|
|
739
|
+
approvals: data.approvals,
|
|
740
|
+
// The server sends these again only when they change: a missing one keeps its last value.
|
|
741
|
+
inbox: data.inbox ?? base.inbox,
|
|
742
|
+
artifacts: data.artifacts ?? base.artifacts,
|
|
743
|
+
subagents: data.subagents ?? base.subagents,
|
|
744
|
+
authors: data.authors ?? base.authors,
|
|
745
|
+
reactions: data.reactions ?? base.reactions,
|
|
746
|
+
pins: data.pins ?? base.pins,
|
|
747
|
+
turns: data.turns ?? base.turns,
|
|
748
|
+
decisions: data.decisions ?? base.decisions,
|
|
749
|
+
plan: data.plan ?? base.plan,
|
|
750
|
+
schedules: data.schedules ?? base.schedules,
|
|
751
|
+
// No goal is null, which the server sends too: only a missing field keeps the last value.
|
|
752
|
+
goal: data.goal === undefined ? base.goal : data.goal,
|
|
753
|
+
// The session's PR watch, or null: the same.
|
|
754
|
+
watch: data.watch === undefined ? base.watch : data.watch,
|
|
755
|
+
// Why subagents' reports wait (a spend limit), or null when none do: the subagents bar says it.
|
|
756
|
+
subagentsHeld: data.subagentsHeld === undefined ? base.subagentsHeld : data.subagentsHeld,
|
|
757
|
+
// What the folder has checked out: `{ branch }` or `{ detached }`, and null outside a repository.
|
|
758
|
+
branch: data.branch === undefined ? base.branch : data.branch,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
let reconnecting = false;
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The live run from an update, whose message may come as what changed (`liveMessage`). One that does not fit what this
|
|
766
|
+
* tab has, which should not happen, keeps the message as it was and connects again for a full view.
|
|
767
|
+
*/
|
|
768
|
+
function mergeLive(current, live) {
|
|
769
|
+
if (live.generation?.message?.keep === undefined) {
|
|
770
|
+
return live;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const had = current.generation?.message;
|
|
774
|
+
let message = liveMessage(had, live.generation.message);
|
|
775
|
+
|
|
776
|
+
if (message === null) {
|
|
777
|
+
message = had;
|
|
778
|
+
|
|
779
|
+
// One batch of events may hold several such updates: connect once.
|
|
780
|
+
if (!reconnecting) {
|
|
781
|
+
reconnecting = true;
|
|
782
|
+
setTimeout(() => {
|
|
783
|
+
reconnecting = false;
|
|
784
|
+
connect();
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return { ...live, generation: { ...live.generation, message } };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** The `moving` marks that a session list has not caught up with yet: it still shows those sessions where they were. */
|
|
793
|
+
export function stillMoving(moving, sessions) {
|
|
794
|
+
const next = { ...moving };
|
|
795
|
+
|
|
796
|
+
for (const session of sessions) {
|
|
797
|
+
if (next[session.id] === Boolean(session.archived)) {
|
|
798
|
+
delete next[session.id];
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
return next;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** Whether two values the server sent as JSON are the same: the same plain values, arrays, and objects, all the way down. */
|
|
806
|
+
function sameJson(a, b) {
|
|
807
|
+
if (a === b) {
|
|
808
|
+
return true;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (
|
|
812
|
+
typeof a !== "object" ||
|
|
813
|
+
typeof b !== "object" ||
|
|
814
|
+
a === null ||
|
|
815
|
+
b === null ||
|
|
816
|
+
Array.isArray(a) !== Array.isArray(b)
|
|
817
|
+
) {
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const keys = Object.keys(a);
|
|
822
|
+
|
|
823
|
+
return (
|
|
824
|
+
keys.length === Object.keys(b).length &&
|
|
825
|
+
keys.every((key) => Object.hasOwn(b, key) && sameJson(a[key], b[key]))
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* What the server sent, or what the store has when it is the same. Each connect sends hello and the lists again, mostly
|
|
831
|
+
* as they were: what did not change keeps its identity, so memos hold and identity checks (`theme.js`) see no change.
|
|
832
|
+
*/
|
|
833
|
+
const kept = (had, next) => (sameJson(had, next) ? had : next);
|
|
834
|
+
|
|
835
|
+
/** A list of items with ids, each item kept where it is the same, and the list itself when all are: one session changed draws one row again. */
|
|
836
|
+
function keptEach(had, next) {
|
|
837
|
+
if (!Array.isArray(had) || !Array.isArray(next)) {
|
|
838
|
+
return next;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const byId = new Map(had.map((item) => [item?.id, item]));
|
|
842
|
+
const items = next.map((item) => kept(byId.get(item?.id), item));
|
|
843
|
+
|
|
844
|
+
return items.length === had.length && items.every((item, index) => item === had[index])
|
|
845
|
+
? had
|
|
846
|
+
: items;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** How far the server's clock is ahead of this browser's, from its hellos (`seen.js`); undefined before the first. */
|
|
850
|
+
let clockOffset;
|
|
851
|
+
|
|
852
|
+
export const serverClockOffset = () => clockOffset ?? 0;
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* When this tab asked for the hello its new connection brings first (`fitClock`): the server reads its clock after
|
|
856
|
+
* that. Undefined for a hello the server sends of itself, or after a stream reconnects by itself.
|
|
857
|
+
*/
|
|
858
|
+
let helloAsked;
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* The server's facts from a hello. Its clock (`now`) is new in each one and is no change to them: the clock goes to
|
|
862
|
+
* `clockOffset`, and the facts keep the clock of the hello that brought them. `asked`: when this tab asked for it.
|
|
863
|
+
*/
|
|
864
|
+
function keptServer(had, next, asked) {
|
|
865
|
+
if (typeof next?.now === "number") {
|
|
866
|
+
clockOffset = fitClock(clockOffset, next.now, moment(), asked);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
return had && next && sameJson({ ...had, now: 0 }, { ...next, now: 0 }) ? had : next;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Set only what changed, and nothing when nothing did: data sent again as it was draws nothing again. */
|
|
873
|
+
function setChanged(change) {
|
|
874
|
+
const changed = Object.entries(change).filter(([key, value]) => store.state[key] !== value);
|
|
875
|
+
|
|
876
|
+
if (changed.length > 0) {
|
|
877
|
+
store.set(Object.fromEntries(changed));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Who hears extensions' events: `extension-ui.js`, which imports this module and so cannot be imported here. */
|
|
882
|
+
const extensionListeners = new Set();
|
|
883
|
+
|
|
884
|
+
/** Hear the events extension modules send their browser modules: `{ module, event, data }`. */
|
|
885
|
+
export function onExtensionEvent(listener) {
|
|
886
|
+
extensionListeners.add(listener);
|
|
887
|
+
|
|
888
|
+
return () => extensionListeners.delete(listener);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** What the server sends, by event name. Both transports deliver the same events. */
|
|
892
|
+
const handlers = {
|
|
893
|
+
// Sent again on each connect, mostly as it was: what is the same keeps its identity (`kept`).
|
|
894
|
+
hello: (data) => {
|
|
895
|
+
const state = store.state;
|
|
896
|
+
// Only a new connection's first hello answers this tab's asking; any later one, the server sent of itself.
|
|
897
|
+
const asked = helloAsked;
|
|
898
|
+
|
|
899
|
+
helloAsked = undefined;
|
|
900
|
+
setChanged({
|
|
901
|
+
me: kept(state.me, data.user),
|
|
902
|
+
users: keptEach(state.users, data.users),
|
|
903
|
+
folderFavorites: kept(state.folderFavorites, data.folderFavorites ?? []),
|
|
904
|
+
models: kept(state.models, data.models),
|
|
905
|
+
guard: kept(state.guard, data.guard),
|
|
906
|
+
server: keptServer(state.server, data.server, asked),
|
|
907
|
+
// Every theme folder (`theme.js`): an older server lists none.
|
|
908
|
+
themeList: kept(state.themeList, Array.isArray(data.themes) ? data.themes : []),
|
|
909
|
+
// This connection's id, which peek lists name (`peeks.js`). `GET /api/me` answers without one.
|
|
910
|
+
...(data.connection === undefined ? {} : { streamId: data.connection }),
|
|
911
|
+
});
|
|
912
|
+
},
|
|
913
|
+
sessions: (list) => {
|
|
914
|
+
const state = store.state;
|
|
915
|
+
const sessions = keptEach(state.sessions, list);
|
|
916
|
+
|
|
917
|
+
setChanged({
|
|
918
|
+
sessions,
|
|
919
|
+
sessionsLoaded: true,
|
|
920
|
+
moving: kept(state.moving, stillMoving(state.moving, sessions)),
|
|
921
|
+
});
|
|
922
|
+
},
|
|
923
|
+
"folder-favorites": (folderFavorites) =>
|
|
924
|
+
setChanged({ folderFavorites: kept(store.state.folderFavorites, folderFavorites) }),
|
|
925
|
+
models: (models) => setChanged({ models: kept(store.state.models, models) }),
|
|
926
|
+
// A theme folder came, went, or changed (`theme.js` loads its new version).
|
|
927
|
+
themes: (themeList) => setChanged({ themeList: kept(store.state.themeList, themeList) }),
|
|
928
|
+
// Every subagent in every session, while this tab shows the subagents board (`subagents-board.js`).
|
|
929
|
+
subagents: (agents) => store.state.board && store.set({ boardAgents: agents }),
|
|
930
|
+
view: applyView,
|
|
931
|
+
// This person's PiP: its conversation, whether it works, and when it last answered (`pip.js`); null for none.
|
|
932
|
+
// Sent again on each connect, mostly as it was.
|
|
933
|
+
pip: (pip) => setChanged({ pip: kept(store.state.pip, pip) }),
|
|
934
|
+
// PiP's conversation, while this tab's PiP panel is open.
|
|
935
|
+
pipview: applyPipView,
|
|
936
|
+
chat: applyChat,
|
|
937
|
+
presence: (data) =>
|
|
938
|
+
data.conversationId === store.state.conversationId && store.set({ presence: data.people }),
|
|
939
|
+
notes: (data) =>
|
|
940
|
+
data.conversationId === store.state.conversationId &&
|
|
941
|
+
store.set({ notes: { text: data.text, rev: data.rev, by: data.by, at: data.at } }),
|
|
942
|
+
browser: (data) =>
|
|
943
|
+
data.conversationId === store.state.conversationId && store.set({ browser: data }),
|
|
944
|
+
// Another session as its peek tile shows it (`peeks.js`): kept after it scrolls away, as the tile's last look.
|
|
945
|
+
peek: (data) =>
|
|
946
|
+
store.set((state) => ({ peeks: { ...state.peeks, [data.conversationId]: data } })),
|
|
947
|
+
users: (users) => setChanged({ users: keptEach(store.state.users, users) }),
|
|
948
|
+
// Pi's lessons changed somewhere: a Memory sheet or menu that shows them fetches them again (`sheets/memory.js`).
|
|
949
|
+
memory: () => store.set((state) => ({ memoryRev: (state.memoryRev ?? 0) + 1 })),
|
|
950
|
+
missing: (data) => store.set({ missing: data.message }),
|
|
951
|
+
// An event an extension module sent its browser module (`extension-ui.js`).
|
|
952
|
+
ext: (data) => {
|
|
953
|
+
for (const listener of extensionListeners) {
|
|
954
|
+
listener(data);
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
// A notice may link to a conversation, such as a mention elsewhere: tapping it goes there.
|
|
958
|
+
notice: (data) => {
|
|
959
|
+
const id = notify(
|
|
960
|
+
data.level,
|
|
961
|
+
data.message,
|
|
962
|
+
data.link
|
|
963
|
+
? () =>
|
|
964
|
+
navigate(data.link.conversationId, {
|
|
965
|
+
sheet: data.link.sheet ? { type: data.link.sheet } : null,
|
|
966
|
+
})
|
|
967
|
+
: undefined,
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
// A mention's: reading that chat takes it down.
|
|
971
|
+
if (data.link?.sheet === "chat") {
|
|
972
|
+
chatNotices.set(id, data.link.conversationId);
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
auth: (data) => handleAuth(data),
|
|
976
|
+
closing: () => store.set({ connection: "closed" }),
|
|
977
|
+
reload: () => {
|
|
978
|
+
// A web file changed on the server. Message drafts live in localStorage and survive the reload; an unsaved notes
|
|
979
|
+
// draft, attachments not yet sent, and an open provider sign-in do not.
|
|
980
|
+
setTimeout(() => location.reload(), 150);
|
|
981
|
+
},
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* What this code understands of the server's updates, sent with each connection. 2: it keeps the queue when an update
|
|
986
|
+
* leaves it out. 3: it takes the message being written as what changed (`mergeLive`). 4: a full view brings only the
|
|
987
|
+
* entries of the newest rows, and it loads earlier ones as it shows them (`loadEarlier`). 5: those entries come light
|
|
988
|
+
* (`partial`) at first, whole in the update after. The server sends a tab below 3 the whole message with each update,
|
|
989
|
+
* and one whose code names none (as a tab asleep through an update keeps) the queue too.
|
|
990
|
+
*/
|
|
991
|
+
const PROTOCOL = 5;
|
|
992
|
+
const TRANSPORT_KEY = "pocket.transport";
|
|
993
|
+
const local = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\])$/.test(location.hostname);
|
|
994
|
+
/** After an event stream stalls on this address, poll for a day, then try the stream again: one slow start is not forever. */
|
|
995
|
+
const POLL_FOR_MS = 24 * 60 * 60_000;
|
|
996
|
+
|
|
997
|
+
const pollHere = () => {
|
|
998
|
+
const saved = localStorage.getItem(TRANSPORT_KEY) ?? "";
|
|
999
|
+
|
|
1000
|
+
// Saved before the choice had a time: a choice made now.
|
|
1001
|
+
if (saved === "poll") {
|
|
1002
|
+
localStorage.setItem(TRANSPORT_KEY, `poll:${Date.now()}`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const [mode, at] = (localStorage.getItem(TRANSPORT_KEY) ?? "").split(":");
|
|
1006
|
+
|
|
1007
|
+
return mode === "poll" && Date.now() - Number(at) < POLL_FOR_MS;
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
/** Event stream reconnects in a row that the browser gave up on, for the pause before the next one. */
|
|
1011
|
+
let streamRetries = 0;
|
|
1012
|
+
|
|
1013
|
+
const connected = () => store.state.connection !== "open" && store.set({ connection: "open" });
|
|
1014
|
+
|
|
1015
|
+
export function connect() {
|
|
1016
|
+
source?.close();
|
|
1017
|
+
store.set({ connection: "connecting" });
|
|
1018
|
+
const id = store.state.conversationId;
|
|
1019
|
+
const query = `tab=${encodeURIComponent(TAB)}&p=${PROTOCOL}${id === null ? "" : `&c=${id}`}`;
|
|
1020
|
+
|
|
1021
|
+
source = pollHere() && !local ? pollEvents(query) : streamEvents(query);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// A page kept for Back and Forward stays alive, frozen, and so would its stream: each holds one of the few connections a
|
|
1025
|
+
// browser makes to a server, and after a handful nothing else loads. It closes on leaving and reconnects on return.
|
|
1026
|
+
addEventListener("pagehide", () => {
|
|
1027
|
+
source?.close();
|
|
1028
|
+
source = null;
|
|
1029
|
+
});
|
|
1030
|
+
addEventListener("pageshow", (event) => {
|
|
1031
|
+
if (event.persisted) {
|
|
1032
|
+
connect();
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
/** Server-sent events: one long response the server writes to as things change. */
|
|
1037
|
+
function streamEvents(query) {
|
|
1038
|
+
helloAsked = moment();
|
|
1039
|
+
const events = new EventSource(`/api/events?${query}`);
|
|
1040
|
+
/** The first batch arrived whole: the session's view, or the session list when no session is open. */
|
|
1041
|
+
let settled = false;
|
|
1042
|
+
const settles = query.includes("&c=") ? ["view", "missing"] : ["sessions"];
|
|
1043
|
+
const connection = {
|
|
1044
|
+
close() {
|
|
1045
|
+
clearTimeout(fallback);
|
|
1046
|
+
events.close();
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
// Some tunnels hold the stream back. A Cloudflare quick tunnel passes it on in 64 KiB blocks and keeps the rest, so
|
|
1050
|
+
// a long session's first view can stop partway after its small first events got through. The stream counts as
|
|
1051
|
+
// working only once that first batch is whole; otherwise poll instead, and remember that for this address.
|
|
1052
|
+
const fallback = local
|
|
1053
|
+
? undefined
|
|
1054
|
+
: setTimeout(() => {
|
|
1055
|
+
if (settled || source !== connection) {
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
localStorage.setItem(TRANSPORT_KEY, `poll:${Date.now()}`);
|
|
1060
|
+
connect();
|
|
1061
|
+
}, 6000);
|
|
1062
|
+
|
|
1063
|
+
for (const [name, handler] of Object.entries(handlers)) {
|
|
1064
|
+
events.addEventListener(name, (event) => {
|
|
1065
|
+
if (source !== connection) {
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (settles.includes(name)) {
|
|
1070
|
+
settled = true;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
handler(JSON.parse(event.data));
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
events.onopen = () => {
|
|
1078
|
+
if (source !== connection) {
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
streamRetries = 0;
|
|
1083
|
+
connected();
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
events.onerror = async () => {
|
|
1087
|
+
if (source !== connection) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
store.set({ connection: "closed" });
|
|
1092
|
+
|
|
1093
|
+
// EventSource retries by itself after a network error; a 401 needs a sign-in instead.
|
|
1094
|
+
try {
|
|
1095
|
+
await api("me");
|
|
1096
|
+
} catch (error) {
|
|
1097
|
+
if (error.status === 401) {
|
|
1098
|
+
connection.close();
|
|
1099
|
+
store.set({ me: null });
|
|
1100
|
+
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// It gives up for good when a retry gets an answer other than 200, such as a tunnel's 502 while the server
|
|
1106
|
+
// restarts. Start a new stream after a pause that grows with each try.
|
|
1107
|
+
if (events.readyState === EventSource.CLOSED && source === connection) {
|
|
1108
|
+
// A stream that failed was not held back: no reason to switch this address to polling.
|
|
1109
|
+
clearTimeout(fallback);
|
|
1110
|
+
streamRetries++;
|
|
1111
|
+
setTimeout(
|
|
1112
|
+
() => source === connection && connect(),
|
|
1113
|
+
Math.min(10_000, 1000 * streamRetries),
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
return connection;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/** Long polling: each request waits for events and returns them; the next one acknowledges what arrived. */
|
|
1122
|
+
function pollEvents(query) {
|
|
1123
|
+
let session = null;
|
|
1124
|
+
let ack = 0;
|
|
1125
|
+
let stopped = false;
|
|
1126
|
+
let request = null;
|
|
1127
|
+
const connection = {
|
|
1128
|
+
close() {
|
|
1129
|
+
stopped = true;
|
|
1130
|
+
request?.abort();
|
|
1131
|
+
|
|
1132
|
+
if (session) {
|
|
1133
|
+
fetch(`/api/poll?session=${session}&close=1`, { keepalive: true }).catch(() => {});
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
(async () => {
|
|
1139
|
+
let failures = 0;
|
|
1140
|
+
|
|
1141
|
+
while (!stopped) {
|
|
1142
|
+
request = new AbortController();
|
|
1143
|
+
const asked = moment();
|
|
1144
|
+
|
|
1145
|
+
try {
|
|
1146
|
+
const response = await fetch(
|
|
1147
|
+
`/api/poll?${query}${session ? `&session=${session}&ack=${ack}` : ""}`,
|
|
1148
|
+
{ signal: request.signal, cache: "no-store" },
|
|
1149
|
+
);
|
|
1150
|
+
|
|
1151
|
+
if (response.status === 401) {
|
|
1152
|
+
stopped = true;
|
|
1153
|
+
store.set({ me: null });
|
|
1154
|
+
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
if (!response.ok) {
|
|
1159
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const data = await response.json();
|
|
1163
|
+
|
|
1164
|
+
if (stopped || source !== connection) {
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (data.session !== session) {
|
|
1169
|
+
// A new session (first poll, or the server restarted): it starts with hello and a full view. The
|
|
1170
|
+
// server made that session, and read the hello's clock, after this request went out.
|
|
1171
|
+
session = data.session;
|
|
1172
|
+
ack = 0;
|
|
1173
|
+
helloAsked = asked;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
failures = 0;
|
|
1177
|
+
connected();
|
|
1178
|
+
|
|
1179
|
+
for (const item of data.events) {
|
|
1180
|
+
if (item.seq <= ack) {
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
ack = item.seq;
|
|
1185
|
+
handlers[item.event]?.(item.data);
|
|
1186
|
+
}
|
|
1187
|
+
} catch {
|
|
1188
|
+
if (stopped) {
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
failures++;
|
|
1193
|
+
|
|
1194
|
+
if (store.state.connection === "open") {
|
|
1195
|
+
store.set({ connection: "closed" });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
await new Promise((resolve) =>
|
|
1199
|
+
setTimeout(resolve, Math.min(10_000, 1000 * failures)),
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
})();
|
|
1204
|
+
|
|
1205
|
+
return connection;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Sign-ins that ended, or whose dialog was closed here: what the server still says about them shows nothing. */
|
|
1209
|
+
const endedSignIns = new Set();
|
|
1210
|
+
|
|
1211
|
+
/** The dialog of a sign-in (`AuthDialog`), as the server's words about it come. */
|
|
1212
|
+
function handleAuth(data) {
|
|
1213
|
+
if (endedSignIns.has(data.flowId)) {
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
if (data.step === "done") {
|
|
1218
|
+
endedSignIns.add(data.flowId);
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
store.set((state) => {
|
|
1222
|
+
const flow =
|
|
1223
|
+
state.auth?.flowId === data.flowId
|
|
1224
|
+
? state.auth
|
|
1225
|
+
: {
|
|
1226
|
+
flowId: data.flowId,
|
|
1227
|
+
providerId: data.providerId,
|
|
1228
|
+
// Its id when the server sends no name (one not restarted since an update).
|
|
1229
|
+
providerName: data.providerName ?? data.providerId,
|
|
1230
|
+
events: [],
|
|
1231
|
+
prompt: null,
|
|
1232
|
+
done: null,
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
if (data.step === "prompt") {
|
|
1236
|
+
return { auth: { ...flow, prompt: { id: data.promptId, ...data.prompt } } };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
if (data.step === "prompt-closed") {
|
|
1240
|
+
return { auth: flow.prompt?.id === data.promptId ? { ...flow, prompt: null } : flow };
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (data.step === "event") {
|
|
1244
|
+
return { auth: { ...flow, events: [...flow.events, data.event] } };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (data.step === "done") {
|
|
1248
|
+
if (data.ok) {
|
|
1249
|
+
notify("info", `Signed in to ${data.providerName ?? data.providerId}.`);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
return { auth: data.ok ? null : { ...flow, prompt: null, done: data } };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
return {};
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* A sign-in's question was answered, and the server took the answer. The server's next words may have come first (the
|
|
1261
|
+
* next question, or the end, which closes the dialog or shows why it failed): the dialog waits for the provider only
|
|
1262
|
+
* while it still shows that sign-in asking that question.
|
|
1263
|
+
*/
|
|
1264
|
+
export function signInAnswered(flowId, promptId) {
|
|
1265
|
+
store.set((state) =>
|
|
1266
|
+
state.auth?.flowId === flowId && state.auth.prompt?.id === promptId
|
|
1267
|
+
? { auth: { ...state.auth, prompt: null } }
|
|
1268
|
+
: {},
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** A sign-in's dialog was closed: it stays closed, whatever the server still says about that sign-in. */
|
|
1273
|
+
export function closeSignIn(flowId) {
|
|
1274
|
+
endedSignIns.add(flowId);
|
|
1275
|
+
store.set((state) => (state.auth?.flowId === flowId ? { auth: null } : {}));
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
export function navigate(conversationId, { replace = false, sheet = null } = {}) {
|
|
1279
|
+
const path = conversationId === null ? "/" : `/s/${conversationId}`;
|
|
1280
|
+
|
|
1281
|
+
if (location.pathname !== path || store.state.conversationId !== conversationId) {
|
|
1282
|
+
pushRoute(path, { replace });
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// The subagents board and extensions' pages show in the conversation's place: going to one, even the one under it,
|
|
1286
|
+
// closes them.
|
|
1287
|
+
if (store.state.board || store.state.page) {
|
|
1288
|
+
store.set({ board: false, boardAgents: null, page: null });
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Its row tapped again counts too.
|
|
1292
|
+
opened(conversationId);
|
|
1293
|
+
|
|
1294
|
+
if (store.state.conversationId === conversationId && source) {
|
|
1295
|
+
if (sheet) {
|
|
1296
|
+
openSheet(sheet);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
store.set((state) => ({
|
|
1303
|
+
conversationId,
|
|
1304
|
+
view: emptyView(),
|
|
1305
|
+
...peopleFor(conversationId),
|
|
1306
|
+
browser: null,
|
|
1307
|
+
history: null,
|
|
1308
|
+
transcriptFrom: null,
|
|
1309
|
+
missing: null,
|
|
1310
|
+
drawer: false,
|
|
1311
|
+
...sheetChange(sheet, state),
|
|
1312
|
+
}));
|
|
1313
|
+
connect();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Back or forward to another route. Back that only closes a layer (a sheet, a panel) is back.js's.
|
|
1317
|
+
onRoute(() => {
|
|
1318
|
+
const id = routeConversation();
|
|
1319
|
+
|
|
1320
|
+
if (id !== store.state.conversationId) {
|
|
1321
|
+
opened(id);
|
|
1322
|
+
store.set({
|
|
1323
|
+
conversationId: id,
|
|
1324
|
+
view: emptyView(),
|
|
1325
|
+
...peopleFor(id),
|
|
1326
|
+
browser: null,
|
|
1327
|
+
history: null,
|
|
1328
|
+
transcriptFrom: null,
|
|
1329
|
+
missing: null,
|
|
1330
|
+
sheet: null,
|
|
1331
|
+
drawer: false,
|
|
1332
|
+
});
|
|
1333
|
+
connect();
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
export async function start() {
|
|
1338
|
+
try {
|
|
1339
|
+
const asked = moment();
|
|
1340
|
+
const hello = await api("me");
|
|
1341
|
+
|
|
1342
|
+
store.set({
|
|
1343
|
+
me: hello.user,
|
|
1344
|
+
users: hello.users,
|
|
1345
|
+
models: hello.models,
|
|
1346
|
+
guard: hello.guard,
|
|
1347
|
+
server: keptServer(store.state.server, hello.server, asked),
|
|
1348
|
+
themeList: Array.isArray(hello.themes) ? hello.themes : [],
|
|
1349
|
+
});
|
|
1350
|
+
connect();
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
store.set({ me: error.status === 401 ? null : undefined });
|
|
1353
|
+
|
|
1354
|
+
if (error.status !== 401) {
|
|
1355
|
+
notify("error", `Server unreachable: ${error.message}`);
|
|
1356
|
+
setTimeout(start, 3000);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// ─── Commands ───────────────────────────────────────────────────────────
|
|
1362
|
+
|
|
1363
|
+
const current = () => store.state.conversationId;
|
|
1364
|
+
|
|
1365
|
+
export const actions = {
|
|
1366
|
+
/** `inlineFiles`: the files the message mentions with @ go along with it. */
|
|
1367
|
+
submit: (text, attachments, mode, inlineFiles = false) =>
|
|
1368
|
+
api(`c/${current()}/submit`, {
|
|
1369
|
+
text,
|
|
1370
|
+
attachments,
|
|
1371
|
+
mode,
|
|
1372
|
+
requestId: uid(),
|
|
1373
|
+
...(inlineFiles ? { inlineFiles } : {}),
|
|
1374
|
+
}),
|
|
1375
|
+
/** Run a command in the session's folder (`!`); `context: false` keeps it from Pi (`!!`). */
|
|
1376
|
+
shell: (command, context, requestId = uid()) =>
|
|
1377
|
+
api(`c/${current()}/shell`, { command, context, requestId }),
|
|
1378
|
+
stopShell: (taskId) => api(`c/${current()}/shell/${taskId}/stop`, {}),
|
|
1379
|
+
/** Run an extension's command; sending the same request id again finds the first run. */
|
|
1380
|
+
extensionCommand: (commandId, args, requestId = uid()) =>
|
|
1381
|
+
api(`c/${current()}/extension-commands`, { commandId, args, requestId }),
|
|
1382
|
+
/** A file or folder for the viewer. */
|
|
1383
|
+
view: (path) => api(`c/${current()}/view?path=${encodeURIComponent(path)}`),
|
|
1384
|
+
/** What changed in the session's folder: git's uncommitted changes, and Pi's edits. */
|
|
1385
|
+
changes: (id = current()) => api(`c/${id}/changes`),
|
|
1386
|
+
branches: () => api(`c/${current()}/branches`),
|
|
1387
|
+
switchBranch: (target) => api(`c/${current()}/branch`, target),
|
|
1388
|
+
/** Undo the uncommitted changes to one file. */
|
|
1389
|
+
revert: (path) => api(`c/${current()}/changes/revert`, { path }),
|
|
1390
|
+
abort: () => api(`c/${current()}/abort`, {}),
|
|
1391
|
+
/** Send the request of a run that waits for its provider's limit now, not at its time. */
|
|
1392
|
+
retryLimit: (id = current()) => api(`c/${id}/limit/retry`, {}),
|
|
1393
|
+
chat: (text, quote) =>
|
|
1394
|
+
api(`c/${current()}/chat`, {
|
|
1395
|
+
text,
|
|
1396
|
+
requestId: uid(),
|
|
1397
|
+
...(quote ? { quote: { entryId: quote.entryId } } : {}),
|
|
1398
|
+
}),
|
|
1399
|
+
react: (entryId, emoji) => api(`c/${current()}/react`, { entryId, emoji }),
|
|
1400
|
+
pin: (target) => api(`c/${current()}/pin`, target),
|
|
1401
|
+
saveNotes: (text, rev) => api(`c/${current()}/notes`, { text, rev }),
|
|
1402
|
+
turns: (action, to) => api(`c/${current()}/turns`, { action, ...(to ? { to } : {}) }),
|
|
1403
|
+
setAccess: (userId, patch) => api(`users/${encodeURIComponent(userId)}`, patch),
|
|
1404
|
+
withdraw: (submissionId) => api(`c/${current()}/withdraw`, { submissionId }),
|
|
1405
|
+
/** Move one's own queued message to "steer" or "followUp", or send it now with "interrupt". */
|
|
1406
|
+
move: (submissionId, mode) => api(`c/${current()}/move`, { submissionId, mode }),
|
|
1407
|
+
editQueued: (submissionId) => api(`c/${current()}/edit-queued`, { submissionId }),
|
|
1408
|
+
configure: (change) => api(`c/${current()}/configure`, change),
|
|
1409
|
+
/** The owner's model and thinking level for new sessions, `{ provider, modelId, thinkingLevel }`; null clears it. */
|
|
1410
|
+
setDefaultModel: (choice) => api("settings", { defaultModel: choice }),
|
|
1411
|
+
compact: (instructions) => api(`c/${current()}/compact`, { instructions }),
|
|
1412
|
+
reset: (note) => api(`c/${current()}/reset`, { note }),
|
|
1413
|
+
setInstructions: (text) => api(`c/${current()}/instructions`, { text }),
|
|
1414
|
+
/** A new session with this one's history through `entryId`. */
|
|
1415
|
+
fork: (entryId, { worktree = false } = {}) =>
|
|
1416
|
+
api(`c/${current()}/fork`, { entryId, ...(worktree ? { worktree } : {}) }),
|
|
1417
|
+
forkSession: (id) => api(`c/${id}/fork`, {}),
|
|
1418
|
+
/** Send a message to Pi again in a fork that ends just before it: `{ text }` edits it, `{ model }` picks another model. */
|
|
1419
|
+
resend: (entryId, change = {}) => api(`c/${current()}/resend`, { entryId, ...change }),
|
|
1420
|
+
setPlan: (on) => api(`c/${current()}/plan`, { on }),
|
|
1421
|
+
approvePlan: () => api(`c/${current()}/plan`, { approve: true }),
|
|
1422
|
+
prompts: () => api(`c/${current()}/prompts`),
|
|
1423
|
+
trust: () => api(`c/${current()}/trust`),
|
|
1424
|
+
/** `trust`, `trust-parent`, or `distrust`, as Pi's `/trust` offers them. */
|
|
1425
|
+
setTrust: (choice) => api(`c/${current()}/trust`, { choice }),
|
|
1426
|
+
/** Pi's sessions from the terminal on this machine; one of them to look at; and one continued as a new session. */
|
|
1427
|
+
piSessions: (query = "") =>
|
|
1428
|
+
api(query === "" ? "pi-sessions" : `pi-sessions?q=${encodeURIComponent(query)}`),
|
|
1429
|
+
piSession: (path) => api(`pi-sessions/preview?path=${encodeURIComponent(path)}`),
|
|
1430
|
+
continuePiSession: (path) => api("pi-sessions/continue", { path }),
|
|
1431
|
+
/** `when` says when, then what Pi gets: `in 2h check the deploy`. Clock times are this device's. */
|
|
1432
|
+
schedule: (when) =>
|
|
1433
|
+
api(`c/${current()}/schedules`, {
|
|
1434
|
+
when,
|
|
1435
|
+
zone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
1436
|
+
}),
|
|
1437
|
+
cancelSchedule: (id) => api(`c/${current()}/schedules/${encodeURIComponent(id)}/cancel`, {}),
|
|
1438
|
+
setGoal: (command) => api(`c/${current()}/goal`, { command }),
|
|
1439
|
+
removeWorktree: (force) => api(`c/${current()}/worktree`, { remove: true, force }),
|
|
1440
|
+
clearGoal: () => api(`c/${current()}/goal`, { clear: true }),
|
|
1441
|
+
/** Watch the session's pull request: its branch's, or `pr` (a number or address). */
|
|
1442
|
+
watchPr: (pr) => api(`c/${current()}/watch`, pr ? { pr } : {}),
|
|
1443
|
+
unwatchPr: () => api(`c/${current()}/watch`, { stop: true }),
|
|
1444
|
+
/** Allow or deny a call; an ask (`host.ask`) is allowed with what was typed in its fields. */
|
|
1445
|
+
approve: (id, allow, values) =>
|
|
1446
|
+
api(`approvals/${encodeURIComponent(id)}`, { allow, ...(values ? { values } : {}) }),
|
|
1447
|
+
/** `worktree`: the session works in a git worktree of its own, made from the folder. */
|
|
1448
|
+
createSession: (cwd, { worktree = false } = {}) =>
|
|
1449
|
+
api("sessions", { cwd, ...(worktree ? { worktree } : {}) }),
|
|
1450
|
+
updateSession: (id, patch) => api(`sessions/${id}`, patch),
|
|
1451
|
+
upload: (file) => api(`c/${current()}/upload?name=${encodeURIComponent(file.name)}`, file),
|
|
1452
|
+
fullEntry: (entryId) => api(`c/${current()}/entry/${entryId}`),
|
|
1453
|
+
/** Show a path in the server machine's file manager: the owner's only. */
|
|
1454
|
+
reveal: (path) => api(`c/${current()}/reveal`, { path }),
|
|
1455
|
+
history: (before) => api(`c/${current()}/history?before=${before}`),
|
|
1456
|
+
context: (before, query) => api(`c/${current()}/context?before=${before}&${query}`),
|
|
1457
|
+
};
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Where the entries this tab has begin in the session's order: a view brings only the newest (protocol 4), and earlier
|
|
1461
|
+
* ones come as they are shown (`loadEarlier`). 0 when it has all of them.
|
|
1462
|
+
*/
|
|
1463
|
+
export function loadedFrom(view) {
|
|
1464
|
+
let at = view.order.length;
|
|
1465
|
+
|
|
1466
|
+
while (at > 0 && view.entries.has(view.order[at - 1])) {
|
|
1467
|
+
at--;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
return at;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Whether the transcript can draw an entry: it is in the unbroken run of entries at the end of the order. An entry kept
|
|
1475
|
+
* from before a gap (a reconnect after many new rows, or a compaction) is held but not drawn.
|
|
1476
|
+
*/
|
|
1477
|
+
export function isLoaded(view, entryId) {
|
|
1478
|
+
const at = view.order.indexOf(entryId);
|
|
1479
|
+
|
|
1480
|
+
return at !== -1 && at >= loadedFrom(view);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/** One request for earlier entries at a time, per conversation: its promise, which later callers share. */
|
|
1484
|
+
let loading = null;
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Load entries of the active context before the ones this tab has: `rows` rows more (with their turn), back to the entry
|
|
1488
|
+
* `from`, or all of them with neither. Resolves once they are in the view; a view of another conversation by then, or a
|
|
1489
|
+
* new order without them (a compaction), takes nothing.
|
|
1490
|
+
*/
|
|
1491
|
+
export function loadEarlier({ rows, from } = {}) {
|
|
1492
|
+
const { view } = store.state;
|
|
1493
|
+
const conversationId = view.conversation?.id;
|
|
1494
|
+
const start = loadedFrom(view);
|
|
1495
|
+
|
|
1496
|
+
if (conversationId === undefined || start === 0) {
|
|
1497
|
+
return Promise.resolve();
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
if (from !== undefined && isLoaded(view, from)) {
|
|
1501
|
+
return Promise.resolve();
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (loading?.conversationId === conversationId) {
|
|
1505
|
+
// One under way may not reach far enough: then this one asks again for the rest.
|
|
1506
|
+
return loading.promise.then(() => loadEarlier({ rows, from }));
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const before = view.order[start];
|
|
1510
|
+
const query = from !== undefined ? `from=${from}` : rows !== undefined ? `rows=${rows}` : "";
|
|
1511
|
+
const promise = actions
|
|
1512
|
+
.context(before, query)
|
|
1513
|
+
.then((earlier) =>
|
|
1514
|
+
store.set((state) => {
|
|
1515
|
+
if (
|
|
1516
|
+
state.view.conversation?.id !== conversationId ||
|
|
1517
|
+
!state.view.order.includes(before)
|
|
1518
|
+
) {
|
|
1519
|
+
return {};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const keep = new Set(state.view.order);
|
|
1523
|
+
const entries = new Map();
|
|
1524
|
+
|
|
1525
|
+
for (const entry of earlier) {
|
|
1526
|
+
if (keep.has(entry.id)) {
|
|
1527
|
+
entries.set(entry.id, entry);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// What the tab has wins, except a light one (`partial`): the one loaded is whole.
|
|
1532
|
+
for (const [id, entry] of state.view.entries) {
|
|
1533
|
+
if (!entry.partial || !entries.has(id)) {
|
|
1534
|
+
entries.set(id, entry);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
return { view: { ...state.view, entries } };
|
|
1539
|
+
}),
|
|
1540
|
+
)
|
|
1541
|
+
.finally(() => {
|
|
1542
|
+
if (loading?.promise === promise) {
|
|
1543
|
+
loading = null;
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
loading = { conversationId, promise };
|
|
1548
|
+
|
|
1549
|
+
return promise;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
/** The kinds of entries that show as transcript rows. */
|
|
1553
|
+
const ROW_KINDS = new Set([
|
|
1554
|
+
"user",
|
|
1555
|
+
"assistant",
|
|
1556
|
+
"compaction",
|
|
1557
|
+
"reset",
|
|
1558
|
+
"shell",
|
|
1559
|
+
"command",
|
|
1560
|
+
"note",
|
|
1561
|
+
"fromPi",
|
|
1562
|
+
]);
|
|
1563
|
+
|
|
1564
|
+
/** Entries that show as transcript rows. Tool results show inside their call's card instead. */
|
|
1565
|
+
export const isRow = (entry) => ROW_KINDS.has(entry.kind);
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Show the transcript from this entry down when it is above the rows shown now, as for a jump to a pinned or quoted
|
|
1569
|
+
* message. Compares places in the transcript, not ids: after a compaction, the summary comes first with the newest id.
|
|
1570
|
+
*/
|
|
1571
|
+
export function revealEntry(entryId) {
|
|
1572
|
+
const { view, transcriptFrom } = store.state;
|
|
1573
|
+
|
|
1574
|
+
if (transcriptFrom === null) {
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const ids = view.order.filter((id) => {
|
|
1579
|
+
const entry = view.entries.get(id);
|
|
1580
|
+
|
|
1581
|
+
return entry !== undefined && isRow(entry);
|
|
1582
|
+
});
|
|
1583
|
+
const at = ids.indexOf(entryId);
|
|
1584
|
+
const from = ids.indexOf(transcriptFrom);
|
|
1585
|
+
|
|
1586
|
+
if (at !== -1 && (from === -1 || at < from)) {
|
|
1587
|
+
store.set({ transcriptFrom: entryId });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// ─── The People panel ──────────────────────────────────────────────────────────────
|
|
1592
|
+
|
|
1593
|
+
const PEOPLE_KEY = "pocket.people";
|
|
1594
|
+
/** Wide enough for the session list, the conversation, and a panel beside it: where the Browser panel docks too. */
|
|
1595
|
+
const PEOPLE_DOCK = matchMedia("(min-width: 1100px)");
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Panels (People, Browser, Files) dock beside the conversation now; narrower, the Browser panel and the Files tile cover
|
|
1599
|
+
* the screen, and back closes them. A change re-renders (below).
|
|
1600
|
+
*/
|
|
1601
|
+
export const panelsBeside = () => PEOPLE_DOCK.matches;
|
|
1602
|
+
|
|
1603
|
+
/** The People panel shows beside the conversation now. */
|
|
1604
|
+
export function peopleDocked(state = store.state) {
|
|
1605
|
+
return (
|
|
1606
|
+
state.peopleOpen &&
|
|
1607
|
+
PEOPLE_DOCK.matches &&
|
|
1608
|
+
Boolean(state.server?.chat) &&
|
|
1609
|
+
state.conversationId !== null &&
|
|
1610
|
+
!state.missing
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* What showing a sheet changes. On wide screens the chat docks beside the conversation instead, as the People panel, and
|
|
1616
|
+
* takes the Browser panel's place: both beside the conversation leave it too narrow.
|
|
1617
|
+
*/
|
|
1618
|
+
function sheetChange(sheet, state) {
|
|
1619
|
+
if (sheet?.type !== "chat" || !PEOPLE_DOCK.matches) {
|
|
1620
|
+
return { sheet };
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
sessionStorage.setItem(PEOPLE_KEY, "1");
|
|
1624
|
+
sessionStorage.removeItem("pocket.browser");
|
|
1625
|
+
sessionStorage.removeItem("pocket.files");
|
|
1626
|
+
sessionStorage.removeItem(EXT_PANEL_KEY);
|
|
1627
|
+
|
|
1628
|
+
return {
|
|
1629
|
+
sheet: null,
|
|
1630
|
+
peopleOpen: true,
|
|
1631
|
+
browserOpen: false,
|
|
1632
|
+
filesOpen: false,
|
|
1633
|
+
extPanel: null,
|
|
1634
|
+
peopleAsk: {
|
|
1635
|
+
tab: sheet.tab ?? null,
|
|
1636
|
+
highlight: sheet.highlight ?? null,
|
|
1637
|
+
n: state.peopleAsk.n + 1,
|
|
1638
|
+
},
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* The Files tile shows now: asked for, in a conversation, for someone who can steer, and not in the Browser panel's
|
|
1644
|
+
* place (one panel at a time). `App` draws it by this, and `openFile` opens files in it by this.
|
|
1645
|
+
*/
|
|
1646
|
+
export function filesShown(state = store.state) {
|
|
1647
|
+
// `browserAvailable()` (browser.js), which imports this module.
|
|
1648
|
+
const browsing =
|
|
1649
|
+
state.browserOpen && state.server?.extensions?.includes("pocket-browser") === true;
|
|
1650
|
+
|
|
1651
|
+
return (
|
|
1652
|
+
state.filesOpen &&
|
|
1653
|
+
state.conversationId !== null &&
|
|
1654
|
+
!state.missing &&
|
|
1655
|
+
state.me?.role !== "viewer" &&
|
|
1656
|
+
!browsing
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/** Hide the People panel. Showing it is `openSheet({ type: "chat" })`, which docks it on wide screens. */
|
|
1661
|
+
export function closePeople() {
|
|
1662
|
+
sessionStorage.removeItem(PEOPLE_KEY);
|
|
1663
|
+
|
|
1664
|
+
// Its active border goes to the conversation, not nowhere.
|
|
1665
|
+
if (document.documentElement.dataset.focus === "people-panel") {
|
|
1666
|
+
document.documentElement.dataset.focus = "pane";
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
store.set({ peopleOpen: false });
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
let chatLogs = 0;
|
|
1673
|
+
|
|
1674
|
+
/** A chat log is on screen, as the People sheet's or panel's Chat tab: call when it shows, and what it returns when it goes. */
|
|
1675
|
+
export function chatLogShown() {
|
|
1676
|
+
chatLogs++;
|
|
1677
|
+
|
|
1678
|
+
return () => {
|
|
1679
|
+
chatLogs--;
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// A window widened with the chat open as a sheet docks it. One narrowed hides the panel until it is wide again.
|
|
1684
|
+
PEOPLE_DOCK.addEventListener("change", () => {
|
|
1685
|
+
const { sheet } = store.state;
|
|
1686
|
+
|
|
1687
|
+
if (PEOPLE_DOCK.matches && sheet?.type === "chat") {
|
|
1688
|
+
openSheet(sheet);
|
|
1689
|
+
} else {
|
|
1690
|
+
store.set({});
|
|
1691
|
+
}
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
export function openSheet(sheet) {
|
|
1695
|
+
store.set((state) => ({ ...sheetChange(sheet, state), drawer: false }));
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Put text into the message box to Pi (after what is there: on a new paragraph, or with `inline`, after a space), and
|
|
1700
|
+
* files to attach, and close any sheet so it shows.
|
|
1701
|
+
*/
|
|
1702
|
+
export function insertIntoComposer(text, files = [], { inline = false } = {}) {
|
|
1703
|
+
store.set((state) => ({
|
|
1704
|
+
sheet: null,
|
|
1705
|
+
composerInsert: { text, files, inline, n: (state.composerInsert?.n ?? 0) + 1 },
|
|
1706
|
+
}));
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
export function quoteReply(text) {
|
|
1710
|
+
store.set({ replyQuote: text });
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/** Open the chat to discuss a transcript message: the next chat message quotes it. */
|
|
1714
|
+
export function discuss(entryId, text) {
|
|
1715
|
+
store.set((state) => ({
|
|
1716
|
+
chatQuote: { entryId, text },
|
|
1717
|
+
...sheetChange({ type: "chat" }, state),
|
|
1718
|
+
drawer: false,
|
|
1719
|
+
}));
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// Tell the server when this tab is hidden or shown: others see "away", and push notifications only reach hidden tabs.
|
|
1723
|
+
let lastVisible = null;
|
|
1724
|
+
|
|
1725
|
+
function reportVisibility() {
|
|
1726
|
+
const visible = document.visibilityState === "visible";
|
|
1727
|
+
|
|
1728
|
+
if (visible === lastVisible || !collab()) {
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
lastVisible = visible;
|
|
1733
|
+
api("visibility", { tab: TAB, visible }).catch(() => {});
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
document.addEventListener("visibilitychange", reportVisibility);
|
|
1737
|
+
store.subscribe((state) => {
|
|
1738
|
+
// Report again after each (re)connect: the server starts every new connection as visible.
|
|
1739
|
+
if (state.connection !== "open") {
|
|
1740
|
+
lastVisible = null;
|
|
1741
|
+
} else if (lastVisible === null && document.visibilityState !== "visible") {
|
|
1742
|
+
reportVisibility();
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
export function closeSheet() {
|
|
1747
|
+
store.set({ sheet: null });
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Per-conversation composer drafts survive reloads, including the automatic ones after a live edit.
|
|
1751
|
+
export const drafts = {
|
|
1752
|
+
get: (id) => localStorage.getItem(`pocket.draft.${id ?? "home"}`) ?? "",
|
|
1753
|
+
set: (id, text) => {
|
|
1754
|
+
if (text) {
|
|
1755
|
+
localStorage.setItem(`pocket.draft.${id ?? "home"}`, text);
|
|
1756
|
+
} else {
|
|
1757
|
+
localStorage.removeItem(`pocket.draft.${id ?? "home"}`);
|
|
1758
|
+
}
|
|
1759
|
+
},
|
|
1760
|
+
};
|