samagotchi 0.54.0 → 0.56.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -1
  3. data/bin/chi +3 -2
  4. data/docs/architecture.md +6 -2
  5. data/docs/cli.md +29 -7
  6. data/docs/configuration.md +113 -8
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/context-telemetry.md +23 -5
  9. data/docs/internals/engine.md +54 -18
  10. data/docs/internals/prompt-caching.md +8 -0
  11. data/docs/sessions.md +2 -2
  12. data/docs/testing.md +23 -3
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -6
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/cache_confirm.rb +51 -0
  17. data/lib/samagotchi/client_id.rb +12 -0
  18. data/lib/samagotchi/config.rb +9 -3
  19. data/lib/samagotchi/engine.rb +102 -8
  20. data/lib/samagotchi/engine_peers.rb +1 -0
  21. data/lib/samagotchi/file_ref.rb +59 -0
  22. data/lib/samagotchi/host_config.rb +6 -3
  23. data/lib/samagotchi/host_registry.rb +4 -3
  24. data/lib/samagotchi/kernel_loop.rb +99 -16
  25. data/lib/samagotchi/llm/chat_loop.rb +122 -71
  26. data/lib/samagotchi/llm/openai_chat.rb +2 -1
  27. data/lib/samagotchi/llm/stream_drops.rb +63 -0
  28. data/lib/samagotchi/llm/turn_settings.rb +9 -5
  29. data/lib/samagotchi/model_settings.rb +5 -2
  30. data/lib/samagotchi/reasoning_history.rb +132 -0
  31. data/lib/samagotchi/reasoning_history_command.rb +107 -0
  32. data/lib/samagotchi/self_report.rb +21 -0
  33. data/lib/samagotchi/session.rb +23 -2
  34. data/lib/samagotchi/session_commands.rb +55 -15
  35. data/lib/samagotchi/session_manager.rb +2 -1
  36. data/lib/samagotchi/session_setup.rb +7 -5
  37. data/lib/samagotchi/setting_explained.rb +20 -0
  38. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
  39. data/lib/samagotchi/terminal_ui/attached_loop.rb +11 -0
  40. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  41. data/lib/samagotchi/terminal_ui.rb +13 -6
  42. data/lib/samagotchi/thinking.rb +20 -25
  43. data/lib/samagotchi/thinking_command.rb +33 -11
  44. data/lib/samagotchi/tool_row_fields.rb +46 -0
  45. data/lib/samagotchi/tool_runner.rb +7 -11
  46. data/lib/samagotchi/tools/builtins.rb +6 -2
  47. data/lib/samagotchi/tools/peers.rb +4 -2
  48. data/lib/samagotchi/tools/task_runtime.rb +2 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +48 -4
  51. data/lib/samagotchi/web/capabilities.rb +13 -9
  52. data/lib/samagotchi/web/editor.rb +38 -0
  53. data/lib/samagotchi/web/message_parts.rb +5 -10
  54. data/lib/samagotchi/web/public/activity.js +33 -18
  55. data/lib/samagotchi/web/public/app.js +256 -1133
  56. data/lib/samagotchi/web/public/commands.js +269 -0
  57. data/lib/samagotchi/web/public/composer.js +105 -0
  58. data/lib/samagotchi/web/public/diff_view.js +30 -4
  59. data/lib/samagotchi/web/public/index.html +11 -3
  60. data/lib/samagotchi/web/public/info_bar.js +80 -0
  61. data/lib/samagotchi/web/public/level_chip.js +174 -0
  62. data/lib/samagotchi/web/public/lists_view.js +725 -0
  63. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  64. data/lib/samagotchi/web/public/model_pick.js +1 -1
  65. data/lib/samagotchi/web/public/question_cards.js +2 -1
  66. data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
  67. data/lib/samagotchi/web/public/refs.js +134 -0
  68. data/lib/samagotchi/web/public/restore.js +81 -0
  69. data/lib/samagotchi/web/public/session_state.js +57 -0
  70. data/lib/samagotchi/web/public/stream_handlers.js +332 -0
  71. data/lib/samagotchi/web/public/thinking_chip.js +38 -144
  72. data/lib/samagotchi/web/public/turn_events.js +17 -258
  73. data/lib/samagotchi/web/public/turn_html.js +5 -1
  74. data/lib/samagotchi/web/server.rb +4 -4
  75. data/lib/samagotchi/worker.rb +17 -5
  76. metadata +18 -2
  77. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -23,52 +23,56 @@ import {
23
23
  } from "./data.js";
24
24
  import { createContextChips } from "./context_chips.js";
25
25
  import {
26
- chipHtml as llmContextChipHtml, chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
26
+ chipModel as llmContextChipModel, choiceWords, createLlmContextControl, NEW_CHAT,
27
27
  withChoice,
28
28
  } from "./llm_context_chip.js";
29
29
  import {
30
- chipHtml as thinkingChipHtml, chipModel as thinkingChipModel, createThinkingControl,
30
+ chipModel as thinkingChipModel, createThinkingControl,
31
31
  withChoice as withThinkingChoice,
32
32
  } from "./thinking_chip.js";
33
- import { renderStable } from "./stable_html.js";
33
+ import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
34
+ import { renderInfoBar } from "./info_bar.js";
34
35
  import { createShownTexts } from "./shown_texts.js";
35
36
  import { MODEL_KEY } from "./model_pick.js";
36
- import { emptySessionState } from "./session_state.js";
37
+ import { emptySessionState, sessionStateFrom } from "./session_state.js";
37
38
  import { createModelPicker } from "./model_picker.js";
38
39
  import { chipName, chipsHtml, imageFiles, restoredChips, thumbsHtml, turnRefs, turnText } from "./images.js";
39
- import { escapeHtml, memChipText, memChipTitle, noteHtml, normalize, steerRowHtml, ownerBadge, previewForCard, firstPreview, relativeTime, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
40
+ import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
40
41
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
41
42
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
42
43
  import { createStageView } from "./stage_view.js";
43
- import { createFamilyPop, familyChipHtml, familyRowsHtml } from "./families_view.js";
44
- import { CONTINUE_OPENER, allViewChains, chainChipHtml, chainTitle, isOpener, chainDate, chainNeighbours, chainRowsHtml, continueRefusal, continuedText, foldChains, openChildrenHtml } from "./chains.js";
44
+ import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
45
45
  import { createTaskStopper } from "./task_stop.js";
46
46
  import { appendQuote } from "./annotations.js";
47
47
  import { parsePresets } from "./annotate_presets.js";
48
48
  import { createAnnotateBar } from "./annotate_bar.js";
49
49
  import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
50
- import { routeChunk } from "./chunk_router.js";
51
- import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, ctxBarTip, generationTokens, lastSpeedText, speedText } from "./ctx.js";
50
+ import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
52
51
  import { createQuestionCards } from "./question_cards.js";
53
- import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
52
+ import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
54
53
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
55
54
  import { createCommandList } from "./command_list.js";
56
55
  import { createPromptHistory } from "./prompt_history.js";
57
56
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
58
- import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
57
+ import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
59
58
  import { shouldFollowScroll, keepFollowing } from "./scroll.js";
60
- import { SHORT_WINDOW_QUERY, stripAutoHidden, stripColumns, stripShowParts } from "./strip.js";
61
- 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";
59
+ import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
60
+ import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
62
61
  import { createNotifications } from "./notifications.js";
63
62
  import { tabTitle, topBarTitle } from "./session_title.js";
64
63
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
65
- import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
66
64
  import {
67
- appendAboveLiveTiming, cancelLineText, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
65
+ appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
68
66
  } from "./timing.js";
69
67
  import { createLiveTurn } from "./live_turn.js";
68
+ import { installRefClicks, setRefKinds } from "./refs.js";
70
69
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
71
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerChoiceRoute, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
70
+ import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
71
+ import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
72
+ import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
73
+ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
74
+ import { createStreamHandlers } from "./stream_handlers.js";
75
+ import { createListsView } from "./lists_view.js";
72
76
 
73
77
  const $ = (s) => document.querySelector(s);
74
78
  const topStripEl = $("#topStrip");
@@ -131,6 +135,15 @@ const thinkingControl = createThinkingControl({
131
135
  running: () => liveTurn.running,
132
136
  run: (line) => runSessionCommand(line),
133
137
  });
138
+ // The open session's reasoning history chip, after the thinking one (a
139
+ // chat host's only): /reasoning-history runs in the session's worker.
140
+ const reasoningHistoryControl = createReasoningHistoryControl({
141
+ el: infoTextEl,
142
+ summary: () => (selected ? current.reasoningHistory : null),
143
+ session: () => selected,
144
+ running: () => liveTurn.running,
145
+ run: (line) => runSessionCommand(line),
146
+ });
134
147
  // The start page's thinking chip: the picked model's level, and a level
135
148
  // for the next new chat only (sent with its create, then dropped).
136
149
  const thinkingNewEl = $("#thinkingNew");
@@ -170,7 +183,11 @@ const markdownWarningEl = $("#markdownWarning");
170
183
  // What the page renders that a notice may stand in for (the server's
171
184
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
172
185
  // drawn (shownElsewhere). An older server sends none: every notice shows.
186
+ // And what this viewer can do (editor: open a path in this machine's
187
+ // editor; per request), with the editor's URL template (editor_url) next
188
+ // to it: refs.js's actions read both (pageViewer).
173
189
  let pageCapabilities = {};
190
+ let pageEditorUrl = null;
174
191
  const promptEl = $("#prompt");
175
192
  const actionBtn = $("#actionBtn");
176
193
  const cancelBtn = $("#cancelBtn");
@@ -286,7 +303,7 @@ async function refresh() {
286
303
  allSessions = [];
287
304
  renderScope();
288
305
  }
289
- render();
306
+ lists.render();
290
307
  }
291
308
 
292
309
  // The list as a projection of the hub's events: a snapshot on every
@@ -298,14 +315,14 @@ function openSessionEvents() {
298
315
  noticeVersions(data);
299
316
  notifications.noticeSessions("snapshot", data);
300
317
  allSessions = applySessionEvent(allSessions, "snapshot", data);
301
- render();
318
+ lists.render();
302
319
  const sess = allSessions.find((s) => s.id === selected);
303
320
  if (sess) followSelectedSession(sess);
304
321
  },
305
322
  session: (data) => {
306
323
  notifications.noticeSessions("session", data);
307
324
  allSessions = applySessionEvent(allSessions, "session", data);
308
- render();
325
+ lists.render();
309
326
  if (data.session && data.session.id === selected) followSelectedSession(data.session);
310
327
  // Its children chip; its chain's ← → links (a link that continues it, its previous one).
311
328
  else if (selected && (data.session?.parent_id === selected || data.session?.continues === selected
@@ -313,12 +330,11 @@ function openSessionEvents() {
313
330
  },
314
331
  session_gone: (data) => {
315
332
  notifications.noticeSessions("session_gone", data);
316
- selecting.picked.delete(data.id);
317
- selecting.skipReasons.delete(data.id);
333
+ lists.forget(data.id);
318
334
  const next = applySessionEvent(allSessions, "session_gone", data);
319
335
  if (next !== allSessions) {
320
336
  allSessions = next;
321
- render();
337
+ lists.render();
322
338
  if (selected) updateInfoBar(); // a deleted child leaves the children chip, a deleted chain link its ← → link
323
339
  }
324
340
  leaveGoneSession(data.id, "deleted");
@@ -399,582 +415,30 @@ function renderScope() {
399
415
  });
400
416
  }
401
417
 
402
- // The strip, its count and the hidden strip's pill leave archived sessions
403
- // out; the all view shows them with "include archived". allSessions keeps
404
- // them all (parent chips, the open session).
405
- function shownSessions() {
406
- return listedSessions(allSessions, false);
407
- }
408
-
409
- function allViewSessions() {
410
- return listedSessions(allSessions, $("#includeArchived").checked);
411
- }
412
-
413
- function filterQuery() {
414
- return $("#filter").value.trim().toLowerCase();
415
- }
416
-
417
- // The page's sessions by id, built again only when the list changed.
418
- let byIdOf = { list: null, map: new Map() };
419
- function sessionsById() {
420
- if (byIdOf.list !== allSessions) byIdOf = { list: allSessions, map: new Map(allSessions.map((s) => [s.id, s])) };
421
- return byIdOf.map;
422
- }
423
-
424
- // A card's title: its preview, or its chain's first link's for a link
425
- // titled with the opener (chains.js chainTitle; display only).
426
- function cardTitle(s) {
427
- return chainTitle(s, sessionsById());
428
- }
429
-
430
- // Whether a session matches the all view's search +q+ (lower case).
431
- function matchesFilter(s, q) {
432
- return cardTitle(s).toLowerCase().includes(q) ||
433
- (s.first_preview || "").toLowerCase().includes(q) ||
434
- s.short_id.toLowerCase().includes(q) ||
435
- s.id.toLowerCase().includes(q) ||
436
- s.status.toLowerCase().includes(q) ||
437
- waitingSearchText(s).includes(q);
438
- }
439
-
440
- // A card: the first message as its title, then status, how long ago, owner
441
- // and memories. The id is in the tooltip (and the search) only. A session
442
- // that waits on the user (a question, an approval, a card with actions)
443
- // wears the warning colour and says what for.
444
- // deletable / archivable: the all-sessions view's cards get a delete and an
445
- // archive (unarchive, on an archived card) button. selectable: select mode's
446
- // checkbox instead (picked: checked), and skipReason: why a batch skipped it.
447
- // family: the family the session heads (sessions_list.js families): with
448
- // members, its chip (which opens their list), and the card waits when any
449
- // member does; with rows (all sessions) an open family lists its members
450
- // inline, the strip's opens in a popover; matchIds: a search's matching
451
- // members. A family in a session chain (chains.js foldChains) carries its
452
- // `day N` chip, which opens the other links in a popover.
453
- function cardHtml(s, { deletable = false, archivable = false, selectable = false, picked = false, skipReason = null, family = null, open = false, rows = false, matchIds = null } = {}) {
454
- const fam = family && family.members.length ? family : null;
455
- const ts = s.updated_at || s.created_at || "";
456
- const tip = `${s.id} · updated ${ts} · ${s.status}`;
457
- const preview = escapeHtml(cardTitle(s));
458
- const memTitle = s.used_memory_names && s.used_memory_names.length ? `mem: ${s.used_memory_names.join(", ")}` : "";
459
- const ago = relativeTime(ts);
460
- const timeHtml = ago ? `<span class="time" title="${escapeHtml(new Date(ts).toLocaleString())}">${escapeHtml(ago)}</span>` : "";
461
- const folder = cardFolder(s.working_directory, scope.projectName);
462
- const folderHtml = folder ? `<span class="folder" title="${escapeHtml(s.working_directory)}">${escapeHtml(folder)}</span>` : "";
463
- const archivedHtml = s.archived ? `<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : "";
464
- const ctx = cardCtxText(s.ctx_pct);
465
- const ctxHtml = ctx ? `<span class="ctx" title="${escapeHtml(ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct }))}">${escapeHtml(ctx)}</span>` : "";
466
- const waiting = waitingBadge(s);
467
- const waitingHtml = waiting ? `<span class="attn attn-${waiting.kind}" title="${escapeHtml(waiting.title)}">${escapeHtml(waiting.text)}</span>` : "";
468
- // A turn a hook stopped wears its own badge, unless the session waits on
469
- // the user (the waiting badge wins: the stop is behind the need).
470
- const stopped = waiting ? null : stoppedBadge(s);
471
- const stoppedHtml = stopped ? `<span class="stopped-badge" title="${escapeHtml(stopped.title)}">${escapeHtml(stopped.text)}</span>` : "";
472
- const dotWord = waiting ? waiting.title : s.status;
473
- const pickHtml = selectable ? `<input type="checkbox" class="card-pick" aria-label="Select session ${escapeHtml(s.short_id)}"${picked ? " checked" : ""}>` : "";
474
- const skipHtml = skipReason ? `<div class="skip-reason">skipped: ${escapeHtml(skipReason)}</div>` : "";
475
- const familyWaiting = !!family && familyWaits(family);
476
- const chainHtml = family?.chain ? chainChipHtml(family, { open: chainOpenHead() === s.id }) : "";
477
- const kidsHtml = fam ? familyChipHtml(fam, { open }) : "";
478
- const rowsHtml = fam && rows && open ? familyRowsHtml(fam, { activeId: selected, matchIds, archivable: !selectable }) : "";
479
- // Its delegates and its chain's other links: the card is .contains-active while one is open.
480
- const memberIds = [...(fam ? fam.members.map((m) => m.session.id) : []),
481
- ...(family?.chain ? family.chain.links.map((x) => x.id).filter((id) => id !== s.id) : [])];
482
- const membersAttr = memberIds.length ? ` data-members="${escapeHtml(memberIds.join(" "))}"` : "";
483
- 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)}${chainHtml}${kidsHtml}${ownerBadgeHtml(s)}${archivedHtml}</div>${rowsHtml}${skipHtml}${pickHtml}${selectable ? "" : cardToolsHtml(s, { deletable, archivable })}</div>`;
484
- }
485
-
486
- function cardToolsHtml(s, { deletable, archivable }) {
487
- if (!deletable && !archivable) return "";
488
- const short = escapeHtml(s.short_id);
489
- const arch = !archivable ? "" : s.archived
490
- ? `<button type="button" class="card-arch" data-arch="${escapeHtml(s.id)}" title="Unarchive: back to the lists" aria-label="Unarchive session ${short}"></button>`
491
- : `<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>`;
492
- const del = deletable ? `<button type="button" class="card-del" data-del="${escapeHtml(s.id)}" title="Delete session" aria-label="Delete session ${short}">\u2715</button>` : "";
493
- return `<div class="card-tools">${arch}${del}</div>`;
494
- }
495
-
496
- // A delegated session's `↳ <parent>` chip; the parent's preview is the tooltip.
497
- function parentChipHtml(s) {
498
- const chip = delegatedBy(s.parent_id, allSessions.find((x) => x.id === s.parent_id) || null);
499
- return chip ? `<span class="parent" title="${escapeHtml(chip.title)}">${escapeHtml(chip.text)}</span>` : "";
500
- }
501
-
502
- function ownerBadgeHtml(s) {
503
- const badge = ownerBadge(s.owner, s.id);
504
- return badge ? `<span class="owner ${badge.kind}" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}</span>` : "";
505
- }
506
-
507
- // The strip's card count follows the window: more cards on wide screens
508
- // (each capped at 440 px) instead of 3 stretched ones. main's 16 px padding
509
- // sits on both sides; measuring main works while the strip is hidden too.
510
- let stripCols = 3;
511
- function fitStripColumns() {
512
- stripCols = stripColumns(topStripEl.parentElement.clientWidth - 32);
513
- topStripEl.style.setProperty("--strip-cols", stripCols);
514
- }
515
-
516
- // The order a list draws its cards in: +next+, what waits on the user
517
- // first (waitingFirst), back to its place once answered. Never under the
518
- // pointer, though: while it is over the list the order last drawn holds,
519
- // and its leaving draws the list again. A hidden list holds nothing: the
520
- // strip keeps :hover for a moment after a click on its All sessions tile
521
- // hid it, and an update held then brought it back from the all view in an
522
- // order nobody saw (a waiting family behind). +idOf+ names a card: a
523
- // session's id, or familyId (a card per family: its head's id). drawnOrder
524
- // is the cards drawn, which select mode picks from.
525
- const drawnOrder = new Map(); // list element → card ids in the order last drawn
526
- const heldLists = new Set(); // lists drawn in a held order
527
- const canHover = globalThis.matchMedia?.("(hover: hover)").matches ?? false;
528
- function listOrder(el, next, idOf = (s) => s.id) {
529
- // The strip's popover holds it too (the pointer leaves the strip for it,
530
- // and a touch screen has no hover at all).
531
- const holding = el.getClientRects().length > 0
532
- && ((canHover && el.matches(":hover")) || (el === topStripEl && familyPop.isOpen()) || chainPopIn(el));
533
- const held = holding ? drawnOrder.get(el) : null;
534
- const ordered = held ? heldOrder(held, next, idOf) : next;
535
- if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
536
- else heldLists.delete(el);
537
- drawnOrder.set(el, ordered.map(idOf));
538
- return ordered;
539
- }
540
-
541
- // The strip's cards: families in the list's order (a family in its
542
- // first-listed member's place, so a hub upsert doesn't move it), waiting
543
- // ones first, as many as fit. A family is one card.
544
- function stripCards() {
545
- return listOrder(topStripEl, waitingFirst(foldChains(families(shownSessions(), { order: "list" }), allSessions)), familyId).slice(0, stripCols);
546
- }
547
-
548
- // The strip's family popover (families_view.js): the open family's rows,
549
- // under its chip.
550
- let stripFamilies = new Map(); // head id → family, as the strip last drew it
551
- const familyPop = createFamilyPop({
418
+ // The session lists (lists_view.js): the strip, all sessions, their cards,
419
+ // select mode, the family and chain popovers.
420
+ const lists = createListsView({
552
421
  doc: document,
553
422
  win: window,
554
- rowsFor: (id) => {
555
- const f = stripFamilies.get(id);
556
- return f && f.members.length ? familyRowsHtml(f, { activeId: selected }) : null;
557
- },
558
- anchorFor: (id) => [...topStripEl.querySelectorAll(".card[data-id] .family-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null,
559
- onPick: (id) => {
560
- familyPop.close();
561
- select(id);
562
- },
563
- // A strip order held while it was open catches up (unless the pointer is
564
- // back over the strip: its leaving does it then).
565
- onClose: () => {
566
- if (heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
567
- },
568
- });
569
- window.addEventListener("keydown", (e) => familyPop.onKeydown(e), true);
570
- document.addEventListener("pointerdown", (e) => familyPop.onPointerDown(e), true);
571
- document.addEventListener("scroll", () => familyPop.reposition(), true);
572
-
573
- // A chain's popover (chains.js): the other links of the chain a card's
574
- // `day N` chip stands for, under the chip, in the strip or all sessions
575
- // (the list it was opened from: chainFrom). A row opens that link.
576
- let chainFamilies = new Map(); // head id → family, as the lists last drew it
577
- let chainFrom = null; // the list element the open popover's chip is in
578
- const chainPop = createFamilyPop({
579
- id: "chainPop",
580
- doc: document,
581
- win: window,
582
- rowsFor: (id) => {
583
- const f = chainFamilies.get(id);
584
- return f?.chain ? chainRowsHtml(f, { activeId: selected }) : null;
585
- },
586
- anchorFor: (id) => (chainFrom ? [...chainFrom.querySelectorAll(".card[data-id] .chain-chip")].find((chip) => chip.closest(".card").dataset.id === id) || null : null),
587
- onPick: (id) => {
588
- chainPop.close();
589
- if (allViewOpen()) leaveAllView(sessionHash(id));
590
- select(id);
591
- },
592
- // chainFrom stays (Esc focuses the chip again after the close); only an
593
- // open popover holds a list (chainPopIn).
594
- onClose: () => {
595
- if (chainFrom === topStripEl && heldLists.has(topStripEl) && !(canHover && topStripEl.matches(":hover"))) renderTop(stripCards());
596
- },
597
- });
598
- function chainOpenHead() {
599
- return chainPop.openHead();
600
- }
601
- function chainPopIn(el) {
602
- return chainPop.isOpen() && chainFrom === el;
603
- }
604
- function toggleChainPop(from, id, e) {
605
- if (chainPop.openHead() === id && chainFrom === from) {
606
- chainPop.close();
607
- return;
608
- }
609
- chainPop.close();
610
- chainFrom = from;
611
- chainPop.open(id);
612
- // From the keyboard the focus goes into the popover; Esc brings it back.
613
- if (e?.detail === 0 && chainPop.isOpen()) $("#chainPop .fr-open")?.focus();
614
- }
615
- window.addEventListener("keydown", (e) => chainPop.onKeydown(e), true);
616
- document.addEventListener("pointerdown", (e) => chainPop.onPointerDown(e), true);
617
- document.addEventListener("scroll", () => chainPop.reposition(), true);
618
-
619
- function render() {
620
- fitStripColumns();
621
- renderTop(stripCards());
622
- if (allViewOpen()) renderAll();
623
- }
624
-
625
- let stripResizeTimer = null;
626
- window.addEventListener("resize", () => {
627
- clearTimeout(stripResizeTimer);
628
- stripResizeTimer = setTimeout(() => {
629
- const before = stripCols;
630
- fitStripColumns();
631
- if (stripCols !== before && allSessions.length) renderTop(stripCards());
632
- familyPop.reposition();
633
- chainPop.reposition();
634
- }, 150);
635
- });
636
-
637
- // onPick: a card's click (open it, or pick it in select mode); onFamily:
638
- // a family chip's (toggle its list); onRow: a family row's (open that
639
- // delegate).
640
- function bindCards(container, onPick, { onFamily = () => {}, onRow = () => {} } = {}) {
641
- container.querySelectorAll(".card[data-id]").forEach((el) => {
642
- el.addEventListener("click", (e) => {
643
- const chainChip = e.target.closest(".chain-chip");
644
- if (chainChip) {
645
- e.stopPropagation();
646
- toggleChainPop(container, el.dataset.id, e);
647
- return;
648
- }
649
- const chip = e.target.closest(".family-chip");
650
- if (chip) {
651
- e.stopPropagation();
652
- onFamily(el.dataset.id, chip, e);
653
- return;
654
- }
655
- const del = e.target.closest(".card-del");
656
- if (del) {
657
- e.stopPropagation();
658
- confirmDelete(del.dataset.del);
659
- return;
660
- }
661
- // Archive from the list: the view stays open, the toast has Undo.
662
- const arch = e.target.closest(".card-arch");
663
- if (arch) {
664
- e.stopPropagation();
665
- const id = arch.dataset.arch;
666
- setArchived(id, !allSessions.find((x) => x.id === id)?.archived);
667
- return;
668
- }
669
- const row = e.target.closest(".family-row[data-id]");
670
- if (row) {
671
- e.stopPropagation();
672
- onRow(row.dataset.id, e);
673
- return;
674
- }
675
- onPick(el.dataset.id, e);
676
- });
677
- });
678
- paintActive();
679
- }
680
-
681
- // The open session's marks: its card (.active), its row in a family's list
682
- // (in a card or the strip's popover), and a quieter .contains-active on the
683
- // card of the family it is folded into.
684
- function paintActive() {
685
- document.querySelectorAll(".card[data-id]").forEach((el) => {
686
- el.classList.toggle("active", !!selected && el.dataset.id === selected);
687
- el.classList.toggle("contains-active", !!selected && (el.dataset.members || "").split(" ").includes(selected));
688
- });
689
- document.querySelectorAll(".family-row[data-id]").forEach((el) => {
690
- el.classList.toggle("active", !!selected && el.dataset.id === selected);
691
- });
692
- // The open session's card may have come into the strip or left it.
693
- updateSessionTitle();
694
- }
695
-
696
- // The latest sessions (as many as fit, a family one card), then the way
697
- // to all of them.
698
- function renderTop(cards) {
699
- topStripEl.classList.remove("loading");
700
- renderStripShow();
701
- stripFamilies = new Map(cards.map((f) => [f.head.id, f]));
702
- noteChainFamilies(cards);
703
- if (scopeError) {
704
- topStripEl.innerHTML = `<div class="hint scope-error">${escapeHtml(scopeError)} · <a href="${escapeHtml(allScopeHref("", null, location.search))}">show every session</a></div>`;
705
- familyPop.close();
706
- chainPop.close();
707
- return;
708
- }
709
- // Only archived sessions: no cards, but the tile stays, the way to them.
710
- if (!allSessions.length) {
711
- topStripEl.innerHTML = "";
712
- familyPop.close();
713
- chainPop.close();
714
- return;
715
- }
716
- const n = shownSessions().length;
717
- const count = n || allSessions.length === n
718
- ? `${n} ${n === 1 ? "session" : "sessions"}`
719
- : `${allSessions.length - n} archived`;
720
- 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>`;
721
- topStripEl.innerHTML = cards.map((f) => cardHtml(f.head, { family: f, open: familyPop.openHead() === f.head.id })).join("") + tile;
722
- bindCards(topStripEl, select, {
723
- onFamily: (id, _chip, e) => {
724
- familyPop.toggle(id);
725
- // From the keyboard (Enter / Space: no pointer, detail 0) the focus
726
- // goes into the popover, which sits at the end of body; Esc brings it back.
727
- if (e.detail === 0 && familyPop.isOpen()) $("#familyPop .fr-open")?.focus();
728
- },
729
- });
730
- $("#allTile").addEventListener("click", openAllView);
731
- $("#stripHide").addEventListener("click", () => setStripHidden(true));
732
- familyPop.refresh();
733
- if (chainFrom === topStripEl) chainPop.refresh();
734
- }
735
-
736
- // The chain families a list drew: the popover's rows come from them.
737
- function noteChainFamilies(fams) {
738
- for (const f of fams) {
739
- if (f.chain) chainFamilies.set(f.head.id, f);
740
- else chainFamilies.delete(f.head.id);
741
- }
742
- }
743
-
744
- // The hidden strip's pill: "▾ 5 sessions · ● 1 live · ● 1 waiting",
745
- // following the list: its sessions, the number the All sessions tile
746
- // shows, and the live and waiting ones (a waiting delegate too).
747
- function renderStripShow() {
748
- const shown = shownSessions();
749
- const { count, live, waiting } = stripShowParts(shown);
750
- const liveHtml = live ? ` · <span class="live"><span class="live-dot" aria-hidden="true"></span>${escapeHtml(live)}</span>` : "";
751
- const waitingHtml = waiting ? ` · <span class="waiting"><span class="waiting-dot" aria-hidden="true"></span>${escapeHtml(waiting)}</span>` : "";
752
- $("#stripShow").innerHTML = `<span class="pill-arrow" aria-hidden="true">\u25BE</span> ${escapeHtml(count)}${liveHtml}${waitingHtml}`;
753
- }
754
-
755
- // A parent's card holds its delegates (families, by the newest member: a
756
- // hub snapshot hands the list back in the server's order), folded unless
757
- // one waits on the user, a search matched one or the user opened it. The
758
- // search runs over the families, so a matching delegate brings its parent's
759
- // card along. Select mode picks cards (a family's rows have no checkbox:
760
- // archiving the parent takes its delegates).
761
- const familyToggles = new Map(); // head id → open, the user's chip clicks (this page's life)
762
- const searchToggles = new Map(); // the same while a search matched a member; a new search clears it
763
- const drawnOpen = new Map(); // head id → open as last drawn in all sessions
764
-
765
- function renderAll() {
766
- const listed = allViewSessions();
767
- const q = filterQuery();
768
- // A search and the archived ones see every link as a card (allViewChains).
769
- const all = allViewChains(families(listed), allSessions, { query: q, includeArchived: $("#includeArchived").checked });
770
- noteChainFamilies(all);
771
- const shown = q ? filterFamilies(all, (s) => matchesFilter(s, q)) : all;
772
- const fams = listOrder(allListEl, waitingFirst(shown), familyId);
773
- const delegates = all.reduce((n, f) => n + f.members.length, 0);
774
- $("#count").textContent = fams.length === all.length
775
- ? `${all.length}${delegates ? ` (+${delegates} ${delegates === 1 ? "delegate" : "delegates"})` : ""}`
776
- : `${fams.length} / ${all.length}`;
777
- updateSelectBar();
778
- if (!fams.length) {
779
- drawnOpen.clear();
780
- allListEl.innerHTML = `<div class="hint">${listed.length ? "No session matches." : "No sessions yet."}</div>`;
781
- if (chainFrom === allListEl) chainPop.refresh();
782
- return;
783
- }
784
- // A hub event redraws the list under a checkbox or a chip the keyboard is on.
785
- const focusSel = [".card-pick", ".family-chip", ".chain-chip"].find((sel) => document.activeElement?.matches?.(`#allList ${sel}`));
786
- const focused = focusSel ? { id: document.activeElement.closest(".card")?.dataset.id, sel: focusSel } : null;
787
- const opts = (s) => (selecting.on
788
- ? { selectable: true, picked: selecting.picked.has(s.id), skipReason: selecting.skipReasons.get(s.id) }
789
- : { deletable: true, archivable: true });
790
- drawnOpen.clear();
791
- allListEl.innerHTML = fams.map((f) => {
792
- const matched = !!f.matchIds?.size;
793
- const open = familyOpen(f, matched ? searchToggles : familyToggles, { matched });
794
- drawnOpen.set(f.head.id, open);
795
- return cardHtml(f.head, { ...opts(f.head), family: f, open, rows: true, matchIds: q ? f.matchIds : null });
796
- }).join("");
797
- bindCards(allListEl, (id, e) => {
798
- if (selecting.on) {
799
- togglePick(id, e);
800
- return;
801
- }
802
- leaveAllView(sessionHash(id));
803
- select(id);
804
- }, {
805
- onFamily: (headId) => {
806
- const matched = !!q && !!fams.find((f) => f.head.id === headId)?.matchIds?.size;
807
- (matched ? searchToggles : familyToggles).set(headId, !drawnOpen.get(headId));
808
- renderAll();
809
- },
810
- // In select mode a row does nothing: rows aren't picked.
811
- onRow: (id) => {
812
- if (selecting.on) return;
813
- leaveAllView(sessionHash(id));
814
- select(id);
815
- },
816
- });
817
- if (focused?.id) allListEl.querySelector(`.card[data-id="${focused.id}"] ${focused.sel}`)?.focus();
818
- if (chainFrom === allListEl) chainPop.refresh();
819
- }
820
-
821
- // ── Select mode (all sessions) ─────────────────────────────────────────────
822
- // "Select" turns the cards into picks: a click (or Shift-click, a range in
823
- // the drawn order) toggles one instead of opening it, and the bar acts on
824
- // the picked ones that are shown. The picks live here, not in the DOM:
825
- // renderAll draws the list again on every hub event.
826
- const selecting = {
827
- on: false,
828
- picked: new Set(),
829
- last: null, // the last toggled id: a Shift-click's range starts there
830
- skipReasons: new Map(), // id → why the last batch skipped it
831
- running: null, // {done, total, archive} while a batch runs
832
- };
833
-
834
- function enterSelect() {
835
- selecting.on = true;
836
- renderAll();
837
- }
838
-
839
- function leaveSelect() {
840
- selecting.on = false;
841
- selecting.picked.clear();
842
- selecting.last = null;
843
- selecting.skipReasons.clear();
844
- if (allViewOpen()) renderAll();
845
- else updateSelectBar();
846
- }
847
-
848
- function togglePick(id, e) {
849
- if (selecting.running) return; // the grid reflows while a batch runs
850
- const order = drawnOrder.get(allListEl) || [];
851
- const on = !selecting.picked.has(id);
852
- const ids = e?.shiftKey ? rangeIds(order, selecting.last, id) : [id];
853
- for (const x of ids) {
854
- if (on) selecting.picked.add(x);
855
- else selecting.picked.delete(x);
856
- paintPick(x);
857
- }
858
- selecting.last = id;
859
- updateSelectBar();
860
- }
861
-
862
- // A card's pick state in place (no redraw: the focus stays put).
863
- function paintPick(id) {
864
- const el = allListEl.querySelector(`.card[data-id="${id}"]`);
865
- if (!el) return;
866
- const on = selecting.picked.has(id);
867
- el.classList.toggle("picked", on);
868
- const box = el.querySelector(".card-pick");
869
- if (box) box.checked = on;
870
- }
871
-
872
- // The bar's numbers: picks of sessions still in the list (a snapshot can
873
- // drop one with no session_gone), and the shown ones a batch would act on.
874
- function selectCounts() {
875
- const byId = new Map(allSessions.map((s) => [s.id, s]));
876
- const shown = drawnOrder.get(allListEl) || [];
877
- const shownSet = new Set(shown);
878
- const live = [...selecting.picked].filter((id) => byId.has(id));
879
- return {
880
- picked: live.length,
881
- hidden: live.filter((id) => !shownSet.has(id)).length,
882
- archive: batchTargets(selecting.picked, shown, true, byId),
883
- unarchive: batchTargets(selecting.picked, shown, false, byId),
884
- };
885
- }
886
-
887
- function updateSelectBar() {
888
- $("#allView").classList.toggle("selecting", selecting.on);
889
- $("#selectBtn").hidden = selecting.on;
890
- const bar = $("#selectBar");
891
- bar.hidden = !selecting.on;
892
- if (!selecting.on) return;
893
- const c = selectCounts();
894
- const run = selecting.running;
895
- $("#selectCount").textContent = run
896
- ? `${run.archive ? "Archiving" : "Unarchiving"} ${run.done}/${run.total}\u2026`
897
- : `${c.picked} selected${c.hidden ? ` \u00b7 ${c.hidden} not shown` : ""}`;
898
- $("#batchArchiveBtn").textContent = `Archive ${c.archive.length}`;
899
- $("#batchArchiveBtn").disabled = !!run || !c.archive.length;
900
- $("#batchUnarchiveBtn").textContent = `Unarchive ${c.unarchive.length}`;
901
- $("#batchUnarchiveBtn").hidden = !c.unarchive.length;
902
- $("#batchUnarchiveBtn").disabled = !!run;
903
- $("#selectAllBtn").disabled = !!run;
904
- $("#selectDoneBtn").disabled = !!run;
905
- }
906
-
907
- // The batch: the picked shown ones, one request at a time in the drawn
908
- // order (parents before their delegates), through the info bar's
909
- // archiveOne. A refused one stays picked with the server's reason on its
910
- // card; the end toast's Undo runs the opposite batch on the ones that went.
911
- async function runSelectBatch(archive) {
912
- if (selecting.running) return;
913
- const byId = new Map(allSessions.map((s) => [s.id, s]));
914
- const ids = batchTargets(selecting.picked, drawnOrder.get(allListEl) || [], archive, byId);
915
- if (!ids.length) return;
916
- selecting.skipReasons.clear();
917
- const outcome = await archiveBatch(ids, archive);
918
- if (!outcome) return;
919
- if (!allViewOpen()) leaveSelect(); // left the view while it ran
920
- else renderAll();
921
- showToast(batchToastText(batchCounts(outcome, archive)), outcome.done.length ? {
922
- label: "Undo",
923
- run: async () => {
924
- const undone = await archiveBatch(outcome.done, !archive);
925
- if (!undone) return;
926
- if (allViewOpen()) renderAll();
927
- showToast(batchToastText(batchCounts(undone, !archive)));
928
- },
929
- } : null);
930
- }
931
-
932
- // runBatch over archiveOne, with the bar's progress; the picks and skip
933
- // reasons follow the outcome. null when a batch already runs.
934
- async function archiveBatch(ids, archive) {
935
- if (selecting.running) return null;
936
- selecting.running = { done: 0, total: ids.length, archive };
937
- updateSelectBar();
938
- try {
939
- const outcome = await runBatch(ids, async (id) => {
940
- const answer = await archiveOne(id, archive);
941
- if (answer.ok) {
942
- render();
943
- updateInfoBar();
944
- }
945
- return answer;
946
- }, (done) => {
947
- selecting.running.done = done;
948
- updateSelectBar();
949
- });
950
- for (const id of [...outcome.done, ...outcome.covered, ...outcome.gone]) {
951
- selecting.picked.delete(id);
952
- selecting.skipReasons.delete(id);
953
- }
954
- for (const { id, reason } of outcome.skipped) selecting.skipReasons.set(id, reason);
955
- return outcome;
956
- } finally {
957
- selecting.running = null;
958
- updateSelectBar();
959
- if (!eventsControl) refresh();
960
- }
961
- }
962
-
963
- $("#batchArchiveBtn").addEventListener("click", () => runSelectBatch(true));
964
- $("#batchUnarchiveBtn").addEventListener("click", () => runSelectBatch(false));
965
- $("#selectBtn").addEventListener("click", enterSelect);
966
- $("#selectDoneBtn").addEventListener("click", leaveSelect);
967
- $("#selectAllBtn").addEventListener("click", () => {
968
- for (const id of drawnOrder.get(allListEl) || []) {
969
- selecting.picked.add(id);
970
- paintPick(id);
971
- }
972
- updateSelectBar();
973
- });
974
- // A Shift-click picks a range: keep the browser from selecting the text
975
- // between the two cards.
976
- allListEl.addEventListener("mousedown", (e) => {
977
- if (selecting.on && e.shiftKey) e.preventDefault();
423
+ topStripEl,
424
+ allListEl,
425
+ sessions: () => allSessions,
426
+ selected: () => selected,
427
+ scope,
428
+ scopeError: () => scopeError,
429
+ eventsLive: () => !!eventsControl,
430
+ refresh: () => refresh(),
431
+ select: (id) => select(id),
432
+ confirmDelete: (id) => confirmDelete(id),
433
+ setArchived: (id, archive) => setArchived(id, archive),
434
+ archiveOne: (id, archive) => archiveOne(id, archive),
435
+ updateInfoBar: () => updateInfoBar(),
436
+ updateSessionTitle: () => updateSessionTitle(),
437
+ showToast: (text, action) => showToast(text, action),
438
+ allViewOpen: () => allViewOpen(),
439
+ openAllView: () => openAllView(),
440
+ leaveAllView: (hash) => leaveAllView(hash),
441
+ setStripHidden: (hidden) => setStripHidden(hidden),
978
442
  });
979
443
 
980
444
  // Delete a session (the info bar's button, an all-sessions card's ✕) after
@@ -996,7 +460,7 @@ async function confirmDelete(id) {
996
460
  }
997
461
  allSessions = allSessions.filter((s) => s.id !== id);
998
462
  if (selected === id) clearSelection();
999
- render();
463
+ lists.render();
1000
464
  }
1001
465
 
1002
466
  // All sessions: its own view at #/sessions, so the browser's back button
@@ -1044,19 +508,19 @@ function applyView() {
1044
508
  if (!open) {
1045
509
  allViewPushed = false;
1046
510
  // A running batch ends on its own and clears the picks then.
1047
- if (selecting.on && !selecting.running) leaveSelect();
511
+ if (lists.selecting.on && !lists.selecting.running) lists.leaveSelect();
1048
512
  topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
1049
513
  }
1050
514
  document.body.classList.toggle("all-view", open);
1051
515
  updateSessionTitle();
1052
516
  // Its chip was in the list just hidden (the strip, or all sessions).
1053
- chainPop.close();
517
+ lists.chainPop.close();
1054
518
  if (open) {
1055
- familyPop.close(); // the strip is hidden in the all view
519
+ lists.familyPop.close(); // the strip is hidden in the all view
1056
520
  // The list is live; only its order can lag (upserts keep cards in
1057
521
  // place), so the all view opens in the server's order.
1058
522
  allSessions = sortedByUpdated(allSessions);
1059
- renderAll();
523
+ lists.renderAll();
1060
524
  if (!eventsControl) refresh();
1061
525
  $("#filter").select();
1062
526
  }
@@ -1151,7 +615,7 @@ function setLiveStatus(status) {
1151
615
  // snapshot or the all view re-sorts (no card jumping under the pointer;
1152
616
  // a session event replaces it in place too, see sessions_list.js).
1153
617
  allSessions[i] = withLiveStatus(allSessions[i], status);
1154
- render();
618
+ lists.render();
1155
619
  }
1156
620
  updateInfoBar();
1157
621
  }
@@ -1194,14 +658,14 @@ function clearSelection() {
1194
658
  seedInitTasks(initTasks, []);
1195
659
  renderInitRow();
1196
660
  setSessionHash(null);
1197
- paintActive();
661
+ lists.paintActive();
1198
662
  resetSessionState();
1199
663
  contextChips.clear();
1200
664
  liveTurn.forget();
1201
665
  setTurnRunning(false);
1202
666
  closeStream();
1203
667
  setMarkdownWarning(null);
1204
- setCapabilities(null);
668
+ setCapabilities(null, null);
1205
669
  updateComposerMode();
1206
670
  updateInfoBar();
1207
671
  promptEl.focus();
@@ -1231,7 +695,7 @@ async function select(id) {
1231
695
  clearNotice();
1232
696
  selected = id;
1233
697
  setSessionHash(id);
1234
- paintActive();
698
+ lists.paintActive();
1235
699
  historyEl.innerHTML = `<div class="hint">Loading…</div>`;
1236
700
  emptyEl.classList.add("hidden");
1237
701
  closeStream();
@@ -1278,7 +742,7 @@ async function resync({ endText = null } = {}) {
1278
742
  return;
1279
743
  }
1280
744
  if (id !== selected) return;
1281
- const kept = keptCommandBubbles(id);
745
+ const kept = keptCommandBubbles(historyEl, id);
1282
746
  resetTurnView();
1283
747
  renderSessionView(data, { kept });
1284
748
  // Preview is read-only: a worker is woken only by POST /turn, so attach
@@ -1295,88 +759,24 @@ async function resync({ endText = null } = {}) {
1295
759
  }
1296
760
  }
1297
761
 
1298
- // The prompt and command bubbles in the history, in order, by kind
1299
- // (kept_commands.js): a prompt, a `!cmd`'s (saved, so redrawn) or another.
1300
- function historyAnchors() {
1301
- const els = [...historyEl.querySelectorAll(".bubble.user, .bubble.command")];
1302
- const kinds = els.map((el) => {
1303
- if (el.classList.contains("user")) return "user";
1304
- const line = el.querySelector(":scope > .command-line")?.textContent;
1305
- return !el.classList.contains("failed") && isShellCommandLine(line) ? "shell" : "other";
1306
- });
1307
- return { els, kinds };
1308
- }
1309
-
1310
- // This session's own command bubbles (/help, /model …: drawn by this page,
1311
- // never saved) with their anchors, before a resync redraws the history.
1312
- // In-flight ones (busy, queued) are left out: the snapshot draws them again.
1313
- function keptCommandBubbles(id) {
1314
- const { els, kinds } = historyAnchors();
1315
- return els
1316
- .map((el, i) => ({ el, anchor: commandAnchor(kinds, i) }))
1317
- .filter(({ el }) => el.dataset.keptFor === id && !el.classList.contains("busy") && !el.classList.contains("queued"))
1318
- .filter(({ el }) => keepsCommandBubble(el.querySelector(":scope > .command-line")?.textContent));
1319
- }
1320
-
1321
- // The kept bubbles back into the redrawn history, each after its turn.
1322
- function restoreCommandBubbles(kept) {
1323
- if (!kept.length) return;
1324
- historyEl.querySelector(".hint")?.remove();
1325
- kept.forEach(({ el, anchor }) => {
1326
- const { els, kinds } = historyAnchors();
1327
- const at = anchorInsertIndex(kinds, anchor);
1328
- if (at < els.length) els[at].before(el);
1329
- else historyEl.appendChild(el);
1330
- });
1331
- }
1332
-
1333
762
  // A session view: its messages, then the turn in progress and the queued
1334
763
  // prompts (a live worker's snapshot) replayed through the stream handlers,
1335
764
  // then a pending question.
1336
765
  function renderSessionView(data, { kept = [] } = {}) {
1337
- const s = data.session;
1338
766
  setMarkdownWarning(data.markdown_warning);
1339
- setCapabilities(data.capabilities);
1340
- current.status = s.status;
1341
- current.model = s.model_name;
1342
- current.served = s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null;
1343
- current.dir = s.working_directory;
1344
- current.owner = s.owner || null;
1345
- current.timing = normalizeTiming(data.timing);
767
+ setCapabilities(data.capabilities, data.editor_url);
768
+ // The saved fill (when there is one) goes in here too, so a reloaded or
769
+ // stopped session shows it before its next turn (a running turn's replay
770
+ // below streams over it).
771
+ Object.assign(current, sessionStateFrom(data));
1346
772
  liveTurn.timing.ended = current.timing.turnRecords.length;
1347
773
  const activeTurnId = current.timing.activeTurn?.id || null;
1348
- // The saved fill, so a reloaded or stopped session shows it before its
1349
- // next turn (a running turn's replay below streams over it).
1350
- const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
1351
- if (savedPct !== null) current.ctxPct = savedPct;
1352
- if (data.timing?.context?.window_tokens) {
1353
- current.ctxWindow = data.timing.context.window_tokens;
1354
- current.ctxWindowSource = data.timing.context.window_source || null;
1355
- }
1356
- current.firstPreview = s.first_preview || previewForCard(s);
1357
- current.usedMemories = s.used_memory_names || [];
1358
- current.preloadedMemories = s.preloaded_memory_names || [];
1359
- current.mutedMemories = s.muted_memory_names || [];
1360
- current.promptNotes = Array.isArray(s.prompt_notes) ? s.prompt_notes : [];
1361
- current.llmContext = s.llm_context || null;
1362
774
  llmContextControl.refresh();
1363
- current.thinking = s.thinking || null;
1364
775
  thinkingControl.refresh();
1365
- current.parentId = s.parent_id || null;
1366
- current.continues = s.continues || null;
1367
- current.commands = Array.isArray(data.commands) ? data.commands : [];
1368
- if (data.messages && data.messages.length) {
1369
- const first = data.messages.find((m) => m.role === "user");
1370
- if (first && first.content) current.firstPreview = firstPreview(first.content);
1371
- renderHistory(data.messages);
1372
- } else {
1373
- renderHistory(data.history || []);
1374
- if (!current.firstPreview && data.history && data.history.length) {
1375
- const txt = firstPreview(data.history[0]);
1376
- if (txt) current.firstPreview = txt;
1377
- }
1378
- }
1379
- restoreCommandBubbles(kept);
776
+ reasoningHistoryControl.refresh();
777
+ if (data.messages && data.messages.length) renderHistory(data.messages);
778
+ else renderHistory(data.history || []);
779
+ restoreCommandBubbles(historyEl, kept);
1380
780
  // A failed turn's prompt went back to the user: shown after the
1381
781
  // conversation, marked failed, as it was live (prompt_restored).
1382
782
  if (data.failed_turn) {
@@ -1453,10 +853,23 @@ function setMarkdownWarning(warning) {
1453
853
  markdownWarningEl.hidden = false;
1454
854
  }
1455
855
 
1456
- function setCapabilities(capabilities) {
856
+ // Both from one payload, together; the body's has-editor and refs-<kind>
857
+ // classes follow (the refs' hover styling keys on refs-<kind>).
858
+ function setCapabilities(capabilities, editorUrl) {
1457
859
  pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
860
+ pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
861
+ document.body.classList.toggle("has-editor", pageEditorUrl !== null);
862
+ setRefKinds(document.body, pageViewer());
1458
863
  }
1459
864
 
865
+ // What this viewer can do with a ref (refs.js REF_ACTIONS).
866
+ function pageViewer() {
867
+ return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
868
+ }
869
+
870
+ // Refs anywhere on the page (a tool row's file, its diff lines): one listener.
871
+ installRefClicks(document.body, pageViewer);
872
+
1460
873
  async function readTurnEnd() {
1461
874
  if (!selected) return;
1462
875
  const id = selected;
@@ -1474,7 +887,7 @@ async function readTurnEnd() {
1474
887
  if (!(await mergeTurnTiming(id, d.timing))) return;
1475
888
  liveTurn.timing.finishLine(line);
1476
889
  setMarkdownWarning(d.markdown_warning);
1477
- setCapabilities(d.capabilities);
890
+ setCapabilities(d.capabilities, d.editor_url);
1478
891
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1479
892
  updateInfoBar();
1480
893
  const sess = allSessions.find(s=>s.id===selected);
@@ -1482,8 +895,8 @@ async function readTurnEnd() {
1482
895
  sess.used_memory_names = current.usedMemories.slice();
1483
896
  sess.status = current.status;
1484
897
  }
1485
- render();
1486
- paintActive();
898
+ lists.render();
899
+ lists.paintActive();
1487
900
  }
1488
901
  } catch(_){}
1489
902
  }
@@ -1718,11 +1131,6 @@ function showSteers(steers) {
1718
1131
  }
1719
1132
  }
1720
1133
 
1721
- // A ✂ row's notice data (turnDom.notice, placeTurnNotice).
1722
- function llmContextNotice(data) {
1723
- return { line: llmContextLine(data), cls: "llm-context", title: llmContextHover(data) };
1724
- }
1725
-
1726
1134
  function showHookNotice(data, { before = null } = {}) {
1727
1135
  removeHintIfEmpty();
1728
1136
  const el = document.createElement("div");
@@ -1957,16 +1365,6 @@ function currentWorkerBadge() {
1957
1365
  installed: versions.installed, served: versions.served, sessionId: selected });
1958
1366
  }
1959
1367
 
1960
- function workerBadgeHtml() {
1961
- const badge = currentWorkerBadge();
1962
- if (!badge) return "";
1963
- const busy = restartingId === selected;
1964
- const button = badge.restart
1965
- ? `<button type="button" class="worker-restart"${busy ? " disabled" : ""}>${busy ? "restarting…" : "Restart"}</button>`
1966
- : "";
1967
- return `<span class="worker-badge" title="${escapeHtml(badge.title)}">${escapeHtml(badge.text)}${button}</span>`;
1968
- }
1969
-
1970
1368
  // Restart: the session moves to a new worker on the newest chi; the page
1971
1369
  // follows it (its bridge_up), the composer's draft stays as it is.
1972
1370
  async function restartWorker() {
@@ -2011,49 +1409,49 @@ function updateInfoBar() {
2011
1409
  }
2012
1410
  if (!selected) {
2013
1411
  // The start page hides the info bar (the storage path is on #/sessions).
2014
- renderStable(infoTextEl, "");
2015
- infoTextEl.title = "";
1412
+ renderInfoBar(infoTextEl, null);
2016
1413
  if (stopBtn) stopBtn.style.display = "none";
2017
1414
  updateSessionTitle();
2018
1415
  return;
2019
1416
  }
2020
1417
  if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
2021
- const idShort = selected.slice(0,8);
2022
- const ctxText = ctxBarText(current.ctxPct, current.ctxWindowSource);
2023
1418
  const tokens = current.timing.tokens;
2024
1419
  const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
2025
1420
  { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
2026
1421
  budget: current.llmContext?.budget_tokens });
2027
1422
  const speedTextNow = lastSpeedText(tokens);
2028
1423
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
2029
- const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
2030
1424
  const sessionDuration = currentSessionDuration();
2031
- const timingText = sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`;
2032
- const statusClass = current.status ? `status ${escapeHtml(current.status)}` : "status";
2033
- const label = modelLabel(current.model, ...(current.served || []));
2034
1425
  // A delegated session names its parent, as a link to it.
2035
1426
  const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
2036
- 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>` : "";
2037
- // A link of a session chain: ← the previous link, → the next one (each
2038
- // only while the list holds it).
2039
- const chainHtml = [
2040
- chain.prev ? chainLinkHtml(chain.prev, "\u2190", "The previous link of this chain") : "",
2041
- chain.next ? chainLinkHtml(chain.next, "\u2192", "The next link of this chain") : "",
2042
- ].join("");
2043
- // A parent names its delegates; the link opens all sessions.
2044
- const kids = childrenSummary({ id: selected }, allSessions);
2045
- // The model notes the session's prompt carried.
2046
- const notes = promptNotesChip(current.promptNotes);
2047
- const notesHtml = notes ? `<span class="model notes-chip" title="${escapeHtml(notes.title)}">${escapeHtml(notes.text)}</span>` : "";
2048
- 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>` : "";
2049
- const attachCmd = `chi --attach ${selected}`;
2050
- const copied = Date.now() - attachCopiedAt < 2000;
2051
- const copyHtml = `<button type="button" class="copy-attach" title="Copy &quot;${escapeHtml(attachCmd)}&quot; to attach a terminal">${copied ? "copied ✓" : "copy chi --attach"}</button>`;
2052
- 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>${chainHtml}${parentHtml}${kidsHtml}${llmContextChipHtml(current.llmContext, escapeHtml)}${thinkingChipHtml(current.thinking, escapeHtml)}${notesHtml}${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><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>`;
2053
- // The session clock ticks every second while a turn runs: only its text
2054
- // changes, the bar's other nodes (a tooltip, a button mid-press) stay.
2055
- renderStable(infoTextEl, metaHtml, { ".session-time": timingText });
2056
- infoTextEl.title = memoriesTooltip(current.usedMemories, current.preloadedMemories, current.mutedMemories);
1427
+ const badge = currentWorkerBadge();
1428
+ // The session clock ticks every second while a turn runs: lit writes its
1429
+ // text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
1430
+ renderInfoBar(infoTextEl, {
1431
+ id: selected,
1432
+ copied: Date.now() - attachCopiedAt < 2000,
1433
+ status: current.status,
1434
+ model: modelLabel(current.model, ...(current.served || [])),
1435
+ // A link of a session chain: ← the previous link, → the next one (each
1436
+ // only while the list holds it).
1437
+ chain: { prev: chainLink(chain.prev), next: chainLink(chain.next) },
1438
+ parent: parentChip ? { id: current.parentId, title: parentChip.title } : null,
1439
+ // A parent names its delegates; the link opens all sessions.
1440
+ kids: childrenSummary({ id: selected }, allSessions),
1441
+ llmContext: current.llmContext,
1442
+ thinking: current.thinking,
1443
+ reasoningHistory: current.reasoningHistory,
1444
+ // The model notes the session's prompt carried.
1445
+ notes: promptNotesChip(current.promptNotes),
1446
+ memories: current.usedMemories,
1447
+ preloaded: current.preloadedMemories,
1448
+ muted: current.mutedMemories,
1449
+ dir: current.dir,
1450
+ ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
1451
+ speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
1452
+ clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
1453
+ worker: badge ? { badge, busy: restartingId === selected } : null,
1454
+ });
2057
1455
  updateSessionTitle();
2058
1456
  }
2059
1457
 
@@ -2062,9 +1460,9 @@ function updateInfoBar() {
2062
1460
  // browser tab names it too. It follows the card's: a chain link opened with
2063
1461
  // the opener reads its chain's first link's title (cardTitle).
2064
1462
  function updateSessionTitle() {
2065
- const shown = selected ? sessionsById().get(selected) : null;
1463
+ const shown = selected ? lists.sessionsById().get(selected) : null;
2066
1464
  const first = current.firstPreview || "";
2067
- const raw = !selected ? "" : (shown && isOpener(first) ? cardTitle(shown) : first);
1465
+ const raw = !selected ? "" : (shown && isOpener(first) ? lists.cardTitle(shown) : first);
2068
1466
  const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
2069
1467
  const text = topBarTitle({
2070
1468
  title,
@@ -2083,24 +1481,12 @@ function updateSessionTitle() {
2083
1481
  }
2084
1482
  }
2085
1483
 
2086
- // The info bar's ← / → link to a chain's link (chains.js chainNeighbours):
2087
- // its date (else its short id), its preview and id in the tooltip.
2088
- function chainLinkHtml(link, arrow, what) {
2089
- const short = String(link.id).slice(0, 8);
2090
- const date = chainDate(link.session?.created_at);
2091
- const preview = link.session ? cardTitle(link.session) : "";
2092
- const title = `${what}: ${short}${preview ? ` · ${preview}` : ""}${link.session?.archived ? " (archived)" : ""}`;
2093
- const text = arrow === "\u2190" ? `${arrow} ${date || short}` : `${date || short} ${arrow}`;
2094
- return `<a class="model chain-link" href="${escapeHtml(sessionHash(link.id))}" title="${escapeHtml(title)}">${escapeHtml(text)}</a>`;
2095
- }
2096
-
2097
- // `memories: a, b · preloaded: c · muted: d`: the used memories always,
2098
- // the session's --memory and --mute lists only when there are any.
2099
- function memoriesTooltip(used, preloaded, muted) {
2100
- const parts = [`memories: ${(used || []).join(", ") || "—"}`];
2101
- if (preloaded && preloaded.length) parts.push(`preloaded: ${preloaded.join(", ")}`);
2102
- if (muted && muted.length) parts.push(`muted: ${muted.join(", ")}`);
2103
- return parts.join(" · ");
1484
+ // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
1485
+ // shows it (info_bar.js chainLinkTemplate), or null.
1486
+ function chainLink(link) {
1487
+ if (!link) return null;
1488
+ return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? lists.cardTitle(link.session) : "",
1489
+ archived: !!link.session?.archived };
2104
1490
  }
2105
1491
 
2106
1492
  function currentSessionDuration() {
@@ -2141,8 +1527,8 @@ function setUsedMemories(names) {
2141
1527
  const sess = allSessions.find(s=>s.id===selected);
2142
1528
  if (sess) {
2143
1529
  sess.used_memory_names = current.usedMemories.slice();
2144
- render();
2145
- paintActive();
1530
+ lists.render();
1531
+ lists.paintActive();
2146
1532
  }
2147
1533
  }
2148
1534
 
@@ -2197,7 +1583,7 @@ function workerGone(id, { opened = true } = {}) {
2197
1583
  const i = allSessions.findIndex((s) => s.id === id);
2198
1584
  if (i >= 0 && allSessions[i].owner === "worker") {
2199
1585
  allSessions[i] = withoutWorker(allSessions[i]);
2200
- render();
1586
+ lists.render();
2201
1587
  }
2202
1588
  updateInfoBar();
2203
1589
  // Once: a worker still alive with a bridge (the stream dropped, not the
@@ -2207,274 +1593,71 @@ function workerGone(id, { opened = true } = {}) {
2207
1593
  else setTimeout(() => { if (id === selected && !(streamControl && streamControl.live)) resync({ endText }); }, STREAM_RETRY_MS);
2208
1594
  }
2209
1595
 
2210
- // The stream's event handlers; renderSessionView replays a snapshot through
2211
- // them too.
2212
- const streamHandlers = {
2213
- generation_chunk: (data) => {
2214
- // Route the chunk's bytes to the text bubble and the thinking block (see
2215
- // chunk_router.js). Raw `content` is only a fallback for an older server.
2216
- const { text, thinking } = routeChunk(data);
2217
- liveTurn.timing.setNote("");
2218
- turnDom.chunk({ text, thinking, iteration: data.iteration ?? null });
2219
- const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
2220
- if (pct !== null && !Number.isNaN(pct)) {
2221
- current.ctxPct = pct;
2222
- updateInfoBar();
2223
- }
2224
- },
2225
- generation_started: (data) => {
2226
- if (data?.context_window_tokens) {
2227
- current.ctxWindow = data.context_window_tokens;
2228
- current.ctxWindowSource = data.context_window_source || null;
2229
- }
2230
- liveTurn.timing.setNote("");
2231
- turnDom.generationStarted(data || {});
2232
- },
2233
- generation_completed: (data) => {
2234
- liveTurn.timing.setNote("");
2235
- if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
2236
- // The generation's speed and the session's token totals, from the server.
2237
- current.timing.tokens = generationTokens(current.timing.tokens, data);
2238
- if (data?.served_model || data?.tokens) updateInfoBar();
2239
- turnDom.generationCompleted();
2240
- },
2241
- // Prompts sent while a turn ran merge into it at an iteration boundary:
2242
- // input_merged names them (their bubbles turn "steered"), then
2243
- // pending_input_merged carries the merged text, shown only for prompts
2244
- // this tab has no bubble for. Its `answer` (the answer the merge
2245
- // superseded) is the TUI's line: here it streamed as that step's text.
2246
- input_merged: (data) => applyPromptOps(data),
2247
- pending_input_merged: (data) => {
2248
- applyPromptOps(data);
2249
- showSteers(data.steers);
2250
- },
2251
- turn_enqueued: (data) => applyPromptOps(data),
2252
- turn_started: (data) => keepFollowing(historyEl, () => {
2253
- if (streamFromGuess) {
1596
+ // The stream's event handlers (stream_handlers.js); renderSessionView
1597
+ // replays a snapshot through them too.
1598
+ const streamHandlers = createStreamHandlers({
1599
+ selected: () => selected,
1600
+ state: () => current,
1601
+ clientId,
1602
+ capabilities: () => pageCapabilities,
1603
+ historyEl,
1604
+ liveTurn,
1605
+ turnDom,
1606
+ shownTexts,
1607
+ questionCards,
1608
+ contextChips,
1609
+ llmContextControl,
1610
+ thinkingControl,
1611
+ reasoningHistoryControl,
1612
+ sentIds,
1613
+ earlyRestores,
1614
+ stream: {
1615
+ takeGuess: () => {
1616
+ const guess = streamFromGuess;
2254
1617
  streamFromGuess = false;
2255
- // A replay of a turn the render drew: re-read and stream from there.
2256
- if (replaysDrawnTurn(data, current.timing.turnRecords)) {
2257
- streamControl?.close();
2258
- streamControl = null;
2259
- resync();
2260
- return;
2261
- }
2262
- }
2263
- // A finished turn still in the stage goes first: the start op looks
2264
- // for a queued prompt among its rows.
2265
- turnDom.handOff?.({ immediate: true });
2266
- markRecapStale();
2267
- shownTexts.turnStarted();
2268
- const promptBubble = applyPromptOps(data);
2269
- setTurnRunning(true);
2270
- setLiveStatus("running");
2271
- liveTurn.timing.detach();
2272
- turnDom.turnStarted({ ...data, promptBubble });
2273
- liveTurn.timing.start(data.started_at, data.turn_id || null);
2274
- }),
2275
- turn_completed: async (data) => {
2276
- endTurnView("completed", null, { emptyAnswer: data.turn_summary?.empty_answer || null });
2277
- const out = turnOutput(data);
2278
- if (out) appendChunk(out);
2279
- // after_turn hooks are running and may present the answer: its
2280
- // answer_display (display: null when they didn't) comes next. A worker
2281
- // that dies before it never sends one: workerGone releases the wait, and
2282
- // displayWait's timeout ends it whatever happens (4.14).
2283
- const display = data.display_pending ? displayWait() : null;
2284
- if (display) liveTurn.displayPending = display;
2285
- liveTurn.endRead = readTurnEnd();
2286
- await liveTurn.endRead;
2287
- // The answer bubble the turn view held at its box's height pops now
2288
- // that the rendered markdown is in it (or the re-read failed: then the
2289
- // plain text pops), and the display if one was coming, so the links
2290
- // never swap in after the pop. The hold reveals by itself after
2291
- // HOLD_MS whatever comes. The stage has no hold.
2292
- if (display) await display.promise;
2293
- turnDom.answerReady?.();
2294
- },
2295
- // An after_turn hook presented the answer (AnswerDisplay): it comes after
2296
- // turn_completed, so re-read the answer once that read is done.
2297
- answer_display: async (data) => {
2298
- try {
2299
- await liveTurn.endRead;
2300
- if (!selected || !data.display) return;
2301
- const d = await getSession(selected, { tail: true, recent: true });
2302
- if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
2303
- } catch (_) {
2304
- } finally {
2305
- liveTurn.releaseDisplay();
2306
- }
2307
- },
2308
- turn_canceled: (data) => {
2309
- endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by), { stopped: true });
2310
- setLiveStatus("idle");
2311
- refreshTimingFromSession();
2312
- },
2313
- // The turn raised (e.g. the model server stayed unreachable). The worker
2314
- // stays up: it rolls the turn back and hands the prompt back
2315
- // (prompt_restored), and the prompts queued behind it run as usual. A turn
2316
- // that got to tool steps stays as it is (kept_steps; no prompt_restored).
2317
- turn_failed: (data) => {
2318
- const text = failedTurnText(data);
2319
- // Only a rolled-back turn's line waits for its prompt_restored.
2320
- liveTurn.failedText = Number.isInteger(data.kept_steps) ? null : text;
2321
- endTurnView("failed", text);
2322
- setLiveStatus("idle");
1618
+ return guess;
1619
+ },
1620
+ restart: () => {
1621
+ streamControl?.close();
1622
+ streamControl = null;
1623
+ resync();
1624
+ },
2323
1625
  },
2324
- // The rolled-back turn left the session: render it from the server again,
2325
- // then show the prompt once more, marked failed (with why). This tab's own
2326
- // prompt goes back into an empty composer.
2327
- prompt_restored: async (data) => {
2328
- keepEarlyRestore(data, earlyRestores, { myId: clientId, sentIds });
2329
- const { refill, label, images: restoredImages } = restoreAction(data, { myId: clientId, sentIds, composerEmpty: !promptEl.value.trim() && !chips.length });
2330
- if (refill) {
2331
- promptEl.value = refill;
1626
+ composer: {
1627
+ empty: () => !promptEl.value.trim() && !chips.length,
1628
+ refill: (text) => {
1629
+ promptEl.value = text;
2332
1630
  fitPrompt();
2333
- }
2334
- if (restoredImages?.length) setChips(restoredChips(selected, restoredImages));
2335
- const failedText = liveTurn.failedText;
2336
- liveTurn.failedText = null;
2337
- await resync();
2338
- // The re-render drew it from the session (failed_turn): drawn again
2339
- // below with this event's label and images.
2340
- const drawn = historyEl.querySelector(".bubble.user[data-failed-turn]");
2341
- if (drawn) {
2342
- if (drawn.nextElementSibling?.classList.contains("cancel")) drawn.nextElementSibling.remove();
2343
- drawn.remove();
2344
- }
2345
- // Not an echo waiting for its turn_enqueued: it keeps its enqueued_id, so
2346
- // a resend's echo isn't matched to it.
2347
- makeUserBubble(data.prompt || "", { state: "failed", label, enqueuedId: data.origin?.enqueued_id || `failed:${Date.now()}`, images: data.images });
2348
- if (failedText) addEndBubble(failedText);
2349
- },
2350
- // A session command ran in the worker (any client's). One that changed
2351
- // the conversation (!rollback, !cmd, a continue answered no) re-reads it.
2352
- command_ran: async (data) => {
2353
- const view = commandView(data, clientId);
2354
- if (view.modelName && view.modelName !== current.model) {
2355
- current.model = view.modelName;
2356
- current.served = null;
2357
- updateInfoBar();
2358
- }
2359
- // /llm-context changed what the next turn runs under.
2360
- if (data.llm_context) {
2361
- current.llmContext = data.llm_context;
2362
- updateInfoBar();
2363
- llmContextControl.refresh();
2364
- }
2365
- // /thinking (or /model) changed the level the next turn runs at.
2366
- if (data.thinking) {
2367
- current.thinking = data.thinking;
2368
- updateInfoBar();
2369
- thinkingControl.refresh();
2370
- }
2371
- if (view.resync) await resync();
2372
- // A card's action that isn't anytime (the step-limit question's
2373
- // /continue) has no bubble: nothing to remove. An anytime or queued
2374
- // one's bubble was drawn at its command_queued: filled in here. One
2375
- // queued for after the turn ran after it: its bubble moves from among
2376
- // the turn's rows to after its answer and timing line.
2377
- const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
2378
- if (view.hidden) queued?.remove();
2379
- else if (queued && view.queued && !view.anytime) {
2380
- queued.remove();
2381
- addCommandBubble(view);
2382
- } else if (queued) drawCommandBubble(queued, view);
2383
- // A command that changed the conversation (!cmd): the redrawn history
2384
- // ends with its command bubble already (the saved "!(…)\n<output>"
2385
- // message renders as one) — no second bubble, or the output shows twice.
2386
- else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
2387
- else addCommandBubble(view);
2388
- },
2389
- // An anytime command (/help, a plugin's /btw) runs at once, beside a
2390
- // turn: its bubble goes here, so the cards it shows come after it. One
2391
- // queued for after the turn (/model X mid-turn) shows waiting here.
2392
- command_queued: (data) => {
2393
- // A card's action waits for its command_ran: shown only if it answers.
2394
- if ((data.anytime || data.waits) && !data.card && !commandBubble(data.command_id)) addCommandBubble(commandView(data, clientId));
2395
- },
2396
- // Due reminders went into the turn (a reminder turn has no prompt bubble).
2397
- reminder_injected: (data) => addCommandBubble({ label: null, line: reminderText(data), text: "" }),
2398
- // A context note joined the conversation (between turns; no turn runs).
2399
- context_added: (data) => {
2400
- addNoteBubble({ label: data.label, content: data.text || "" });
2401
- // An attached context source's note: its chip changed.
2402
- if (data.context_source) contextChips.refresh();
2403
- },
2404
- continue_offered: (data) => questionCards.renderContinue(data),
2405
- continue_resolved: (data) => questionCards.resolveContinue(data),
2406
- tool_call_started: (data) => turnDom.toolStarted(data),
2407
- tool_call_completed: (data) => turnDom.toolCompleted(data),
2408
- // The engine's list, after every memory read and at a turn's end.
2409
- used_memories_updated: (data) => {
2410
- if (Array.isArray(data.used_memory_names)) setUsedMemories(data.used_memory_names);
2411
- },
2412
- context_status: (data) => {
2413
- const pct = extractCtxPct(data, current.ctxWindow, current.llmContext?.budget_tokens);
2414
- if (pct !== null) {
2415
- current.ctxPct = pct;
2416
- updateInfoBar();
2417
- }
2418
- },
2419
- question_requested: (data) => {
2420
- const pq = data.pending_question;
2421
- if (pq && pq.id) questionCards.renderQuestion(pq);
2422
- },
2423
- question_answered: (data) => {
2424
- questionCards.resolveQuestion(data.id, { answer: data.answer });
2425
- },
2426
- // A delegate's question relayed to its parent's card, or no longer.
2427
- question_relay: (data) => questionCards.markRelayed(data.id, data.relayed_to),
2428
- question_cancelled: (data) => {
2429
- questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
2430
- },
2431
- // One collected just as a turn started describes the chat before it.
2432
- recap_ready: (data) => {
2433
- // After the turn it covers, never above it.
2434
- turnDom.handOff?.({ immediate: true });
2435
- if (!liveTurn.running) showRecap(data.recap);
2436
- },
2437
- guardrail_warning: (data) => showGuardrailWarning(data.message, data.label || "guardrails"),
2438
- // A notice during a turn is a row of its current step. One the page
2439
- // shows elsewhere (a hook's links in the rendered answer) isn't drawn,
2440
- // not even in the stage trail; a joined turn's notices come here too.
2441
- hook_notice: (data) => {
2442
- if (shownElsewhere(data, pageCapabilities)) return;
2443
- if (!turnDom.notice(data)) showHookNotice(data);
2444
- },
2445
- // The loop asks again after an empty answer: a row of the empty step
2446
- // (a reload puts it back from the snapshot's cards).
2447
- empty_answer_retry: (data) => { turnDom.notice({ line: emptyRetryLine(data) }); },
2448
- steer_cut: (data) => { turnDom.notice({ line: steerCutLine(data), cls: "steer-cut" }); },
2449
- // A batch of LLM context edits the server applied: a ✂ row of the
2450
- // current step, its hover listing the outputs.
2451
- llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
2452
- // The provider is asked again after an error, or the turn waits for
2453
- // plugins' setup: a note on the live timing line until it streams.
2454
- generation_retrying: (data) => {
2455
- // A dropped stream's step asked again: what it streamed is void.
2456
- if (data?.restarted) turnDom.generationRestarted(data);
2457
- liveTurn.timing.setNote(retryStatusLine(data));
2458
- },
2459
- plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
2460
- plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
2461
- plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),
2462
- // A plugin's card, or a new version of one (the same id).
2463
- card: (data) => {
2464
- upsertCard(data);
2465
- if (data.body) refreshCardBodies();
2466
- },
2467
- // The stream's cursor could not be replayed: render from scratch.
2468
- reset: () => resync(),
2469
- // Synthetic (snapshotEvents): a prompt merged into the turn in progress.
2470
- merged_input: (data) => {
2471
- const origin = (data.origins || [])[0] || {};
2472
- const own = isOwn(origin.client_id, clientId);
2473
- makeUserBubble(data.content, {
2474
- state: "steered", own, enqueuedId: origin.enqueued_id || null, label: own ? null : clientLabel(origin.client_id),
2475
- });
1631
+ },
1632
+ setChips: (list) => setChips(list),
2476
1633
  },
2477
- };
1634
+ getSession,
1635
+ resync: () => resync(),
1636
+ updateInfoBar: () => updateInfoBar(),
1637
+ applyPromptOps: (data) => applyPromptOps(data),
1638
+ showSteers: (steers) => showSteers(steers),
1639
+ markRecapStale: () => markRecapStale(),
1640
+ setTurnRunning: (running) => setTurnRunning(running),
1641
+ setLiveStatus: (status) => setLiveStatus(status),
1642
+ endTurnView: (kind, endText, opts) => endTurnView(kind, endText, opts),
1643
+ appendChunk: (text) => appendChunk(text),
1644
+ readTurnEnd: () => readTurnEnd(),
1645
+ applyFinalMarkdown: (messages) => applyFinalMarkdown(messages),
1646
+ refreshTimingFromSession: () => refreshTimingFromSession(),
1647
+ makeUserBubble: (prompt, opts) => makeUserBubble(prompt, opts),
1648
+ addEndBubble: (text) => addEndBubble(text),
1649
+ commandBubble: (id) => commandBubble(id),
1650
+ addCommandBubble: (view) => addCommandBubble(view),
1651
+ addNoteBubble: (note) => addNoteBubble(note),
1652
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
1653
+ setUsedMemories: (names) => setUsedMemories(names),
1654
+ showRecap: (text) => showRecap(text),
1655
+ showGuardrailWarning: (text, label) => showGuardrailWarning(text, label),
1656
+ showHookNotice: (data) => showHookNotice(data),
1657
+ onInitEvent: (type, data) => onInitEvent(type, data),
1658
+ upsertCard: (card) => upsertCard(card),
1659
+ refreshCardBodies: () => refreshCardBodies(),
1660
+ });
2478
1661
 
2479
1662
  async function refreshTimingFromSession() {
2480
1663
  if (!selected) return;
@@ -2508,7 +1691,7 @@ function closeStream() {
2508
1691
  function addCreatedSession(created) {
2509
1692
  const { bridge_port: _port, ...session } = created;
2510
1693
  allSessions = applySessionEvent(allSessions, "session", { session });
2511
- render();
1694
+ lists.render();
2512
1695
  }
2513
1696
 
2514
1697
  // A first message: an idle session, then the turn (or the command) into it
@@ -2524,36 +1707,17 @@ async function handleSendTurn() {
2524
1707
  if (!prompt) return;
2525
1708
  // An image-only send's text is its placeholders: not for ↑.
2526
1709
  const typed = promptEl.value.trim() !== "";
2527
- // /cut TEXT and /queue TEXT (the terminals' words for a delivery): the
2528
- // text as a cut, or the page's own answer (/queue, the bare word).
2529
- let delivery = null;
2530
- const choice = steerChoiceRoute(prompt, { running: liveTurn.running && !!selected });
2531
- if (choice?.reply) {
2532
- if (selected) addCommandBubble({ label: null, line: prompt, text: choice.reply });
2533
- else showNotice(choice.reply);
2534
- promptEl.value = "";
2535
- fitPrompt();
2536
- return;
2537
- }
2538
1710
  // The composer holds the line as typed until the ack (composerAfterAck).
2539
1711
  const line = prompt;
2540
- if (choice) {
2541
- prompt = choice.send;
2542
- delivery = choice.delivery || null;
2543
- }
2544
- if (!selected) {
2545
- // The start page's own reply (a web-local command) or the hint for a
2546
- // typo: neither needs a session, so none is made for it (2.29).
2547
- const local = startPageReplyOrHint(prompt, { commands: current.commands, images: chips.length });
2548
- if (local) {
2549
- promptEl.value = "";
2550
- fitPrompt();
2551
- showNotice(local);
2552
- return;
2553
- }
1712
+ // Where the line goes (composer.js): the page's own reply, a session to
1713
+ // make first (the start page), a command, or a turn.
1714
+ let route = routeComposerLine(prompt, {
1715
+ session: !!selected, running: liveTurn.running, commands: current.commands, images: chips.length,
1716
+ });
1717
+ if (route.create) {
2554
1718
  actionBtn.disabled = true;
2555
1719
  try {
2556
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
1720
+ const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: route.send,
2557
1721
  llm_context: choiceWords(newChatLlmContext),
2558
1722
  thinking: newChatThinking || undefined });
2559
1723
  // The choice was for this chat only.
@@ -2569,20 +1733,19 @@ async function handleSendTurn() {
2569
1733
  } finally {
2570
1734
  actionBtn.disabled = false;
2571
1735
  }
1736
+ // In the new session, by its command list (select read it).
1737
+ route = routeSessionLine(route.send, { commands: current.commands, images: chips.length, delivery: route.delivery });
1738
+ }
1739
+ if (route.reply) {
1740
+ // The start page's reply is a notice: no session is made for it (2.29).
1741
+ if (selected) addCommandBubble({ label: null, line: route.line, text: route.reply });
1742
+ else showNotice(route.reply);
1743
+ clearComposer();
1744
+ return;
2572
1745
  }
2573
- // A session command by the session's list (the new one's too: select
2574
- // read it); an unknown /word is a prompt, as in a terminal — but a typo
2575
- // of a command (/modle) gets the hint, not a turn.
2576
- if (!chips.length && sessionCommandLine(prompt, current.commands)) return handleSendCommand(prompt);
2577
- if (!chips.length) {
2578
- const typo = unknownCommandHint(prompt, current.commands);
2579
- if (typo) {
2580
- addCommandBubble({ label: null, line: prompt, text: typo });
2581
- promptEl.value = "";
2582
- fitPrompt();
2583
- return;
2584
- }
2585
- }
1746
+ if (route.command) return composerCommands.send(route.command);
1747
+ prompt = route.send;
1748
+ const delivery = route.delivery;
2586
1749
  const hint = historyEl.querySelector(".hint");
2587
1750
  if (hint) hint.remove();
2588
1751
  // Sending moves a finished turn out of the stage at once (else the echo
@@ -2605,8 +1768,7 @@ async function handleSendTurn() {
2605
1768
  // The worker knew it as a command (its list was newer than ours):
2606
1769
  // its command_ran shows it, not a prompt bubble.
2607
1770
  echo.remove();
2608
- promptEl.value = "";
2609
- fitPrompt();
1771
+ clearComposer();
2610
1772
  followWokenWorker();
2611
1773
  return;
2612
1774
  }
@@ -2650,47 +1812,36 @@ async function handleSendTurn() {
2650
1812
  }
2651
1813
  }
2652
1814
 
2653
- // /model, !ls, /continue no … run in the session's worker; every client
2654
- // shows the command_ran, this one too.
2655
- async function handleSendCommand(line) {
2656
- const local = webLocalReply(line);
2657
- if (local) {
2658
- addCommandBubble({ label: null, line, text: local });
2659
- promptEl.value = "";
2660
- fitPrompt();
2661
- return;
2662
- }
2663
- actionBtn.disabled = true;
2664
- try {
2665
- await sendCommand(selected, line, { clientId });
2666
- promptEl.value = "";
2667
- fitPrompt();
2668
- refreshHistory();
2669
- // The command woke the worker: follow its stream if none is open.
2670
- followWokenWorker();
2671
- } catch (e) {
2672
- if (e.code === "owned_by_tui") {
2673
- current.owner = "tui";
2674
- updateComposerMode();
2675
- return;
2676
- }
2677
- addCommandBubble({ label: null, line, text: e.message, failed: true });
2678
- } finally {
2679
- actionBtn.disabled = current.owner === "tui";
2680
- promptEl.focus();
2681
- }
1815
+ // /model, !ls … from the composer, and a control's command (the llm ctx
1816
+ // and thinking chips): composer.js.
1817
+ const composerCommands = createComposerCommands({
1818
+ session: () => selected,
1819
+ clientId,
1820
+ sendCommand,
1821
+ followWokenWorker: () => followWokenWorker(),
1822
+ refreshHistory: () => refreshHistory(),
1823
+ failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1824
+ ownedByTui: () => {
1825
+ current.owner = "tui";
1826
+ updateComposerMode();
1827
+ },
1828
+ composer: {
1829
+ busy: () => { actionBtn.disabled = true; },
1830
+ clear: () => clearComposer(),
1831
+ done: () => {
1832
+ actionBtn.disabled = current.owner === "tui";
1833
+ promptEl.focus();
1834
+ },
1835
+ },
1836
+ });
1837
+
1838
+ function runSessionCommand(line) {
1839
+ return composerCommands.run(line);
2682
1840
  }
2683
1841
 
2684
- // A session command from a control (the llm ctx chip), not the composer:
2685
- // its command_ran shows its line and reply, as a typed one's would.
2686
- async function runSessionCommand(line) {
2687
- if (!selected) return;
2688
- try {
2689
- await sendCommand(selected, line, { clientId });
2690
- followWokenWorker();
2691
- } catch (e) {
2692
- addCommandBubble({ label: null, line, text: e.message, failed: true });
2693
- }
1842
+ function clearComposer() {
1843
+ promptEl.value = "";
1844
+ fitPrompt();
2694
1845
  }
2695
1846
 
2696
1847
  function addCommandBubble(view) {
@@ -2704,24 +1855,6 @@ function addCommandBubble(view) {
2704
1855
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2705
1856
  }
2706
1857
 
2707
- // A command bubble's line and output (again, when its command_ran fills in
2708
- // an anytime command's bubble in place).
2709
- function drawCommandBubble(div, { label, line, text, busy = false, failed = false, waiting = false, commandId = null }) {
2710
- div.className = "bubble command" + (busy ? " busy" : "") + (failed ? " failed" : "") + (waiting ? " queued" : "");
2711
- if (commandId) div.dataset.commandId = commandId;
2712
- div.replaceChildren();
2713
- const head = document.createElement("div");
2714
- head.className = "command-line";
2715
- head.textContent = label ? `${label}> ${line}` : line;
2716
- div.appendChild(head);
2717
- if (text) {
2718
- const body = document.createElement("pre");
2719
- body.className = "command-output";
2720
- body.textContent = text;
2721
- div.appendChild(body);
2722
- }
2723
- }
2724
-
2725
1858
  function commandBubble(commandId) {
2726
1859
  if (!commandId) return null;
2727
1860
  return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
@@ -3104,7 +2237,7 @@ async function setArchived(id, archive, { undo = true } = {}) {
3104
2237
  alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
3105
2238
  return;
3106
2239
  }
3107
- render();
2240
+ lists.render();
3108
2241
  updateInfoBar();
3109
2242
  if (!eventsControl) refresh();
3110
2243
  const what = archive ? "Archived" : "Unarchived";
@@ -3148,10 +2281,6 @@ function showToast(text, action = null, { sticky = false, html = false } = {}) {
3148
2281
  if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
3149
2282
  }
3150
2283
 
3151
- $("#includeArchived").addEventListener("change", () => {
3152
- if (allViewOpen()) renderAll();
3153
- });
3154
-
3155
2284
  // Annotate (annotate_bar.js): a selection in the conversation offers
3156
2285
  // "Annotate" and the presets, which append a quote to the composer.
3157
2286
  const annotateBar = createAnnotateBar({
@@ -3168,6 +2297,12 @@ const annotateBar = createAnnotateBar({
3168
2297
  },
3169
2298
  });
3170
2299
  historyEl.addEventListener("scroll", annotateBar.schedule);
2300
+ // A command bubble's Apply (CacheConfirm): sends the change's --yes line
2301
+ // once; its own bubble then says what it did.
2302
+ historyEl.addEventListener("click", (e) => {
2303
+ const button = e.target.closest?.("[data-confirm-line]");
2304
+ if (button) applyConfirm(button, runSessionCommand);
2305
+ });
3171
2306
  turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
3172
2307
  turnRoots().forEach((root) => installCopy(root));
3173
2308
 
@@ -3198,7 +2333,7 @@ function applyStripHidden() {
3198
2333
  updateSessionTitle();
3199
2334
  }
3200
2335
  function setStripHidden(hidden) {
3201
- if (hidden) familyPop.close();
2336
+ if (hidden) lists.familyPop.close();
3202
2337
  try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
3203
2338
  stripShownWhileShort = !hidden && shortWindow.matches;
3204
2339
  applyStripHidden();
@@ -3211,23 +2346,11 @@ shortWindow.addEventListener("change", () => {
3211
2346
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
3212
2347
  $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
3213
2348
 
3214
- $("#filter").addEventListener("input", () => {
3215
- searchToggles.clear();
3216
- renderAll();
3217
- });
3218
- // A list whose order was held under the pointer (listOrder) catches up.
3219
- topStripEl.addEventListener("pointerleave", () => {
3220
- // Not while its family popover is open: the pointer went there (close() catches up).
3221
- if (heldLists.has(topStripEl) && !familyPop.isOpen()) renderTop(stripCards());
3222
- });
3223
- allListEl.addEventListener("pointerleave", () => {
3224
- if (heldLists.has(allListEl) && allViewOpen()) renderAll();
3225
- });
3226
2349
  // Esc leaves select mode first, then the all-sessions view.
3227
2350
  document.addEventListener("keydown", (e) => {
3228
2351
  if (e.key !== "Escape" || !allViewOpen()) return;
3229
- if (selecting.on) {
3230
- if (!selecting.running) leaveSelect();
2352
+ if (lists.selecting.on) {
2353
+ if (!lists.selecting.running) lists.leaveSelect();
3231
2354
  return;
3232
2355
  }
3233
2356
  closeAllView();