samagotchi 0.34.0 → 0.36.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/README.md +2 -0
  4. data/bin/chi +3 -0
  5. data/docs/broadcast.md +129 -0
  6. data/docs/cli.md +8 -0
  7. data/docs/configuration.md +2 -0
  8. data/docs/context.md +37 -0
  9. data/docs/guardrails.md +14 -4
  10. data/docs/hooks.md +25 -4
  11. data/docs/memory.md +3 -0
  12. data/docs/plugins.md +29 -3
  13. data/docs/sessions.md +6 -6
  14. data/lib/samagotchi/bridge/card_store.rb +1 -1
  15. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  16. data/lib/samagotchi/broadcast/recipients.rb +63 -0
  17. data/lib/samagotchi/broadcast/scope_card.rb +94 -0
  18. data/lib/samagotchi/broadcast/tags.rb +154 -0
  19. data/lib/samagotchi/broadcast_command.rb +228 -0
  20. data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -6
  21. data/lib/samagotchi/bundles/coordinator/plugin.rb +72 -4
  22. data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +59 -11
  23. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  24. data/lib/samagotchi/bundles/github-pr/plugin.rb +444 -3
  25. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +22 -0
  26. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  27. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  28. data/lib/samagotchi/bundles/mcp/plugin.rb +44 -11
  29. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +32 -12
  30. data/lib/samagotchi/bundles/source-links/manifest.yml +4 -3
  31. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  32. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  33. data/lib/samagotchi/bundles/system/memory_guide.md +4 -2
  34. data/lib/samagotchi/bundles/system/self_map.md +4 -0
  35. data/lib/samagotchi/children_status.rb +2 -16
  36. data/lib/samagotchi/config.rb +6 -0
  37. data/lib/samagotchi/engine.rb +29 -5
  38. data/lib/samagotchi/git_head.rb +24 -0
  39. data/lib/samagotchi/guardrails/parent_approvals.rb +6 -0
  40. data/lib/samagotchi/guardrails/rules.rb +15 -4
  41. data/lib/samagotchi/guardrails/targets.rb +9 -2
  42. data/lib/samagotchi/guardrails/verdict.rb +4 -3
  43. data/lib/samagotchi/hooks/registry.rb +11 -5
  44. data/lib/samagotchi/log_subscriber.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/index_updater.rb +6 -3
  46. data/lib/samagotchi/note_command.rb +13 -13
  47. data/lib/samagotchi/note_delivery.rb +42 -0
  48. data/lib/samagotchi/plugin/context.rb +4 -4
  49. data/lib/samagotchi/session_commands.rb +1 -0
  50. data/lib/samagotchi/session_manager.rb +4 -3
  51. data/lib/samagotchi/sessions_command.rb +3 -2
  52. data/lib/samagotchi/system_prompt.rb +1 -0
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -2
  54. data/lib/samagotchi/terminal_ui/status_row.rb +23 -2
  55. data/lib/samagotchi/terminal_ui.rb +14 -0
  56. data/lib/samagotchi/tool_activity.rb +14 -3
  57. data/lib/samagotchi/tool_declarations.rb +8 -4
  58. data/lib/samagotchi/tools/builtins.rb +1 -1
  59. data/lib/samagotchi/tools/memory.rb +103 -2
  60. data/lib/samagotchi/tools/send_note.rb +5 -11
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +3 -0
  63. data/lib/samagotchi/web/capabilities.rb +19 -0
  64. data/lib/samagotchi/web/public/activity.js +26 -6
  65. data/lib/samagotchi/web/public/app.js +515 -288
  66. data/lib/samagotchi/web/public/card.js +12 -3
  67. data/lib/samagotchi/web/public/command_view.js +2 -3
  68. data/lib/samagotchi/web/public/families_view.js +171 -0
  69. data/lib/samagotchi/web/public/format.js +52 -0
  70. data/lib/samagotchi/web/public/index.html +94 -21
  71. data/lib/samagotchi/web/public/live_timing.js +154 -0
  72. data/lib/samagotchi/web/public/live_turn.js +44 -0
  73. data/lib/samagotchi/web/public/sessions_list.js +228 -32
  74. data/lib/samagotchi/web/public/strip.js +5 -2
  75. data/lib/samagotchi/web/public/turn_html.js +107 -0
  76. data/lib/samagotchi/web/public/turn_model.js +5 -4
  77. data/lib/samagotchi/web/public/turn_view.js +43 -129
  78. data/lib/samagotchi/web/session_summary.rb +4 -0
  79. data/lib/samagotchi/worker.rb +166 -307
  80. data/lib/samagotchi/worker_inbound.rb +251 -0
  81. data/lib/samagotchi/worker_wakes.rb +120 -0
  82. metadata +15 -1
@@ -27,9 +27,11 @@ import { MODEL_KEY } from "./model_pick.js";
27
27
  import { emptySessionState } from "./session_state.js";
28
28
  import { createModelPicker } from "./model_picker.js";
29
29
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
30
- import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, userBodyHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy } from "./format.js";
31
- import { EMPTY_ANSWER_CLASS, createTurnView, turnHistoryHtml } from "./turn_view.js";
30
+ import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary } from "./format.js";
31
+ import { createTurnView, turnHistoryHtml } from "./turn_view.js";
32
+ import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
32
33
  import { createStageView } from "./stage_view.js";
34
+ import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
33
35
  import { createTaskStopper } from "./task_stop.js";
34
36
  import { appendQuote } from "./annotations.js";
35
37
  import { parsePresets } from "./annotate_presets.js";
@@ -38,7 +40,7 @@ import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from
38
40
  import { routeChunk } from "./chunk_router.js";
39
41
  import { extractCtxPct, savedCtxPct, cardCtxText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
40
42
  import { createQuestionCards } from "./question_cards.js";
41
- import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
43
+ import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
42
44
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
43
45
  import { createCommandList } from "./command_list.js";
44
46
  import { createPromptHistory } from "./prompt_history.js";
@@ -46,14 +48,15 @@ import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
46
48
  import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
47
49
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
48
50
  import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
49
- import { applySessionEvent, heldOrder, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText, withChildrenAfterParents } from "./sessions_list.js";
51
+ import { applySessionEvent, batchCounts, batchTargets, batchToastText, families, familyId, familyOpen, familyWaits, filterFamilies, flattenFamilies, heldOrder, inTree, rangeIds, runBatch, listedSessions, sortedByUpdated, stoppedBadge, waitingBadge, waitingFirst, waitingSearchText } from "./sessions_list.js";
50
52
  import { createNotifications } from "./notifications.js";
51
53
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
52
54
  import {
53
- appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS, turnTimingText,
55
+ appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, STOPPED_LINE_CLASS,
54
56
  } from "./timing.js";
57
+ import { createLiveTurn } from "./live_turn.js";
55
58
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
56
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
59
+ import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
57
60
 
58
61
  const $ = (s) => document.querySelector(s);
59
62
  const topStripEl = $("#topStrip");
@@ -73,6 +76,10 @@ const contextChips = createContextChips({
73
76
  toast: (text) => showToast(text),
74
77
  });
75
78
  const markdownWarningEl = $("#markdownWarning");
79
+ // What the page renders that a notice may stand in for (the server's
80
+ // Web::Capabilities, next to markdown_warning); a notice it names isn't
81
+ // drawn (shownElsewhere). An older server sends none: every notice shows.
82
+ let pageCapabilities = {};
76
83
  const promptEl = $("#prompt");
77
84
  const actionBtn = $("#actionBtn");
78
85
  const cancelBtn = $("#cancelBtn");
@@ -121,22 +128,20 @@ function findInTurnRoots(selector) {
121
128
  // The open session's fields (session_state.js); resetSessionState() starts
122
129
  // them over.
123
130
  let current = emptySessionState();
124
- let turnRunning = false;
125
- let liveTurnTimingEl = null;
126
- // The running turn's id (turn_started's turn_id, the id of its timing
127
- // record), so a turn-end re-read finishes that turn's line with that
128
- // turn's record, whatever started since.
129
- let liveTurnId = null;
130
- // The turns this page saw end: the records of its last full render, +1 at
131
- // each turn's end, never fewer than a merge brought. The live line numbers
132
- // the next one, so a turn queued behind another reads its own number while
133
- // the other's re-read is still out.
134
- let turnsEnded = 0;
135
- // What the running turn waits on, after its live timing line: a provider
136
- // retry (generation_retrying) or plugins' setup (plugin_init_wait); "" when
137
- // it streams again.
138
- let liveTurnNote = "";
139
- let timingInterval = null;
131
+ // The running turn as the page follows it (live_turn.js): whether one
132
+ // runs, its timing line and the turns this page saw end (live_timing.js),
133
+ // its merges' bubble flags, its turn-end reads.
134
+ const liveTurn = createLiveTurn({
135
+ timing: () => current.timing,
136
+ // The stage's status row while it holds the turn (the line moves into
137
+ // the history with it), else the history.
138
+ place: (el) => {
139
+ const stageHost = turnDom.timingHost?.() || null;
140
+ (stageHost || historyEl).appendChild(el);
141
+ return !!stageHost;
142
+ },
143
+ onTick: () => updateInfoBar(),
144
+ });
140
145
 
141
146
  // This tab's id in the session's events: tells its own prompts from the
142
147
  // ones other clients (an attached TUI, another tab, reminders) sent.
@@ -157,20 +162,12 @@ let selectSeq = null;
157
162
  // The stream was opened from 0 (a wake with no cursor to go on) and no
158
163
  // new turn has started on it yet: see followWokenWorker.
159
164
  let streamFromGuess = false;
160
- // The last input_merged had an origin with no bubble: its merged text
161
- // (pending_input_merged) gets one ("report": a delegate report's).
162
- let unmatchedMerge = false;
163
- // The running turn is one chi ran for delegate reports and its report
164
- // bubble isn't drawn yet: that bubble starts the turn.
165
- let wakeStart = false;
166
165
  // The enqueued_ids this page sent (from the /turn acks): only these come back
167
166
  // into the composer when their turn fails.
168
167
  const sentIds = new Set();
169
168
  // Own prompt_restored events that came before their /turn ack, by
170
169
  // enqueued_id: the ack puts that prompt back instead of clearing it.
171
170
  const earlyRestores = new Map();
172
- // The last turn_failed's line, shown again after prompt_restored's resync.
173
- let lastFailedText = null;
174
171
 
175
172
  // The page's scope (scope.js): ?dir= is the folder a project view came
176
173
  // from (new chats start there); the server names its project, if any.
@@ -219,13 +216,17 @@ function openSessionEvents() {
219
216
  allSessions = applySessionEvent(allSessions, "session", data);
220
217
  render();
221
218
  if (data.session && data.session.id === selected) followSelectedSession(data.session);
219
+ else if (selected && data.session?.parent_id === selected) updateInfoBar(); // its children chip
222
220
  },
223
221
  session_gone: (data) => {
224
222
  notifications.noticeSessions("session_gone", data);
223
+ selecting.picked.delete(data.id);
224
+ selecting.skipReasons.delete(data.id);
225
225
  const next = applySessionEvent(allSessions, "session_gone", data);
226
226
  if (next !== allSessions) {
227
227
  allSessions = next;
228
228
  render();
229
+ if (selected) updateInfoBar(); // a deleted child leaves the children chip
229
230
  }
230
231
  leaveGoneSession(data.id, "deleted");
231
232
  },
@@ -314,26 +315,34 @@ function allViewSessions() {
314
315
  return listedSessions(allSessions, $("#includeArchived").checked);
315
316
  }
316
317
 
317
- function filteredSessions(list) {
318
- const q = $("#filter").value.trim().toLowerCase();
319
- if (!q) return list;
320
- return list.filter(
321
- (s) =>
322
- previewForCard(s).toLowerCase().includes(q) ||
323
- (s.first_preview || "").toLowerCase().includes(q) ||
324
- s.short_id.toLowerCase().includes(q) ||
325
- s.id.toLowerCase().includes(q) ||
326
- s.status.toLowerCase().includes(q) ||
327
- waitingSearchText(s).includes(q),
328
- );
318
+ function filterQuery() {
319
+ return $("#filter").value.trim().toLowerCase();
320
+ }
321
+
322
+ // Whether a session matches the all view's search +q+ (lower case).
323
+ function matchesFilter(s, q) {
324
+ return previewForCard(s).toLowerCase().includes(q) ||
325
+ (s.first_preview || "").toLowerCase().includes(q) ||
326
+ s.short_id.toLowerCase().includes(q) ||
327
+ s.id.toLowerCase().includes(q) ||
328
+ s.status.toLowerCase().includes(q) ||
329
+ waitingSearchText(s).includes(q);
329
330
  }
330
331
 
331
332
  // A card: the first message as its title, then status, how long ago, owner
332
333
  // and memories. The id is in the tooltip (and the search) only. A session
333
334
  // that waits on the user (a question, an approval, a card with actions)
334
335
  // wears the warning colour and says what for.
335
- // deletable: the all-sessions view's cards get a delete button.
336
- function cardHtml(s, { deletable = false } = {}) {
336
+ // deletable / archivable: the all-sessions view's cards get a delete and an
337
+ // archive (unarchive, on an archived card) button. selectable: select mode's
338
+ // checkbox instead (picked: checked), and skipReason: why a batch skipped it.
339
+ // family: the family the session heads (sessions_list.js families): with
340
+ // members, its chip (which opens their list), and the card waits when any
341
+ // member does; with rows (all sessions) an open family lists its members
342
+ // inline, the strip's opens in a popover; matchIds: a search's matching
343
+ // members.
344
+ function cardHtml(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}) {
345
+ const fam = family && family.members.length ? family : null;
337
346
  const ts = s.updated_at || s.created_at || "";
338
347
  const tip = `${s.id} · updated ${ts} · ${s.status}`;
339
348
  const preview = escapeHtml(previewForCard(s));
@@ -352,7 +361,23 @@ function cardHtml(s, { deletable = false } = {}) {
352
361
  const stopped = waiting ? null : stoppedBadge(s);
353
362
  const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
354
363
  const dotWord = waiting ? waiting.title : s.status;
355
- return `<div class="card${s.archived ? " archived" : ""}${waiting ? " waiting" : ""}" data-id="${s.id}" title="${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${escapeHtml(s.short_id)}">\u2715</button>` : ""}</div>`;
364
+ const pickHtml = selectable ? `<input type="checkbox" class="card-pick" aria-label="Select session ${escapeHtml(s.short_id)}"${picked ? " checked" : ""}>` : "";
365
+ const skipHtml = skipReason ? `<div class="skip-reason">skipped: ${escapeHtml(skipReason)}</div>` : "";
366
+ const familyWaiting = !!fam && familyWaits(fam);
367
+ const kidsHtml = fam ? familyChipHtml(fam, { open }) : "";
368
+ const rowsHtml = fam && rows && open ? familyRowsHtml(fam, { activeId: selected, matchIds, archivable: !selectable }) : "";
369
+ const membersAttr = fam ? ` data-members="${escapeHtml(fam.members.map((m) => m.session.id).join(" "))}"` : "";
370
+ return `<div class="card${s.archived ? " archived" : ""}${waiting || familyWaiting ? " waiting" : ""}${selectable && picked ? " picked" : ""}${fam && rows && open ? " family-open" : ""}" data-id="${s.id}"${membersAttr} title=""${escapeHtml(tip)}${memTitle ? ` · ${escapeHtml(memTitle)}` : ""}"><div class="preview">${preview}</div>${s.recap ? `<div class="recap" title="${escapeHtml(s.recap)}">${escapeHtml(s.recap)}</div>` : ""}<div class="meta"><span class="status dot ${escapeHtml(s.status)}${waiting ? " waiting" : ""}" title="${escapeHtml(dotWord)}" aria-label="${escapeHtml(dotWord)}"></span>${waitingHtml}${stoppedHtml}${timeHtml}${ctxHtml}${folderHtml}${parentChipHtml(s)}${kidsHtml}${ownerBadgeHtml(s)}${archivedHtml}${s.used_memory_names && s.used_memory_names.length ? `<span title="${escapeHtml(s.used_memory_names.join(", "))}">mem ${s.used_memory_names.length}</span>` : ""}</div>${rowsHtml}${skipHtml}${pickHtml}${selectable ? "" : cardToolsHtml(s, { deletable, archivable })}</div>`;
371
+ }
372
+
373
+ function cardToolsHtml(s, { deletable, archivable }) {
374
+ if (!deletable && !archivable) return "";
375
+ const short = escapeHtml(s.short_id);
376
+ const arch = !archivable ? "" : s.archived
377
+ ? `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Unarchive: back to the lists" aria-label="Unarchive session ${short}"></button>`
378
+ : `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Archive: hide from the lists, keep for good" aria-label="Archive session ${short}"></button>`;
379
+ const del = deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${short}">\u2715</button>` : "";
380
+ return `<div class="card-tools">${arch}${del}</div>`;
356
381
  }
357
382
 
358
383
  // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
@@ -375,30 +400,62 @@ function fitStripColumns() {
375
400
  topStripEl.style.setProperty("--strip-cols", stripCols);
376
401
  }
377
402
 
378
- // The order a list draws its cards in: the sessions that wait on the user
379
- // first (waitingFirst), back to their place once answered. Never under the
403
+ // The order a list draws its cards in: +next+, what waits on the user
404
+ // first (waitingFirst), back to its place once answered. Never under the
380
405
  // pointer, though: while it is over the list the order last drawn holds,
381
- // and its leaving draws the list again.
382
- const drawnOrder = new Map(); // list element → ids in the order last drawn
406
+ // and its leaving draws the list again. +idOf+ names a card: a session's
407
+ // id, or familyId (a card per family: its head's id). drawnOrder is the
408
+ // cards drawn, which select mode picks from.
409
+ const drawnOrder = new Map(); // list element → card ids in the order last drawn
383
410
  const heldLists = new Set(); // lists drawn in a held order
384
411
  const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
385
- function listOrder(el, list) {
386
- const next = waitingFirst(list);
387
- const held = canHover && el.matches(":hover") ? drawnOrder.get(el) : null;
388
- const ordered = held ? heldOrder(held, next) : next;
389
- if (held && ordered.some((s, i) => s !== next[i])) heldLists.add(el);
412
+ function listOrder(el, next, idOf = (s) => s.id) {
413
+ // The strip's popover holds it too (the pointer leaves the strip for it,
414
+ // and a touch screen has no hover at all).
415
+ const holding = (canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen());
416
+ const held = holding ? drawnOrder.get(el) : null;
417
+ const ordered = held ? heldOrder(held, next, idOf) : next;
418
+ if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
390
419
  else heldLists.delete(el);
391
- drawnOrder.set(el, ordered.map((s) => s.id));
420
+ drawnOrder.set(el, ordered.map(idOf));
392
421
  return ordered;
393
422
  }
394
423
 
395
- function stripSessions() {
396
- return listOrder(topStripEl, shownSessions()).slice(0, stripCols);
424
+ // The strip's cards: families in the list's order (a family in its
425
+ // first-listed member's place, so a hub upsert doesn't move it), waiting
426
+ // ones first, as many as fit. A family is one card.
427
+ function stripCards() {
428
+ return listOrder(topStripEl, waitingFirst(families(shownSessions(), { order: "list" })), familyId).slice(0, stripCols);
397
429
  }
398
430
 
431
+ // The strip's family popover (families_view.js): the open family's rows,
432
+ // under its chip.
433
+ let stripFamilies = new Map(); // head id → family, as the strip last drew it
434
+ const familyPop = createFamilyPop({
435
+ doc: document,
436
+ win: window,
437
+ rowsFor: (id) => {
438
+ const f = stripFamilies.get(id);
439
+ return f && f.members.length ? familyRowsHtml(f, { activeId: selected }) : null;
440
+ },
441
+ anchorFor: (id) => [...topStripEl.querySelectorAll(".card[data-id] .family-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null,
442
+ onPick: (id) => {
443
+ familyPop.close();
444
+ select(id);
445
+ },
446
+ // A strip order held while it was open catches up (unless the pointer is
447
+ // back over the strip: its leaving does it then).
448
+ onClose: () => {
449
+ if (heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
450
+ },
451
+ });
452
+ window.addEventListener("keydown", (e) => familyPop.onKeydown(e), true);
453
+ document.addEventListener("pointerdown", (e) => familyPop.onPointerDown(e), true);
454
+ document.addEventListener("scroll", () => familyPop.reposition(), true);
455
+
399
456
  function render() {
400
457
  fitStripColumns();
401
- renderTop(stripSessions());
458
+ renderTop(stripCards());
402
459
  if (allViewOpen()) renderAll();
403
460
  }
404
461
 
@@ -408,36 +465,77 @@ window.addEventListener("resize", () => {
408
465
  stripResizeTimer = setTimeout(() => {
409
466
  const before = stripCols;
410
467
  fitStripColumns();
411
- if (stripCols !== before && allSessions.length) renderTop(stripSessions());
468
+ if (stripCols !== before && allSessions.length) renderTop(stripCards());
469
+ familyPop.reposition();
412
470
  }, 150);
413
471
  });
414
472
 
415
- function bindCards(container, onPick) {
473
+ // onPick: a card's click (open it, or pick it in select mode); onFamily:
474
+ // a family chip's (toggle its list); onRow: a family row's (open that
475
+ // delegate).
476
+ function bindCards(container, onPick, { onFamily = () => {}, onRow = () => {} } = {}) {
416
477
  container.querySelectorAll(".card[data-id]").forEach((el) => {
417
478
  el.addEventListener("click", (e) => {
479
+ const chip = e.target.closest(".family-chip");
480
+ if (chip) {
481
+ e.stopPropagation();
482
+ onFamily(el.dataset.id, chip, e);
483
+ return;
484
+ }
418
485
  const del = e.target.closest(".card-del");
419
486
  if (del) {
420
487
  e.stopPropagation();
421
488
  confirmDelete(del.dataset.del);
422
489
  return;
423
490
  }
424
- onPick(el.dataset.id);
491
+ // Archive from the list: the view stays open, the toast has Undo.
492
+ const arch = e.target.closest(".card-arch");
493
+ if (arch) {
494
+ e.stopPropagation();
495
+ const id = arch.dataset.arch;
496
+ setArchived(id, !allSessions.find((x) => x.id === id)?.archived);
497
+ return;
498
+ }
499
+ const row = e.target.closest(".family-row[data-id]");
500
+ if (row) {
501
+ e.stopPropagation();
502
+ onRow(row.dataset.id, e);
503
+ return;
504
+ }
505
+ onPick(el.dataset.id, e);
425
506
  });
426
- if (selected && el.dataset.id === selected) el.classList.add("active");
507
+ });
508
+ paintActive();
509
+ }
510
+
511
+ // The open session's marks: its card (.active), its row in a family's list
512
+ // (in a card or the strip's popover), and a quieter .contains-active on the
513
+ // card of the family it is folded into.
514
+ function paintActive() {
515
+ document.querySelectorAll(".card[data-id]").forEach((el) => {
516
+ el.classList.toggle("active", !!selected && el.dataset.id === selected);
517
+ el.classList.toggle("contains-active", !!selected && (el.dataset.members || "").split(" ").includes(selected));
518
+ });
519
+ document.querySelectorAll(".family-row[data-id]").forEach((el) => {
520
+ el.classList.toggle("active", !!selected && el.dataset.id === selected);
427
521
  });
428
522
  }
429
523
 
430
- // The latest sessions (as many as fit), then the way to all of them.
431
- function renderTop(sessions) {
524
+ // The latest sessions (as many as fit, a family one card), then the way
525
+ // to all of them.
526
+ function renderTop(cards) {
432
527
  topStripEl.classList.remove("loading");
433
528
  renderStripShow();
529
+ stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
434
530
  if (scopeError) {
435
531
  topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref("", null, location.search))}">show every session</a></div>`;
532
+ familyPop.close();
436
533
  return;
437
534
  }
438
535
  // Only archived sessions: no cards, but the tile stays, the way to them.
439
536
  if (!allSessions.length) {
440
537
  topStripEl.innerHTML = "";
538
+ familyPop.close();
441
539
  return;
442
540
  }
443
541
  const n = shownSessions().length;
@@ -445,37 +543,252 @@ function renderTop(sessions) {
445
543
  ? `${n} ${n === 1 ? "session" : "sessions"}`
446
544
  : `${allSessions.length - n} archived`;
447
545
  const tile = `<div class="strip-side"><button type="button" class="card" id="allTile" title="All sessions, with search">All sessions →<span class="count">${count}</span></button><button type="button" class="strip-toggle" id="stripHide" title="Hide the latest sessions">hide ▴</button></div>`;
448
- topStripEl.innerHTML = sessions.map((s) => cardHtml(s)).join("") + tile;
449
- bindCards(topStripEl, select);
546
+ topStripEl.innerHTML = cards.map((f) => cardHtml(f.head, { family: f, open: familyPop.openHead() === f.head.id })).join("") + tile;
547
+ bindCards(topStripEl, select, {
548
+ onFamily: (id, _chip, e) => {
549
+ familyPop.toggle(id);
550
+ // From the keyboard (Enter / Space: no pointer, detail 0) the focus
551
+ // goes into the popover, which sits at the end of body; Esc brings it back.
552
+ if (e.detail === 0 && familyPop.isOpen()) $("#familyPop .fr-open")?.focus();
553
+ },
554
+ });
450
555
  $("#allTile").addEventListener("click", openAllView);
451
556
  $("#stripHide").addEventListener("click", () => setStripHidden(true));
557
+ familyPop.refresh();
452
558
  }
453
559
 
454
560
  // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
455
- // following the list.
561
+ // following the list: its cards (a family is one), the live and waiting
562
+ // sessions (a waiting delegate too).
456
563
  function renderStripShow() {
457
- const { count, live, waiting } = stripShowParts(shownSessions());
564
+ const shown = shownSessions();
565
+ const { count, live, waiting } = stripShowParts(shown, { cards: families(shown).length });
458
566
  const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
459
567
  const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
460
568
  $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
461
569
  }
462
570
 
463
- // A delegated session sits right after its parent: applied on every
464
- // render, since a hub snapshot hands the list back in the server's order.
571
+ // A parent's card holds its delegates (families, by the newest member: a
572
+ // hub snapshot hands the list back in the server's order), folded unless
573
+ // one waits on the user, a search matched one or the user opened it. The
574
+ // search runs over the families, so a matching delegate brings its parent's
575
+ // card along. Select mode picks cards (a family's rows have no checkbox:
576
+ // archiving the parent takes its delegates).
577
+ const familyToggles = new Map(); // head id → open, the user's chip clicks (this page's life)
578
+ const searchToggles = new Map(); // the same while a search matched a member; a new search clears it
579
+ const drawnOpen = new Map(); // head id → open as last drawn in all sessions
580
+
465
581
  function renderAll() {
466
582
  const listed = allViewSessions();
467
- const sessions = listOrder(allListEl, withChildrenAfterParents(filteredSessions(listed)));
468
- $("#count").textContent = sessions.length === listed.length
469
- ? `${listed.length}`
470
- : `${sessions.length} / ${listed.length}`;
471
- if (!sessions.length) {
583
+ const q = filterQuery();
584
+ const all = families(listed);
585
+ const shown = q ? filterFamilies(all, (s) => matchesFilter(s, q)) : all;
586
+ const fams = listOrder(allListEl, waitingFirst(shown), familyId);
587
+ const delegates = all.reduce((n, f) => n + f.members.length, 0);
588
+ $("#count").textContent = fams.length === all.length
589
+ ? `${all.length}${delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : ""}`
590
+ : `${fams.length} / ${all.length}`;
591
+ updateSelectBar();
592
+ if (!fams.length) {
593
+ drawnOpen.clear();
472
594
  allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
473
595
  return;
474
596
  }
475
- allListEl.innerHTML = sessions.map((s) => cardHtml(s, { deletable: true })).join("");
476
- bindCards(allListEl, (id) => { leaveAllView(sessionHash(id)); select(id); });
597
+ // A hub event redraws the list under a checkbox or a chip the keyboard is on.
598
+ const focused = document.activeElement?.matches?.("#allList .card-pick, #allList .family-chip")
599
+ ? { id: document.activeElement.closest(".card")?.dataset.id, sel: document.activeElement.matches(".card-pick") ? ".card-pick" : ".family-chip" } : null;
600
+ const opts = (s) => (selecting.on
601
+ ? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
602
+ : { deletable: true, archivable: true });
603
+ drawnOpen.clear();
604
+ allListEl.innerHTML = fams.map((f) => {
605
+ const matched = !!f.matchIds?.size;
606
+ const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
607
+ drawnOpen.set(f.head.id, open);
608
+ return cardHtml(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
609
+ }).join("");
610
+ bindCards(allListEl, (id, e) => {
611
+ if (selecting.on) {
612
+ togglePick(id, e);
613
+ return;
614
+ }
615
+ leaveAllView(sessionHash(id));
616
+ select(id);
617
+ }, {
618
+ onFamily: (headId) => {
619
+ const matched = !!q && !!fams.find((f) => f.head.id === headId)?.matchIds?.size;
620
+ (matched ? searchToggles : familyToggles).set(headId, !drawnOpen.get(headId));
621
+ renderAll();
622
+ },
623
+ // In select mode a row does nothing: rows aren't picked.
624
+ onRow: (id) => {
625
+ if (selecting.on) return;
626
+ leaveAllView(sessionHash(id));
627
+ select(id);
628
+ },
629
+ });
630
+ if (focused?.id) allListEl.querySelector(`.card[data-id="${focused.id}"] ${focused.sel}`)?.focus();
631
+ }
632
+
633
+ // ── Select mode (all sessions) ─────────────────────────────────────────────
634
+ // "Select" turns the cards into picks: a click (or Shift-click, a range in
635
+ // the drawn order) toggles one instead of opening it, and the bar acts on
636
+ // the picked ones that are shown. The picks live here, not in the DOM:
637
+ // renderAll draws the list again on every hub event.
638
+ const selecting = {
639
+ on: false,
640
+ picked: new Set(),
641
+ last: null, // the last toggled id: a Shift-click's range starts there
642
+ skipReasons: new Map(), // id → why the last batch skipped it
643
+ running: null, // {done, total, archive} while a batch runs
644
+ };
645
+
646
+ function enterSelect() {
647
+ selecting.on = true;
648
+ renderAll();
649
+ }
650
+
651
+ function leaveSelect() {
652
+ selecting.on = false;
653
+ selecting.picked.clear();
654
+ selecting.last = null;
655
+ selecting.skipReasons.clear();
656
+ if (allViewOpen()) renderAll();
657
+ else updateSelectBar();
658
+ }
659
+
660
+ function togglePick(id, e) {
661
+ if (selecting.running) return; // the grid reflows while a batch runs
662
+ const order = drawnOrder.get(allListEl) || [];
663
+ const on = !selecting.picked.has(id);
664
+ const ids = e?.shiftKey ? rangeIds(order, selecting.last, id) : [id];
665
+ for (const x of ids) {
666
+ if (on) selecting.picked.add(x);
667
+ else selecting.picked.delete(x);
668
+ paintPick(x);
669
+ }
670
+ selecting.last = id;
671
+ updateSelectBar();
672
+ }
673
+
674
+ // A card's pick state in place (no redraw: the focus stays put).
675
+ function paintPick(id) {
676
+ const el = allListEl.querySelector(`.card[data-id="${id}"]`);
677
+ if (!el) return;
678
+ const on = selecting.picked.has(id);
679
+ el.classList.toggle("picked", on);
680
+ const box = el.querySelector(".card-pick");
681
+ if (box) box.checked = on;
682
+ }
683
+
684
+ // The bar's numbers: picks of sessions still in the list (a snapshot can
685
+ // drop one with no session_gone), and the shown ones a batch would act on.
686
+ function selectCounts() {
687
+ const byId = new Map(allSessions.map((s) => [s.id, s]));
688
+ const shown = drawnOrder.get(allListEl) || [];
689
+ const shownSet = new Set(shown);
690
+ const live = [...selecting.picked].filter((id) => byId.has(id));
691
+ return {
692
+ picked: live.length,
693
+ hidden: live.filter((id) => !shownSet.has(id)).length,
694
+ archive: batchTargets(selecting.picked, shown, true, byId),
695
+ unarchive: batchTargets(selecting.picked, shown, false, byId),
696
+ };
477
697
  }
478
698
 
699
+ function updateSelectBar() {
700
+ $("#allView").classList.toggle("selecting", selecting.on);
701
+ $("#selectBtn").hidden = selecting.on;
702
+ const bar = $("#selectBar");
703
+ bar.hidden = !selecting.on;
704
+ if (!selecting.on) return;
705
+ const c = selectCounts();
706
+ const run = selecting.running;
707
+ $("#selectCount").textContent = run
708
+ ? `${run.archive ? "Archiving" : "Unarchiving"} ${run.done}/${run.total}\u2026`
709
+ : `${c.picked} selected${c.hidden ? ` \u00b7 ${c.hidden} not shown` : ""}`;
710
+ $("#batchArchiveBtn").textContent = `Archive ${c.archive.length}`;
711
+ $("#batchArchiveBtn").disabled = !!run || !c.archive.length;
712
+ $("#batchUnarchiveBtn").textContent = `Unarchive ${c.unarchive.length}`;
713
+ $("#batchUnarchiveBtn").hidden = !c.unarchive.length;
714
+ $("#batchUnarchiveBtn").disabled = !!run;
715
+ $("#selectAllBtn").disabled = !!run;
716
+ $("#selectDoneBtn").disabled = !!run;
717
+ }
718
+
719
+ // The batch: the picked shown ones, one request at a time in the drawn
720
+ // order (parents before their delegates), through the info bar's
721
+ // archiveOne. A refused one stays picked with the server's reason on its
722
+ // card; the end toast's Undo runs the opposite batch on the ones that went.
723
+ async function runSelectBatch(archive) {
724
+ if (selecting.running) return;
725
+ const byId = new Map(allSessions.map((s) => [s.id, s]));
726
+ const ids = batchTargets(selecting.picked, drawnOrder.get(allListEl) || [], archive, byId);
727
+ if (!ids.length) return;
728
+ selecting.skipReasons.clear();
729
+ const outcome = await archiveBatch(ids, archive);
730
+ if (!outcome) return;
731
+ if (!allViewOpen()) leaveSelect(); // left the view while it ran
732
+ else renderAll();
733
+ showToast(batchToastText(batchCounts(outcome, archive)), outcome.done.length ? {
734
+ label: "Undo",
735
+ run: async () => {
736
+ const undone = await archiveBatch(outcome.done, !archive);
737
+ if (!undone) return;
738
+ if (allViewOpen()) renderAll();
739
+ showToast(batchToastText(batchCounts(undone, !archive)));
740
+ },
741
+ } : null);
742
+ }
743
+
744
+ // runBatch over archiveOne, with the bar's progress; the picks and skip
745
+ // reasons follow the outcome. null when a batch already runs.
746
+ async function archiveBatch(ids, archive) {
747
+ if (selecting.running) return null;
748
+ selecting.running = { done: 0, total: ids.length, archive };
749
+ updateSelectBar();
750
+ try {
751
+ const outcome = await runBatch(ids, async (id) => {
752
+ const answer = await archiveOne(id, archive);
753
+ if (answer.ok) {
754
+ render();
755
+ updateInfoBar();
756
+ }
757
+ return answer;
758
+ }, (done) => {
759
+ selecting.running.done = done;
760
+ updateSelectBar();
761
+ });
762
+ for (const id of [...outcome.done, ...outcome.covered, ...outcome.gone]) {
763
+ selecting.picked.delete(id);
764
+ selecting.skipReasons.delete(id);
765
+ }
766
+ for (const { id, reason } of outcome.skipped) selecting.skipReasons.set(id, reason);
767
+ return outcome;
768
+ } finally {
769
+ selecting.running = null;
770
+ updateSelectBar();
771
+ if (!eventsControl) refresh();
772
+ }
773
+ }
774
+
775
+ $("#batchArchiveBtn").addEventListener("click", () => runSelectBatch(true));
776
+ $("#batchUnarchiveBtn").addEventListener("click", () => runSelectBatch(false));
777
+ $("#selectBtn").addEventListener("click", enterSelect);
778
+ $("#selectDoneBtn").addEventListener("click", leaveSelect);
779
+ $("#selectAllBtn").addEventListener("click", () => {
780
+ for (const id of drawnOrder.get(allListEl) || []) {
781
+ selecting.picked.add(id);
782
+ paintPick(id);
783
+ }
784
+ updateSelectBar();
785
+ });
786
+ // A Shift-click picks a range: keep the browser from selecting the text
787
+ // between the two cards.
788
+ allListEl.addEventListener("mousedown", (e) => {
789
+ if (selecting.on && e.shiftKey) e.preventDefault();
790
+ });
791
+
479
792
  // Delete a session (the info bar's button, an all-sessions card's ✕) after
480
793
  // a confirm; the list drops it without a reload. The server stops a live
481
794
  // worker first and refuses a session a terminal chi has open.
@@ -542,10 +855,13 @@ function applyView() {
542
855
  const open = allViewOpen();
543
856
  if (!open) {
544
857
  allViewPushed = false;
858
+ // A running batch ends on its own and clears the picks then.
859
+ if (selecting.on && !selecting.running) leaveSelect();
545
860
  topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
546
861
  }
547
862
  document.body.classList.toggle("all-view", open);
548
863
  if (open) {
864
+ familyPop.close(); // the strip is hidden in the all view
549
865
  // The list is live; only its order can lag (upserts keep cards in
550
866
  // place), so the all view opens in the server's order.
551
867
  allSessions = sortedByUpdated(allSessions);
@@ -582,7 +898,7 @@ function updateComposerMode() {
582
898
  fitPrompt();
583
899
  }
584
900
  if (selected) modelPicker.close();
585
- const running = turnRunning && !!selected;
901
+ const running = liveTurn.running && !!selected;
586
902
  cancelBtn.style.display = running ? "" : "none";
587
903
  if (running) {
588
904
  setPlaceholder("Running… a message now steers this turn · Enter to send · Shift+Enter newline", "Steer the turn…");
@@ -615,7 +931,7 @@ function updateComposerMode() {
615
931
  }
616
932
 
617
933
  function setTurnRunning(running) {
618
- turnRunning = !!running;
934
+ liveTurn.running = !!running;
619
935
  updateComposerMode();
620
936
  updateInfoBar();
621
937
  }
@@ -673,15 +989,14 @@ function clearSelection() {
673
989
  seedInitTasks(initTasks, []);
674
990
  renderInitRow();
675
991
  setSessionHash(null);
676
- document.querySelectorAll(".card").forEach((el) => el.classList.remove("active"));
992
+ paintActive();
677
993
  resetSessionState();
678
994
  contextChips.clear();
679
- clearTimingInterval();
680
- liveTurnTimingEl = null;
681
- liveTurnId = null;
995
+ liveTurn.forget();
682
996
  setTurnRunning(false);
683
997
  closeStream();
684
998
  setMarkdownWarning(null);
999
+ setCapabilities(null);
685
1000
  updateComposerMode();
686
1001
  updateInfoBar();
687
1002
  updateFirstPeek();
@@ -712,9 +1027,7 @@ async function select(id) {
712
1027
  clearNotice();
713
1028
  selected = id;
714
1029
  setSessionHash(id);
715
- document.querySelectorAll(".card").forEach((el) =>
716
- el.classList.toggle("active", el.dataset.id === id),
717
- );
1030
+ paintActive();
718
1031
  historyEl.innerHTML = `<div class="hint">Loading…</div>`;
719
1032
  emptyEl.classList.add("hidden");
720
1033
  closeStream();
@@ -731,15 +1044,12 @@ async function select(id) {
731
1044
  // Clear everything that belongs to one turn's rendering.
732
1045
  function resetTurnView() {
733
1046
  closeStream();
734
- clearTimingInterval();
735
- liveTurnTimingEl = null;
736
- liveTurnId = null;
1047
+ // workerGone and a re-render come through here: the answer_display wait
1048
+ // goes too.
1049
+ liveTurn.forget();
737
1050
  questionCards.removeQuestion();
738
1051
  questionCards.forgetContinue();
739
- unmatchedMerge = false;
740
- wakeStart = false;
741
1052
  setTurnRunning(false);
742
- releaseDisplayWait();
743
1053
  }
744
1054
 
745
1055
  // (Re)render the selected session from the server and stream on from the
@@ -765,7 +1075,6 @@ async function resync({ endText = null } = {}) {
765
1075
  return;
766
1076
  }
767
1077
  if (id !== selected) return;
768
- releaseDisplayWait();
769
1078
  resetTurnView();
770
1079
  renderSessionView(data);
771
1080
  // Preview is read-only: a worker is woken only by POST /turn, so attach
@@ -788,13 +1097,14 @@ async function resync({ endText = null } = {}) {
788
1097
  function renderSessionView(data) {
789
1098
  const s = data.session;
790
1099
  setMarkdownWarning(data.markdown_warning);
1100
+ setCapabilities(data.capabilities);
791
1101
  current.status = s.status;
792
1102
  current.model = s.model_name;
793
1103
  current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
794
1104
  current.dir = s.working_directory;
795
1105
  current.owner = s.owner || null;
796
1106
  current.timing = normalizeTiming(data.timing);
797
- turnsEnded = current.timing.turnRecords.length;
1107
+ liveTurn.timing.ended = current.timing.turnRecords.length;
798
1108
  const activeTurnId = current.timing.activeTurn?.id || null;
799
1109
  // The saved fill, so a reloaded or stopped session shows it before its
800
1110
  // next turn (a running turn's replay below streams over it).
@@ -838,20 +1148,21 @@ function renderSessionView(data) {
838
1148
  else showRecap(data.recap);
839
1149
  // A live worker's last cards and between-turns notices, where they
840
1150
  // arrived; the running turn's go into its step once it is drawn.
841
- const snapshotCards = splitSnapshotCards(data.cards);
1151
+ const snapshotCards = splitSnapshotCards(data.cards, pageCapabilities);
842
1152
  placeSnapshotCards(snapshotCards.placed);
843
1153
  const replay = snapshotEvents({
844
1154
  current_turn: data.current_turn,
845
1155
  queued: data.queued,
846
1156
  started_at: current.timing.activeTurn?.started_at,
847
1157
  });
848
- replay.forEach((event) => streamHandlers[event.type]?.(event));
1158
+ // Marked replayed: a call's duration isn't timed from the replay's clock.
1159
+ replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
849
1160
  if (!data.current_turn) {
850
1161
  setTurnRunning(current.status === "running");
851
- if (turnRunning && current.timing.activeTurn?.started_at) startLiveTurnTiming(current.timing.activeTurn.started_at, activeTurnId);
852
- } else if (turnRunning) {
1162
+ if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
1163
+ } else if (liveTurn.running) {
853
1164
  // The replayed turn_started carries no id: the running turn's record's.
854
- liveTurnId = activeTurnId;
1165
+ liveTurn.timing.turnId = activeTurnId;
855
1166
  }
856
1167
  turnDom.flush();
857
1168
  snapshotCards.current.forEach((card) => upsertCard(card));
@@ -893,19 +1204,8 @@ function setMarkdownWarning(warning) {
893
1204
  markdownWarningEl.hidden = false;
894
1205
  }
895
1206
 
896
- // The turn-end re-read (turn_completed): the final answer's rendered
897
- // markdown, the status and the timing. Kept as turnEndRead, so an
898
- // answer_display re-read waits for it.
899
- let turnEndRead = Promise.resolve();
900
- // The wait of a turn_completed with display_pending for its answer_display
901
- // (turn_events.js displayWait). The worker may die in between and never send
902
- // one, so workerGone (through resetTurnView) and a re-render release it as
903
- // well as its own timeout: it must never hold the turn's answer forever.
904
- let displayPending = null;
905
-
906
- function releaseDisplayWait() {
907
- displayPending?.finish();
908
- displayPending = null;
1207
+ function setCapabilities(capabilities) {
1208
+ pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
909
1209
  }
910
1210
 
911
1211
  async function readTurnEnd() {
@@ -913,7 +1213,7 @@ async function readTurnEnd() {
913
1213
  const id = selected;
914
1214
  // The line and the turn this read is for, before the await: a turn
915
1215
  // queued behind this one may start (a new live line) before it lands.
916
- const line = { el: liveTurnTimingEl, turnId: liveTurnId };
1216
+ const line = liveTurn.timing.capture();
917
1217
  try {
918
1218
  const d = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
919
1219
  if (id !== selected) return;
@@ -921,8 +1221,9 @@ async function readTurnEnd() {
921
1221
  if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
922
1222
  if (d.session.status) current.status = d.session.status;
923
1223
  if (!(await mergeTurnTiming(id, d.timing))) return;
924
- finishTurnLine(line);
1224
+ liveTurn.timing.finishLine(line);
925
1225
  setMarkdownWarning(d.markdown_warning);
1226
+ setCapabilities(d.capabilities);
926
1227
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
927
1228
  updateInfoBar();
928
1229
  const sess = allSessions.find(s=>s.id===selected);
@@ -931,7 +1232,7 @@ async function readTurnEnd() {
931
1232
  sess.status = current.status;
932
1233
  }
933
1234
  render();
934
- document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
1235
+ paintActive();
935
1236
  }
936
1237
  } catch(_){}
937
1238
  }
@@ -994,15 +1295,6 @@ function addEndBubble(text, { stopped = false } = {}) {
994
1295
  if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
995
1296
  }
996
1297
 
997
- // The notice of a turn that ended with no answer (turn_summary.empty_answer),
998
- // as a reload draws it (turn_view.js EMPTY_ANSWER_CLASS).
999
- function emptyAnswerEl(emptyAnswer) {
1000
- const el = document.createElement("div");
1001
- el.className = EMPTY_ANSWER_CLASS;
1002
- el.textContent = emptyAnswerLine(emptyAnswer);
1003
- return el;
1004
- }
1005
-
1006
1298
  // Everything a turn's end (completed, canceled, failed) closes: then its
1007
1299
  // end line (+endText+, the cancel or failed line), then the step cards that
1008
1300
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
@@ -1010,13 +1302,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1010
1302
  setTurnRunning(false);
1011
1303
  // Every ended turn has a record (completed, canceled, failed); a worker
1012
1304
  // gone mid-turn is re-rendered (resync), which counts again.
1013
- if (kind !== "gone") turnsEnded += 1;
1305
+ if (kind !== "gone") liveTurn.timing.ended += 1;
1014
1306
  // A completed turn with no answer: its notice where the answer goes
1015
1307
  // (the turn view places it), else after the turn; before the timing.
1016
- const endNote = emptyAnswer ? emptyAnswerEl(emptyAnswer) : null;
1308
+ const endNote = emptyAnswer ? htmlElement(emptyAnswerHtml(emptyAnswer)) : null;
1017
1309
  const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
1018
1310
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
1019
- finishLiveTurnTiming(null, { canceled: kind === "canceled" });
1311
+ liveTurn.timing.finish({ canceled: kind === "canceled" });
1020
1312
  if (endText) addEndBubble(endText, { stopped });
1021
1313
  if (kept.length) {
1022
1314
  kept.forEach((el) => appendToHistory(el));
@@ -1031,28 +1323,13 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1031
1323
  // turn starts, or steered when merged into a running turn. This tab's own
1032
1324
  // prompts are echoed at send (data-own) and tagged by their turn_enqueued.
1033
1325
 
1034
- const BADGES = { queued: "queued", steered: "steered", failed: "failed" };
1035
-
1326
+ // The bubble a reload draws (turn_html.js), with this page's own fields.
1036
1327
  function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null } = {}) {
1037
1328
  const hint = historyEl.querySelector(".hint");
1038
1329
  if (hint) hint.remove();
1039
- const div = document.createElement("div");
1040
- div.className = "bubble user";
1041
- div.dataset.userContent = normalize(prompt);
1042
- div.setAttribute(COPY_SOURCE_ATTR, prompt);
1043
- if (own) div.dataset.own = "1";
1044
- if (enqueuedId) div.dataset.enqueuedId = enqueuedId;
1045
- if (label) {
1046
- const from = document.createElement("span");
1047
- from.className = "origin-label";
1048
- from.textContent = label;
1049
- div.appendChild(from);
1050
- }
1051
- const message = document.createElement("div");
1052
- message.className = "user-message";
1053
- message.innerHTML = userBodyHtml(prompt) + thumbsHtml(selected, images);
1054
- div.appendChild(message);
1055
- setBubbleState(div, state);
1330
+ const div = htmlElement(userBubbleHtml({ content: prompt }, {
1331
+ label, state, own, enqueuedId, key: normalize(prompt), thumbs: thumbsHtml(selected, images),
1332
+ }));
1056
1333
  const follow = isNearBottom();
1057
1334
  appendToHistory(div);
1058
1335
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
@@ -1068,10 +1345,7 @@ function setBubbleState(bubble, state) {
1068
1345
  }
1069
1346
  bubble.classList.add(state);
1070
1347
  bubble.dataset.userState = state;
1071
- const badge = document.createElement("span");
1072
- badge.className = "state-badge";
1073
- badge.textContent = BADGES[state];
1074
- bubble.appendChild(badge);
1348
+ bubble.insertAdjacentHTML("beforeend", stateBadgeHtml(state));
1075
1349
  }
1076
1350
 
1077
1351
  function bubbleById(enqueuedId) {
@@ -1091,18 +1365,18 @@ function untaggedOwnBubble(prompt) {
1091
1365
  // Returns the bubble of the turn a turn_started starts (the stage takes it
1092
1366
  // into its prompt line), else null.
1093
1367
  function applyPromptOps(event) {
1094
- if (event.type === "turn_started") wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
1095
- const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge, wakeStart });
1096
- if (event.type === "input_merged") unmatchedMerge = false;
1097
- if (event.type === "pending_input_merged") unmatchedMerge = false;
1368
+ if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
1369
+ const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
1370
+ if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
1371
+ if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
1098
1372
  let started = null;
1099
1373
  for (const op of ops) {
1100
1374
  if (op.op === "report") {
1101
- unmatchedMerge = "report";
1375
+ liveTurn.unmatchedMerge = "report";
1102
1376
  } else if (op.op === "add") {
1103
1377
  const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images });
1104
1378
  if (op.state === "started") started = bubble;
1105
- if (event.type === "pending_input_merged") wakeStart = false;
1379
+ if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
1106
1380
  } else if (op.op === "tag") {
1107
1381
  if (bubbleById(op.enqueuedId)) continue;
1108
1382
  const echo = untaggedOwnBubble(op.prompt);
@@ -1122,7 +1396,7 @@ function applyPromptOps(event) {
1122
1396
  } else if (op.op === "steer") {
1123
1397
  const bubble = bubbleById(op.enqueuedId);
1124
1398
  if (bubble) setBubbleState(bubble, "steered");
1125
- else unmatchedMerge = true;
1399
+ else liveTurn.unmatchedMerge = true;
1126
1400
  }
1127
1401
  }
1128
1402
  return started;
@@ -1150,7 +1424,7 @@ function showRecap(text, turnsSince = 0) {
1150
1424
  // A stale one sits where it was written, above the turns it doesn't cover.
1151
1425
  // Never in the stage: a recap is between turns.
1152
1426
  const before = recapPlace(turnPromptBubbles(), turnsSince);
1153
- keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurnTimingEl)));
1427
+ keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
1154
1428
  }
1155
1429
 
1156
1430
  // A turn started: the recap describes the chat before it.
@@ -1402,6 +1676,11 @@ function removeRecap() {
1402
1676
  let attachCopiedAt = 0;
1403
1677
  infoTextEl.addEventListener("click", (e) => {
1404
1678
  if (e.target.closest(".worker-restart")) restartWorker();
1679
+ // Pushed as openAllView does, so the view's back returns here.
1680
+ if (e.target.closest(".children-chip")) {
1681
+ e.preventDefault();
1682
+ openAllView();
1683
+ }
1405
1684
  });
1406
1685
  infoTextEl.addEventListener("click", async (e) => {
1407
1686
  if (!e.target.closest(".copy-attach") || !selected) return;
@@ -1484,10 +1763,13 @@ function updateInfoBar() {
1484
1763
  // A delegated session names its parent, as a link to it.
1485
1764
  const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
1486
1765
  const parentHtml = parentChip ? `<a class="model parent" href="${escapeHtml(sessionHash(current.parentId))}" title="${escapeHtml(parentChip.title)}">delegated by ${escapeHtml(String(current.parentId).slice(0, 8))}</a>` : "";
1766
+ // A parent names its delegates; the link opens all sessions.
1767
+ const kids = childrenSummary({ id: selected }, allSessions);
1768
+ const kidsHtml = kids ? `<a class="model children-chip${kids.waitingText ? " waiting" : ""}" href="${escapeHtml(ALL_SESSIONS_HASH)}" title="${escapeHtml(`${kids.title} (all sessions)`)}">${escapeHtml(kids.text)}${kids.waitingText ? ` · <span class="kids-waiting">${escapeHtml(kids.waitingText)}</span>` : ""}</a>` : "";
1487
1769
  const attachCmd = `chi --attach ${selected}`;
1488
1770
  const copied = Date.now() - attachCopiedAt < 2000;
1489
1771
  const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
1490
- const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1772
+ const metaHtml = `<div class="meta"><span class="id" title="${escapeHtml(selected)}">${escapeHtml(idShort)}</span>${copyHtml}<span class="${statusClass}">${escapeHtml(current.status||"")}</span><span class="model${label.mismatch ? " served-mismatch" : ""}" title="${escapeHtml(label.title)}">${escapeHtml(label.text)}</span>${parentHtml}${kidsHtml}${current.usedMemories.length ? `<span class="model mem-chip" title="${escapeHtml(memChipTitle(current.usedMemories))}">${escapeHtml(memChipText(current.usedMemories))}</span>` : ""}<span class="dir" title="${escapeHtml(current.dir||"")}"><bdi>${escapeHtml(current.dir||"")}</bdi></span>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
1491
1773
  const previewHtml = `<div class="preview-line"><span class="preview">${escapeHtml(current.firstPreview || "—")}</span> · <span class="mem" title="${escapeHtml(current.usedMemories.join(", "))}">mem: ${escapeHtml(memText)}</span></div>`;
1492
1774
  // The session clock ticks every second while a turn runs: only its text
1493
1775
  // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
@@ -1505,80 +1787,16 @@ function memoriesTooltip(used, preloaded, muted) {
1505
1787
  }
1506
1788
 
1507
1789
  function currentSessionDuration() {
1508
- if (turnRunning && current.timing.startedAt) {
1790
+ if (liveTurn.running && current.timing.startedAt) {
1509
1791
  return elapsedSince(current.timing.startedAt);
1510
1792
  }
1511
1793
  return current.timing.sessionDurationMs;
1512
1794
  }
1513
1795
 
1514
- function clearTimingInterval() {
1515
- if (timingInterval !== null) {
1516
- clearInterval(timingInterval);
1517
- timingInterval = null;
1518
- }
1519
- }
1520
-
1521
- let liveTurnNumber = null;
1522
- // The live line sits in the stage's status row, which already says the turn
1523
- // runs ("running a tool"): it drops its own "running" there. It never moves
1524
- // mid-turn (finishLiveTurnTiming rewrites it before any hand-off).
1525
- let liveTurnTimingInStage = false;
1526
-
1527
- function startLiveTurnTiming(startedAt = new Date().toISOString(), turnId = null) {
1528
- clearTimingInterval();
1529
- current.timing.activeTurn = turnId ? { id: turnId, started_at: startedAt } : { started_at: startedAt };
1530
- liveTurnId = turnId || null;
1531
- // The turns ended so far, so this one comes next.
1532
- liveTurnNumber = turnsEnded + 1;
1533
- liveTurnNote = "";
1534
- liveTurnTimingEl = document.createElement("div");
1535
- liveTurnTimingEl.className = "turn-timing live";
1536
- // The stage's status row while it holds the turn: the line moves into the
1537
- // history with it.
1538
- const stageHost = turnDom.timingHost?.() || null;
1539
- liveTurnTimingInStage = !!stageHost;
1540
- (stageHost || historyEl).appendChild(liveTurnTimingEl);
1541
- updateLiveTiming();
1542
- timingInterval = setInterval(updateLiveTiming, 1000);
1543
- }
1544
-
1545
1796
  // The stage takes a running turn's rows (its extras); else the history.
1546
1797
  function appendToHistory(el) {
1547
1798
  if (turnDom.place?.(el)) return;
1548
- appendAboveLiveTiming(historyEl, el, liveTurnTimingEl);
1549
- }
1550
-
1551
- function updateLiveTiming() {
1552
- const duration = elapsedSince(current.timing.activeTurn?.started_at);
1553
- if (liveTurnTimingEl && duration !== null) {
1554
- liveTurnTimingEl.textContent = turnTimingText(liveTurnNumber, duration, { running: !liveTurnTimingInStage }) + (liveTurnNote ? ` · ${liveTurnNote}` : "");
1555
- }
1556
- updateInfoBar();
1557
- }
1558
-
1559
- // Set (or clear, "") what the running turn waits on.
1560
- function setLiveTurnNote(text) {
1561
- if (liveTurnNote === text) return;
1562
- liveTurnNote = text;
1563
- if (current.timing.activeTurn) updateLiveTiming();
1564
- }
1565
-
1566
- // A canceled turn's line says so (+canceled+, or its record's status), as
1567
- // a reload's does.
1568
- function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
1569
- const duration = Number.isFinite(Number(record?.duration_ms))
1570
- ? Number(record.duration_ms)
1571
- : elapsedSince(current.timing.activeTurn?.started_at);
1572
- if (liveTurnTimingEl && duration !== null) {
1573
- liveTurnNote = "";
1574
- liveTurnTimingEl.classList.remove("live");
1575
- const at = record ? current.timing.turnRecords.indexOf(record) : -1;
1576
- liveTurnTimingEl.textContent = turnTimingText(at >= 0 ? at + 1 : liveTurnNumber, duration,
1577
- { canceled: canceled || record?.status === "canceled" });
1578
- }
1579
- current.timing.activeTurn = null;
1580
- clearTimingInterval();
1581
- updateInfoBar();
1799
+ appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
1582
1800
  }
1583
1801
 
1584
1802
  // A turn-end re-read's timing (?tail=1&recent=1: the newest turn's records
@@ -1587,7 +1805,7 @@ function finishLiveTurnTiming(record = null, { canceled = false } = {}) {
1587
1805
  // turn that started meanwhile keeps its line and its own active turn.
1588
1806
  // @return false when the session changed meanwhile
1589
1807
  async function mergeTurnTiming(id, data) {
1590
- const keepActive = () => (turnRunning ? current.timing.activeTurn : null);
1808
+ const keepActive = () => (liveTurn.running ? current.timing.activeTurn : null);
1591
1809
  const merged = mergeTiming(current.timing, data);
1592
1810
  current.timing = { ...merged.timing, activeTurn: keepActive() };
1593
1811
  if (merged.short) {
@@ -1599,30 +1817,10 @@ async function mergeTurnTiming(id, data) {
1599
1817
  if (id !== selected) return false;
1600
1818
  }
1601
1819
  }
1602
- turnsEnded = Math.max(turnsEnded, current.timing.turnRecords.length);
1820
+ liveTurn.timing.ended = Math.max(liveTurn.timing.ended, current.timing.turnRecords.length);
1603
1821
  return true;
1604
1822
  }
1605
1823
 
1606
- // Finish the timing line a turn-end read captured (+line+: {el, turnId})
1607
- // with that turn's record: its place in the records and its duration (a
1608
- // canceled one says so). No id (an older worker's events): the last
1609
- // record. No record: the line stays as the turn's end left it. The ticker
1610
- // stops only if that line is still the live one.
1611
- function finishTurnLine(line) {
1612
- const records = current.timing.turnRecords;
1613
- const record = line.turnId ? records.find((r) => r.id === line.turnId) : records[records.length - 1];
1614
- if (line.el === liveTurnTimingEl) {
1615
- current.timing.activeTurn = null;
1616
- clearTimingInterval();
1617
- }
1618
- const duration = Number(record?.duration_ms);
1619
- if (line.el && record && Number.isFinite(duration)) {
1620
- line.el.classList.remove("live");
1621
- line.el.textContent = turnTimingText(records.indexOf(record) + 1, duration, { canceled: record.status === "canceled" });
1622
- }
1623
- updateInfoBar();
1624
- }
1625
-
1626
1824
  function updateFirstPeek() {
1627
1825
  if (current.ctxPct !== null && current.ctxPct > 20 && current.firstPreview) {
1628
1826
  firstPeekEl.textContent = current.firstPreview;
@@ -1640,7 +1838,7 @@ function setUsedMemories(names) {
1640
1838
  if (sess) {
1641
1839
  sess.used_memory_names = current.usedMemories.slice();
1642
1840
  render();
1643
- document.querySelectorAll(".card").forEach(el=>el.classList.toggle("active", el.dataset.id===selected));
1841
+ paintActive();
1644
1842
  }
1645
1843
  }
1646
1844
 
@@ -1683,7 +1881,7 @@ function workerGone(id, { opened = true } = {}) {
1683
1881
  if (id !== selected) return;
1684
1882
  // A turn that was running is over: its worker can't finish it.
1685
1883
  const stopped = stoppingId === id;
1686
- const endText = workerGoneText({ turnRunning, stopped });
1884
+ const endText = workerGoneText({ turnRunning: liveTurn.running, stopped });
1687
1885
  if (endText) {
1688
1886
  endTurnView("gone", endText, { stopped });
1689
1887
  if (!stopped) setLiveStatus("idle");
@@ -1712,7 +1910,7 @@ const streamHandlers = {
1712
1910
  // Route the chunk's bytes to the text bubble and the thinking block (see
1713
1911
  // chunk_router.js). Raw `content` is only a fallback for an older server.
1714
1912
  const { text, thinking } = routeChunk(data);
1715
- setLiveTurnNote("");
1913
+ liveTurn.timing.setNote("");
1716
1914
  turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
1717
1915
  const pct = extractCtxPct(data, current.ctxWindow);
1718
1916
  if (pct !== null && !Number.isNaN(pct)) {
@@ -1723,11 +1921,11 @@ const streamHandlers = {
1723
1921
  },
1724
1922
  generation_started: (data) => {
1725
1923
  if (data?.context_window_tokens) current.ctxWindow = data.context_window_tokens;
1726
- setLiveTurnNote("");
1924
+ liveTurn.timing.setNote("");
1727
1925
  turnDom.generationStarted(data || {});
1728
1926
  },
1729
1927
  generation_completed: (data) => {
1730
- setLiveTurnNote("");
1928
+ liveTurn.timing.setNote("");
1731
1929
  if (data?.served_model) current.served = [data.served_model, data.requested_model];
1732
1930
  // The generation's speed and the session's token totals, from the server.
1733
1931
  current.timing.tokens = generationTokens(current.timing.tokens, data);
@@ -1764,10 +1962,9 @@ const streamHandlers = {
1764
1962
  const promptBubble = applyPromptOps(data);
1765
1963
  setTurnRunning(true);
1766
1964
  setLiveStatus("running");
1767
- clearTimingInterval();
1768
- liveTurnTimingEl = null;
1965
+ liveTurn.timing.detach();
1769
1966
  turnDom.turnStarted({ ...data, promptBubble });
1770
- startLiveTurnTiming(data.started_at, data.turn_id || null);
1967
+ liveTurn.timing.start(data.started_at, data.turn_id || null);
1771
1968
  }),
1772
1969
  turn_completed: async (data) => {
1773
1970
  endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
@@ -1778,9 +1975,9 @@ const streamHandlers = {
1778
1975
  // that dies before it never sends one: workerGone releases the wait, and
1779
1976
  // displayWait's timeout ends it whatever happens (4.14).
1780
1977
  const display = data.display_pending ? displayWait() : null;
1781
- if (display) displayPending = display;
1782
- turnEndRead = readTurnEnd();
1783
- await turnEndRead;
1978
+ if (display) liveTurn.displayPending = display;
1979
+ liveTurn.endRead = readTurnEnd();
1980
+ await liveTurn.endRead;
1784
1981
  // The answer bubble the turn view held at its box's height pops now
1785
1982
  // that the rendered markdown is in it (or the re-read failed: then the
1786
1983
  // plain text pops), and the display if one was coming, so the links
@@ -1793,13 +1990,13 @@ const streamHandlers = {
1793
1990
  // turn_completed, so re-read the answer once that read is done.
1794
1991
  answer_display: async (data) => {
1795
1992
  try {
1796
- await turnEndRead;
1993
+ await liveTurn.endRead;
1797
1994
  if (!selected || !data.display) return;
1798
1995
  const d = await getSession(selected, { tail: true, recent: true });
1799
1996
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1800
1997
  } catch (_) {
1801
1998
  } finally {
1802
- releaseDisplayWait();
1999
+ liveTurn.releaseDisplay();
1803
2000
  }
1804
2001
  },
1805
2002
  turn_canceled: (data) => {
@@ -1811,8 +2008,8 @@ const streamHandlers = {
1811
2008
  // stays up: it rolls the turn back and hands the prompt back
1812
2009
  // (prompt_restored), and the prompts queued behind it run as usual.
1813
2010
  turn_failed: (data) => {
1814
- lastFailedText = failedTurnText(data);
1815
- endTurnView("failed", lastFailedText);
2011
+ liveTurn.failedText = failedTurnText(data);
2012
+ endTurnView("failed", liveTurn.failedText);
1816
2013
  setLiveStatus("idle");
1817
2014
  },
1818
2015
  // The rolled-back turn left the session: render it from the server again,
@@ -1826,8 +2023,8 @@ const streamHandlers = {
1826
2023
  fitPrompt();
1827
2024
  }
1828
2025
  if (restoredImages?.length) setChips(restoredChips(selected, restoredImages));
1829
- const failedText = lastFailedText;
1830
- lastFailedText = null;
2026
+ const failedText = liveTurn.failedText;
2027
+ liveTurn.failedText = null;
1831
2028
  await resync();
1832
2029
  // The re-render drew it from the session (failed_turn): drawn again
1833
2030
  // below with this event's label and images.
@@ -1904,19 +2101,24 @@ const streamHandlers = {
1904
2101
  recap_ready: (data) => {
1905
2102
  // After the turn it covers, never above it.
1906
2103
  turnDom.handOff?.({ immediate: true });
1907
- if (!turnRunning) showRecap(data.recap);
2104
+ if (!liveTurn.running) showRecap(data.recap);
1908
2105
  },
1909
2106
  guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
1910
- // A notice during a turn is a row of its current step.
1911
- hook_notice: (data) => { if (!turnDom.notice(data)) showHookNotice(data); },
2107
+ // A notice during a turn is a row of its current step. One the page
2108
+ // shows elsewhere (a hook's links in the rendered answer) isn't drawn,
2109
+ // not even in the stage trail; a joined turn's notices come here too.
2110
+ hook_notice: (data) => {
2111
+ if (shownElsewhere(data, pageCapabilities)) return;
2112
+ if (!turnDom.notice(data)) showHookNotice(data);
2113
+ },
1912
2114
  // The loop asks again after an empty answer: a row of the empty step
1913
2115
  // (a reload puts it back from the snapshot's cards).
1914
2116
  empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
1915
2117
  steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data) }); },
1916
2118
  // The provider is asked again after an error, or the turn waits for
1917
2119
  // plugins' setup: a note on the live timing line until it streams.
1918
- generation_retrying: (data) => setLiveTurnNote(retryStatusLine(data)),
1919
- plugin_init_wait: (data) => setLiveTurnNote(initWaitLine(data)),
2120
+ generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
2121
+ plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
1920
2122
  plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
1921
2123
  plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
1922
2124
  // A plugin's card, or a new version of one (the same id).
@@ -1939,12 +2141,12 @@ const streamHandlers = {
1939
2141
  async function refreshTimingFromSession() {
1940
2142
  if (!selected) return;
1941
2143
  const id = selected;
1942
- const line = { el: liveTurnTimingEl, turnId: liveTurnId };
2144
+ const line = liveTurn.timing.capture();
1943
2145
  try {
1944
2146
  const data = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
1945
2147
  if (id !== selected) return;
1946
2148
  if (data.session?.status) current.status = data.session.status;
1947
- if (await mergeTurnTiming(id, data.timing)) finishTurnLine(line);
2149
+ if (await mergeTurnTiming(id, data.timing)) liveTurn.timing.finishLine(line);
1948
2150
  } catch (_) {}
1949
2151
  }
1950
2152
 
@@ -2429,25 +2631,40 @@ function markArchived(ids, archived) {
2429
2631
  allSessions = allSessions.map((s) => (set.has(s.id) ? { ...s, archived } : s));
2430
2632
  }
2431
2633
 
2432
- async function setArchived(id, archive, { undo = true } = {}) {
2433
- const short = id.slice(0, 8);
2434
- let result;
2435
- // A live worker is stopped by the archive: its stream ending is a stop.
2436
- if (archive) stoppingId = id;
2634
+ // One archive / unarchive request, the core the info bar, a card's button
2635
+ // and a batch share. The ids in the answer flip in the list at once. When
2636
+ // the open session's worker was stopped (it, or a delegate of the archived
2637
+ // one), its stream ending is a stop, not a lost worker.
2638
+ // @return {ok: true, result} | {ok: false, error}
2639
+ async function archiveOne(id, archive) {
2640
+ // A live worker in the tree is stopped by the archive: the open
2641
+ // session's stream ending then is a stop.
2642
+ const mine = archive && selected && inTree(allSessions, id, selected) ? selected : null;
2643
+ if (mine) stoppingId = mine;
2437
2644
  try {
2438
- result = archive ? await archiveSession(id) : await unarchiveSession(id);
2645
+ const result = archive ? await archiveSession(id) : await unarchiveSession(id);
2439
2646
  markArchived(archive ? result.archived : result.unarchived, archive);
2440
- if (archive && id === selected && (result.stopped || []).includes(id)) {
2647
+ if (mine && mine === selected && (result.stopped || []).includes(mine)) {
2441
2648
  // Its worker is gone: don't wait for its stream to notice.
2442
2649
  streamControl?.close();
2443
2650
  current.status = "stopped";
2444
- workerGone(id);
2651
+ workerGone(mine);
2445
2652
  }
2446
- } catch (e) {
2447
- alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${e.message}`);
2448
- return;
2653
+ return { ok: true, result };
2654
+ } catch (error) {
2655
+ return { ok: false, error };
2449
2656
  } finally {
2450
- if (archive) stoppingId = null;
2657
+ // Another call may have taken it meanwhile: clear only our own.
2658
+ if (mine && stoppingId === mine) stoppingId = null;
2659
+ }
2660
+ }
2661
+
2662
+ async function setArchived(id, archive, { undo = true } = {}) {
2663
+ const short = id.slice(0, 8);
2664
+ const answer = await archiveOne(id, archive);
2665
+ if (!answer.ok) {
2666
+ alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
2667
+ return;
2451
2668
  }
2452
2669
  render();
2453
2670
  updateInfoBar();
@@ -2482,7 +2699,7 @@ const annotateBar = createAnnotateBar({
2482
2699
  presets: parsePresets(document.body.dataset.annotatePresets),
2483
2700
  canAnnotate: () => !!selected && current.owner !== "tui",
2484
2701
  roots: turnRoots,
2485
- isLive: (root) => turnRunning && turnDom.containsLive(root),
2702
+ isLive: (root) => liveTurn.running && turnDom.containsLive(root),
2486
2703
  onQuote: (text) => {
2487
2704
  promptEl.value = appendQuote(promptEl.value, text);
2488
2705
  fitPrompt();
@@ -2521,6 +2738,7 @@ function applyStripHidden() {
2521
2738
  }));
2522
2739
  }
2523
2740
  function setStripHidden(hidden) {
2741
+ if (hidden) familyPop.close();
2524
2742
  try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
2525
2743
  stripShownWhileShort = !hidden && shortWindow.matches;
2526
2744
  applyStripHidden();
@@ -2533,17 +2751,26 @@ shortWindow.addEventListener("change", () => {
2533
2751
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
2534
2752
  $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
2535
2753
 
2536
- $("#filter").addEventListener("input", renderAll);
2754
+ $("#filter").addEventListener("input", () => {
2755
+ searchToggles.clear();
2756
+ renderAll();
2757
+ });
2537
2758
  // A list whose order was held under the pointer (listOrder) catches up.
2538
2759
  topStripEl.addEventListener("pointerleave", () => {
2539
- if (heldLists.has(topStripEl)) renderTop(stripSessions());
2760
+ // Not while its family popover is open: the pointer went there (close() catches up).
2761
+ if (heldLists.has(topStripEl) && !familyPop.isOpen()) renderTop(stripCards());
2540
2762
  });
2541
2763
  allListEl.addEventListener("pointerleave", () => {
2542
2764
  if (heldLists.has(allListEl) && allViewOpen()) renderAll();
2543
2765
  });
2544
- // Esc leaves the all-sessions view.
2766
+ // Esc leaves select mode first, then the all-sessions view.
2545
2767
  document.addEventListener("keydown", (e) => {
2546
- if (e.key === "Escape" && allViewOpen()) closeAllView();
2768
+ if (e.key !== "Escape" || !allViewOpen()) return;
2769
+ if (selecting.on) {
2770
+ if (!selecting.running) leaveSelect();
2771
+ return;
2772
+ }
2773
+ closeAllView();
2547
2774
  });
2548
2775
  applyView();
2549
2776