samagotchi 0.53.0 → 0.55.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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -2
  5. data/docs/architecture.md +94 -54
  6. data/docs/configuration.md +30 -2
  7. data/docs/context.md +104 -9
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/internals/bundles.md +73 -0
  10. data/docs/internals/context-telemetry.md +27 -6
  11. data/docs/internals/engine.md +55 -24
  12. data/docs/internals/tool-guardrails.md +8 -6
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +3 -3
  15. data/docs/testing.md +23 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -6
  17. data/lib/samagotchi/bundle_command.rb +31 -36
  18. data/lib/samagotchi/bundle_needs.rb +4 -5
  19. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/child_move.rb +6 -4
  24. data/lib/samagotchi/config.rb +2 -1
  25. data/lib/samagotchi/context_command.rb +1 -1
  26. data/lib/samagotchi/context_providers.rb +11 -14
  27. data/lib/samagotchi/context_status.rb +10 -18
  28. data/lib/samagotchi/engine.rb +117 -134
  29. data/lib/samagotchi/extension_load.rb +102 -0
  30. data/lib/samagotchi/file_ref.rb +59 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/kernel_loop.rb +5 -3
  33. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  35. data/lib/samagotchi/memory_bundle/builder.rb +170 -178
  36. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  37. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  38. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  39. data/lib/samagotchi/memory_bundle/manifest.rb +4 -2
  40. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  41. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  42. data/lib/samagotchi/memory_bundle/provenance.rb +38 -40
  43. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  44. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  45. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  46. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  47. data/lib/samagotchi/plugin/loader.rb +8 -8
  48. data/lib/samagotchi/self_report.rb +2 -2
  49. data/lib/samagotchi/send_command.rb +1 -1
  50. data/lib/samagotchi/session_continue.rb +275 -0
  51. data/lib/samagotchi/session_manager.rb +9 -253
  52. data/lib/samagotchi/session_metrics.rb +3 -1
  53. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  54. data/lib/samagotchi/terminal_ui.rb +12 -2
  55. data/lib/samagotchi/tool_activity.rb +84 -14
  56. data/lib/samagotchi/tool_row_fields.rb +46 -0
  57. data/lib/samagotchi/tool_runner.rb +7 -11
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +26 -3
  60. data/lib/samagotchi/web/capabilities.rb +13 -9
  61. data/lib/samagotchi/web/editor.rb +38 -0
  62. data/lib/samagotchi/web/message_parts.rb +5 -10
  63. data/lib/samagotchi/web/public/activity.js +35 -18
  64. data/lib/samagotchi/web/public/app.js +240 -1136
  65. data/lib/samagotchi/web/public/commands.js +236 -0
  66. data/lib/samagotchi/web/public/composer.js +101 -0
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/diff_view.js +30 -4
  69. data/lib/samagotchi/web/public/index.html +9 -2
  70. data/lib/samagotchi/web/public/info_bar.js +78 -0
  71. data/lib/samagotchi/web/public/lists_view.js +725 -0
  72. data/lib/samagotchi/web/public/llm_context_chip.js +9 -6
  73. data/lib/samagotchi/web/public/question_card.js +9 -1
  74. data/lib/samagotchi/web/public/question_cards.js +7 -4
  75. data/lib/samagotchi/web/public/refs.js +134 -0
  76. data/lib/samagotchi/web/public/restore.js +81 -0
  77. data/lib/samagotchi/web/public/session_state.js +52 -0
  78. data/lib/samagotchi/web/public/stream_handlers.js +324 -0
  79. data/lib/samagotchi/web/public/thinking_chip.js +6 -7
  80. data/lib/samagotchi/web/public/timing.js +8 -2
  81. data/lib/samagotchi/web/public/turn_events.js +17 -258
  82. data/lib/samagotchi/web/public/turn_html.js +15 -7
  83. data/lib/samagotchi/web/server.rb +4 -4
  84. metadata +17 -2
  85. data/lib/samagotchi/web/public/stable_html.js +0 -18
@@ -23,52 +23,55 @@ 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 { renderInfoBar } from "./info_bar.js";
34
34
  import { createShownTexts } from "./shown_texts.js";
35
35
  import { MODEL_KEY } from "./model_pick.js";
36
- import { emptySessionState } from "./session_state.js";
36
+ import { emptySessionState, sessionStateFrom } from "./session_state.js";
37
37
  import { createModelPicker } from "./model_picker.js";
38
38
  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";
39
+ import { escapeHtml, noteHtml, normalize, steerRowHtml, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
40
40
  import { createTurnView, turnHistoryNodes } from "./turn_view.js";
41
41
  import { BADGES, emptyAnswerHtml, htmlElement, stateBadgeHtml, userBubbleHtml } from "./turn_html.js";
42
42
  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";
43
+ import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenHtml } from "./chains.js";
45
44
  import { createTaskStopper } from "./task_stop.js";
46
45
  import { appendQuote } from "./annotations.js";
47
46
  import { parsePresets } from "./annotate_presets.js";
48
47
  import { createAnnotateBar } from "./annotate_bar.js";
49
48
  import { COPY_SOURCE_ATTR, copySource, copyText, installCopy, showsAnswer } from "./copy.js";
50
- import { routeChunk } from "./chunk_router.js";
51
- import { extractCtxPct, savedCtxPct, cardCtxText, ctxBarText, generationTokens, lastSpeedText, speedText, tokensTipText } from "./ctx.js";
49
+ import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
52
50
  import { createQuestionCards } from "./question_cards.js";
53
- import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, shownElsewhere, splitSnapshotCards, turnNoticePlace } from "./card.js";
51
+ import { cardClass, cardInnerHtml, cardPlace, isCard, isNoticeCard, noticeInnerHtml, splitSnapshotCards, turnNoticePlace } from "./card.js";
54
52
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
55
53
  import { createCommandList } from "./command_list.js";
56
54
  import { createPromptHistory } from "./prompt_history.js";
57
55
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
58
- import { allScopeHref, cardFolder, projectScopeHref, scopeDir } from "./scope.js";
56
+ import { allScopeHref, projectScopeHref, scopeDir } from "./scope.js";
59
57
  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";
58
+ import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
59
+ import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
62
60
  import { createNotifications } from "./notifications.js";
63
61
  import { tabTitle, topBarTitle } from "./session_title.js";
64
62
  import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
65
- import { anchorInsertIndex, commandAnchor, isShellCommandLine, keepsCommandBubble } from "./kept_commands.js";
66
63
  import {
67
- appendAboveLiveTiming, cancelLineText, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
64
+ appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
68
65
  } from "./timing.js";
69
66
  import { createLiveTurn } from "./live_turn.js";
67
+ import { installRefClicks, setRefKinds } from "./refs.js";
70
68
  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";
69
+ import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
70
+ import { drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
71
+ import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
72
+ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
73
+ import { createStreamHandlers } from "./stream_handlers.js";
74
+ import { createListsView } from "./lists_view.js";
72
75
 
73
76
  const $ = (s) => document.querySelector(s);
74
77
  const topStripEl = $("#topStrip");
@@ -170,7 +173,11 @@ const markdownWarningEl = $("#markdownWarning");
170
173
  // What the page renders that a notice may stand in for (the server's
171
174
  // Web::Capabilities, next to markdown_warning); a notice it names isn't
172
175
  // drawn (shownElsewhere). An older server sends none: every notice shows.
176
+ // And what this viewer can do (editor: open a path in this machine's
177
+ // editor; per request), with the editor's URL template (editor_url) next
178
+ // to it: refs.js's actions read both (pageViewer).
173
179
  let pageCapabilities = {};
180
+ let pageEditorUrl = null;
174
181
  const promptEl = $("#prompt");
175
182
  const actionBtn = $("#actionBtn");
176
183
  const cancelBtn = $("#cancelBtn");
@@ -286,7 +293,7 @@ async function refresh() {
286
293
  allSessions = [];
287
294
  renderScope();
288
295
  }
289
- render();
296
+ lists.render();
290
297
  }
291
298
 
292
299
  // The list as a projection of the hub's events: a snapshot on every
@@ -298,14 +305,14 @@ function openSessionEvents() {
298
305
  noticeVersions(data);
299
306
  notifications.noticeSessions("snapshot", data);
300
307
  allSessions = applySessionEvent(allSessions, "snapshot", data);
301
- render();
308
+ lists.render();
302
309
  const sess = allSessions.find((s) => s.id === selected);
303
310
  if (sess) followSelectedSession(sess);
304
311
  },
305
312
  session: (data) => {
306
313
  notifications.noticeSessions("session", data);
307
314
  allSessions = applySessionEvent(allSessions, "session", data);
308
- render();
315
+ lists.render();
309
316
  if (data.session && data.session.id === selected) followSelectedSession(data.session);
310
317
  // Its children chip; its chain's ← → links (a link that continues it, its previous one).
311
318
  else if (selected && (data.session?.parent_id === selected || data.session?.continues === selected
@@ -313,12 +320,11 @@ function openSessionEvents() {
313
320
  },
314
321
  session_gone: (data) => {
315
322
  notifications.noticeSessions("session_gone", data);
316
- selecting.picked.delete(data.id);
317
- selecting.skipReasons.delete(data.id);
323
+ lists.forget(data.id);
318
324
  const next = applySessionEvent(allSessions, "session_gone", data);
319
325
  if (next !== allSessions) {
320
326
  allSessions = next;
321
- render();
327
+ lists.render();
322
328
  if (selected) updateInfoBar(); // a deleted child leaves the children chip, a deleted chain link its ← → link
323
329
  }
324
330
  leaveGoneSession(data.id, "deleted");
@@ -399,582 +405,30 @@ function renderScope() {
399
405
  });
400
406
  }
401
407
 
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(tokensTipText(s.tokens, undefined, s.memory_index))}">${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({
408
+ // The session lists (lists_view.js): the strip, all sessions, their cards,
409
+ // select mode, the family and chain popovers.
410
+ const lists = createListsView({
552
411
  doc: document,
553
412
  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();
413
+ topStripEl,
414
+ allListEl,
415
+ sessions: () => allSessions,
416
+ selected: () => selected,
417
+ scope,
418
+ scopeError: () => scopeError,
419
+ eventsLive: () => !!eventsControl,
420
+ refresh: () => refresh(),
421
+ select: (id) => select(id),
422
+ confirmDelete: (id) => confirmDelete(id),
423
+ setArchived: (id, archive) => setArchived(id, archive),
424
+ archiveOne: (id, archive) => archiveOne(id, archive),
425
+ updateInfoBar: () => updateInfoBar(),
426
+ updateSessionTitle: () => updateSessionTitle(),
427
+ showToast: (text, action) => showToast(text, action),
428
+ allViewOpen: () => allViewOpen(),
429
+ openAllView: () => openAllView(),
430
+ leaveAllView: (hash) => leaveAllView(hash),
431
+ setStripHidden: (hidden) => setStripHidden(hidden),
978
432
  });
979
433
 
980
434
  // Delete a session (the info bar's button, an all-sessions card's ✕) after
@@ -996,7 +450,7 @@ async function confirmDelete(id) {
996
450
  }
997
451
  allSessions = allSessions.filter((s) => s.id !== id);
998
452
  if (selected === id) clearSelection();
999
- render();
453
+ lists.render();
1000
454
  }
1001
455
 
1002
456
  // All sessions: its own view at #/sessions, so the browser's back button
@@ -1044,19 +498,19 @@ function applyView() {
1044
498
  if (!open) {
1045
499
  allViewPushed = false;
1046
500
  // A running batch ends on its own and clears the picks then.
1047
- if (selecting.on && !selecting.running) leaveSelect();
501
+ if (lists.selecting.on && !lists.selecting.running) lists.leaveSelect();
1048
502
  topStripEl.scrollLeft = 0; // a phone's strip scrolls sideways to the tile
1049
503
  }
1050
504
  document.body.classList.toggle("all-view", open);
1051
505
  updateSessionTitle();
1052
506
  // Its chip was in the list just hidden (the strip, or all sessions).
1053
- chainPop.close();
507
+ lists.chainPop.close();
1054
508
  if (open) {
1055
- familyPop.close(); // the strip is hidden in the all view
509
+ lists.familyPop.close(); // the strip is hidden in the all view
1056
510
  // The list is live; only its order can lag (upserts keep cards in
1057
511
  // place), so the all view opens in the server's order.
1058
512
  allSessions = sortedByUpdated(allSessions);
1059
- renderAll();
513
+ lists.renderAll();
1060
514
  if (!eventsControl) refresh();
1061
515
  $("#filter").select();
1062
516
  }
@@ -1151,7 +605,7 @@ function setLiveStatus(status) {
1151
605
  // snapshot or the all view re-sorts (no card jumping under the pointer;
1152
606
  // a session event replaces it in place too, see sessions_list.js).
1153
607
  allSessions[i] = withLiveStatus(allSessions[i], status);
1154
- render();
608
+ lists.render();
1155
609
  }
1156
610
  updateInfoBar();
1157
611
  }
@@ -1194,14 +648,14 @@ function clearSelection() {
1194
648
  seedInitTasks(initTasks, []);
1195
649
  renderInitRow();
1196
650
  setSessionHash(null);
1197
- paintActive();
651
+ lists.paintActive();
1198
652
  resetSessionState();
1199
653
  contextChips.clear();
1200
654
  liveTurn.forget();
1201
655
  setTurnRunning(false);
1202
656
  closeStream();
1203
657
  setMarkdownWarning(null);
1204
- setCapabilities(null);
658
+ setCapabilities(null, null);
1205
659
  updateComposerMode();
1206
660
  updateInfoBar();
1207
661
  promptEl.focus();
@@ -1231,7 +685,7 @@ async function select(id) {
1231
685
  clearNotice();
1232
686
  selected = id;
1233
687
  setSessionHash(id);
1234
- paintActive();
688
+ lists.paintActive();
1235
689
  historyEl.innerHTML = `<div class="hint">Loading…</div>`;
1236
690
  emptyEl.classList.add("hidden");
1237
691
  closeStream();
@@ -1278,7 +732,7 @@ async function resync({ endText = null } = {}) {
1278
732
  return;
1279
733
  }
1280
734
  if (id !== selected) return;
1281
- const kept = keptCommandBubbles(id);
735
+ const kept = keptCommandBubbles(historyEl, id);
1282
736
  resetTurnView();
1283
737
  renderSessionView(data, { kept });
1284
738
  // Preview is read-only: a worker is woken only by POST /turn, so attach
@@ -1295,88 +749,23 @@ async function resync({ endText = null } = {}) {
1295
749
  }
1296
750
  }
1297
751
 
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
752
  // A session view: its messages, then the turn in progress and the queued
1334
753
  // prompts (a live worker's snapshot) replayed through the stream handlers,
1335
754
  // then a pending question.
1336
755
  function renderSessionView(data, { kept = [] } = {}) {
1337
- const s = data.session;
1338
756
  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);
757
+ setCapabilities(data.capabilities, data.editor_url);
758
+ // The saved fill (when there is one) goes in here too, so a reloaded or
759
+ // stopped session shows it before its next turn (a running turn's replay
760
+ // below streams over it).
761
+ Object.assign(current, sessionStateFrom(data));
1346
762
  liveTurn.timing.ended = current.timing.turnRecords.length;
1347
763
  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
764
  llmContextControl.refresh();
1363
- current.thinking = s.thinking || null;
1364
765
  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);
766
+ if (data.messages && data.messages.length) renderHistory(data.messages);
767
+ else renderHistory(data.history || []);
768
+ restoreCommandBubbles(historyEl, kept);
1380
769
  // A failed turn's prompt went back to the user: shown after the
1381
770
  // conversation, marked failed, as it was live (prompt_restored).
1382
771
  if (data.failed_turn) {
@@ -1453,10 +842,23 @@ function setMarkdownWarning(warning) {
1453
842
  markdownWarningEl.hidden = false;
1454
843
  }
1455
844
 
1456
- function setCapabilities(capabilities) {
845
+ // Both from one payload, together; the body's has-editor and refs-<kind>
846
+ // classes follow (the refs' hover styling keys on refs-<kind>).
847
+ function setCapabilities(capabilities, editorUrl) {
1457
848
  pageCapabilities = capabilities && typeof capabilities === "object" ? capabilities : {};
849
+ pageEditorUrl = pageCapabilities.editor === true && typeof editorUrl === "string" && editorUrl ? editorUrl : null;
850
+ document.body.classList.toggle("has-editor", pageEditorUrl !== null);
851
+ setRefKinds(document.body, pageViewer());
852
+ }
853
+
854
+ // What this viewer can do with a ref (refs.js REF_ACTIONS).
855
+ function pageViewer() {
856
+ return { editor: pageCapabilities.editor === true, editorUrl: pageEditorUrl };
1458
857
  }
1459
858
 
859
+ // Refs anywhere on the page (a tool row's file, its diff lines): one listener.
860
+ installRefClicks(document.body, pageViewer);
861
+
1460
862
  async function readTurnEnd() {
1461
863
  if (!selected) return;
1462
864
  const id = selected;
@@ -1474,7 +876,7 @@ async function readTurnEnd() {
1474
876
  if (!(await mergeTurnTiming(id, d.timing))) return;
1475
877
  liveTurn.timing.finishLine(line);
1476
878
  setMarkdownWarning(d.markdown_warning);
1477
- setCapabilities(d.capabilities);
879
+ setCapabilities(d.capabilities, d.editor_url);
1478
880
  if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1479
881
  updateInfoBar();
1480
882
  const sess = allSessions.find(s=>s.id===selected);
@@ -1482,8 +884,8 @@ async function readTurnEnd() {
1482
884
  sess.used_memory_names = current.usedMemories.slice();
1483
885
  sess.status = current.status;
1484
886
  }
1485
- render();
1486
- paintActive();
887
+ lists.render();
888
+ lists.paintActive();
1487
889
  }
1488
890
  } catch(_){}
1489
891
  }
@@ -1718,11 +1120,6 @@ function showSteers(steers) {
1718
1120
  }
1719
1121
  }
1720
1122
 
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
1123
  function showHookNotice(data, { before = null } = {}) {
1727
1124
  removeHintIfEmpty();
1728
1125
  const el = document.createElement("div");
@@ -1957,16 +1354,6 @@ function currentWorkerBadge() {
1957
1354
  installed: versions.installed, served: versions.served, sessionId: selected });
1958
1355
  }
1959
1356
 
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
1357
  // Restart: the session moves to a new worker on the newest chi; the page
1971
1358
  // follows it (its bridge_up), the composer's draft stays as it is.
1972
1359
  async function restartWorker() {
@@ -2011,49 +1398,48 @@ function updateInfoBar() {
2011
1398
  }
2012
1399
  if (!selected) {
2013
1400
  // The start page hides the info bar (the storage path is on #/sessions).
2014
- renderStable(infoTextEl, "");
2015
- infoTextEl.title = "";
1401
+ renderInfoBar(infoTextEl, null);
2016
1402
  if (stopBtn) stopBtn.style.display = "none";
2017
1403
  updateSessionTitle();
2018
1404
  return;
2019
1405
  }
2020
1406
  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
1407
  const tokens = current.timing.tokens;
2024
- const ctxTip = tokensTipText(tokens, undefined, current.timing.memoryIndex,
2025
- { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
2026
- budget: current.llmContext?.budget_tokens });
1408
+ const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
1409
+ { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
1410
+ budget: current.llmContext?.budget_tokens });
2027
1411
  const speedTextNow = lastSpeedText(tokens);
2028
1412
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
2029
- const speedTip = `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}`;
2030
1413
  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
1414
  // A delegated session names its parent, as a link to it.
2035
1415
  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>${ctxText ? `<span class="model ctx" title="${escapeHtml(ctxTip)}">${escapeHtml(ctxText)}</span>` : ""}${speedTextNow ? `<span class="model speed" title="${escapeHtml(speedTip)}">${escapeHtml(speedTextNow)}</span>` : ""}${timingText ? `<span class="model session-time"></span>` : ""}${workerBadgeHtml()}</div>`;
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);
1416
+ const badge = currentWorkerBadge();
1417
+ // The session clock ticks every second while a turn runs: lit writes its
1418
+ // text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
1419
+ renderInfoBar(infoTextEl, {
1420
+ id: selected,
1421
+ copied: Date.now() - attachCopiedAt < 2000,
1422
+ status: current.status,
1423
+ model: modelLabel(current.model, ...(current.served || [])),
1424
+ // A link of a session chain: ← the previous link, → the next one (each
1425
+ // only while the list holds it).
1426
+ chain: { prev: chainLink(chain.prev), next: chainLink(chain.next) },
1427
+ parent: parentChip ? { id: current.parentId, title: parentChip.title } : null,
1428
+ // A parent names its delegates; the link opens all sessions.
1429
+ kids: childrenSummary({ id: selected }, allSessions),
1430
+ llmContext: current.llmContext,
1431
+ thinking: current.thinking,
1432
+ // The model notes the session's prompt carried.
1433
+ notes: promptNotesChip(current.promptNotes),
1434
+ memories: current.usedMemories,
1435
+ preloaded: current.preloadedMemories,
1436
+ muted: current.mutedMemories,
1437
+ dir: current.dir,
1438
+ ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
1439
+ speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
1440
+ clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
1441
+ worker: badge ? { badge, busy: restartingId === selected } : null,
1442
+ });
2057
1443
  updateSessionTitle();
2058
1444
  }
2059
1445
 
@@ -2062,9 +1448,9 @@ function updateInfoBar() {
2062
1448
  // browser tab names it too. It follows the card's: a chain link opened with
2063
1449
  // the opener reads its chain's first link's title (cardTitle).
2064
1450
  function updateSessionTitle() {
2065
- const shown = selected ? sessionsById().get(selected) : null;
1451
+ const shown = selected ? lists.sessionsById().get(selected) : null;
2066
1452
  const first = current.firstPreview || "";
2067
- const raw = !selected ? "" : (shown && isOpener(first) ? cardTitle(shown) : first);
1453
+ const raw = !selected ? "" : (shown && isOpener(first) ? lists.cardTitle(shown) : first);
2068
1454
  const title = raw === "\u2014" ? "" : raw; // "—": no first message yet
2069
1455
  const text = topBarTitle({
2070
1456
  title,
@@ -2083,24 +1469,12 @@ function updateSessionTitle() {
2083
1469
  }
2084
1470
  }
2085
1471
 
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(" · ");
1472
+ // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
1473
+ // shows it (info_bar.js chainLinkTemplate), or null.
1474
+ function chainLink(link) {
1475
+ if (!link) return null;
1476
+ return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? lists.cardTitle(link.session) : "",
1477
+ archived: !!link.session?.archived };
2104
1478
  }
2105
1479
 
2106
1480
  function currentSessionDuration() {
@@ -2141,8 +1515,8 @@ function setUsedMemories(names) {
2141
1515
  const sess = allSessions.find(s=>s.id===selected);
2142
1516
  if (sess) {
2143
1517
  sess.used_memory_names = current.usedMemories.slice();
2144
- render();
2145
- paintActive();
1518
+ lists.render();
1519
+ lists.paintActive();
2146
1520
  }
2147
1521
  }
2148
1522
 
@@ -2197,7 +1571,7 @@ function workerGone(id, { opened = true } = {}) {
2197
1571
  const i = allSessions.findIndex((s) => s.id === id);
2198
1572
  if (i >= 0 && allSessions[i].owner === "worker") {
2199
1573
  allSessions[i] = withoutWorker(allSessions[i]);
2200
- render();
1574
+ lists.render();
2201
1575
  }
2202
1576
  updateInfoBar();
2203
1577
  // Once: a worker still alive with a bridge (the stream dropped, not the
@@ -2207,274 +1581,70 @@ function workerGone(id, { opened = true } = {}) {
2207
1581
  else setTimeout(() => { if (id === selected && !(streamControl && streamControl.live)) resync({ endText }); }, STREAM_RETRY_MS);
2208
1582
  }
2209
1583
 
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) {
1584
+ // The stream's event handlers (stream_handlers.js); renderSessionView
1585
+ // replays a snapshot through them too.
1586
+ const streamHandlers = createStreamHandlers({
1587
+ selected: () => selected,
1588
+ state: () => current,
1589
+ clientId,
1590
+ capabilities: () => pageCapabilities,
1591
+ historyEl,
1592
+ liveTurn,
1593
+ turnDom,
1594
+ shownTexts,
1595
+ questionCards,
1596
+ contextChips,
1597
+ llmContextControl,
1598
+ thinkingControl,
1599
+ sentIds,
1600
+ earlyRestores,
1601
+ stream: {
1602
+ takeGuess: () => {
1603
+ const guess = streamFromGuess;
2254
1604
  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");
1605
+ return guess;
1606
+ },
1607
+ restart: () => {
1608
+ streamControl?.close();
1609
+ streamControl = null;
1610
+ resync();
1611
+ },
2323
1612
  },
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;
1613
+ composer: {
1614
+ empty: () => !promptEl.value.trim() && !chips.length,
1615
+ refill: (text) => {
1616
+ promptEl.value = text;
2332
1617
  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
- });
1618
+ },
1619
+ setChips: (list) => setChips(list),
2476
1620
  },
2477
- };
1621
+ getSession,
1622
+ resync: () => resync(),
1623
+ updateInfoBar: () => updateInfoBar(),
1624
+ applyPromptOps: (data) => applyPromptOps(data),
1625
+ showSteers: (steers) => showSteers(steers),
1626
+ markRecapStale: () => markRecapStale(),
1627
+ setTurnRunning: (running) => setTurnRunning(running),
1628
+ setLiveStatus: (status) => setLiveStatus(status),
1629
+ endTurnView: (kind, endText, opts) => endTurnView(kind, endText, opts),
1630
+ appendChunk: (text) => appendChunk(text),
1631
+ readTurnEnd: () => readTurnEnd(),
1632
+ applyFinalMarkdown: (messages) => applyFinalMarkdown(messages),
1633
+ refreshTimingFromSession: () => refreshTimingFromSession(),
1634
+ makeUserBubble: (prompt, opts) => makeUserBubble(prompt, opts),
1635
+ addEndBubble: (text) => addEndBubble(text),
1636
+ commandBubble: (id) => commandBubble(id),
1637
+ addCommandBubble: (view) => addCommandBubble(view),
1638
+ addNoteBubble: (note) => addNoteBubble(note),
1639
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
1640
+ setUsedMemories: (names) => setUsedMemories(names),
1641
+ showRecap: (text) => showRecap(text),
1642
+ showGuardrailWarning: (text, label) => showGuardrailWarning(text, label),
1643
+ showHookNotice: (data) => showHookNotice(data),
1644
+ onInitEvent: (type, data) => onInitEvent(type, data),
1645
+ upsertCard: (card) => upsertCard(card),
1646
+ refreshCardBodies: () => refreshCardBodies(),
1647
+ });
2478
1648
 
2479
1649
  async function refreshTimingFromSession() {
2480
1650
  if (!selected) return;
@@ -2508,7 +1678,7 @@ function closeStream() {
2508
1678
  function addCreatedSession(created) {
2509
1679
  const { bridge_port: _port, ...session } = created;
2510
1680
  allSessions = applySessionEvent(allSessions, "session", { session });
2511
- render();
1681
+ lists.render();
2512
1682
  }
2513
1683
 
2514
1684
  // A first message: an idle session, then the turn (or the command) into it
@@ -2524,36 +1694,17 @@ async function handleSendTurn() {
2524
1694
  if (!prompt) return;
2525
1695
  // An image-only send's text is its placeholders: not for ↑.
2526
1696
  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
1697
  // The composer holds the line as typed until the ack (composerAfterAck).
2539
1698
  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
- }
1699
+ // Where the line goes (composer.js): the page's own reply, a session to
1700
+ // make first (the start page), a command, or a turn.
1701
+ let route = routeComposerLine(prompt, {
1702
+ session: !!selected, running: liveTurn.running, commands: current.commands, images: chips.length,
1703
+ });
1704
+ if (route.create) {
2554
1705
  actionBtn.disabled = true;
2555
1706
  try {
2556
- const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: prompt,
1707
+ const s = await createIdleSession({ dir: scope.dir, model: chosenModel(), preview: route.send,
2557
1708
  llm_context: choiceWords(newChatLlmContext),
2558
1709
  thinking: newChatThinking || undefined });
2559
1710
  // The choice was for this chat only.
@@ -2569,20 +1720,19 @@ async function handleSendTurn() {
2569
1720
  } finally {
2570
1721
  actionBtn.disabled = false;
2571
1722
  }
1723
+ // In the new session, by its command list (select read it).
1724
+ route = routeSessionLine(route.send, { commands: current.commands, images: chips.length, delivery: route.delivery });
1725
+ }
1726
+ if (route.reply) {
1727
+ // The start page's reply is a notice: no session is made for it (2.29).
1728
+ if (selected) addCommandBubble({ label: null, line: route.line, text: route.reply });
1729
+ else showNotice(route.reply);
1730
+ clearComposer();
1731
+ return;
2572
1732
  }
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
- }
1733
+ if (route.command) return composerCommands.send(route.command);
1734
+ prompt = route.send;
1735
+ const delivery = route.delivery;
2586
1736
  const hint = historyEl.querySelector(".hint");
2587
1737
  if (hint) hint.remove();
2588
1738
  // Sending moves a finished turn out of the stage at once (else the echo
@@ -2605,8 +1755,7 @@ async function handleSendTurn() {
2605
1755
  // The worker knew it as a command (its list was newer than ours):
2606
1756
  // its command_ran shows it, not a prompt bubble.
2607
1757
  echo.remove();
2608
- promptEl.value = "";
2609
- fitPrompt();
1758
+ clearComposer();
2610
1759
  followWokenWorker();
2611
1760
  return;
2612
1761
  }
@@ -2650,47 +1799,36 @@ async function handleSendTurn() {
2650
1799
  }
2651
1800
  }
2652
1801
 
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
- }
1802
+ // /model, !ls … from the composer, and a control's command (the llm ctx
1803
+ // and thinking chips): composer.js.
1804
+ const composerCommands = createComposerCommands({
1805
+ session: () => selected,
1806
+ clientId,
1807
+ sendCommand,
1808
+ followWokenWorker: () => followWokenWorker(),
1809
+ refreshHistory: () => refreshHistory(),
1810
+ failed: (line, text) => addCommandBubble({ label: null, line, text, failed: true }),
1811
+ ownedByTui: () => {
1812
+ current.owner = "tui";
1813
+ updateComposerMode();
1814
+ },
1815
+ composer: {
1816
+ busy: () => { actionBtn.disabled = true; },
1817
+ clear: () => clearComposer(),
1818
+ done: () => {
1819
+ actionBtn.disabled = current.owner === "tui";
1820
+ promptEl.focus();
1821
+ },
1822
+ },
1823
+ });
1824
+
1825
+ function runSessionCommand(line) {
1826
+ return composerCommands.run(line);
2682
1827
  }
2683
1828
 
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
- }
1829
+ function clearComposer() {
1830
+ promptEl.value = "";
1831
+ fitPrompt();
2694
1832
  }
2695
1833
 
2696
1834
  function addCommandBubble(view) {
@@ -2704,24 +1842,6 @@ function addCommandBubble(view) {
2704
1842
  if (follow) historyEl.scrollTop = historyEl.scrollHeight;
2705
1843
  }
2706
1844
 
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
1845
  function commandBubble(commandId) {
2726
1846
  if (!commandId) return null;
2727
1847
  return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
@@ -3104,7 +2224,7 @@ async function setArchived(id, archive, { undo = true } = {}) {
3104
2224
  alert(`Could not ${archive ? "archive" : "unarchive"} session ${short}: ${answer.error.message}`);
3105
2225
  return;
3106
2226
  }
3107
- render();
2227
+ lists.render();
3108
2228
  updateInfoBar();
3109
2229
  if (!eventsControl) refresh();
3110
2230
  const what = archive ? "Archived" : "Unarchived";
@@ -3148,10 +2268,6 @@ function showToast(text, action = null, { sticky = false, html = false } = {}) {
3148
2268
  if (!sticky) toastTimer = setTimeout(() => { el.hidden = true; }, 6000);
3149
2269
  }
3150
2270
 
3151
- $("#includeArchived").addEventListener("change", () => {
3152
- if (allViewOpen()) renderAll();
3153
- });
3154
-
3155
2271
  // Annotate (annotate_bar.js): a selection in the conversation offers
3156
2272
  // "Annotate" and the presets, which append a quote to the composer.
3157
2273
  const annotateBar = createAnnotateBar({
@@ -3198,7 +2314,7 @@ function applyStripHidden() {
3198
2314
  updateSessionTitle();
3199
2315
  }
3200
2316
  function setStripHidden(hidden) {
3201
- if (hidden) familyPop.close();
2317
+ if (hidden) lists.familyPop.close();
3202
2318
  try { localStorage.setItem("chi_strip_hidden", hidden ? "1" : "0"); } catch (_) {}
3203
2319
  stripShownWhileShort = !hidden && shortWindow.matches;
3204
2320
  applyStripHidden();
@@ -3211,23 +2327,11 @@ shortWindow.addEventListener("change", () => {
3211
2327
  $("#stripShow").addEventListener("click", () => setStripHidden(false));
3212
2328
  $("#stripHideTop").addEventListener("click", () => setStripHidden(true));
3213
2329
 
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
2330
  // Esc leaves select mode first, then the all-sessions view.
3227
2331
  document.addEventListener("keydown", (e) => {
3228
2332
  if (e.key !== "Escape" || !allViewOpen()) return;
3229
- if (selecting.on) {
3230
- if (!selecting.running) leaveSelect();
2333
+ if (lists.selecting.on) {
2334
+ if (!lists.selecting.running) lists.leaveSelect();
3231
2335
  return;
3232
2336
  }
3233
2337
  closeAllView();