samagotchi 0.66.0 → 0.68.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 (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. metadata +27 -1
@@ -32,47 +32,49 @@ import {
32
32
  } from "./thinking_chip.js";
33
33
  import { createReasoningHistoryControl } from "./reasoning_history_chip.js";
34
34
  import { renderInfoBar } from "./info_bar.js";
35
+ import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
35
36
  import { createShownTexts } from "./shown_texts.js";
36
37
  import { MODEL_KEY } from "./model_pick.js";
37
38
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
38
39
  import { createModelPicker } from "./model_picker.js";
39
- import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText } from "./images.js";
40
- import { normalize, firstPreview, failedTurnText, modelLabel, deleteConfirmText, recapLabel, canStopSession, goneSessionNotice, withLiveStatus, withoutWorker, recapPlace, delegatedBy, childrenSummary, promptNotesChip } from "./format.js";
41
- import { createTurnView, stepText, turnHistoryNodes } from "./turn_view.js";
42
- import { BADGES, elementOf, emptyAnswerTemplate, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
40
+ import { chipName, chipsTemplate, imageFiles, restoredChips, thumbsTemplate, turnRefs, turnText, uploadChips } from "./images.js";
41
+ import { normalize, firstPreview, failedTurnText, deleteConfirmText, goneSessionNotice, withLiveStatus, withoutWorker } from "./format.js";
42
+ import { createTurnView, turnHistoryNodes } from "./turn_view.js";
43
+ import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
44
+ import { createBubbles, hintEl, outputBubbleTemplate, setBubbleDelivery } from "./bubbles.js";
45
+ import { createCardsPlace } from "./cards_place.js";
43
46
  import { createStageView, FOLD_MS } from "./stage_view.js";
44
- import { CONTINUE_OPENER, isOpener, chainDate, chainNeighbours, continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
47
+ import { CONTINUE_OPENER, isOpener, continueRefusal, continuedText, openChildrenTemplate } from "./chains.js";
45
48
  import { createTaskStopper } from "./task_stop.js";
46
49
  import { appendQuote } from "./annotations.js";
47
50
  import { parsePresets } from "./annotate_presets.js";
48
51
  import { createAnnotateBar } from "./annotate_bar.js";
49
52
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
50
53
  import { copyText, installCopy } from "./copy.js";
51
- import { ctxBarText, ctxBarTip, lastSpeedText, speedText } from "./ctx.js";
52
54
  import { createQuestionCards } from "./question_cards.js";
53
- import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, splitSnapshotCards, turnNoticePlace } from "./card.js";
55
+ import { splitSnapshotCards } from "./card.js";
54
56
  import { PROMPT_LINE_PX, PROMPT_MIN_PX, gripFloor, promptHeight } from "./composer_size.js";
55
57
  import { createCommandList } from "./command_list.js";
56
58
  import { createPromptHistory } from "./prompt_history.js";
57
59
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
58
60
  import { scopeDir, scopeTemplate } from "./scope.js";
59
61
  import { html, nothing, render } from "./vendor/lit-html.js";
60
- import { easeScrollTo, FOLLOW_MS, shouldFollowScroll, keepFollowing } from "./scroll.js";
62
+ import { easeScrollTo, FOLLOW_MS, shouldFollowScroll } from "./scroll.js";
61
63
  import { SHORT_WINDOW_QUERY, stripAutoHidden } from "./strip.js";
62
- import { applySessionEvent, flattenFamilies, inTree, sortedByUpdated } from "./sessions_list.js";
64
+ import { applySessionEvent, inTree, sortedByUpdated } from "./sessions_list.js";
63
65
  import { createNotifications } from "./notifications.js";
64
66
  import { tabTitle, topBarTitle } from "./session_title.js";
65
- import { restartConfirmText, versionNotice, workerBadge } from "./update_notice.js";
67
+ import { restartConfirmText, versionNotice } from "./update_notice.js";
66
68
  import {
67
- appendAboveLiveTiming, formatDuration, mergeTiming, normalizeTiming, sessionDurationNow, STOPPED_LINE_CLASS,
69
+ appendAboveLiveTiming, mergeTiming, normalizeTiming,
68
70
  } from "./timing.js";
69
71
  import { createLiveTurn } from "./live_turn.js";
70
72
  import { applyFinalAnswer, pairAnswerRefs } from "./answer_refs.js";
71
73
  import { installRefClicks } from "./refs.js";
72
74
  import { linkRefs, touchedFiles } from "./ref_scan.js";
73
- import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
74
- import { isReportClient, hookNoticeLabel, llmContextNotice, newClientId, noticeLine, promptOps, snapshotEvents, workerGoneText } from "./turn_events.js";
75
- import { applyConfirm, drawCommandBubble, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
75
+ import { DONE_MS, applyInitEvent, dropDone, fadeDone, initRowTemplate, newInitTasks, seedInitTasks } from "./init_row.js";
76
+ import { newClientId, snapshotEvents, workerGoneText } from "./turn_events.js";
77
+ import { applyConfirm, keptCommandBubbles, restoreCommandBubbles } from "./commands.js";
76
78
  import { createComposerCommands, routeComposerLine, routeSessionLine } from "./composer.js";
77
79
  import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js";
78
80
  import { createStreamHandlers } from "./stream_handlers.js";
@@ -88,7 +90,6 @@ const allListEl = $("#allList");
88
90
  const historyEl = $("#history");
89
91
  const emptyEl = $("#empty");
90
92
  const composerEl = $("#composer");
91
- const infoBarEl = $("#infoBar");
92
93
  // The open session's title in the top bar, when no strip card in sight
93
94
  // shows it; the tab's base ("Chi · <project>"), set by renderScope.
94
95
  const sessionTitleEl = $("#sessionTitle");
@@ -291,13 +292,38 @@ const questionCards = createQuestionCards({
291
292
  removeHintIfEmpty: () => removeHintIfEmpty(),
292
293
  askingText: () => turnDom.askingText(),
293
294
  });
294
- // The event id (or, from an older worker, the event_seq) the selected session
295
- // was rendered at (null: no live worker then), where a stream opened later
296
- // has to start.
297
- let selectSeq = null;
298
- // The stream was opened from 0 (a wake with no cursor to go on) and no
299
- // new turn has started on it yet: see followWokenWorker.
300
- let streamFromGuess = false;
295
+ // The history's bubbles (bubbles.js): output, end lines, every client's
296
+ // prompts, the recap, warnings and notices, command and note bubbles.
297
+ const bubbles = createBubbles({
298
+ historyEl,
299
+ appendToHistory: (el) => appendToHistory(el),
300
+ isNearBottom: () => isNearBottom(),
301
+ turnDom,
302
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
303
+ shownTexts,
304
+ liveTurn,
305
+ selected: () => selected,
306
+ clientId,
307
+ });
308
+ const {
309
+ removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
310
+ showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
311
+ } = bubbles;
312
+ // The plugin cards and a snapshot's notices in the history (cards_place.js).
313
+ const cards = createCardsPlace({
314
+ historyEl,
315
+ appendToHistory: (el) => appendToHistory(el),
316
+ isNearBottom: () => isNearBottom(),
317
+ turnDom,
318
+ findInTurnRoots: (selector) => findInTurnRoots(selector),
319
+ selected: () => selected,
320
+ clientId,
321
+ questionCards,
322
+ bubbles,
323
+ followWokenWorker: () => followWokenWorker(),
324
+ });
325
+ const { upsertCard, placeSnapshotCards, refreshCardBodies } = cards;
326
+ turnRoots().forEach((root) => root.addEventListener("click", cards.onCardAction));
301
327
  // The enqueued_ids this page sent (from the /turn acks): only these come back
302
328
  // into the composer when their turn fails.
303
329
  const sentIds = new Set();
@@ -383,8 +409,9 @@ function openSessionEvents() {
383
409
  // never forcing it) or when a newer chi is installed than chi web runs.
384
410
  // Once per notice per tab: every reconnect's snapshot says it again.
385
411
  const versions = { served: null, installed: null };
386
- // The session whose Restart is under way (its button says so).
387
- let restartingId = null;
412
+ // The sessions whose Restart is under way (their button says so): one
413
+ // session's restart never blocks another's.
414
+ const restartingIds = new Set();
388
415
  const versionNoticesShown = new Set();
389
416
  function noticeVersions(data) {
390
417
  if (typeof data.version === "string") versions.served = data.version;
@@ -645,7 +672,7 @@ function setLiveStatus(status) {
645
672
  const initRowEl = $("#initRow");
646
673
  const initTasks = newInitTasks();
647
674
  function renderInitRow() {
648
- initRowEl.innerHTML = initRowHtml(initTasks);
675
+ render(initRowTemplate(initTasks), initRowEl);
649
676
  initRowEl.hidden = initTasks.size === 0;
650
677
  }
651
678
  function onInitEvent(type, data) {
@@ -655,7 +682,8 @@ function onInitEvent(type, data) {
655
682
  const session = selected;
656
683
  setTimeout(() => {
657
684
  if (session !== selected) return;
658
- initRowEl.querySelector(`.init-task.done[data-id="${CSS.escape(done)}"]`)?.classList.add("fading");
685
+ fadeDone(initTasks, done);
686
+ renderInitRow();
659
687
  setTimeout(() => {
660
688
  if (session !== selected) return;
661
689
  dropDone(initTasks, done);
@@ -767,8 +795,8 @@ async function resync({ endText = null } = {}) {
767
795
  renderSessionView(data, { kept });
768
796
  // Preview is read-only: a worker is woken only by POST /turn, so attach
769
797
  // only when a live bridge exists.
770
- selectSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
771
- if (selectSeq !== null) startStream(id, selectSeq);
798
+ current.streamSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
799
+ if (current.streamSeq !== null) startStream(id, current.streamSeq);
772
800
  else if (endText) addEndBubble(endText);
773
801
  } finally {
774
802
  resyncsInFlight -= 1;
@@ -853,15 +881,6 @@ function renderSessionView(data, { kept = [] } = {}) {
853
881
  updateComposerMode();
854
882
  }
855
883
 
856
- // The history's one line while it has nothing else (loading, an error,
857
- // no output yet).
858
- function hintEl(text) {
859
- const el = document.createElement("div");
860
- el.className = "hint";
861
- el.textContent = text;
862
- return el;
863
- }
864
-
865
884
  function renderHistory(items) {
866
885
  if (!items.length) {
867
886
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
@@ -871,12 +890,7 @@ function renderHistory(items) {
871
890
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images) }));
872
891
  linkTextRefs();
873
892
  } else {
874
- historyEl.replaceChildren(...items.map((c) => {
875
- const el = document.createElement("div");
876
- el.className = "bubble output";
877
- el.textContent = String(c);
878
- return el;
879
- }));
893
+ historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
880
894
  }
881
895
  historyEl.scrollTop = historyEl.scrollHeight;
882
896
  }
@@ -984,30 +998,6 @@ function atHistoryEnd() {
984
998
  return shouldFollowScroll({ scrollHeight, scrollTop, clientHeight });
985
999
  }
986
1000
 
987
- function appendChunk(text) {
988
- if (!text) return;
989
- const k = normalize(text);
990
- if (!k || shownTexts.shows(k)) return;
991
- const follow = isNearBottom();
992
- const hint = historyEl.querySelector(".hint");
993
- if (hint) hint.remove();
994
- const div = document.createElement("div");
995
- div.className = "bubble output";
996
- div.textContent = text;
997
- appendToHistory(div);
998
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
999
- }
1000
-
1001
- // +stopped+: a stop someone chose (a cancel, the session's stop), drawn in
1002
- // the stopped style (timing.js STOPPED_LINE_CLASS); else a failure's line.
1003
- function addEndBubble(text, { stopped = false } = {}) {
1004
- const div = document.createElement("div");
1005
- div.className = stopped ? STOPPED_LINE_CLASS : "bubble cancel";
1006
- div.textContent = text;
1007
- appendToHistory(div);
1008
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1009
- }
1010
-
1011
1001
  // Everything a turn's end (completed, canceled, failed) closes: then its
1012
1002
  // end line (+endText+, the cancel or failed line), then the step cards that
1013
1003
  // left the collapsed block (card.js leavesBlock), where a reload puts them.
@@ -1031,352 +1021,8 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1031
1021
  if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
1032
1022
  }
1033
1023
 
1034
- // ── Prompt bubbles ─────────────────────────────────────────────────────────
1035
- // Every client's prompts, keyed by enqueued_id: queued (badge) until their
1036
- // turn starts, or steered when merged into a running turn. This tab's own
1037
- // prompts are echoed at send (data-own) and tagged by their turn_enqueued.
1038
-
1039
- // The bubble a reload draws (turn_html.js), with this page's own fields.
1040
- // +delivery+: how it went into a running turn (next_step, cut, queue): its
1041
- // queued badge says what it waits for.
1042
- function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null } = {}) {
1043
- const hint = historyEl.querySelector(".hint");
1044
- if (hint) hint.remove();
1045
- const div = elementOf(userBubbleTemplate({ content: prompt }, {
1046
- label, state, own, enqueuedId, delivery, key: normalize(prompt), thumbs: thumbsTemplate(selected, images),
1047
- }));
1048
- const follow = isNearBottom();
1049
- appendToHistory(div);
1050
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1051
- return div;
1052
- }
1053
-
1054
- function setBubbleState(bubble, state) {
1055
- bubble.classList.remove("queued", "steered", "failed");
1056
- bubble.querySelector(".state-badge")?.remove();
1057
- if (!BADGES[state]) {
1058
- delete bubble.dataset.userState;
1059
- return;
1060
- }
1061
- bubble.classList.add(state);
1062
- bubble.dataset.userState = state;
1063
- bubble.append(elementOf(stateBadgeTemplate(state, bubble.dataset.delivery || null)));
1064
- }
1065
-
1066
- // A bubble's delivery changed (the ack's cut: off goes in at the next
1067
- // step; a queue to a turn that ended meanwhile is just the next turn): its
1068
- // badge follows.
1069
- function setBubbleDelivery(bubble, delivery) {
1070
- if ((bubble.dataset.delivery || null) === (delivery || null)) return;
1071
- if (delivery) bubble.dataset.delivery = delivery;
1072
- else delete bubble.dataset.delivery;
1073
- if (bubble.dataset.userState) setBubbleState(bubble, bubble.dataset.userState);
1074
- }
1075
-
1076
- function bubbleById(enqueuedId) {
1077
- if (!enqueuedId) return null;
1078
- return findInTurnRoots(".bubble.user[data-enqueued-id]").find(
1079
- (el) => el.dataset.enqueuedId === enqueuedId,
1080
- ) || null;
1081
- }
1082
-
1083
- // This tab's oldest untagged echo, preferring one with the same text.
1084
- function untaggedOwnBubble(prompt) {
1085
- const echoes = findInTurnRoots(".bubble.user[data-own]:not([data-enqueued-id])");
1086
- const text = normalize(prompt || "");
1087
- return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
1088
- }
1089
-
1090
- // Returns the bubble of the turn a turn_started starts (the stage takes it
1091
- // into its prompt line), else null.
1092
- function applyPromptOps(event) {
1093
- if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
1094
- const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
1095
- if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
1096
- if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
1097
- let started = null;
1098
- for (const op of ops) {
1099
- if (op.op === "report") {
1100
- liveTurn.unmatchedMerge = "report";
1101
- } else if (op.op === "add") {
1102
- const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1103
- if (op.state === "started") started = bubble;
1104
- if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
1105
- } else if (op.op === "tag") {
1106
- if (bubbleById(op.enqueuedId)) continue;
1107
- const echo = untaggedOwnBubble(op.prompt);
1108
- if (echo) {
1109
- echo.dataset.enqueuedId = op.enqueuedId;
1110
- if (op.delivery) setBubbleDelivery(echo, op.delivery);
1111
- }
1112
- // Re-rendered from a snapshot: the echo is gone, the prompt still queued.
1113
- else makeUserBubble(op.prompt, { state: "queued", own: true, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery });
1114
- } else if (op.op === "start") {
1115
- const bubble = bubbleById(op.enqueuedId) || untaggedOwnBubble(op.prompt);
1116
- // A resync (a new worker woken by this page's send) re-rendered the
1117
- // history and took the echo with it: draw the prompt again.
1118
- if (!bubble) started = makeUserBubble(op.prompt, { own: true, enqueuedId: op.enqueuedId, images: op.images });
1119
- else {
1120
- if (op.enqueuedId) bubble.dataset.enqueuedId = op.enqueuedId;
1121
- setBubbleState(bubble, null);
1122
- started = bubble;
1123
- }
1124
- } else if (op.op === "steer") {
1125
- const bubble = bubbleById(op.enqueuedId);
1126
- if (bubble) setBubbleState(bubble, "steered");
1127
- else liveTurn.unmatchedMerge = true;
1128
- }
1129
- }
1130
- return started;
1131
- }
1132
-
1133
- // ── Idle recap ─────────────────────────────────────────────────────────────
1134
- // A summary the worker writes after a quiet stretch or as it leaves: shown at
1135
- // the end of the history. A turn starting leaves it in place, marked
1136
- // earlier; only a new recap replaces it.
1137
-
1138
- function showRecap(text, turnsSince = 0) {
1139
- if (!text) return;
1140
- removeRecap();
1141
- const details = document.createElement("details");
1142
- details.className = "bubble recap";
1143
- if (turnsSince > 0) details.classList.add("stale");
1144
- details.open = true;
1145
- const summary = document.createElement("summary");
1146
- summary.textContent = recapLabel(turnsSince);
1147
- const body = document.createElement("div");
1148
- body.className = "recap-body";
1149
- body.textContent = text;
1150
- details.appendChild(summary);
1151
- details.appendChild(body);
1152
- // A stale one sits where it was written, above the turns it doesn't cover.
1153
- // Never in the stage: a recap is between turns.
1154
- turnDom.settleFold?.();
1155
- const before = recapPlace(turnPromptBubbles(), turnsSince);
1156
- keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
1157
- }
1158
-
1159
- // A turn started: the recap describes the chat before it.
1160
- function markRecapStale() {
1161
- historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
1162
- el.classList.add("stale");
1163
- const summary = el.querySelector("summary");
1164
- if (summary) summary.textContent = recapLabel(0, true);
1165
- });
1166
- }
1167
-
1168
- // A guardrail hook or rule file that failed to load (once per worker), or
1169
- // a bundle's plugin (+label+ "plugins").
1170
- // +before+: the element it goes above (a snapshot's), else the end.
1171
- function showGuardrailWarning(text, label = "guardrails", { before = null } = {}) {
1172
- if (!text) return;
1173
- const el = document.createElement("div");
1174
- el.className = "bubble guardrail-warning";
1175
- el.textContent = `${label}: ${text}`;
1176
- const follow = isNearBottom();
1177
- if (before) historyEl.insertBefore(el, before);
1178
- else appendToHistory(el);
1179
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1180
- }
1181
-
1182
- // A hook's line to the user (event[:notify]): "<bundle>: text", in the
1183
- // warning colour for level warn.
1184
- // +before+: the element it goes above (a snapshot's notice), else the end.
1185
- // A plugin's steers (pending_input_merged's steers): a row of the running
1186
- // turn's step, else (no turn runs) a bubble.
1187
- function showSteers(steers) {
1188
- for (const steer of steers || []) {
1189
- if (turnDom.steer(steer)) continue;
1190
- removeHintIfEmpty();
1191
- const row = elementOf(steerRowTemplate(steer, { bubble: true }));
1192
- const follow = isNearBottom();
1193
- appendToHistory(row);
1194
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1195
- }
1196
- }
1197
-
1198
- function showHookNotice(data, { before = null } = {}) {
1199
- removeHintIfEmpty();
1200
- const el = document.createElement("div");
1201
- el.className = "bubble hook-notice" + (data.level === "warn" ? " warn" : "") + (data.cls ? ` ${data.cls}` : "");
1202
- el.textContent = data.line || `${hookNoticeLabel(data.hook)}: ${data.text || ""}`;
1203
- if (data.title) el.title = data.title;
1204
- const follow = isNearBottom();
1205
- if (before) historyEl.insertBefore(el, before);
1206
- else appendToHistory(el);
1207
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1208
- }
1209
-
1210
- // ── plugin cards ───────────────────────────────────────────────────────────
1211
- // A plugin's card (docs/plugins.md, Cards): a glass card between turns, or
1212
- // a row of the running turn's step. The same id updates the card in place.
1213
- // An action button runs its command line in the session (sendCommand), as
1214
- // if typed.
1215
-
1216
- function cardElement(id) {
1217
- if (!id) return null;
1218
- return findInTurnRoots(".plugin-card").find((el) => el.dataset.cardId === String(id)) || null;
1219
- }
1220
-
1221
- // Show +card+: update the one with its id where it is, else add it (a
1222
- // running turn's card as a row of its step; else before +before+ or at the
1223
- // end of the history). A running turn's card with actions asks the user
1224
- // something (check-in's Nudge / Keep going / Stop): it goes under the turn's
1225
- // block, not into a step that collapses when the next one starts.
1226
- function upsertCard(card, { before = null } = {}) {
1227
- if (!card || !card.id) return;
1228
- const existing = cardElement(card.id);
1229
- if (existing) {
1230
- const drawn = drawCard(existing, card);
1231
- drawn.classList.add("card-updated");
1232
- setTimeout(() => drawn.classList.remove("card-updated"), 900);
1233
- return;
1234
- }
1235
- removeHintIfEmpty();
1236
- const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
1237
- const asks = (card.actions || []).some((a) => a && a.command);
1238
- if (card.in_turn && !before && !asks && turnDom.card(el)) return;
1239
- const follow = isNearBottom();
1240
- if (before) historyEl.insertBefore(el, before);
1241
- else appendToHistory(el);
1242
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1243
- }
1244
-
1245
- // A snapshot's cards and notices, each above the first turn after it; a
1246
- // turn's own notice where it showed live (placeTurnNotice).
1247
- function placeSnapshotCards(entries) {
1248
- const users = turnPromptBubbles();
1249
- for (const entry of entries) {
1250
- const before = cardPlace(users, entry);
1251
- if (isCard(entry)) upsertCard(entry, { before });
1252
- else if (entry.type === "question") placeResolvedQuestion(entry, before);
1253
- else if (entry.type === "guardrail_warning") showGuardrailWarning(entry.message, entry.label || "guardrails", { before });
1254
- else if (entry.in_turn) placeTurnNotice(entry, before);
1255
- else if (entry.type === "hook_notice") showHookNotice(entry, { before });
1256
- }
1257
- }
1258
-
1259
- // A finished turn's row, a hook's notice or the loop's asking again
1260
- // (+before+: the next turn's prompt, or null for the last turn): a row of
1261
- // its step, above the row of the call it came before, as live; else (a
1262
- // plain answer, a step the history doesn't have) a bubble before the
1263
- // turn's answer, or before its timing line when it has none.
1264
- function placeTurnNotice(entry, before) {
1265
- const extra = entry.type === "llm_context_edited" ? llmContextNotice(entry) : {};
1266
- const line = noticeLine(entry);
1267
- const turnEls = reloadedTurnEls(before);
1268
- const place = turnNoticePlace(entry);
1269
- const block = turnEls.find((el) => el.matches(".turn-work"));
1270
- const step = place && block ? block.querySelectorAll(":scope > .gen")[place.step] : null;
1271
- if (!step) {
1272
- showHookNotice({ ...entry, ...extra, line }, { before: turnAnswerEl(turnEls) || before });
1273
- return;
1274
- }
1275
- const el = document.createElement("div");
1276
- el.className = "hook-notice" + (entry.level === "warn" ? " warn" : "") + (extra.cls ? ` ${extra.cls}` : "");
1277
- el.textContent = line;
1278
- if (extra.title) el.title = extra.title;
1279
- let body = step.querySelector(":scope > .activity-body");
1280
- if (!body) {
1281
- body = document.createElement("div");
1282
- body.className = "activity-body";
1283
- step.appendChild(body);
1284
- }
1285
- const row = [...body.children].find((r) => r.dataset.key === place.rowKey);
1286
- body.insertBefore(el, row || null);
1287
- }
1288
-
1289
- // A prompt that started a turn: not a line steered into a running one.
1290
- const TURN_PROMPT = ".bubble.user:not(.steered)";
1291
-
1292
- // The history's turn prompts, oldest first (what turns_since counts).
1293
- function turnPromptBubbles() {
1294
- return [...historyEl.querySelectorAll(`:scope > ${TURN_PROMPT}`)];
1295
- }
1296
-
1297
- // The elements of the reloaded turn that ends before +before+ (the next
1298
- // turn's prompt, or null for the last turn), its prompt first.
1299
- function reloadedTurnEls(before) {
1300
- const turnEls = [];
1301
- for (let el = before ? before.previousElementSibling : historyEl.lastElementChild; el; el = el.previousElementSibling) {
1302
- turnEls.unshift(el);
1303
- if (el.matches(TURN_PROMPT)) break;
1304
- }
1305
- return turnEls;
1306
- }
1307
-
1308
- // What goes after a reloaded turn's own rows and cards: its answer, else
1309
- // its timing line; null when it has neither.
1310
- function turnAnswerEl(turnEls) {
1311
- return turnEls.filter((el) => el.matches(".bubble.output, .empty-answer")).pop() || turnEls.find((el) => el.matches(".turn-timing")) || null;
1312
- }
1313
-
1314
- // A finished turn's question (the snapshot's cards keep it with its
1315
- // answer): the card as it was left live, resolved, before the lines the
1316
- // user sent into the turn after it (a steered bubble's data-step: the step
1317
- // that read it) and the turn's answer. A pending one is the running turn's
1318
- // (pending_question draws it).
1319
- function placeResolvedQuestion(entry, before) {
1320
- const pq = entry.pending_question;
1321
- if (!pq?.id || !(entry.answer || entry.cancelled)) return;
1322
- const turnEls = reloadedTurnEls(before);
1323
- const asked = Number(entry.iteration) || 1;
1324
- // The reply that asked: its step's text in the reloaded block (the card
1325
- // keeps it, as live), never the running turn's.
1326
- const said = stepText(turnEls.find((el) => el.matches(".turn-work")), asked);
1327
- questionCards.renderQuestion(pq, { said });
1328
- const card = questionCards.questionCard();
1329
- if (!card || card.dataset.qid !== String(pq.id)) return;
1330
- questionCards.resolveQuestion(pq.id, { answer: entry.answer || null, cancelled: !!entry.cancelled, reason: entry.reason || "" });
1331
- const later = turnEls.find((el) => el.matches(".bubble.user.steered") && Number(el.dataset.step) > asked);
1332
- historyEl.insertBefore(card, later || turnAnswerEl(turnEls) || before);
1333
- }
1334
-
1335
- // A live card's body comes as text; the worker's snapshot (through the
1336
- // server) has it rendered. One re-read for a burst of cards.
1337
- let cardBodiesTimer = null;
1338
- function refreshCardBodies() {
1339
- clearTimeout(cardBodiesTimer);
1340
- const id = selected;
1341
- cardBodiesTimer = setTimeout(async () => {
1342
- try {
1343
- const data = await getSession(id, { cards: true });
1344
- if (id !== selected) return;
1345
- for (const card of data.cards || []) {
1346
- const el = card.type === "card" && cardElement(card.id);
1347
- if (el) refreshCardBody(el, card);
1348
- }
1349
- } catch (_) {}
1350
- }, 150);
1351
- }
1352
-
1353
- function onCardAction(e) {
1354
- const button = e.target.closest(".plugin-card .card-action");
1355
- if (!button || !selected) return;
1356
- const card = button.closest(".plugin-card");
1357
- const err = card.querySelector(".card-error");
1358
- err.classList.add("hidden");
1359
- button.disabled = true;
1360
- sendCommand(selected, button.dataset.command, { clientId, card: true })
1361
- .then(() => {
1362
- // The command woke the worker: follow its stream if none is open.
1363
- followWokenWorker();
1364
- })
1365
- .catch((error) => {
1366
- err.textContent = error.message;
1367
- err.classList.remove("hidden");
1368
- })
1369
- .finally(() => { button.disabled = false; });
1370
- }
1371
- turnRoots().forEach((root) => root.addEventListener("click", onCardAction));
1372
-
1373
- function removeRecap() {
1374
- historyEl.querySelectorAll(".bubble.recap").forEach((el) => el.remove());
1375
- }
1376
-
1377
1024
  // The info bar's "copy chi --attach": the full id, which the page shows
1378
- // only short; "copied ✓" for 2 s.
1379
- let attachCopiedAt = 0;
1025
+ // only short; "copied ✓" for 2 s (current.attachCopiedAt: that session's).
1380
1026
  infoTextEl.addEventListener("click", (e) => {
1381
1027
  if (e.target.closest(".worker-restart")) restartWorker();
1382
1028
  // Pushed as openAllView does, so the view's back returns here.
@@ -1387,28 +1033,26 @@ infoTextEl.addEventListener("click", (e) => {
1387
1033
  });
1388
1034
  infoTextEl.addEventListener("click", async (e) => {
1389
1035
  if (!e.target.closest(".copy-attach") || !selected) return;
1390
- if (!(await copyText(`chi --attach ${selected}`))) return;
1391
- attachCopiedAt = Date.now();
1036
+ const id = selected;
1037
+ if (!(await copyText(`chi --attach ${id}`)) || id !== selected) return;
1038
+ current.attachCopiedAt = Date.now();
1392
1039
  updateInfoBar();
1393
1040
  setTimeout(updateInfoBar, 2000);
1394
1041
  });
1395
1042
 
1396
- // The open session's worker runs an older chi than the newest installed:
1397
- // a badge, with Restart when the worker can (else the stop command).
1043
+ // The open session's worker badge (info_bar_model.js workerBadgeFor).
1398
1044
  function currentWorkerBadge() {
1399
- if (!selected || current.owner !== "worker") return null;
1400
- return workerBadge({ workerVersion: current.workerVersion, features: current.workerFeatures,
1401
- installed: versions.installed, served: versions.served, sessionId: selected });
1045
+ return workerBadgeFor(selected, current, versions);
1402
1046
  }
1403
1047
 
1404
1048
  // Restart: the session moves to a new worker on the newest chi; the page
1405
1049
  // follows it (its bridge_up), the composer's draft stays as it is.
1406
1050
  async function restartWorker() {
1407
1051
  const badge = currentWorkerBadge();
1408
- if (!badge || !badge.restart || restartingId) return;
1052
+ if (!badge || !badge.restart || restartingIds.has(selected)) return;
1409
1053
  if (!confirm(restartConfirmText(current.workerVersion, badge.newest))) return;
1410
1054
  const id = selected;
1411
- restartingId = id;
1055
+ restartingIds.add(id);
1412
1056
  updateInfoBar();
1413
1057
  try {
1414
1058
  const result = await restartSession(id);
@@ -1416,80 +1060,41 @@ async function restartWorker() {
1416
1060
  } catch (e) {
1417
1061
  showToast(`Can't restart: ${String(e.message).replace(/ \(\d+\)$/, "")}`, null, { sticky: true });
1418
1062
  } finally {
1419
- restartingId = null;
1063
+ restartingIds.delete(id);
1420
1064
  updateInfoBar();
1421
1065
  }
1422
1066
  }
1423
1067
 
1068
+ // The session bar (info_bar_model.js sessionBar): its buttons, and the
1069
+ // info line rendered by info_bar.js.
1424
1070
  function updateInfoBar() {
1071
+ const { buttons, bar } = sessionBar({
1072
+ id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1073
+ continuing: continuingIds, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1074
+ now: Date.now(),
1075
+ });
1425
1076
  const stopBtn = document.getElementById("infoStopBtn");
1426
1077
  const deleteBtn = document.getElementById("infoDeleteBtn");
1427
1078
  const archiveBtn = document.getElementById("infoArchiveBtn");
1428
1079
  const continueBtn = document.getElementById("infoContinueBtn");
1429
- const chain = selected ? chainNeighbours(allSessions, selected, { continues: current.continues }) : { prev: null, next: null };
1430
1080
  if (continueBtn) {
1431
- // A link continued already has its → link instead; a terminal's
1432
- // session is continued there.
1433
- continueBtn.style.display = selected && !chain.next && current.owner !== "tui" ? "" : "none";
1434
- const busy = !!selected && continuingIds.has(selected);
1435
- continueBtn.disabled = busy;
1436
- continueBtn.textContent = busy ? "continuing…" : "continue →";
1081
+ continueBtn.style.display = buttons.continue.shown ? "" : "none";
1082
+ continueBtn.disabled = buttons.continue.disabled;
1083
+ continueBtn.textContent = buttons.continue.text;
1437
1084
  }
1438
- if (deleteBtn) deleteBtn.style.display = selected ? "" : "none";
1085
+ if (deleteBtn) deleteBtn.style.display = buttons.delete.shown ? "" : "none";
1439
1086
  if (archiveBtn) {
1440
- archiveBtn.style.display = selected ? "" : "none";
1441
- const archived = selectedArchived();
1442
- archiveBtn.textContent = archived ? "unarchive" : "archive";
1443
- archiveBtn.classList.toggle("archived", archived);
1444
- archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
1445
- }
1446
- if (!selected) {
1447
- // The start page hides the info bar (the storage path is on #/sessions).
1448
- renderInfoBar(infoTextEl, null);
1449
- if (stopBtn) stopBtn.style.display = "none";
1450
- updateSessionTitle();
1451
- return;
1087
+ archiveBtn.style.display = buttons.archive.shown ? "" : "none";
1088
+ archiveBtn.textContent = buttons.archive.text;
1089
+ archiveBtn.classList.toggle("archived", buttons.archive.archived);
1090
+ archiveBtn.title = buttons.archive.title;
1452
1091
  }
1453
- if (stopBtn) stopBtn.style.display = canStopSession({ owner: current.owner, streaming: !!streamControl }) ? "" : "none";
1454
- const tokens = current.timing.tokens;
1455
- const ctxTip = ctxBarTip(tokens, current.timing.memoryIndex,
1456
- { tokens: current.ctxWindow, source: current.ctxWindowSource, pct: current.ctxPct,
1457
- budget: current.llmContext?.budget_tokens });
1458
- const speedTextNow = lastSpeedText(tokens);
1459
- const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
1460
- const sessionDuration = currentSessionDuration();
1461
- // A delegated session names its parent, as a link to it.
1462
- const parentChip = delegatedBy(current.parentId, allSessions.find((x) => x.id === current.parentId) || null);
1463
- const badge = currentWorkerBadge();
1464
- // The session clock ticks every second while a turn runs: lit writes its
1465
- // text alone, the bar's other nodes (a tooltip, a button mid-press) stay.
1466
- renderInfoBar(infoTextEl, {
1467
- id: selected,
1468
- copied: Date.now() - attachCopiedAt < 2000,
1469
- status: current.status,
1470
- model: modelLabel(current.model, ...(current.served || [])),
1471
- // A link of a session chain: ← the previous link, → the next one (each
1472
- // only while the list holds it).
1473
- chain: { prev: chainLink(chain.prev), next: chainLink(chain.next) },
1474
- parent: parentChip ? { id: current.parentId, title: parentChip.title } : null,
1475
- // A parent names its delegates; the link opens all sessions.
1476
- kids: childrenSummary({ id: selected }, allSessions),
1477
- llmContext: current.llmContext,
1478
- thinking: current.thinking,
1479
- reasoningHistory: current.reasoningHistory,
1480
- // The tools chip (toolkit_chip.js): null on full.
1481
- toolkit: current.toolkit,
1482
- // The model notes the session's prompt carried.
1483
- notes: promptNotesChip(current.promptNotes),
1484
- memories: current.usedMemories,
1485
- preloaded: current.preloadedMemories,
1486
- muted: current.mutedMemories,
1487
- dir: current.dir,
1488
- ctx: { text: ctxBarText(current.ctxPct, current.ctxWindowSource), tip: ctxTip },
1489
- speed: speedTextNow ? { text: speedTextNow, tip: `Decode speed of the last generation${avgSpeed ? `; session average ${avgSpeed}` : ""}` } : null,
1490
- clock: sessionDuration === null ? "" : `session ${formatDuration(sessionDuration)}`,
1491
- worker: badge ? { badge, busy: restartingId === selected } : null,
1492
- });
1092
+ if (stopBtn) stopBtn.style.display = buttons.stop.shown ? "" : "none";
1093
+ // No session: the start page hides the info bar (the storage path is on
1094
+ // #/sessions). The session clock ticks every second while a turn runs:
1095
+ // lit writes its text alone, the bar's other nodes (a tooltip, a button
1096
+ // mid-press) stay.
1097
+ renderInfoBar(infoTextEl, bar);
1493
1098
  updateSessionTitle();
1494
1099
  }
1495
1100
 
@@ -1519,18 +1124,6 @@ function updateSessionTitle() {
1519
1124
  }
1520
1125
  }
1521
1126
 
1522
- // A chain's link (chains.js chainNeighbours) as the info bar's ← / →
1523
- // shows it (info_bar.js chainLinkTemplate), or null.
1524
- function chainLink(link) {
1525
- if (!link) return null;
1526
- return { id: link.id, date: chainDate(link.session?.created_at), preview: link.session ? lists.cardTitle(link.session) : "",
1527
- archived: !!link.session?.archived };
1528
- }
1529
-
1530
- function currentSessionDuration() {
1531
- return sessionDurationNow(current.timing, liveTurn.running);
1532
- }
1533
-
1534
1127
  // The stage takes a running turn's rows (its extras); else the history.
1535
1128
  function appendToHistory(el) {
1536
1129
  // (The stage's place first moves a turn still sliding away into the history.)
@@ -1577,12 +1170,12 @@ function setUsedMemories(names) {
1577
1170
  // worker have been up already, turn_started says so (replaysDrawnTurn).
1578
1171
  function followWokenWorker() {
1579
1172
  if (streamControl?.live) return;
1580
- startStream(selected, selectSeq ?? 0);
1581
- streamFromGuess = selectSeq === null;
1173
+ startStream(selected, current.streamSeq ?? 0);
1174
+ current.streamFromGuess = current.streamSeq === null;
1582
1175
  }
1583
1176
 
1584
1177
  function startStream(id, fromSeq) {
1585
- streamFromGuess = false;
1178
+ current.streamFromGuess = false;
1586
1179
  if (streamControl) {
1587
1180
  streamControl.close();
1588
1181
  streamControl = null;
@@ -1652,8 +1245,8 @@ const streamHandlers = createStreamHandlers({
1652
1245
  earlyRestores,
1653
1246
  stream: {
1654
1247
  takeGuess: () => {
1655
- const guess = streamFromGuess;
1656
- streamFromGuess = false;
1248
+ const guess = current.streamFromGuess;
1249
+ current.streamFromGuess = false;
1657
1250
  return guess;
1658
1251
  },
1659
1252
  restart: () => {
@@ -1711,12 +1304,6 @@ async function refreshTimingFromSession() {
1711
1304
  }
1712
1305
 
1713
1306
 
1714
- function removeHintIfEmpty() {
1715
- const hint = historyEl.querySelector(".hint");
1716
- if (!hint) return;
1717
- // Remove the placeholder when the card will be the first content in history.
1718
- if (!historyEl.querySelector(".bubble")) hint.remove();
1719
- }
1720
1307
  function closeStream() {
1721
1308
  turnDom.reset();
1722
1309
  if (streamControl) {
@@ -1806,18 +1393,18 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1806
1393
  updateInfoBar();
1807
1394
  }
1808
1395
  actionBtn.disabled = true;
1396
+ // The session the line goes to: the page may switch to another while an
1397
+ // image uploads or the turn is sent, and what follows stays with this one.
1809
1398
  const sentTo = selected;
1810
1399
  try {
1811
- for (const chip of sending) {
1812
- if (!chip.ref) chip.ref = await uploadImage(selected, chip.file, chip.name);
1813
- }
1814
- const ack = await sendTurn(selected, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1400
+ await uploadChips(sending, sentTo, { upload: uploadImage });
1401
+ const ack = await sendTurn(sentTo, prompt, { clientId, images: turnRefs(sending), history: typed, delivery });
1815
1402
  if (ack?.command_id) {
1816
1403
  // The worker knew it as a command (its list was newer than ours):
1817
1404
  // its command_ran shows it, not a prompt bubble.
1818
1405
  echo.remove();
1819
1406
  clearComposer();
1820
- followWokenWorker();
1407
+ if (sentTo === selected) followWokenWorker();
1821
1408
  return;
1822
1409
  }
1823
1410
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
@@ -1834,7 +1421,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1834
1421
  promptEl.value = composerAfterAck({ sent: line, current: promptEl.value, refill: restored?.refill || "" });
1835
1422
  fitPrompt();
1836
1423
  clearChips();
1837
- if (restored?.images?.length) setChips(restoredChips(selected, restored.images));
1424
+ if (restored?.images?.length) setChips(restoredChips(sentTo, restored.images));
1838
1425
  // Its turn_enqueued usually tagged the echo already; the ack covers a
1839
1426
  // worker that wasn't live (no turn_enqueued then).
1840
1427
  if (ack?.enqueued_id && !echo.dataset.enqueuedId && !bubbleById(ack.enqueued_id)) {
@@ -1843,17 +1430,18 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1843
1430
  // A live stream carries this turn already; restarting it would drop the
1844
1431
  // events in between. Without one, POST /turn just woke a worker: its
1845
1432
  // events start after the seq the session was rendered at (0 for a new
1846
- // worker process).
1847
- followWokenWorker();
1433
+ // worker process). A switch since then opened the other session's own.
1434
+ if (sentTo === selected) followWokenWorker();
1848
1435
  } catch (e) {
1849
1436
  echo.remove();
1850
1437
  // The turn never reached the worker's queue: an early restore waiting
1851
1438
  // for its ack is never taken back (4.13), so drop it here. The prompt
1852
1439
  // is still in the composer, as the echo was removed.
1853
1440
  dropEarlyRestores(earlyRestores);
1854
- // The chips stay (uploaded ones keep their ref) for another try: an
1441
+ // The chips stay (uploaded ones keep their ref, to sentTo) for another try: an
1855
1442
  // image refused (bad_image, too_large, bad_images) says why below.
1856
1443
  if (e.code === "owned_by_tui") {
1444
+ if (sentTo !== selected) return alert(e.message);
1857
1445
  current.owner = "tui";
1858
1446
  updateComposerMode();
1859
1447
  return;
@@ -1862,7 +1450,7 @@ async function handleSendTurn({ delivery: chosen = null } = {}) {
1862
1450
  // says so, and the line stays in the composer.
1863
1451
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
1864
1452
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
1865
- if (e.code === "delivery_unavailable") return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1453
+ if (e.code === "delivery_unavailable" && sentTo === selected) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
1866
1454
  alert(e.message);
1867
1455
  } finally {
1868
1456
  actionBtn.disabled = current.owner === "tui";
@@ -1902,30 +1490,6 @@ function clearComposer() {
1902
1490
  fitPrompt();
1903
1491
  }
1904
1492
 
1905
- function addCommandBubble(view) {
1906
- removeHintIfEmpty();
1907
- const div = document.createElement("div");
1908
- // Page-only: kept across a resync of this session (keptCommandBubbles).
1909
- if (selected) div.dataset.keptFor = selected;
1910
- drawCommandBubble(div, view);
1911
- const follow = isNearBottom();
1912
- appendToHistory(div);
1913
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1914
- }
1915
-
1916
- function commandBubble(commandId) {
1917
- if (!commandId) return null;
1918
- return findInTurnRoots(".bubble.command").find((el) => el.dataset.commandId === String(commandId)) || null;
1919
- }
1920
-
1921
- function addNoteBubble(note) {
1922
- removeHintIfEmpty();
1923
- const div = elementOf(noteBubbleTemplate(note));
1924
- const follow = isNearBottom();
1925
- appendToHistory(div);
1926
- if (follow) historyEl.scrollTop = historyEl.scrollHeight;
1927
- }
1928
-
1929
1493
  async function handleCancel() {
1930
1494
  if (!selected) return;
1931
1495
  cancelBtn.disabled = true;