@oharness/server 0.7.1 → 0.8.0

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.
Files changed (61) hide show
  1. package/dist/browser-pane.d.ts +48 -0
  2. package/dist/browser-pane.js +185 -0
  3. package/dist/browser-pane.js.map +1 -0
  4. package/dist/i18n.js +132 -0
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/main.js +20 -0
  7. package/dist/main.js.map +1 -1
  8. package/dist/page/client/account.d.ts +8 -0
  9. package/dist/page/client/account.js +1895 -0
  10. package/dist/page/client/account.js.map +1 -0
  11. package/dist/page/client/artifacts.d.ts +7 -0
  12. package/dist/page/client/artifacts.js +767 -0
  13. package/dist/page/client/artifacts.js.map +1 -0
  14. package/dist/page/client/base.d.ts +8 -0
  15. package/dist/page/client/base.js +400 -0
  16. package/dist/page/client/base.js.map +1 -0
  17. package/dist/page/client/browser.d.ts +21 -0
  18. package/dist/page/client/browser.js +904 -0
  19. package/dist/page/client/browser.js.map +1 -0
  20. package/dist/page/client/composer.d.ts +8 -0
  21. package/dist/page/client/composer.js +1694 -0
  22. package/dist/page/client/composer.js.map +1 -0
  23. package/dist/page/client/dialogs.d.ts +7 -0
  24. package/dist/page/client/dialogs.js +724 -0
  25. package/dist/page/client/dialogs.js.map +1 -0
  26. package/dist/page/client/events.d.ts +7 -0
  27. package/dist/page/client/events.js +288 -0
  28. package/dist/page/client/events.js.map +1 -0
  29. package/dist/page/client/index.d.ts +16 -0
  30. package/dist/page/client/index.js +46 -0
  31. package/dist/page/client/index.js.map +1 -0
  32. package/dist/page/client/panels.d.ts +7 -0
  33. package/dist/page/client/panels.js +659 -0
  34. package/dist/page/client/panels.js.map +1 -0
  35. package/dist/page/client/pickers.d.ts +7 -0
  36. package/dist/page/client/pickers.js +624 -0
  37. package/dist/page/client/pickers.js.map +1 -0
  38. package/dist/page/client/routines.d.ts +6 -0
  39. package/dist/page/client/routines.js +910 -0
  40. package/dist/page/client/routines.js.map +1 -0
  41. package/dist/page/client/shell.d.ts +6 -0
  42. package/dist/page/client/shell.js +690 -0
  43. package/dist/page/client/shell.js.map +1 -0
  44. package/dist/page/client/state.d.ts +7 -0
  45. package/dist/page/client/state.js +1275 -0
  46. package/dist/page/client/state.js.map +1 -0
  47. package/dist/page/client/transcript.d.ts +8 -0
  48. package/dist/page/client/transcript.js +699 -0
  49. package/dist/page/client/transcript.js.map +1 -0
  50. package/dist/page/client/usage.d.ts +7 -0
  51. package/dist/page/client/usage.js +954 -0
  52. package/dist/page/client/usage.js.map +1 -0
  53. package/dist/page/style.d.ts +1 -0
  54. package/dist/page/style.js +2793 -0
  55. package/dist/page/style.js.map +1 -0
  56. package/dist/page.js +62 -13871
  57. package/dist/page.js.map +1 -1
  58. package/dist/server.d.ts +24 -0
  59. package/dist/server.js +349 -22
  60. package/dist/server.js.map +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,1275 @@
1
+ /**
2
+ * Session state: what the server says is true, the sidebar list it paints,
3
+ * and the row menu.
4
+ *
5
+ * One part of the client script; see ./index.ts for how the parts join.
6
+ */
7
+ export const STATE = String.raw `
8
+ // --- state ----------------------------------------------------------------
9
+
10
+ let currentCwd = "";
11
+
12
+ /**
13
+ * A token count at the precision anyone reads one at.
14
+ *
15
+ * Exact below a thousand, because early in a session the difference between
16
+ * 300 and 900 is the whole story. Rounded after that: nobody is checking the
17
+ * hundreds column of a number that changes every turn, and a line under the
18
+ * composer that grows a digit every message is a line that moves.
19
+ */
20
+ function formatTokens(n) {
21
+ if (n < 1000) return String(n);
22
+ if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + "k";
23
+ return (n / 1000000).toFixed(1) + "M";
24
+ }
25
+
26
+ /**
27
+ * How many tokens this session has moved, and how many of them were free.
28
+ *
29
+ * The cache figure is a share of what went *in* — reads over reads plus
30
+ * writes plus fresh input — because that is the number someone can act on: a
31
+ * low one means the prompt is being rebuilt every turn and the bill says so.
32
+ * Output is not in that denominator; nothing generated can be cached.
33
+ *
34
+ * Both stay empty until there is something to say. A session that has not
35
+ * sent a message yet showing "0 tokens · 0% cached" reads as a failure.
36
+ */
37
+ function paintTokens(usage) {
38
+ const u = usage || {};
39
+ const sent = (u.input || 0) + (u.cacheRead || 0) + (u.cacheWrite || 0);
40
+ const all = sent + (u.output || 0);
41
+ tokensEl.textContent = all ? t("usage.tokens", { n: formatTokens(all) }) : "";
42
+ cacheEl.textContent = u.cacheRead
43
+ ? t("usage.cached", { pct: Math.round((u.cacheRead / sent) * 100) })
44
+ : "";
45
+ }
46
+
47
+ function applyState(s) {
48
+ if (!s) return;
49
+ if (s.model) modelPicker.set(modelPicker.options || [], s.model);
50
+ if (s.mode) modePicker.set(MODES, s.mode);
51
+ effortPicker.set(s);
52
+ if (s.cwd) {
53
+ // Kept so the approval dialog can show a path the way the user thinks of
54
+ // it — src/web/page.ts, not the whole absolute path they already know.
55
+ currentCwd = s.cwd.replace(/\/+$/, "");
56
+ // The transcript is drawn before the state arrives, so a chip painted then
57
+ // still names the tree of the tab this one replaced.
58
+ refreshWorkspace();
59
+ }
60
+ metaEl.textContent = s.costUsd > 0 ? "$" + s.costUsd.toFixed(4) : "";
61
+ paintTokens(s.usage);
62
+ const meta = document.getElementById("meta");
63
+ // A meter reading 0% is noise: it says nothing and takes the eye.
64
+ // Shown from half a window on, not from half a percent. Below that there is
65
+ // nothing to act on, and a bar sitting at 2% all day is a permanent piece of
66
+ // furniture that says the same thing every time you look at it.
67
+ meta.classList.toggle("has-ctx", typeof s.contextUsed === "number" && s.contextUsed >= 0.5);
68
+ if (typeof s.contextUsed === "number") {
69
+ const pct = Math.round(s.contextUsed * 100);
70
+ ctxBar.style.width = pct + "%";
71
+ ctxBar.style.background = pct >= 90 ? "var(--err)" : pct >= 70 ? "var(--warn)" : "var(--ok)";
72
+ ctxText.textContent = pct + "%";
73
+ }
74
+ setBusy(Boolean(s.busy));
75
+ // Addressed by session: a reload and a tab switch both land here, and the
76
+ // pane this state describes is not always the one an event last touched.
77
+ if (s.sessionId) pane(s.sessionId).queued = s.queued || [];
78
+ renderQueued();
79
+ // The state knows the session's name; a tab opened before it had one would
80
+ // otherwise keep the placeholder until the page reloaded.
81
+ if (s.sessionId && s.title && pane(s.sessionId).title !== s.title) {
82
+ pane(s.sessionId).title = s.title;
83
+ paintTabs();
84
+ }
85
+ // The side panel keeps the conversation it shows in step with the person's
86
+ // tab groups, so it needs to know which one that is.
87
+ if (s.sessionId && s.sessionId === activeId) embedTell({ type: "session", id: s.sessionId, cwd: currentCwd });
88
+ }
89
+
90
+ let workingNode = null;
91
+ let workingSince = 0;
92
+ let workingTimer = null;
93
+ let workingLabel = "Thinking";
94
+
95
+ /**
96
+ * The gap between sending and the first token.
97
+ *
98
+ * A model can spend twenty seconds reasoning before it emits anything, and
99
+ * until now the page showed nothing at all in that window — indistinguishable
100
+ * from a request that never left. The row appears immediately and reports what
101
+ * the agent is doing and for how long.
102
+ */
103
+ function startWorking(label) {
104
+ workingLabel = label || t("work.thinking");
105
+ if (workingNode) return void paintWorking();
106
+ workingSince = Date.now();
107
+
108
+ workingNode = el("div", "working");
109
+ workingNode.appendChild(el("span", "spinner"));
110
+ workingNode.appendChild(el("span", "working-label"));
111
+ workingNode.appendChild(el("span", "working-time"));
112
+ assistantTurn().appendChild(workingNode);
113
+
114
+ workingTimer = setInterval(paintWorking, 1000);
115
+ paintWorking();
116
+ follow();
117
+ }
118
+
119
+ function paintWorking() {
120
+ if (!workingNode) return;
121
+ const seconds = Math.round((Date.now() - workingSince) / 1000);
122
+ workingNode.querySelector(".working-label").textContent = workingLabel + "…";
123
+ // Elapsed time, because "is it stuck?" is the question this row answers.
124
+ workingNode.querySelector(".working-time").textContent = seconds >= 2 ? seconds + "s" : "";
125
+ }
126
+
127
+ function stopWorking() {
128
+ if (workingTimer) clearInterval(workingTimer);
129
+ workingTimer = null;
130
+ if (workingNode && workingNode.parentNode) workingNode.remove();
131
+ workingNode = null;
132
+ }
133
+
134
+ function setBusy(busy) {
135
+ if (P) P.busy = busy;
136
+ paintTabs();
137
+ // While a turn runs, the send arrow becomes a stop square in the same spot.
138
+ sendBtn.disabled = busy;
139
+ sendBtn.hidden = busy;
140
+ stopBtn.hidden = !busy;
141
+ if (!busy) stopWorking();
142
+ // The row itself does not change, but whether the queue can be released by
143
+ // hand does, and that is drawn on the send button.
144
+ renderQueued();
145
+ }
146
+
147
+ /** "just now", "12m", "3h", "yesterday", "6 Aug" — scannable at a glance. */
148
+ function ago(ms) {
149
+ const seconds = Math.max(0, (Date.now() - ms) / 1000);
150
+ if (seconds < 60) return t("time.now");
151
+ if (seconds < 3600) return Math.floor(seconds / 60) + "m";
152
+ if (seconds < 86400) return Math.floor(seconds / 3600) + "h";
153
+ if (seconds < 172800) return t("time.yesterday");
154
+ return new Date(ms).toLocaleDateString(undefined, { day: "numeric", month: "short" });
155
+ }
156
+
157
+ /**
158
+ * Which projects the user opened or closed, remembered across reloads.
159
+ *
160
+ * Only what was actually clicked is stored, so a project nobody touched keeps
161
+ * following the default — the one being worked in is open, the rest are shut.
162
+ * A sidebar that reopens with every tree expanded makes closing one pointless,
163
+ * and one that reopens fully collapsed hides the sessions it exists to show.
164
+ */
165
+ const projectOpen = readProjectOpen();
166
+
167
+ function readProjectOpen() {
168
+ try {
169
+ const raw = JSON.parse(localStorage.getItem("oharness.projects") || "{}");
170
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
171
+ } catch {
172
+ return {};
173
+ }
174
+ }
175
+
176
+ function toggleProject(cwd, open) {
177
+ projectOpen[cwd] = !open;
178
+ try {
179
+ localStorage.setItem("oharness.projects", JSON.stringify(projectOpen));
180
+ } catch {
181
+ // Private browsing, a full quota: opening still works for this page load.
182
+ }
183
+ paintSessions();
184
+ }
185
+
186
+ /** The key the routine group's open state is kept under, beside the projects'. */
187
+ const ROUTINE_GROUP = "\u0000routines";
188
+
189
+ /**
190
+ * The header over routine runs. A fold like a project's, with the clock the
191
+ * Routines page uses, and none of a project's actions: there is no new session
192
+ * to start in it, and deleting runs is done row by row.
193
+ */
194
+ function routineHead(count, open) {
195
+ const head = el("button", "fold" + (open ? " open" : ""));
196
+ head.type = "button";
197
+ head.appendChild(navIcon("nav-routines"));
198
+ head.appendChild(el("span", "fold-name", t("session.routines")));
199
+ head.appendChild(el("span", "count", String(count)));
200
+ head.addEventListener("click", () => toggleProject(ROUTINE_GROUP, open));
201
+ return head;
202
+ }
203
+
204
+ /** A project header: a folder, a name, and how many sessions it holds. */
205
+ function projectHead(cwd, name, count, open) {
206
+ const head = el("button", "fold" + (open ? " open" : ""));
207
+ head.type = "button";
208
+ // The folder itself says which state it is in — it is the only thing on the
209
+ // row that can, now that there is no arrow beside it.
210
+ head.appendChild(svgIcon(open ? "folderOpen" : "folder"));
211
+ head.appendChild(el("span", "fold-name", name));
212
+ if ((projects.find((project) => project.path === cwd) || {}).pinned) {
213
+ head.appendChild(svgIcon("pin", "pin-mark"));
214
+ }
215
+ head.addEventListener("click", () => toggleProject(cwd, open));
216
+
217
+ // A fresh session in this project without switching to it first. Where the
218
+ // count used to sit: the count said how much was there, and this is the one
219
+ // thing people came to the header to do about it.
220
+ const add = el("button", "more fold-new");
221
+ add.type = "button";
222
+ add.title = t("nav.new");
223
+ add.setAttribute("aria-label", t("nav.new"));
224
+ add.appendChild(navIcon("new"));
225
+ add.addEventListener("click", (e) => {
226
+ e.stopPropagation();
227
+ void switchTo({ action: "new", cwd });
228
+ });
229
+
230
+ /*
231
+ * Deleting a project is deleting its transcripts, and it lives here rather
232
+ * than beside "remove from list" in the picker because this header is the
233
+ * only place the two can be told apart. The sidebar groups by the tree a
234
+ * session was recorded in, not by the list of projects — so forgetting a
235
+ * path leaves this header exactly where it was, and somebody who reached
236
+ * for "delete" and watched nothing happen would be right to think the app
237
+ * was broken. What removes a project from here is removing its sessions.
238
+ */
239
+ const more = el("button", "more");
240
+ more.type = "button";
241
+ more.title = t("session.more");
242
+ more.setAttribute("aria-label", t("projects.delete", { n: count }));
243
+ more.appendChild(svgIcon("more"));
244
+ more.addEventListener("click", (e) => {
245
+ e.stopPropagation();
246
+ if (menuRow === head) return closeRowMenu();
247
+ openMenu(more);
248
+ });
249
+ // The same menu under the pointer: a right-click on a project is asking for
250
+ // it, and the browser's own (Back, Reload, View Source) is about the page.
251
+ head.addEventListener("contextmenu", (e) => {
252
+ e.preventDefault();
253
+ e.stopPropagation();
254
+ openMenu(pointAnchor(e));
255
+ });
256
+
257
+ function openMenu(anchor) {
258
+ // Armed once before it fires, like the session row it borrows from: this
259
+ // is a modal's worth of consequence reached from a list row, and the
260
+ // consequence is spelled out where a dialog would put it.
261
+ const del = menuItem(t("projects.delete", { n: count }), { key: "d", danger: true, run: () => {
262
+ if (!del.classList.contains("armed")) {
263
+ del.classList.add("armed");
264
+ del.querySelector(".menu-label").textContent = t("projects.deleteConfirm");
265
+ rowMenu.replaceChildren(el("div", "menu-note", t("projects.deleteNote")), del);
266
+ placeRowMenu(anchor);
267
+ return;
268
+ }
269
+ closeRowMenu();
270
+ void deleteProject(cwd, name);
271
+ } });
272
+
273
+ const pinned = (projects.find((project) => project.path === cwd) || {}).pinned === true;
274
+ const items = [
275
+ menuItem(t("projects.open"), { key: "o", run: () => {
276
+ closeRowMenu();
277
+ void chooseProject(cwd);
278
+ } }),
279
+ menuItem(t("projects.rename"), { key: "n", run: () => {
280
+ closeRowMenu();
281
+ void renameProjectUi(cwd, name);
282
+ } }),
283
+ menuItem(t(IS_MAC ? "projects.revealMac" : IS_WIN ? "projects.revealWin" : "projects.reveal"), { key: "f", run: () => {
284
+ closeRowMenu();
285
+ void api("/reveal", { path: cwd }).then((body) => {
286
+ if (body && body.error) addNotice(body.error, "err");
287
+ });
288
+ } }),
289
+ menuItem(t("projects.export"), { key: "e", run: () => {
290
+ closeRowMenu();
291
+ exportProjectUi(cwd);
292
+ } }),
293
+ menuItem(t(pinned ? "projects.unpin" : "projects.pin"), { key: "p", run: () => {
294
+ closeRowMenu();
295
+ void togglePin("project", cwd, !pinned);
296
+ } }),
297
+ ];
298
+ // Forgetting a path is only offered where it can do something: this row
299
+ // stays on screen either way, because the sidebar groups by the tree a
300
+ // transcript was recorded in — but the switcher's list is the one place
301
+ // this project would stop appearing, and reaching that from here saves
302
+ // opening the switcher to find it.
303
+ if (projects.some((project) => project.path === cwd && !project.current)) {
304
+ items.push(menuItem(t("projects.forget"), { key: "r", run: () => {
305
+ closeRowMenu();
306
+ void api("/project", { path: cwd, remove: true }).then((body) => {
307
+ if (body && body.error) return void addNotice(body.error, "err");
308
+ addNotice(t("projects.forgot", { name }));
309
+ return loadProjects();
310
+ });
311
+ } }));
312
+ }
313
+ items.push(menuSep(), del);
314
+ openRowMenu(anchor, head, items);
315
+ }
316
+
317
+ // The header is a button, so the menu control cannot live inside it.
318
+ const wrap = el("div", "fold-row");
319
+ wrap.appendChild(head);
320
+ wrap.appendChild(add);
321
+ wrap.appendChild(more);
322
+
323
+ draggable(wrap, {
324
+ kind: "project",
325
+ key: cwd,
326
+ group: (projects.find((project) => project.path === cwd) || {}).pinned ? "pinned" : "",
327
+ siblings: () => [...sessionList.querySelectorAll(".fold-row")].map((row) => row.dataset.cwd),
328
+ commit: (paths) => {
329
+ projectOrder = paths;
330
+ paintSessions();
331
+ void api("/order", { kind: "project", ids: paths }).then((body) => {
332
+ if (body && body.error) addNotice(body.error, "err");
333
+ });
334
+ },
335
+ });
336
+ wrap.dataset.cwd = cwd;
337
+ return wrap;
338
+ }
339
+
340
+ /**
341
+ * Pin or unpin, and repaint from what the server confirms.
342
+ *
343
+ * The row is not moved optimistically: a pin changes where something sits in
344
+ * a list, and a row that jumped and then jumped back because the write failed
345
+ * is worse than one that waits for the answer it is about to reflect.
346
+ */
347
+ async function togglePin(kind, id, pinned) {
348
+ const body = await api("/pin", { kind, id, pinned });
349
+ if (body && body.error) return void addNotice(body.error, "err");
350
+ if (kind === "project") await loadProjects();
351
+ else await loadSessions();
352
+ }
353
+
354
+ /**
355
+ * Delete everything recorded about one tree.
356
+ *
357
+ * The tabs go with it for the same reason a deleted session's does: a pane
358
+ * pointing at a transcript that is gone accepts a prompt and fails on the
359
+ * first write. The server decides where the window lands afterwards and says
360
+ * so; the page follows rather than guessing, because only the server knows
361
+ * whether anywhere else was left to go.
362
+ */
363
+ async function deleteProject(cwd, name) {
364
+ const body = await api("/project", { path: cwd, purge: true });
365
+ // Said, but not returned on: deletion happens before the window is moved, so
366
+ // a failure here can still have thrown transcripts away. Leaving the stale
367
+ // rows on screen would be the one outcome worse than the error itself.
368
+ if (body.error) {
369
+ addNotice(body.error, "err");
370
+ sessionRows = sessionRows.filter((s) => s.cwd !== cwd);
371
+ paintSessions();
372
+ await loadSessions();
373
+ return void (await loadProjects());
374
+ }
375
+
376
+ const open = new Set(body.open || []);
377
+ for (const [id, pane] of [...panes]) {
378
+ if (open.has(id)) continue;
379
+ pane.node.remove();
380
+ panes.delete(id);
381
+ }
382
+ paintTabs();
383
+
384
+ if (body.state) await adoptSession(body.state);
385
+ addNotice(t("projects.deleted", { name, n: body.deleted }));
386
+
387
+ sessionRows = sessionRows.filter((s) => s.cwd !== cwd);
388
+ paintSessions();
389
+ await loadSessions();
390
+ await loadProjects();
391
+ }
392
+
393
+ let sessionRows = [];
394
+
395
+ async function loadSessions() {
396
+ const body = await getJson("/sessions");
397
+ sessionRows = body.sessions || [];
398
+ sessionOrder = body.order || {};
399
+ paintSessions();
400
+ }
401
+
402
+ /**
403
+ * How many sessions a project shows before it says there are more.
404
+ *
405
+ * Enough to recognise the work in progress, few enough that the project under
406
+ * it is still on screen — the sidebar is how every *other* project is reached,
407
+ * and one busy tree can hold dozens of transcripts.
408
+ */
409
+ const PREVIEW = 5;
410
+
411
+ /** Projects the user asked to see in full, for this page load. */
412
+ const showingAll = new Set();
413
+
414
+ /**
415
+ * Order somebody arranged by hand, and what they kept at the top.
416
+ *
417
+ * Read from the server with the lists themselves rather than kept only here:
418
+ * a sidebar that remembered an arrangement until the next reload would be
419
+ * offering a control that does not work, and the two windows a desktop app
420
+ * can have open would each hold their own idea of the order.
421
+ */
422
+ let sessionOrder = {};
423
+ let projectOrder = [];
424
+
425
+ /**
426
+ * Sort one list by pin, then by hand, then by whatever it came in as.
427
+ *
428
+ * The hand-made order is partial on purpose: it holds what was dragged, and a
429
+ * row nobody has touched keeps arriving where it belongs — which for sessions
430
+ * is the top, because the newest one is almost always the one being looked
431
+ * for. Ordering by an index taken once would send tomorrow's session to the
432
+ * bottom of a list arranged today.
433
+ */
434
+ function arrange(rows, keyOf, order, pinnedOf) {
435
+ const rank = new Map((order || []).map((key, i) => [key, i]));
436
+ const placed = (row) => (rank.has(keyOf(row)) ? rank.get(keyOf(row)) : Infinity);
437
+ return rows
438
+ .map((row, i) => ({ row, i }))
439
+ .sort((a, b) => {
440
+ const pin = Number(pinnedOf(b.row)) - Number(pinnedOf(a.row));
441
+ if (pin) return pin;
442
+ const hand = placed(a.row) - placed(b.row);
443
+ if (hand) return hand;
444
+ // Both untouched, or both dragged to the same place: whatever order the
445
+ // list already had, which is recency for sessions.
446
+ return a.i - b.i;
447
+ })
448
+ .map((entry) => entry.row);
449
+ }
450
+
451
+ /**
452
+ * The session list: by project, and by nothing else.
453
+ *
454
+ * Sessions are stored per user directory, not per project, so this list spans
455
+ * every tree opened here, and flat it reads another project's transcripts as
456
+ * this one's history. Age is not a second cut: the rows already carry their
457
+ * age, they arrive newest first, and date headings split one project's work
458
+ * across four of them to say what "2h" says on the row itself.
459
+ */
460
+ function paintSessions() {
461
+ // The rows are rebuilt, so the row an open menu points at is about to be
462
+ // replaced by a different node in a different place.
463
+ closeRowMenu();
464
+ sessionList.replaceChildren();
465
+
466
+ const active = sessionRows.find((s) => s.current);
467
+ const here = active ? active.cwd : (projects.find((p) => p.current) || {}).path;
468
+
469
+ // A routine's runs are kept together rather than mixed into the project's
470
+ // own work: they arrive on their own, often overnight, and the question
471
+ // someone opens them with is "what did my routines do", not "what was I
472
+ // doing in this project". Claude Code's desktop app draws the same line.
473
+ const scheduled = sessionRows.filter((s) => s.routine);
474
+ const byProject = new Map();
475
+ for (const s of sessionRows) {
476
+ if (s.routine) continue;
477
+ const key = s.cwd || "";
478
+ if (!byProject.has(key)) byProject.set(key, []);
479
+ byProject.get(key).push(s);
480
+ }
481
+
482
+ // The project being worked in comes first whether or not it holds the most
483
+ // recent session; the rest follow by how recently they were touched — and
484
+ // then a pin or a drag overrides both, because those were said out loud.
485
+ const pinnedProject = (cwd) => (projects.find((p) => p.path === cwd) || {}).pinned === true;
486
+ const order = arrange(
487
+ [...byProject.keys()].sort((a, b) => {
488
+ if (a === here) return -1;
489
+ if (b === here) return 1;
490
+ return byProject.get(b)[0].updatedAt - byProject.get(a)[0].updatedAt;
491
+ }),
492
+ (cwd) => cwd,
493
+ projectOrder,
494
+ pinnedProject,
495
+ );
496
+
497
+ // Everything here belongs to the project already named at the top of the
498
+ // sidebar: a header repeating it is noise, and the rows do not need
499
+ // indenting under a heading that is not there.
500
+ const named = !(order.length === 1 && order[0] === here) || scheduled.length > 0;
501
+
502
+ if (scheduled.length) {
503
+ const open = ROUTINE_GROUP in projectOpen ? projectOpen[ROUTINE_GROUP] === true : true;
504
+ sessionList.appendChild(routineHead(scheduled.length, open));
505
+ if (open) {
506
+ const all = showingAll.has(ROUTINE_GROUP) || scheduled.length <= PREVIEW + 1;
507
+ for (const s of all ? scheduled : scheduled.slice(0, PREVIEW)) {
508
+ sessionList.appendChild(sessionRow(s, true));
509
+ }
510
+ if (!all) sessionList.appendChild(showMore(ROUTINE_GROUP, true));
511
+ }
512
+ }
513
+
514
+ for (const cwd of order) {
515
+ const list = arrange(byProject.get(cwd), (s) => s.id, sessionOrder[cwd], (s) => s.pinned);
516
+ let open = true;
517
+ if (named) {
518
+ open = cwd in projectOpen ? projectOpen[cwd] === true : cwd === here;
519
+ sessionList.appendChild(projectHead(cwd, projectName(cwd), list.length, open));
520
+ }
521
+ if (!open) continue;
522
+
523
+ // A "Show more" that reveals one row costs a click to say what it already
524
+ // took a row to say, so the last one comes along rather than hiding.
525
+ // Pinned rows are never in the hidden tail: a pin that can be scrolled
526
+ // out of existence is not a pin.
527
+ const all = showingAll.has(cwd) || list.length <= PREVIEW + 1;
528
+ const shown = all
529
+ ? list
530
+ : list.filter((s, i) => i < PREVIEW || s.pinned);
531
+ for (const s of shown) sessionList.appendChild(sessionRow(s, named));
532
+ if (!all) sessionList.appendChild(showMore(cwd, named));
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Dragging a row to a new place in its list.
538
+ *
539
+ * One helper for both lists, because the two differ only in what a row is
540
+ * keyed by and where the new order is sent. A row can only be dropped among
541
+ * its own kind — and a session only within its own project, since a
542
+ * transcript belongs to the tree it was recorded in and moving it to another
543
+ * project's list would be claiming something untrue about where it ran.
544
+ *
545
+ * Pinned and unpinned are separate groups for the same reason: pinned rows
546
+ * sort first no matter what order they were given, so a drop that crossed the
547
+ * line would be recorded and then ignored. Nothing lights up there now, which
548
+ * is the truth — to move a row past the pinned ones, unpin them or pin it.
549
+ *
550
+ * The insertion point is drawn on the row being hovered rather than as a
551
+ * floating line: the list is rebuilt constantly, and a separate indicator
552
+ * element would have to be found and removed on every repaint.
553
+ */
554
+ let dragging = null;
555
+
556
+ function draggable(node, { kind, key, group, siblings, commit }) {
557
+ node.draggable = true;
558
+
559
+ node.addEventListener("dragstart", (e) => {
560
+ dragging = { kind, key, group };
561
+ node.classList.add("dragging");
562
+ // Firefox refuses to start a drag without data on the transfer.
563
+ if (e.dataTransfer) {
564
+ e.dataTransfer.effectAllowed = "move";
565
+ e.dataTransfer.setData("text/plain", key);
566
+ }
567
+ closeRowMenu();
568
+ });
569
+
570
+ node.addEventListener("dragend", () => {
571
+ dragging = null;
572
+ node.classList.remove("dragging");
573
+ for (const other of sessionList.querySelectorAll(".drop-before,.drop-after")) {
574
+ other.classList.remove("drop-before", "drop-after");
575
+ }
576
+ });
577
+
578
+ node.addEventListener("dragover", (e) => {
579
+ if (!dragging || dragging.kind !== kind || dragging.group !== group) return;
580
+ if (dragging.key === key) return;
581
+ e.preventDefault();
582
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
583
+ const box = node.getBoundingClientRect();
584
+ const after = e.clientY > box.top + box.height / 2;
585
+ node.classList.toggle("drop-before", !after);
586
+ node.classList.toggle("drop-after", after);
587
+ });
588
+
589
+ node.addEventListener("dragleave", () => {
590
+ node.classList.remove("drop-before", "drop-after");
591
+ });
592
+
593
+ node.addEventListener("drop", (e) => {
594
+ if (!dragging || dragging.kind !== kind || dragging.group !== group) return;
595
+ e.preventDefault();
596
+ const after = node.classList.contains("drop-after");
597
+ node.classList.remove("drop-before", "drop-after");
598
+ const moved = dragging.key;
599
+ if (moved === key) return;
600
+
601
+ // The whole list, not just the rows on screen: a hidden tail that
602
+ // reordered itself the moment it was revealed would be the same bug as
603
+ // not recording the order at all.
604
+ const next = siblings().filter((one) => one !== moved);
605
+ const at = next.indexOf(key);
606
+ next.splice(after ? at + 1 : at, 0, moved);
607
+ commit(next);
608
+ });
609
+ }
610
+
611
+ /** The row that unfolds the rest of a project's sessions. */
612
+ function showMore(cwd, nested) {
613
+ const more = el("button", "show-more" + (nested ? " nested" : ""), t("session.showMore"));
614
+ more.type = "button";
615
+ more.addEventListener("click", () => {
616
+ showingAll.add(cwd);
617
+ paintSessions();
618
+ });
619
+ return more;
620
+ }
621
+
622
+ /**
623
+ * The overflow menu, one node reused by every row.
624
+ *
625
+ * A menu per row would put hundreds of hidden popups in the sidebar, and each
626
+ * repaint of the list would rebuild them all.
627
+ */
628
+ const rowMenu = el("div", "row-menu");
629
+ rowMenu.hidden = true;
630
+ document.body.appendChild(rowMenu);
631
+ /** The row the menu currently belongs to, so it can be un-highlighted. */
632
+ let menuRow = null;
633
+
634
+ function closeRowMenu() {
635
+ if (menuRow) menuRow.classList.remove("menu-open");
636
+ menuRow = null;
637
+ rowMenu.hidden = true;
638
+ rowMenu.replaceChildren();
639
+ rowMenu.classList.remove("fit-anchor", "account-menu");
640
+ rowMenu.style.width = "";
641
+ }
642
+
643
+ /**
644
+ * Anchored under its button, nudged back inside the viewport if it overflows.
645
+ *
646
+ * Called again whenever the contents change: confirming grows the menu, and a
647
+ * menu near the bottom of the list would otherwise grow off the screen.
648
+ */
649
+ function placeRowMenu(button) {
650
+ const anchor = button.getBoundingClientRect();
651
+ const box = rowMenu.getBoundingClientRect();
652
+ const left = Math.min(anchor.left, window.innerWidth - box.width - 8);
653
+ const gap = rowMenu.classList.contains("account-menu") ? 8 : 4;
654
+ const below = anchor.bottom + gap;
655
+ const top = below + box.height > window.innerHeight - 8 ? anchor.top - box.height - gap : below;
656
+ rowMenu.style.left = Math.max(8, left) + "px";
657
+ rowMenu.style.top = Math.max(8, top) + "px";
658
+ }
659
+
660
+ /**
661
+ * One line of a row menu: what it does, and the letter that does it.
662
+ *
663
+ * The letter is not a second way to reach a rare action — it is how the menu
664
+ * stops being a mouse-only detour for the three things done most often to a
665
+ * session. Held on the element rather than in a table, so the key handler
666
+ * reads the menu that is actually open instead of a list that has to be kept
667
+ * in step with it.
668
+ */
669
+ /**
670
+ * The three themes, side by side and pressed rather than cycled.
671
+ *
672
+ * A cycling entry made the reader click twice to reach dark and told them the
673
+ * current state only by reading a word. Three targets say what the options are
674
+ * and which one is on, in one glance and one click — and it is the control the
675
+ * site already uses in the same menu, so the two are recognisably the same
676
+ * thing rather than two designs for one preference.
677
+ *
678
+ * Not a menuItem: this row is not a command, and closing the menu on click
679
+ * would hide the change the click was for.
680
+ */
681
+ function themeRow() {
682
+ const row = el("div", "menu-theme");
683
+ row.appendChild(el("span", "menu-label", t("theme.label")));
684
+ const group = el("div", "theme-group");
685
+ group.setAttribute("role", "group");
686
+ group.setAttribute("aria-label", t("theme.label"));
687
+ THEMES.forEach((option) => {
688
+ const button = el("button", "theme-btn");
689
+ button.type = "button";
690
+ button.title = t("theme." + option);
691
+ button.setAttribute("aria-label", t("theme." + option));
692
+ button.setAttribute("aria-pressed", String(option === theme));
693
+ button.appendChild(svgIcon("theme" + option[0].toUpperCase() + option.slice(1)));
694
+ button.addEventListener("click", (e) => {
695
+ e.stopPropagation();
696
+ theme = option;
697
+ applyTheme();
698
+ // The menu stays open and repaints its own pressed states: the point of
699
+ // three buttons is that the next one is one click away.
700
+ [...group.children].forEach((node, i) => {
701
+ node.setAttribute("aria-pressed", String(THEMES[i] === theme));
702
+ });
703
+ });
704
+ group.appendChild(button);
705
+ });
706
+ row.appendChild(group);
707
+ return row;
708
+ }
709
+
710
+ function menuItem(label, opts) {
711
+ const item = el("button", "menu-item" + (opts.danger ? " danger" : "") + (opts.cls ? " " + opts.cls : ""));
712
+ item.type = "button";
713
+ item.appendChild(el("span", "menu-label", label));
714
+ if (opts.key) {
715
+ item.appendChild(el("kbd", "menu-key", opts.key.toUpperCase()));
716
+ item.dataset.key = opts.key.toLowerCase();
717
+ }
718
+ item.addEventListener("click", opts.run);
719
+ return item;
720
+ }
721
+
722
+ /** The rule between what a menu does and what it destroys. */
723
+ function menuSep() {
724
+ return el("div", "menu-sep");
725
+ }
726
+
727
+ /**
728
+ * Something placeRowMenu can hang a menu from when there is no button: the
729
+ * point a right-click landed on, as a rectangle with no size.
730
+ */
731
+ function pointAnchor(e) {
732
+ const rect = { left: e.clientX, right: e.clientX, top: e.clientY, bottom: e.clientY, width: 0, height: 0 };
733
+ return { getBoundingClientRect: () => rect };
734
+ }
735
+
736
+ function openRowMenu(button, row, items) {
737
+ closeRowMenu();
738
+ rowMenu.replaceChildren(...items);
739
+ rowMenu.hidden = false;
740
+ menuRow = row;
741
+ row.classList.add("menu-open");
742
+ placeRowMenu(button);
743
+ }
744
+
745
+ // The menu is positioned in viewport coordinates, so anything that moves the
746
+ // row it points at — scrolling the list, resizing — leaves it stranded.
747
+ document.addEventListener("click", (e) => {
748
+ if (!rowMenu.hidden && !rowMenu.contains(e.target)) closeRowMenu();
749
+ });
750
+ document.addEventListener("keydown", (e) => {
751
+ if (e.key === "Escape") return void closeRowMenu();
752
+ if (rowMenu.hidden || e.metaKey || e.ctrlKey || e.altKey) return;
753
+ // Not while something is being typed — a menu open behind a rename field
754
+ // would eat the letters going into it.
755
+ const on = document.activeElement;
756
+ if (on && (on.tagName === "INPUT" || on.tagName === "TEXTAREA" || on.isContentEditable)) return;
757
+ const item = rowMenu.querySelector('[data-key="' + e.key.toLowerCase() + '"]');
758
+ if (!item) return;
759
+ e.preventDefault();
760
+ item.click();
761
+ });
762
+ window.addEventListener("resize", closeRowMenu);
763
+ sessionList.addEventListener("scroll", closeRowMenu);
764
+ // Nowhere in the sidebar is the browser's menu the one meant: a row answers
765
+ // with its own above, and the space between rows answers with nothing rather
766
+ // than with Back, Reload and View Source. Text fields keep theirs — paste.
767
+ sidebarEl.addEventListener("contextmenu", (e) => {
768
+ if (e.target.closest("input, textarea, [contenteditable]")) return;
769
+ e.preventDefault();
770
+ });
771
+
772
+ function sessionRow(s, nested) {
773
+ const item = el("div", "session" + (s.current ? " active" : "") + (nested ? " nested" : ""));
774
+ // The id is a timestamp and a hash: true, and useless for picking one out
775
+ // of a list. The first thing asked is what a person remembers.
776
+ item.appendChild(el("div", "title", s.title || t("session.untitled")));
777
+ // Outside the title rather than in it: that line ellipsises, and a mark that
778
+ // vanishes with the end of a long name is not a mark.
779
+ if (s.pinned) item.appendChild(svgIcon("pin", "pin-mark"));
780
+
781
+ draggable(item, {
782
+ kind: "session",
783
+ key: s.id,
784
+ group: (s.cwd || "") + (s.pinned ? " pinned" : ""),
785
+ // Every session in this project, in the order they are currently shown —
786
+ // including the ones folded away, which are still in the list this order
787
+ // is about.
788
+ siblings: () =>
789
+ arrange(
790
+ sessionRows.filter((one) => (one.cwd || "") === (s.cwd || "")),
791
+ (one) => one.id,
792
+ sessionOrder[s.cwd || ""],
793
+ (one) => one.pinned,
794
+ ).map((one) => one.id),
795
+ commit: (ids) => {
796
+ sessionOrder = { ...sessionOrder, [s.cwd || ""]: ids };
797
+ paintSessions();
798
+ void api("/order", { kind: "session", cwd: s.cwd || "", ids }).then((body) => {
799
+ if (body && body.error) addNotice(body.error, "err");
800
+ });
801
+ },
802
+ });
803
+
804
+ // When it was, how long it ran, and where it came from — what picks a
805
+ // session out of a list. What it cost is not: it is the same three cents on
806
+ // every row, it answers a question nobody asks while looking for a
807
+ // transcript, and it now has a page and a line under the composer of its
808
+ // own, which is where a number worth comparing belongs.
809
+ //
810
+ // The turn count went the same way, and for a plainer reason: it is not how
811
+ // anybody picks a session out of a list. It says how long a conversation ran,
812
+ // not what it was about or whether this is the one being looked for.
813
+ const bits = [ago(s.updatedAt)];
814
+ if (s.forkedFrom) bits.push("fork");
815
+ item.appendChild(el("div", "sub", bits.join(" · ")));
816
+
817
+ const more = el("button", "more");
818
+ more.type = "button";
819
+ more.title = t("session.more");
820
+ more.setAttribute("aria-label", t("session.more"));
821
+ more.appendChild(svgIcon("more"));
822
+ more.addEventListener("click", (e) => {
823
+ e.stopPropagation();
824
+ if (menuRow === item) return closeRowMenu();
825
+ openMenu(more);
826
+ });
827
+ // Right-click opens the row's own menu where the pointer is, instead of the
828
+ // browser's, which has nothing to say about a session.
829
+ item.addEventListener("contextmenu", (e) => {
830
+ e.preventDefault();
831
+ e.stopPropagation();
832
+ openMenu(pointAnchor(e));
833
+ });
834
+
835
+ function openMenu(anchor) {
836
+ // A transcript cannot be recovered, so the item asks once rather than
837
+ // deleting on the click that opened the menu — but a modal for something
838
+ // reached from a list row is heavier than what it protects against. The
839
+ // consequence is spelled out above the button, the way a dialog would.
840
+ const del = menuItem(t("session.delete"), { key: "d", danger: true, run: () => {
841
+ if (!del.classList.contains("armed")) {
842
+ del.classList.add("armed");
843
+ del.querySelector(".menu-label").textContent = t("session.deleteConfirm");
844
+ rowMenu.replaceChildren(el("div", "menu-note", t("session.deleteNote")), del);
845
+ placeRowMenu(anchor);
846
+ return;
847
+ }
848
+ closeRowMenu();
849
+ void deleteSession(s.id);
850
+ } });
851
+
852
+ openRowMenu(anchor, item, [
853
+ menuItem(t(s.pinned ? "session.unpin" : "session.pin"), { key: "p", run: () => {
854
+ closeRowMenu();
855
+ void togglePin("session", s.id, !s.pinned);
856
+ } }),
857
+ menuItem(t("session.rename"), { key: "r", run: () => {
858
+ closeRowMenu();
859
+ renameInPlace(item, s);
860
+ } }),
861
+ // From where it stands, unlike the fork on a turn, which branches at
862
+ // that turn. Same verb, and the difference is which point it keeps.
863
+ menuItem(t("session.fork"), { key: "f", run: () => {
864
+ closeRowMenu();
865
+ void openSession(s.id, s.cwd).then(() => switchTo({ action: "fork" }));
866
+ } }),
867
+ menuSep(),
868
+ del,
869
+ ]);
870
+ }
871
+ item.appendChild(more);
872
+
873
+ item.addEventListener("click", () => {
874
+ setNav(null);
875
+ if (s.current) return;
876
+ // A session belongs to the tree it was recorded in — resuming it under
877
+ // another project's harness would point its tools at files the transcript
878
+ // never mentions — so the row hands over the tree it names and the session
879
+ // opens on that one.
880
+ void openSession(s.id, s.cwd);
881
+ });
882
+ return item;
883
+ }
884
+
885
+ /**
886
+ * Rename a session where it sits.
887
+ *
888
+ * In the row rather than in a dialog, because the thing being edited is one
889
+ * short line that is already on screen and a modal would ask somebody to
890
+ * retype it while covering it up. The list is rebuilt on every repaint, so the
891
+ * field commits or cancels before anything else can pull the row out from
892
+ * under it.
893
+ */
894
+ function renameInPlace(row, s) {
895
+ const title = row.querySelector(".title");
896
+ if (!title) return;
897
+ const field = el("input", "title-edit");
898
+ field.value = s.title || "";
899
+ field.placeholder = t("session.untitled");
900
+
901
+ let done = false;
902
+ const finish = async (save) => {
903
+ if (done) return;
904
+ done = true;
905
+ const next = field.value.trim();
906
+ field.replaceWith(title);
907
+ if (!save || !next || next === s.title) return;
908
+ const body = await api("/rename", { id: s.id, title: next });
909
+ if (body && body.error) return void addNotice(body.error, "err");
910
+ s.title = next;
911
+ title.textContent = next;
912
+ // The tab carries the title too, and the sidebar is not the only place
913
+ // this name appears.
914
+ paintTabs();
915
+ await loadSessions();
916
+ };
917
+
918
+ field.addEventListener("keydown", (e) => {
919
+ e.stopPropagation();
920
+ if (e.key === "Enter") void finish(true);
921
+ if (e.key === "Escape") void finish(false);
922
+ });
923
+ // Clicking away is a commit, not a cancel: the field looks like the row it
924
+ // replaced, and losing what was typed to a click elsewhere reads as a bug.
925
+ field.addEventListener("blur", () => void finish(true));
926
+ field.addEventListener("click", (e) => e.stopPropagation());
927
+
928
+ title.replaceWith(field);
929
+ field.focus();
930
+ field.select();
931
+ }
932
+
933
+ /**
934
+ * Delete a session for good: transcript, archive and metadata.
935
+ *
936
+ * If it is open as a tab the tab has to go with it — a pane pointing at a
937
+ * deleted session would accept a prompt and fail on the first write.
938
+ */
939
+ async function deleteSession(id) {
940
+ const body = await api("/session", { action: "delete", id });
941
+ if (body.error) return void addNotice(body.error, "err");
942
+
943
+ if (panes.has(id)) {
944
+ panes.get(id).node.remove();
945
+ panes.delete(id);
946
+ if (activeId === id) {
947
+ const next = (body.open || []).find((other) => panes.has(other)) || [...panes.keys()][0];
948
+ if (next) await focusTab(next);
949
+ else await switchTo({ action: "new" });
950
+ }
951
+ paintTabs();
952
+ }
953
+
954
+ // Lineage is recorded on the branches, not the parent, so deleting a parent
955
+ // leaves them pointing at nothing. Said once, rather than blocked.
956
+ if (body.forks) addNotice(t("session.deletedForks", { n: body.forks }), "notice");
957
+
958
+ sessionRows = sessionRows.filter((s) => s.id !== id);
959
+ paintSessions();
960
+ await loadSessions();
961
+ }
962
+
963
+ /** Reopen a session, on the harness for the tree it was recorded in. */
964
+ async function openSession(id, cwd) {
965
+ await switchTo({ action: "resume", id, ...(cwd ? { cwd } : {}) });
966
+ await loadProjects();
967
+ }
968
+
969
+ // The side panel asks for a conversation when the person moves to one of its
970
+ // tab groups. Only the extension page this one is framed in may ask, and all
971
+ // it can ask for is a conversation this page could open anyway.
972
+ if (IS_EMBED) {
973
+ window.addEventListener("message", (event) => {
974
+ if (event.source !== window.parent || !/^chrome-extension:/.test(event.origin)) return;
975
+ const data = event.data || {};
976
+ if (data.type === "open-session" && typeof data.id === "string" && data.id !== activeId) {
977
+ if (panes.has(data.id)) void focusTab(data.id);
978
+ else void openSession(data.id, typeof data.cwd === "string" ? data.cwd : undefined);
979
+ return;
980
+ }
981
+ // "Use this tab": the person pressed it in the panel, on the tab they
982
+ // are looking at, for the conversation the panel shows.
983
+ if (data.type === "adopt-tab" && (typeof data.tabId === "number" || typeof data.tabId === "string") && activeId) {
984
+ void api("/browser/adopt", { session: activeId, tabId: data.tabId }).then((result) => {
985
+ if (result && result.error) addNotice(result.error, "err");
986
+ else if (result && result.url) addNotice(t("embed.adopted", { url: result.url }));
987
+ embedTell({ type: "adopted", ok: !(result && result.error) });
988
+ });
989
+ }
990
+ });
991
+ }
992
+
993
+ let modelOptions = [];
994
+ let providers = [];
995
+
996
+ async function loadModels() {
997
+ const body = await getJson("/models");
998
+ if (body.error) return void addNotice(body.error, "err");
999
+ const models = body.models || [];
1000
+ providers = body.providers || [];
1001
+ // Kept for the connect dialog, which needs somewhere to send a person for a
1002
+ // key and is opened from places that have no response of their own to read.
1003
+ modelsSite = body.site || modelsSite;
1004
+
1005
+ // Grouped by vendor. Three things decide the order, in this order:
1006
+ //
1007
+ // the recommended vendor, which leads even unconnected — for someone with
1008
+ // nothing set up it is the answer to the question the list poses, and
1009
+ // burying it under the vendors they happen to hold keys for hides the
1010
+ // one row that would have saved them the trouble;
1011
+ // whether the vendor has anything to list, because a local server nobody
1012
+ // is running counts as connected — no key is needed to reach it — and
1013
+ // three empty stubs above Anthropic is not a ranking anyone asked for;
1014
+ // whether it is connected, so an unauthenticated model is still shown —
1015
+ // it explains why the list is short — but not above one that will
1016
+ // actually answer.
1017
+ //
1018
+ // Catalog order breaks the ties, which is where the head of the list is set.
1019
+ const populated = new Set(models.map((m) => m.provider));
1020
+ const order = new Map(
1021
+ providers.map((p, i) => [
1022
+ p.id,
1023
+ (p.recommended ? 0 : (populated.has(p.id) ? 0 : 2000) + (p.configured ? 0 : 1000)) + i,
1024
+ ]),
1025
+ );
1026
+ // One closure per vendor, not one per model: four hundred rows repaint on
1027
+ // every keystroke in the search field.
1028
+ const vendorLabels = new Map(providers.map((p) => [p.id, p.label || p.id]));
1029
+ const markers = new Map(
1030
+ providers.map((p) => [
1031
+ p.id,
1032
+ (extra) => providerMark(p.id, vendorLabels.get(p.id) || p.id, extra),
1033
+ ]),
1034
+ );
1035
+ const markFor = (id) =>
1036
+ markers.get(id) || ((extra) => providerMark(id, id, extra));
1037
+
1038
+ // No context window here. It is a number you consult when choosing a model
1039
+ // once, not every time you pick one off a list you already know, and printed
1040
+ // down the right of four hundred rows it competes with the only thing the
1041
+ // row is for — the name. The panel and the provider dialog still carry it,
1042
+ // where a model is being chosen rather than switched to.
1043
+ const listed = models.map((m) => ({
1044
+ value: m.id,
1045
+ // Without the provider: the group header above the row already names it,
1046
+ // and "official/" repeated down every row pushes the part that differs to
1047
+ // the right. An aggregator's own namespace stays — "~anthropic/…" under
1048
+ // OpenRouter is what tells its rows apart. The whole id is still what
1049
+ // search matches, so typing it as /model takes it works here too.
1050
+ label: m.id.indexOf(m.provider + "/") === 0 ? m.id.slice(m.provider.length + 1) : m.id,
1051
+ keywords: m.id,
1052
+ group: m.provider,
1053
+ mark: markFor(m.provider),
1054
+ // The id is left whole even where it already ends in ":free". That suffix
1055
+ // is OpenRouter's routing variant and part of the string somebody types
1056
+ // into /model, and trimming it to save the repetition would render
1057
+ // minimax-m2.7 and minimax-m2.7:free as the same row — two models that
1058
+ // differ in what they cost, told apart by a badge alone. A vendor whose
1059
+ // free models are not named for it gets the badge and nothing else, which
1060
+ // is the case this exists for.
1061
+ ...(m.free ? { tag: t("models.free") } : {}),
1062
+ }));
1063
+
1064
+ // A vendor with nothing to list is still an answer. Local servers and the
1065
+ // gateways nobody bundles model names for only have a roster once they are
1066
+ // asked for one, and leaving them out of the picker entirely reads as "not
1067
+ // supported" rather than "not connected yet" — so each gets one row that
1068
+ // goes where that is fixed.
1069
+ const stubs = providers
1070
+ .filter((p) => !populated.has(p.id))
1071
+ .map((p) => ({
1072
+ value: "setup:" + p.id,
1073
+ label: t(p.configured ? "menu.discover" : "menu.setUp"),
1074
+ group: p.id,
1075
+ mark: markFor(p.id),
1076
+ sub: "",
1077
+ }));
1078
+
1079
+ // Sorted as one list rather than models-then-stubs. Appended, a vendor with
1080
+ // nothing discovered yet landed under four hundred rows of the vendors that
1081
+ // had — which put the recommended plan, whose whole roster arrives only
1082
+ // after signing in, at the very bottom of the menu meant to sell it.
1083
+ // Within a vendor, the server's order: newest first by family, variants
1084
+ // after their model (model-order.ts). A string sort here put claude-opus-4-5
1085
+ // above claude-opus-5 and scattered :batch rows through the list.
1086
+ const rank = new Map(models.map((m, i) => [m.id, i]));
1087
+ modelOptions = listed.concat(stubs).sort(
1088
+ (a, b) =>
1089
+ (order.get(a.group) ?? 9999) - (order.get(b.group) ?? 9999) ||
1090
+ (rank.get(a.value) ?? 0) - (rank.get(b.value) ?? 0),
1091
+ );
1092
+
1093
+ const current = models.find((m) => m.current);
1094
+ const recommended = new Set(providers.filter((p) => p.recommended).map((p) => p.id));
1095
+ modelPicker.groupTag = (group) => (recommended.has(group) ? t("models.recommended") : null);
1096
+ modelPicker.groupMark = (group) => markFor(group)();
1097
+ modelPicker.options = modelOptions;
1098
+ modelPicker.set(modelOptions, current ? current.id : null);
1099
+ }
1100
+
1101
+ const connectionCards = new Map();
1102
+
1103
+ function connectionCard(card) {
1104
+ connectionCards.set(card.id, card);
1105
+ learnCloudApp(card.app, card.label, card.logo);
1106
+ const target = pane(card.session);
1107
+ let box = target.node.querySelector('[data-connection-id="' + card.id + '"]');
1108
+ if (!box) {
1109
+ box = el("section", "row-card column connection-card");
1110
+ box.dataset.connectionId = card.id;
1111
+ box.setAttribute("aria-live", "polite");
1112
+ target.node.appendChild(box);
1113
+ }
1114
+ box.replaceChildren();
1115
+ const head = el("div", "connection-head");
1116
+ /*
1117
+ * The app's own mark, when Cloud sent one and it is https. A logo is the
1118
+ * fastest way to tell Gmail from a page pretending to be Gmail, which is
1119
+ * exactly why the URL is not taken on trust: it arrives from a vendor
1120
+ * catalogue, and an http or javascript: src would be a hole opened for
1121
+ * decoration. A card without one is a card with a blank square, never a
1122
+ * broken image.
1123
+ */
1124
+ if (card.logo) {
1125
+ let safe;
1126
+ try { const parsed = new URL(card.logo); if (parsed.protocol === "https:") safe = parsed.href; } catch { safe = undefined; }
1127
+ if (safe) {
1128
+ const logo = el("img", "connection-logo");
1129
+ logo.src = safe; logo.alt = ""; logo.loading = "lazy"; logo.referrerPolicy = "no-referrer";
1130
+ logo.onerror = () => logo.remove();
1131
+ head.appendChild(logo);
1132
+ }
1133
+ }
1134
+ head.appendChild(el("span", "connection-name", card.label));
1135
+ box.appendChild(head);
1136
+ /*
1137
+ * The key when there is one, the text when there is not. The engine writes
1138
+ * both because the same card also reaches surfaces with no translation
1139
+ * table; here, where there is one, a card that spoke a different language
1140
+ * from the window around it was the bug.
1141
+ */
1142
+ box.appendChild(el("p", "", card.messageKey ? t(card.messageKey) : card.message));
1143
+ if (card.state !== "required" && card.state !== "authorizing") return;
1144
+ const actions = el("div", "connection-actions");
1145
+ const start = el("button", "ghost", card.state === "authorizing" ? t("connect.open") : t("connect.action", { app: card.label }));
1146
+ const cancel = el("button", "ghost", t("connect.cancel"));
1147
+ actions.appendChild(start);
1148
+ actions.appendChild(cancel);
1149
+ box.appendChild(actions);
1150
+ start.onclick = async () => {
1151
+ start.disabled = true;
1152
+ // Reserve a window in the click gesture; credentials never enter its URL.
1153
+ const popup = window.open("about:blank", "_blank");
1154
+ if (popup) popup.opener = null;
1155
+ try {
1156
+ const result = await api("/cloud-connection", { id: card.id, action: "start" }, 60000);
1157
+ if (result.error) throw new Error(result.error);
1158
+ if (result.redirectUrl) {
1159
+ const url = new URL(result.redirectUrl);
1160
+ if (url.protocol !== "https:" || url.username || url.password) throw new Error("无效授权链接");
1161
+ if (popup && !popup.closed) popup.location.replace(url.href);
1162
+ else {
1163
+ const currentBox = target.node.querySelector('[data-connection-id="' + card.id + '"]');
1164
+ if (currentBox && connectionCards.get(card.id)?.state === "authorizing") {
1165
+ const link = el("a", "", t("connect.openLink"));
1166
+ link.href = url.href; link.target = "_blank"; link.rel = "noopener noreferrer";
1167
+ currentBox.appendChild(link);
1168
+ }
1169
+ }
1170
+ } else if (popup) popup.close();
1171
+ } catch (error) {
1172
+ if (popup) popup.close();
1173
+ box.appendChild(el("p", "err", error.message || t("connect.failed")));
1174
+ } finally { start.disabled = false; }
1175
+ };
1176
+ cancel.onclick = async () => {
1177
+ cancel.disabled = true;
1178
+ try {
1179
+ const result = await api("/cloud-connection", { id: card.id, action: "cancel" });
1180
+ if (result.error) throw new Error(result.error);
1181
+ } catch { box.appendChild(el("p", "err", t("connect.cancelFailed"))); }
1182
+ finally { cancel.disabled = false; }
1183
+ };
1184
+ }
1185
+
1186
+ function renderHistory(entries, into) {
1187
+ P = into || P;
1188
+ const hasHistory = (entries || []).length > 0;
1189
+ P.node.replaceChildren();
1190
+ // A resumed session with nothing in it should still show the invitation
1191
+ // rather than a blank rectangle — and, since nothing has been said in it
1192
+ // yet, the choice of where it runs.
1193
+ if (!hasHistory && emptyEl) P.node.appendChild(emptyEl);
1194
+ if (P === panes.get(activeId)) paintWorkspace(!hasHistory);
1195
+ P.tools.clear();
1196
+ P.turn = null;
1197
+ P.streaming = null;
1198
+ for (const entry of entries || []) {
1199
+ if (entry.role === "user") {
1200
+ if (entry.text || (entry.images && entry.images.length)) {
1201
+ addUser(entry.text || "", entry.images);
1202
+ }
1203
+ continue;
1204
+ }
1205
+ P.turn = null;
1206
+ // Folded shut and without a duration: the seconds are a property of the
1207
+ // stream, not of the message, so a reload has no honest number to show.
1208
+ if (entry.thinking) {
1209
+ const block = el("details", "thinking");
1210
+ const summary = el("summary");
1211
+ summary.appendChild(el("span", "t-label", t("work.thoughtAbout")));
1212
+ block.appendChild(summary);
1213
+ block.appendChild(el("div", "thinking-body", entry.thinking));
1214
+ assistantTurn().appendChild(block);
1215
+ }
1216
+ if (entry.text) assistantTurn().appendChild(renderMarkdown(entry.text));
1217
+ for (const call of entry.tools || []) toolCard(call.id, call.name, call.name, call.input);
1218
+ for (const result of entry.results || []) {
1219
+ finishTool(result.id, [{ type: "text", text: result.text }], 0, result.isError);
1220
+ }
1221
+ // The cards, again. Live, these come from the "artifact" event, which a
1222
+ // replay does not produce — so a resumed conversation had every message and
1223
+ // no way back to the things they made. The engine rebuilds the store from
1224
+ // the same tool calls; this rebuilds their cards from them.
1225
+ for (const call of entry.tools || []) {
1226
+ if (call.name !== "artifact_write") continue;
1227
+ const failed = (entry.results || []).some((r) => r.id === call.id && r.isError);
1228
+ if (failed) continue;
1229
+ const card = replayedArtifactCard(call.input);
1230
+ if (card) assistantTurn().appendChild(card);
1231
+ }
1232
+ if (P.turn) turnActions(P.turn, entry.keepMessages);
1233
+ }
1234
+ for (const card of connectionCards.values()) {
1235
+ if (pane(card.session) === P) connectionCard(card);
1236
+ }
1237
+ // A question still waiting, drawn again under the history that just
1238
+ // replaced the pane. Settled ones are already in it, as their tool card.
1239
+ for (const entry of questionCards.values()) {
1240
+ if (entry.status === "open" && pane(entry.session) === P) paintQuestion(entry);
1241
+ }
1242
+ atBottom = true;
1243
+ log.scrollTop = log.scrollHeight;
1244
+ }
1245
+
1246
+ async function switchTo(body) {
1247
+ const state = await api("/session", body);
1248
+ if (state.error) return void addNotice(state.error, "err");
1249
+
1250
+ // A tab per session, focused on arrival.
1251
+ const record = pane(state.sessionId);
1252
+ record.title = state.title || "";
1253
+ record.cwd = state.cwd || "";
1254
+ activeId = state.sessionId;
1255
+ P = record;
1256
+ for (const [id, entry] of panes) entry.node.hidden = id !== activeId;
1257
+ paintTabs();
1258
+
1259
+ const { entries } = await getJson("/history?session=" + encodeURIComponent(state.sessionId));
1260
+ renderHistory(entries, record);
1261
+ applyState(state);
1262
+ setNav(null);
1263
+ await loadSessions();
1264
+ await loadSidePanels();
1265
+ }
1266
+
1267
+ function addNotice(text, cls) {
1268
+ // Connecting a provider reports back while a panel is up; that answer has to
1269
+ // land where the user is looking, not in the transcript hidden behind it.
1270
+ if (!panelView.hidden) return void panelNotes.appendChild(el("div", cls || "notice", text));
1271
+ assistantTurn().appendChild(el("div", cls || "notice", text));
1272
+ follow();
1273
+ }
1274
+ `;
1275
+ //# sourceMappingURL=state.js.map