samagotchi 0.133.0 → 0.135.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -1
  3. data/bin/chi +2 -3
  4. data/docs/architecture.md +6 -3
  5. data/docs/cli.md +50 -34
  6. data/docs/configuration.md +9 -6
  7. data/docs/guardrails.md +146 -13
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/internals/engine.md +6 -3
  10. data/docs/internals/participants.md +7 -6
  11. data/docs/internals/tool-guardrails.md +2 -0
  12. data/docs/internals/web-frontend.md +262 -0
  13. data/docs/plugins.md +2 -1
  14. data/docs/sessions.md +5 -2
  15. data/docs/testing.md +34 -4
  16. data/lib/samagotchi/bridge.rb +11 -6
  17. data/lib/samagotchi/bridge_client.rb +8 -5
  18. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  19. data/lib/samagotchi/chi_home.rb +12 -2
  20. data/lib/samagotchi/config.rb +0 -2
  21. data/lib/samagotchi/desktop/macos.rb +19 -4
  22. data/lib/samagotchi/engine.rb +82 -12
  23. data/lib/samagotchi/guardrail_wiring.rb +8 -6
  24. data/lib/samagotchi/guardrails/approval.rb +41 -5
  25. data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
  26. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
  27. data/lib/samagotchi/guardrails/pair_command.rb +828 -86
  28. data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
  29. data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
  30. data/lib/samagotchi/guardrails/shared_git.rb +1 -1
  31. data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
  32. data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
  33. data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
  34. data/lib/samagotchi/guardrails/targets.rb +8 -3
  35. data/lib/samagotchi/guardrails_command.rb +13 -10
  36. data/lib/samagotchi/home_command.rb +22 -6
  37. data/lib/samagotchi/home_doctor.rb +36 -3
  38. data/lib/samagotchi/home_notice.rb +8 -1
  39. data/lib/samagotchi/hooks/loader.rb +5 -0
  40. data/lib/samagotchi/llm/chat_loop.rb +4 -3
  41. data/lib/samagotchi/log_line.rb +12 -0
  42. data/lib/samagotchi/message_parts.rb +1 -1
  43. data/lib/samagotchi/project_env.rb +3 -11
  44. data/lib/samagotchi/revise_anchor.rb +33 -0
  45. data/lib/samagotchi/self_report.rb +3 -1
  46. data/lib/samagotchi/session_inbox.rb +20 -12
  47. data/lib/samagotchi/session_manager.rb +13 -11
  48. data/lib/samagotchi/system_one/approval_question.rb +21 -16
  49. data/lib/samagotchi/system_one/self_protect.rb +70 -16
  50. data/lib/samagotchi/testing/waiting.rb +13 -0
  51. data/lib/samagotchi/toolkit_binding.rb +16 -0
  52. data/lib/samagotchi/tools/delegate_relay.rb +6 -5
  53. data/lib/samagotchi/tools/delegate_wait.rb +5 -5
  54. data/lib/samagotchi/tools/execute.rb +6 -6
  55. data/lib/samagotchi/tools/output_guardrails.rb +9 -2
  56. data/lib/samagotchi/tools/read.rb +5 -5
  57. data/lib/samagotchi/turn_anchors.rb +49 -0
  58. data/lib/samagotchi/update_command.rb +7 -3
  59. data/lib/samagotchi/update_hint.rb +6 -1
  60. data/lib/samagotchi/verify_anchor.rb +5 -5
  61. data/lib/samagotchi/version.rb +1 -1
  62. data/lib/samagotchi/web/app.rb +2 -14
  63. data/lib/samagotchi/web/message_display.rb +17 -1
  64. data/lib/samagotchi/web/participant_access.rb +2 -2
  65. data/lib/samagotchi/web/participant_view.rb +8 -0
  66. data/lib/samagotchi/web/public/activity.js +1 -1
  67. data/lib/samagotchi/web/public/annotations.js +1 -1
  68. data/lib/samagotchi/web/public/answer_fold.js +262 -0
  69. data/lib/samagotchi/web/public/answer_refs.js +2 -0
  70. data/lib/samagotchi/web/public/app.js +462 -338
  71. data/lib/samagotchi/web/public/bubbles.js +3 -3
  72. data/lib/samagotchi/web/public/cards_place.js +9 -4
  73. data/lib/samagotchi/web/public/chip_popover.js +73 -11
  74. data/lib/samagotchi/web/public/claim_link.js +4 -2
  75. data/lib/samagotchi/web/public/composer.css +11 -1
  76. data/lib/samagotchi/web/public/context_chips.js +9 -6
  77. data/lib/samagotchi/web/public/data.js +4 -2
  78. data/lib/samagotchi/web/public/dock_reserve.js +13 -15
  79. data/lib/samagotchi/web/public/feature_gates.js +33 -0
  80. data/lib/samagotchi/web/public/history_reveal.js +27 -0
  81. data/lib/samagotchi/web/public/index.html +82 -37
  82. data/lib/samagotchi/web/public/latest.js +46 -0
  83. data/lib/samagotchi/web/public/lists_view.js +15 -13
  84. data/lib/samagotchi/web/public/live_timing.js +2 -0
  85. data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
  86. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  87. data/lib/samagotchi/web/public/method_api.js +14 -0
  88. data/lib/samagotchi/web/public/notifications.js +4 -11
  89. data/lib/samagotchi/web/public/participant.js +11 -6
  90. data/lib/samagotchi/web/public/participant_names.js +1 -1
  91. data/lib/samagotchi/web/public/participants_panel.js +4 -2
  92. data/lib/samagotchi/web/public/project_picker.js +18 -22
  93. data/lib/samagotchi/web/public/question_cards.js +2 -2
  94. data/lib/samagotchi/web/public/refs.js +6 -7
  95. data/lib/samagotchi/web/public/restore.js +2 -0
  96. data/lib/samagotchi/web/public/review_start.js +1 -1
  97. data/lib/samagotchi/web/public/revise_stack.js +372 -0
  98. data/lib/samagotchi/web/public/scope.js +12 -20
  99. data/lib/samagotchi/web/public/send_turn.js +29 -16
  100. data/lib/samagotchi/web/public/session_actions.js +15 -14
  101. data/lib/samagotchi/web/public/session_fields.js +152 -0
  102. data/lib/samagotchi/web/public/session_payload.js +35 -0
  103. data/lib/samagotchi/web/public/session_state.js +2 -0
  104. data/lib/samagotchi/web/public/session_store.js +191 -0
  105. data/lib/samagotchi/web/public/stage_model.js +4 -4
  106. data/lib/samagotchi/web/public/stage_view.js +11 -16
  107. data/lib/samagotchi/web/public/start_page.js +4 -4
  108. data/lib/samagotchi/web/public/start_settings.js +29 -18
  109. data/lib/samagotchi/web/public/start_toolkit.js +5 -4
  110. data/lib/samagotchi/web/public/stream_handlers.js +88 -90
  111. data/lib/samagotchi/web/public/task_stop.js +1 -1
  112. data/lib/samagotchi/web/public/timing.js +7 -3
  113. data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
  114. data/lib/samagotchi/web/public/turn_events.js +2 -2
  115. data/lib/samagotchi/web/public/turn_html.js +17 -9
  116. data/lib/samagotchi/web/public/turn_model.js +6 -6
  117. data/lib/samagotchi/web/public/turn_range.js +32 -0
  118. data/lib/samagotchi/web/public/verdict_card.js +5 -13
  119. data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
  120. data/lib/samagotchi/web/public/verify_target.js +7 -10
  121. data/lib/samagotchi/web/public/working_set_view.js +3 -1
  122. data/lib/samagotchi/web/server.rb +4 -4
  123. data/lib/samagotchi/web/session_control.rb +2 -2
  124. data/lib/samagotchi/worker.rb +9 -9
  125. data/lib/samagotchi/worker_inbound.rb +6 -5
  126. metadata +16 -3
  127. data/lib/samagotchi/web/public/hold.js +0 -78
@@ -43,9 +43,14 @@ import { renderInfoBar } from "./info_bar.js";
43
43
  import { sessionBar, workerBadgeFor } from "./info_bar_model.js";
44
44
  import { createShownTexts } from "./shown_texts.js";
45
45
  import { emptySessionState, sessionStateFrom } from "./session_state.js";
46
+ import { sessionFieldsFrom } from "./session_payload.js";
47
+ import { fieldsFromCard } from "./session_fields.js";
48
+ import { createSessionStore } from "./session_store.js";
49
+ import { createFeatureGates } from "./feature_gates.js";
50
+ import { stillOn } from "./latest.js";
46
51
  import { thumbsTemplate } from "./images.js";
47
- import { normalize, failedTurnText, cantText, withLiveStatus, withoutWorker } from "./format.js";
48
- import { createTurnView, turnHistoryNodes } from "./turn_view.js";
52
+ import { normalize, failedTurnText, cantText, withoutWorker } from "./format.js";
53
+ import { turnHistoryNodes } from "./turn_block.js";
49
54
  import { elementOf, emptyAnswerTemplate } from "./turn_html.js";
50
55
  import { createBubbles, hintEl, outputBubbleTemplate } from "./bubbles.js";
51
56
  import { createCardsPlace } from "./cards_place.js";
@@ -68,6 +73,8 @@ import { VERIFY_PRESET, VERIFY_UNAVAILABLE, verifyWorkerUnavailable, withVerify
68
73
  import { OWNER_FINAL, verifyTurnOf } from "./verify_target.js";
69
74
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
70
75
  import { copyText, installCopy } from "./copy.js";
76
+ import { installAnswerFold, sweepAnswerFolds } from "./answer_fold.js";
77
+ import { answerLabel, installReviseStacks, reviseChipTemplate, reviseWorkerUnavailable } from "./revise_stack.js";
71
78
  import { createQuestionCards } from "./question_cards.js";
72
79
  import { splitSnapshotCards } from "./card.js";
73
80
  import { PROMPT_LINE_PX, PROMPT_MIN_PX } from "./composer_size.js";
@@ -152,8 +159,8 @@ const contextChips = createContextChips({
152
159
  // /llm-context runs in the session's worker.
153
160
  const llmContextControl = createLlmContextControl({
154
161
  el: infoTextEl,
155
- summary: () => (selected ? current.llmContext : null),
156
- session: () => selected,
162
+ summary: () => (store.selected ? store.current.llmContext : null),
163
+ session: () => store.selected,
157
164
  running: () => liveTurn.running,
158
165
  run: (line) => runSessionCommand(line),
159
166
  });
@@ -161,8 +168,8 @@ const llmContextControl = createLlmContextControl({
161
168
  // in the session's worker.
162
169
  const thinkingControl = createThinkingControl({
163
170
  el: infoTextEl,
164
- summary: () => (selected ? current.thinking : null),
165
- session: () => selected,
171
+ summary: () => (store.selected ? store.current.thinking : null),
172
+ session: () => store.selected,
166
173
  running: () => liveTurn.running,
167
174
  run: (line) => runSessionCommand(line),
168
175
  });
@@ -170,8 +177,8 @@ const thinkingControl = createThinkingControl({
170
177
  // chat host's only): /reasoning-history runs in the session's worker.
171
178
  const reasoningHistoryControl = createReasoningHistoryControl({
172
179
  el: infoTextEl,
173
- summary: () => (selected ? current.reasoningHistory : null),
174
- session: () => selected,
180
+ summary: () => (store.selected ? store.current.reasoningHistory : null),
181
+ session: () => store.selected,
175
182
  running: () => liveTurn.running,
176
183
  run: (line) => runSessionCommand(line),
177
184
  });
@@ -179,10 +186,10 @@ const reasoningHistoryControl = createReasoningHistoryControl({
179
186
  // (Invite, revoke, caps, the participant view).
180
187
  const participantsControl = createParticipantsControl({
181
188
  el: infoTextEl,
182
- session: () => selected,
189
+ session: () => store.selected,
183
190
  api: { list: listParticipants, invite: inviteParticipant, revoke: revokeParticipant, cap: setParticipantCap },
184
191
  startReview: (opts) => sessionActions.reviewSelected(opts),
185
- reviewing: () => sessionActions.reviewing.has(selected),
192
+ reviewing: () => sessionActions.reviewing.has(store.selected),
186
193
  // An older prompt drawn before the list came says "a participant": named now.
187
194
  onNames: () => {
188
195
  nameUnnamedBadges(document);
@@ -191,7 +198,7 @@ const participantsControl = createParticipantsControl({
191
198
  return badge && !badge.unnamed ? badge.name : null;
192
199
  });
193
200
  },
194
- onChange: () => updateInfoBar(),
201
+ onChange: () => store.touch("infoBar"),
195
202
  toast: (text, action, options) => showToast(text, action, options),
196
203
  });
197
204
  // The start page's model picker, its ⚙ panel and 🧰 list with the override chips
@@ -209,10 +216,10 @@ const startPage = createStartPage({
209
216
  reviewEl: $("#reviewNew"),
210
217
  toolkitBtnEl: $("#toolkitBtn"),
211
218
  toolkitEl: $("#toolkitNew"),
212
- selected: () => selected,
213
- state: () => current,
219
+ selected: () => store.selected,
220
+ state: () => store.current,
214
221
  running: () => liveTurn.running,
215
- sessions: () => allSessions,
222
+ sessions: () => store.sessions,
216
223
  run: (line) => runSessionCommand(line),
217
224
  focusPrompt: () => focusPrompt(),
218
225
  toast: (text) => showToast(text),
@@ -237,8 +244,65 @@ const attachInput = $("#attachInput");
237
244
  const infoCancelBtn = $("#infoCancelBtn");
238
245
  const composeHintEl = $("#composeHint");
239
246
 
240
- let selected = null;
247
+ // The open session (store.selected), its fields (store.current: session_state.js;
248
+ // forgetSession() starts them over) and every card (store.sessions, the hub's
249
+ // projection): session_store.js. Read store.current fresh after every await.
250
+ // A write redraws what reads it (the redraws are registered below, before
251
+ // the page's first draw); an input the store doesn't hold calls
252
+ // store.touch(name).
253
+ const store = createSessionStore({ initial: emptySessionState() });
254
+ // The page actions the open session's worker must support (feature_gates.js),
255
+ // the store's featureGates redraw: a gate's redraw runs when its answer
256
+ // flips (a restart brings the feature, the worker goes). forgetSession and
257
+ // renderSessionView prime them: the history they draw has the answer already.
258
+ const featureGates = createFeatureGates([
259
+ // Verify fresh on a verdict's card.
260
+ {
261
+ name: "verify",
262
+ unavailable: (cur) => verifyWorkerUnavailable(cur.workerFeatures, cur.bridgeUp),
263
+ redraw: () => redrawFresh(historyEl, verifyFreshState()),
264
+ },
265
+ // Revise on an answer and the composer's Revising chip (revise_stack.js).
266
+ {
267
+ name: "revise",
268
+ unavailable: (cur) => reviseWorkerUnavailable(cur.workerFeatures, cur.bridgeUp),
269
+ redraw: () => redrawRevise(),
270
+ },
271
+ ]);
272
+ featureGates.prime(store.current);
273
+ // The open session's id, for the stale-answer guards (latest.js stillOn).
274
+ const openId = () => store.selected;
241
275
  let pendingVerifyAnchor = null;
276
+ // The composer's armed Revise ({turn_id, label}: the answer the next
277
+ // message rewrites), shown as its chip; Verify's lifecycle (an emptied
278
+ // composer, another session, the send's ack let go of it). Set only
279
+ // through setReviseAnchor (it redraws the chip and the answers' buttons).
280
+ let pendingReviseAnchor = null;
281
+ let reviseStacks = null;
282
+ const reviseChipEl = $("#reviseChip");
283
+ const reviseUnavailable = () => reviseWorkerUnavailable(store.current.workerFeatures, store.current.bridgeUp);
284
+ function redrawRevise() {
285
+ if (reviseChipEl) {
286
+ render(reviseChipTemplate(pendingReviseAnchor, {
287
+ unavailable: reviseUnavailable(),
288
+ onClear: () => { setReviseAnchor(null); focusPrompt(); },
289
+ }), reviseChipEl);
290
+ reviseChipEl.hidden = !pendingReviseAnchor;
291
+ }
292
+ reviseStacks?.decorate();
293
+ }
294
+ function setReviseAnchor(anchor) {
295
+ if (anchor === pendingReviseAnchor) return;
296
+ pendingReviseAnchor = anchor;
297
+ redrawRevise();
298
+ }
299
+ // A restored Revise turn's draft ({turn_id}): armed again, its label from
300
+ // the answer it names.
301
+ function restoreRevise(anchor) {
302
+ if (!anchor?.turn_id) return;
303
+ const answer = historyEl.querySelector(`.bubble.output[data-answer-turn="${CSS.escape(anchor.turn_id)}"]`);
304
+ setReviseAnchor({ turn_id: anchor.turn_id, label: answerLabel(answer) });
305
+ }
242
306
  let annotateBar = null;
243
307
  // The session the composer's text was last written in ("" the start page,
244
308
  // undefined unknown): the composer is one for every session, and continue →
@@ -247,7 +311,7 @@ let draftFrom;
247
311
  // Record that the composer's text was written now, in session +id+ (default
248
312
  // the open one). Every write of text into the composer comes through here.
249
313
  function noteDraft(id) {
250
- draftFrom = id ?? selected ?? "";
314
+ draftFrom = id ?? store.selected ?? "";
251
315
  }
252
316
  let streamControl = null;
253
317
  // The session list's stream (GET /api/events); null while the page falls
@@ -257,31 +321,14 @@ let eventsControl = null;
257
321
  // session while one ran (its bridge_up event): the tail runs once more.
258
322
  let resyncsInFlight = 0;
259
323
  let resyncAgain = false;
260
- let allSessions = [];
261
324
  // The running turn's texts on the page (the turn output dedupe).
262
325
  const shownTexts = createShownTexts();
263
- // The page's view of a turn (web.view, or ?view=stage|turn): the running
264
- // turn pinned above the composer (the stage, stage_view.js), or one block
265
- // per turn with its steps in the history (turn_view.js).
266
- const view = ["stage", "turn"].includes(document.body.dataset.view) ? document.body.dataset.view : "stage";
267
- // The running turn's DOM: the turn block, or the stage (the turn block in
268
- // its cloud).
269
- const viewArgs = {
270
- historyEl,
271
- appendToHistory: (el) => appendToHistory(el),
272
- isNearBottom: () => isNearBottom(),
273
- sawText: (k) => shownTexts.saw(k),
274
- sessionId: () => selected,
275
- // The stop-task button on a running task_wait, for a worker that has
276
- // the route (its sidecar features).
277
- taskStop: createTaskStopper({ sessionId: () => selected, features: () => current.workerFeatures }),
278
- };
279
326
  const markdownPlace = createMarkdownPlace({
280
327
  capabilities: () => pageCapabilities,
281
328
  renderText,
282
329
  roots: () => turnRoots(),
283
330
  touchedFiles,
284
- currentDir: () => current.dir,
331
+ currentDir: () => store.current.dir,
285
332
  linkRefs,
286
333
  applyFinalAnswer,
287
334
  findInTurnRoots: (selector) => findInTurnRoots(selector),
@@ -294,28 +341,38 @@ const { renderMarkdown, pairRefsIn, linkRefsIn, applyFinalMarkdown, linkTextRefs
294
341
  // The stage view's working set (web.working_set, or ?working_set=1|0):
295
342
  // what each turn brought into context, in a rail in the stage, a footprint
296
343
  // under each finished turn and a "set N" chip in the mem chip's place
297
- // (working_set_view.js). The turn view ignores it.
298
- const workingSet = view === "stage" && document.body.dataset.workingSet === "1"
344
+ // (working_set_view.js).
345
+ const workingSet = document.body.dataset.workingSet === "1"
299
346
  ? createWorkingSet({
300
- historyEl, stageRoot: () => turnDom.root, state: () => current, session: () => selected, infoEl: infoTextEl,
301
- onChange: () => updateInfoBar(), getWorkingSet,
347
+ historyEl, stageRoot: () => turnDom.root, state: () => store.current, session: () => store.selected, infoEl: infoTextEl,
348
+ onChange: () => store.touch("infoBar"), getWorkingSet,
302
349
  // A drop or undrop from the list: the session's command, as typed.
303
- sendLine: (line) => (selected ? sendCommand(selected, line, { clientId }) : null),
350
+ sendLine: (line) => (store.selected ? sendCommand(store.selected, line, { clientId }) : null),
304
351
  })
305
352
  : null;
306
353
  document.body.classList.toggle("working-set", !!workingSet);
307
- const turnDom = view === "stage"
308
- ? createStageView({
309
- ...viewArgs, isNearBottom: () => atHistoryEnd(), dockEl: $("#dock"), centerEl: $("#center"),
310
- renderMarkdown, linkRefs: linkRefsIn, workingSet,
311
- })
312
- : createTurnView(viewArgs);
354
+ // The running turn's DOM: the stage pinned above the composer
355
+ // (stage_view.js), the turn block (turn_block.js) in its cloud.
356
+ const turnDom = createStageView({
357
+ historyEl,
358
+ isNearBottom: () => atHistoryEnd(),
359
+ sawText: (k) => shownTexts.saw(k),
360
+ sessionId: () => store.selected,
361
+ // The stop-task button on a running task_wait, for a worker that has
362
+ // the route (its sidecar features).
363
+ taskStop: createTaskStopper({ sessionId: () => store.selected, features: () => store.current.workerFeatures }),
364
+ dockEl: $("#dock"),
365
+ centerEl: $("#center"),
366
+ renderMarkdown,
367
+ linkRefs: linkRefsIn,
368
+ workingSet,
369
+ });
313
370
  // The history's bottom reserve and the toast's place follow the composer
314
371
  // card and the stage floating over the history (dock_reserve.js).
315
372
  const { placeToast } = createDockReserve({
316
373
  dockEl: $("#dock"),
317
374
  centerEl: $("#center"),
318
- stageEl: view === "stage" ? turnDom.root : null,
375
+ stageEl: turnDom.root,
319
376
  historyEl,
320
377
  toastEl: $("#toast"),
321
378
  modelPanelEl: $("#modelPanel"),
@@ -324,29 +381,26 @@ const { placeToast } = createDockReserve({
324
381
  // Where a running turn's rows can be: the history, and the stage while it
325
382
  // holds a turn (lookups by id, copy, annotate, card actions).
326
383
  function turnRoots() {
327
- return turnDom.root ? [historyEl, turnDom.root] : [historyEl];
384
+ return [historyEl, turnDom.root];
328
385
  }
329
- const systemOneWrongAction = createSystemOneWrongAction({ sessionId: () => selected });
386
+ const systemOneWrongAction = createSystemOneWrongAction({ sessionId: () => store.selected });
330
387
  turnRoots().forEach((root) => root.addEventListener("click", systemOneWrongAction));
331
388
  function findInTurnRoots(selector) {
332
389
  return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
333
390
  }
334
- // The open session's fields (session_state.js); resetSessionState() starts
335
- // them over.
336
- let current = emptySessionState();
337
391
  // The running turn as the page follows it (live_turn.js): whether one
338
392
  // runs, its timing line and the turns this page saw end (live_timing.js),
339
393
  // its merges' bubble flags, its turn-end reads.
340
394
  const liveTurn = createLiveTurn({
341
- timing: () => current.timing,
395
+ timing: () => store.current.timing,
342
396
  // The stage's status row while it holds the turn (the line moves into
343
397
  // the history with it), else the history.
344
398
  place: (el) => {
345
- const stageHost = turnDom.timingHost?.() || null;
399
+ const stageHost = turnDom.timingHost();
346
400
  (stageHost || historyEl).appendChild(el);
347
401
  return !!stageHost;
348
402
  },
349
- onTick: () => updateInfoBar(),
403
+ onTick: () => store.touch("infoBar"),
350
404
  });
351
405
 
352
406
  // The composer's delivery line while the open session's turn runs
@@ -355,22 +409,22 @@ const liveTurn = createLiveTurn({
355
409
  const deliveryControl = createDeliveryControl({
356
410
  el: $("#deliveryLine"),
357
411
  read: () => {
358
- const turn = turnDom.current?.().turn || null;
412
+ const turn = turnDom.current().turn || null;
359
413
  const running = turn ? turn.gens.flatMap((g) => g.tools).reverse().find((r) => r.status === "running") : null;
360
414
  return {
361
- phase: turnDom.phase?.() || liveSlots(turn).phase,
415
+ phase: turnDom.phase() || liveSlots(turn).phase,
362
416
  gen: turn ? currentGen(turn) : null,
363
417
  tool: running ? { name: toolName(running), startedAt: running.startedAt ?? null } : null,
364
418
  cutAfter: liveTurn.cutAfter,
365
419
  serverGen: liveTurn.serverGen,
366
- canQueue: current.workerFeatures.includes("delivery"),
367
- model: current.model,
420
+ canQueue: store.current.workerFeatures.includes("delivery"),
421
+ model: store.current.model,
368
422
  participantTurn: !!liveTurn.participantTurn,
369
423
  };
370
424
  },
371
425
  composerEmpty: () => !composer.hasDraft(),
372
426
  send: (delivery) => handleSendTurn({ delivery }),
373
- session: () => selected,
427
+ session: () => store.selected,
374
428
  });
375
429
 
376
430
  // This tab's id in the session's events: tells its own prompts from the
@@ -378,7 +432,7 @@ const deliveryControl = createDeliveryControl({
378
432
  const clientId = newClientId();
379
433
  // The question and continue cards in the history (question_cards.js).
380
434
  const questionCards = createQuestionCards({
381
- sessionId: () => selected,
435
+ sessionId: () => store.selected,
382
436
  clientId,
383
437
  place: (card) => appendToHistory(card),
384
438
  isNearBottom: () => isNearBottom(),
@@ -399,7 +453,7 @@ const bubbles = createBubbles({
399
453
  findInTurnRoots: (selector) => findInTurnRoots(selector),
400
454
  shownTexts,
401
455
  liveTurn,
402
- selected: () => selected,
456
+ selected: () => store.selected,
403
457
  clientId,
404
458
  });
405
459
  const {
@@ -414,7 +468,7 @@ const cards = createCardsPlace({
414
468
  historyEl,
415
469
  turnDom,
416
470
  findInTurnRoots: (selector) => findInTurnRoots(selector),
417
- selected: () => selected,
471
+ selected: () => store.selected,
418
472
  clientId,
419
473
  questionCards,
420
474
  bubbles,
@@ -443,20 +497,28 @@ const scope = {
443
497
  };
444
498
  // The server refused ?dir (a folder deleted since the link was made).
445
499
  let scopeError = null;
500
+ // The list's first load sweeps the folded answers of sessions gone since
501
+ // (answer_fold.js), once per page.
502
+ let foldsSwept = false;
503
+ function sweepFoldsOnce() {
504
+ if (foldsSwept) return;
505
+ foldsSwept = true;
506
+ sweepAnswerFolds(store.sessions, { scoped: !!scope.dir });
507
+ }
446
508
 
447
509
  // The list by fetching: the fallback when there is no events stream (a
448
510
  // chi web without a hub, or ?dir the server refused: the 400 is read here).
449
511
  async function refresh() {
450
512
  try {
451
- allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
452
- notifications.noticeList(allSessions);
513
+ store.update({ sessions: await listSessions("updated_at", "desc", { dir: scope.dir }) });
514
+ notifications.noticeList(store.sessions);
515
+ sweepFoldsOnce();
453
516
  } catch (e) {
454
517
  if (e.status !== 400 || !scope.dir) throw e;
455
518
  scopeError = e.message;
456
- allSessions = [];
519
+ store.update({ sessions: [] });
457
520
  renderScope();
458
521
  }
459
- lists.render();
460
522
  }
461
523
 
462
524
  // The list as a projection of the hub's events: a snapshot on every
@@ -464,34 +526,27 @@ async function refresh() {
464
526
  // worker follow its events (a new worker's bridge_up reconnects the stream).
465
527
  function openSessionEvents() {
466
528
  eventsControl = openEvents({
467
- snapshot: (data) => {
529
+ // The list redraws the lists and the bar (its children chip, its chain's
530
+ // ← → links): store.update({sessions}).
531
+ snapshot: (data) => store.batch(() => {
468
532
  noticeVersions(data);
469
533
  if (updateSheet.reconnected(data.version)) showToast(`chi web reconnected on chi ${data.version}`);
470
534
  if (data.chi_update) updateFrame(data.chi_update, { snapshot: true });
471
535
  notifications.noticeSessions("snapshot", data);
472
- allSessions = applySessionEvent(allSessions, "snapshot", data);
473
- lists.render();
474
- const sess = allSessions.find((s) => s.id === selected);
536
+ store.update({ sessions: applySessionEvent(store.sessions, "snapshot", data) });
537
+ sweepFoldsOnce();
538
+ const sess = store.sessions.find((s) => s.id === store.selected);
475
539
  if (sess) followSelectedSession(sess);
476
- },
477
- session: (data) => {
540
+ }),
541
+ session: (data) => store.batch(() => {
478
542
  notifications.noticeSessions("session", data);
479
- allSessions = applySessionEvent(allSessions, "session", data);
480
- lists.render();
481
- if (data.session && data.session.id === selected) followSelectedSession(data.session);
482
- // Its children chip; its chain's ← → links (a link that continues it, its previous one).
483
- else if (selected && (data.session?.parent_id === selected || data.session?.continues === selected
484
- || (data.session?.id && data.session.id === current.continues))) updateInfoBar();
485
- },
543
+ store.update({ sessions: applySessionEvent(store.sessions, "session", data) });
544
+ if (data.session && data.session.id === store.selected) followSelectedSession(data.session);
545
+ }),
486
546
  session_gone: (data) => {
487
547
  notifications.noticeSessions("session_gone", data);
488
548
  lists.forget(data.id);
489
- const next = applySessionEvent(allSessions, "session_gone", data);
490
- if (next !== allSessions) {
491
- allSessions = next;
492
- lists.render();
493
- if (selected) updateInfoBar(); // a deleted child leaves the children chip, a deleted chain link its ← → link
494
- }
549
+ store.update({ sessions: applySessionEvent(store.sessions, "session_gone", data) });
495
550
  leaveGoneSession(data.id, "deleted");
496
551
  },
497
552
  chi: (data) => noticeVersions(data),
@@ -537,7 +592,7 @@ function noticeVersions(data) {
537
592
  const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
538
593
  installed: versions.installed, available: versions.available,
539
594
  updateBlocked: versions.updateBlocked, shown: versionNoticesShown });
540
- updateInfoBar(); // the worker badge compares with the newest installed
595
+ store.touch("infoBar"); // the worker badge compares with the newest installed
541
596
  if (!notice) return;
542
597
  versionNoticesShown.add(notice.key);
543
598
  if (notice.key.startsWith("available:")) writeStored(AVAILABLE_SHOWN_KEY, notice.key.slice("available:".length));
@@ -592,18 +647,13 @@ function copyUpdateCommand(command) {
592
647
  // (a turn another client sent woke one, or a replacement after a kill)
593
648
  // gets the stream attached through one resync.
594
649
  function followSelectedSession(sess) {
595
- current.owner = sess.owner || null;
596
- current.workerVersion = sess.worker_version || null;
597
- const verifyWas = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
598
- current.workerFeatures = Array.isArray(sess.worker_features) ? sess.worker_features : [];
599
- current.bridgeUp = sess.bridge_up === true;
600
- // The Verify gate flipped (a restart brought the feature): the cards' Verify fresh part follows.
601
- if (verifyWas !== verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp)) {
602
- redrawFresh(historyEl, verifyFreshState());
603
- }
604
650
  const streaming = !!(streamControl && streamControl.live);
605
- if (!streaming) current.status = sess.status;
606
- updateInfoBar();
651
+ // A Verify gate that flips (a restart brought the feature) redraws the
652
+ // cards' Verify fresh part: featureGates.
653
+ store.batch(() => {
654
+ store.update({ current: fieldsFromCard(sess) });
655
+ store.setStatus(sess.status, "hub", { streaming });
656
+ });
607
657
  if (!sess.bridge_up || streaming) return;
608
658
  if (resyncsInFlight > 0) resyncAgain = true;
609
659
  else resync();
@@ -614,7 +664,7 @@ function followSelectedSession(sess) {
614
664
  // new session starts on the start page.
615
665
  function renderScope() {
616
666
  const el = $("#scope");
617
- render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash, search: location.search }) }), el);
667
+ render(scopeTemplate(scope, { error: scopeError, where: () => ({ hash: location.hash }) }), el);
618
668
  el.hidden = false;
619
669
  // The tab: "<session title> · Chi · <project>" (updateSessionTitle).
620
670
  tabBase = scope.projectName ? `Chi · ${scope.projectName}` : "Chi";
@@ -623,7 +673,6 @@ function renderScope() {
623
673
 
624
674
  createProjectPicker({
625
675
  el: $("#scope"), scope, scopeError: () => scopeError, api,
626
- where: () => ({ search: location.search }),
627
676
  });
628
677
 
629
678
  // The session lists (lists_view.js): the strip, all sessions, their cards,
@@ -633,8 +682,8 @@ const lists = createListsView({
633
682
  win: window,
634
683
  topStripEl,
635
684
  allListEl,
636
- sessions: () => allSessions,
637
- selected: () => selected,
685
+ sessions: () => store.sessions,
686
+ selected: () => store.selected,
638
687
  scope,
639
688
  scopeError: () => scopeError,
640
689
  eventsLive: () => !!eventsControl,
@@ -643,7 +692,6 @@ const lists = createListsView({
643
692
  confirmDelete: (id) => confirmDelete(id),
644
693
  setArchived: (id, archive) => setArchived(id, archive),
645
694
  archiveOne: (id, archive) => archiveOne(id, archive),
646
- updateInfoBar: () => updateInfoBar(),
647
695
  updateSessionTitle: () => updateSessionTitle(),
648
696
  showToast: (text, action) => showToast(text, action),
649
697
  allViewOpen: () => allViewOpen(),
@@ -665,19 +713,17 @@ const stopSheet = createStopSheet({
665
713
  });
666
714
 
667
715
  const sessionActions = createSessionActions({
668
- selected: () => selected,
669
- state: () => current,
670
- sessions: () => allSessions,
671
- setSessions: (list) => { allSessions = list; },
716
+ selected: () => store.selected,
717
+ store,
718
+ sessions: () => store.sessions,
719
+ setSessions: (list) => store.update({ sessions: list }),
672
720
  api: { continueSession, startReview, archiveSession, unarchiveSession, deleteSession, stopSession,
673
721
  sessionHolds, stopSessionHolds },
674
722
  eventsLive: () => !!eventsControl,
675
723
  refresh,
676
- lists,
677
724
  select,
678
725
  clearSelection,
679
726
  showNotice,
680
- updateInfoBar,
681
727
  toast: showToast,
682
728
  addCreatedSession,
683
729
  fillOpener,
@@ -696,7 +742,7 @@ const rename = createRename({
696
742
  const row = topStripEl.querySelector(".card.active .preview-row");
697
743
  return row && row.getClientRects().length ? row : null; // not in the All sessions view's hidden strip
698
744
  },
699
- session: () => selected,
745
+ session: () => store.selected,
700
746
  titleOf: (id) => lists.sessionsById().get(id)?.title || null,
701
747
  shownTitle: () => shownTitle(),
702
748
  closePopovers: () => {
@@ -730,7 +776,7 @@ function closeAllView() {
730
776
  if (allViewPushed) {
731
777
  history.back();
732
778
  } else {
733
- leaveAllView(sessionHash(selected));
779
+ leaveAllView(sessionHash(store.selected));
734
780
  }
735
781
  }
736
782
  // Leave all sessions for a place of our own (a picked session, a new session):
@@ -757,7 +803,7 @@ function selectFromHash() {
757
803
  const claim = claimFromHash(location.hash);
758
804
  const id = sessionIdFromHash(location.hash) || claim?.sessionId || null;
759
805
  if (claim) pendingClaim = claim;
760
- if (id && id !== selected) select(id);
806
+ if (id && id !== store.selected) select(id);
761
807
  else if (claim) showPendingClaim();
762
808
  }
763
809
  let pendingClaim = null;
@@ -770,10 +816,10 @@ const CLAIM_ROOTS = ".bubble.output, .bubble.user .user-message, .gen-text";
770
816
  // switched), so the same link opened again is a hashchange.
771
817
  function showPendingClaim() {
772
818
  const claim = pendingClaim;
773
- if (!claim || claim.sessionId !== selected) return;
819
+ if (!claim || claim.sessionId !== store.selected) return;
774
820
  pendingClaim = null;
775
- if (!allViewOpen() && location.hash !== sessionHash(selected)) {
776
- history.replaceState(null, "", location.pathname + location.search + sessionHash(selected));
821
+ if (!allViewOpen() && location.hash !== sessionHash(store.selected)) {
822
+ history.replaceState(null, "", location.pathname + location.search + sessionHash(store.selected));
777
823
  }
778
824
  if (showClaim(historyEl, claim, { roots: CLAIM_ROOTS, opts: claimOpts() })) return;
779
825
  historyEl.scrollTop = 0;
@@ -781,25 +827,25 @@ function showPendingClaim() {
781
827
  }
782
828
  // How the participant page shows this page's text (claim_link.js).
783
829
  function claimOpts() {
784
- return { home: document.body.dataset.home || "", cwd: current.dir || "" };
830
+ return { home: document.body.dataset.home || "", cwd: store.current.dir || "" };
785
831
  }
786
832
  // Annotate's "Copy link" for a selection in a turn's answer, step or own
787
833
  // message: the participant page's link when the session has a person
788
834
  // active (it opens in the browser their review link was opened in: one
789
835
  // link per browser, the cookie), else this page's own.
790
836
  function claimLinkFor(source, range) {
791
- if (!selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
837
+ if (!store.selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
792
838
  const turnId = turnIdOf(historyEl, source.root);
793
839
  if (!turnId) return null;
794
840
  const claim = { turnId, ...selectionClaim(source.root, range, claimOpts()) };
795
841
  if (!claim.q) return null;
796
842
  const target = participantsControl.claimTarget();
797
843
  if (!target) {
798
- return { url: `${location.origin}${location.pathname}${location.search}${ownerClaimHash(selected, claim)}`,
844
+ return { url: `${location.origin}${location.pathname}${location.search}${ownerClaimHash(store.selected, claim)}`,
799
845
  title: "A link to this on your page (no person is invited to this session)" };
800
846
  }
801
847
  const whose = target.names.map((name) => `${name}'s`).join(" or ");
802
- return { url: participantClaimLink(target.base, selected, claim),
848
+ return { url: participantClaimLink(target.base, store.selected, claim),
803
849
  title: `A link to this for ${whose} browser: it opens where their review link was opened (one link per browser). Opened here, it shows your page.` };
804
850
  }
805
851
  async function copyClaimLink({ url }) {
@@ -822,8 +868,8 @@ function applyView() {
822
868
  lists.familyPop.close(); // the strip is hidden in the all view
823
869
  // The list is live; only its order can lag (upserts keep cards in
824
870
  // place), so the all view opens in the server's order.
825
- allSessions = sortedByUpdated(allSessions);
826
- lists.renderAll();
871
+ // A new list: the lists' redraw draws the all view (lists.render).
872
+ store.update({ sessions: sortedByUpdated(store.sessions) });
827
873
  if (!eventsControl) refresh();
828
874
  $("#filter").select();
829
875
  }
@@ -837,6 +883,8 @@ $("#allBack").addEventListener("click", closeAllView);
837
883
  // A phone's composer fits a few words and has no Shift+Enter: the short
838
884
  // placeholder there.
839
885
  const narrowScreen = window.matchMedia("(max-width: 600px)");
886
+ // An input the store doesn't hold, with the composer as its only reader: a
887
+ // direct call (the bar doesn't read it).
840
888
  narrowScreen.addEventListener("change", () => updateComposerMode());
841
889
  function setPlaceholder(long, short) {
842
890
  promptEl.placeholder = narrowScreen.matches ? short : long;
@@ -851,7 +899,7 @@ let hadSession = false;
851
899
  // its next step (steering). Cancel is its own button then.
852
900
  function updateComposerMode() {
853
901
  // In a session the box is one line: its minimum follows the class.
854
- if (document.body.classList.toggle("has-session", !!selected) !== hadSession) {
902
+ if (document.body.classList.toggle("has-session", !!store.selected) !== hadSession) {
855
903
  hadSession = !hadSession;
856
904
  // Back on the start page: its remembered model, not the last link's.
857
905
  if (!hadSession) startPage.restoreStartPick();
@@ -860,12 +908,12 @@ function updateComposerMode() {
860
908
  }
861
909
  startPage.syncModelPick();
862
910
  // The start page's choice was for a new session: opening a session drops it.
863
- if (selected) startPage.dropChoices();
864
- const running = liveTurn.running && !!selected;
911
+ if (store.selected) startPage.dropChoices();
912
+ const running = liveTurn.running && !!store.selected;
865
913
  cancelBtn.style.display = running ? "" : "none";
866
914
  // No images for a session a terminal owns (it takes no message from here);
867
915
  // on a phone none while a turn runs either: Stop turn needs the row's room.
868
- attachBtn.hidden = (!!selected && current.owner === "tui") || (running && narrowScreen.matches);
916
+ attachBtn.hidden = (!!store.selected && store.current.owner === "tui") || (running && narrowScreen.matches);
869
917
  syncDeliveryLine();
870
918
  if (running) {
871
919
  setPlaceholder("Running… a message goes in at the next step · Enter to send · Alt+Enter queues", "Steer the turn…");
@@ -874,15 +922,14 @@ function updateComposerMode() {
874
922
  emptyEl.classList.add("hidden");
875
923
  return;
876
924
  }
877
- if (!selected) {
925
+ if (!store.selected) {
878
926
  setPlaceholder("Ask chi anything…", "Ask chi anything…");
879
927
  composeHintEl.textContent = "Enter to start · Shift+Enter for a newline";
880
928
  actionBtn.textContent = "Start";
881
929
  actionBtn.disabled = false;
882
930
  emptyEl.classList.remove("hidden");
883
931
  historyEl.replaceChildren();
884
- updateInfoBar();
885
- } else if (current.owner === "tui") {
932
+ } else if (store.current.owner === "tui") {
886
933
  setPlaceholder("A terminal chi owns this session · run it with chi --shared to send from here", "A terminal chi owns this session");
887
934
  composeHintEl.textContent = "";
888
935
  actionBtn.textContent = "Send";
@@ -902,31 +949,28 @@ function updateComposerMode() {
902
949
  // while idle, where a message goes in while the turn runs.
903
950
  function syncDeliveryLine() {
904
951
  // A terminal owns the session: the composer is off, so is its row.
905
- if (!selected || current.owner === "tui") deliveryControl.show("off");
906
- else deliveryControl.show(liveTurn.running && current.owner !== "tui" ? "running" : "idle");
952
+ if (!store.selected || store.current.owner === "tui") deliveryControl.show("off");
953
+ else deliveryControl.show(liveTurn.running && store.current.owner !== "tui" ? "running" : "idle");
907
954
  }
908
955
 
909
956
  function setTurnRunning(running) {
910
- liveTurn.running = !!running;
911
- // A fresh link's picker goes now, not at the timing re-read after the turn.
912
- if (running && selected) current.turnStarted = true;
913
- updateComposerMode();
914
- updateInfoBar();
957
+ store.batch(() => {
958
+ liveTurn.running = !!running;
959
+ // A fresh link's picker goes now, not at the timing re-read after the turn.
960
+ if (running && store.selected) store.update({ current: { turnStarted: true } });
961
+ // liveTurn.running isn't the store's: the composer and the bar read it.
962
+ store.touch("composer");
963
+ store.touch("infoBar");
964
+ });
915
965
  }
916
966
 
917
967
  // Session status is turn state (idle/running): follow the live stream in the
918
968
  // info bar and the session's card without a refetch.
919
969
  function setLiveStatus(status) {
920
- current.status = status;
921
- const i = allSessions.findIndex((s) => s.id === selected);
922
- if (i >= 0) {
923
- // Its card gets the new dot and time; it keeps its place until the next
924
- // snapshot or the all view re-sorts (no card jumping under the pointer;
925
- // a session event replaces it in place too, see sessions_list.js).
926
- allSessions[i] = withLiveStatus(allSessions[i], status);
927
- lists.render();
928
- }
929
- updateInfoBar();
970
+ // Its card gets the new dot and time (withLiveStatus); it keeps its place
971
+ // until the next snapshot or the all view re-sorts (no card jumping under
972
+ // the pointer; a session event replaces it in place too, see sessions_list.js).
973
+ store.setStatus(status, "stream");
930
974
  }
931
975
 
932
976
  // Plugins' slow setup still running in the open session's worker (chi.init),
@@ -941,46 +985,52 @@ function onInitEvent(type, data) {
941
985
  const done = applyInitEvent(initTasks, type, data);
942
986
  renderInitRow();
943
987
  if (!done) return;
944
- const session = selected;
988
+ const still = stillOn(openId);
945
989
  setTimeout(() => {
946
- if (session !== selected) return;
990
+ if (!still()) return;
947
991
  fadeDone(initTasks, done);
948
992
  renderInitRow();
949
993
  setTimeout(() => {
950
- if (session !== selected) return;
994
+ if (!still()) return;
951
995
  dropDone(initTasks, done);
952
996
  renderInitRow();
953
997
  }, 400);
954
998
  }, DONE_MS);
955
999
  }
956
1000
 
957
- // Forget what the info bar, composer and command list knew about the open
958
- // session; select() and clearSelection() both start from here, so a field
959
- // the previous session set (its "delegated by" parent, its commands) never
960
- // shows for the next one.
961
- function resetSessionState() {
962
- current = emptySessionState();
1001
+ // Forget everything the page knew about the open session: its fields (the
1002
+ // info bar, composer, command list), the turn on view, its stream and
1003
+ // question cards, its init row, the payload's markdown warning and tip and
1004
+ // capabilities, and the composer's Verify claim and Revise (+keepVerify+:
1005
+ // the same session opened again keeps them). The one reset list select() and
1006
+ // clearSelection() share, so a field the previous session set (its
1007
+ // "delegated by" parent, its commands, its card actions) never shows for
1008
+ // the next one, also when the next one's read fails.
1009
+ function forgetSession({ keepVerify = false } = {}) {
1010
+ if (!keepVerify) pendingVerifyAnchor = null;
1011
+ if (!keepVerify) setReviseAnchor(null);
1012
+ store.batch(() => {
1013
+ store.replaceCurrent(emptySessionState());
1014
+ featureGates.prime(store.current);
1015
+ });
963
1016
  commandList.close();
964
- }
965
-
966
- function clearSelection() {
967
- selected = null;
1017
+ resetTurnView();
968
1018
  seedInitTasks(initTasks, []);
969
1019
  renderInitRow();
970
- setSessionHash(null);
971
- lists.paintActive();
972
- resetSessionState();
973
- contextChips.clear();
974
- participantsControl.load(null);
975
- liveTurn.forget();
976
- setTurnRunning(false);
977
- closeStream();
978
1020
  setMarkdownWarning(null);
979
1021
  markdownTip.show(null);
980
1022
  setCapabilities(null, null);
981
1023
  composer.newVisit();
982
- updateComposerMode();
983
- updateInfoBar();
1024
+ }
1025
+
1026
+ function clearSelection() {
1027
+ store.batch(() => {
1028
+ store.update({ selected: null });
1029
+ setSessionHash(null);
1030
+ forgetSession();
1031
+ contextChips.clear();
1032
+ participantsControl.load(null);
1033
+ });
984
1034
  focusPrompt();
985
1035
  }
986
1036
 
@@ -997,30 +1047,30 @@ function clearNotice() {
997
1047
  }
998
1048
  promptEl.addEventListener("input", clearNotice);
999
1049
  promptEl.addEventListener("input", () => noteDraft());
1000
- // An emptied composer lets go of its Apply turn and its Verify claim.
1050
+ // An emptied composer lets go of its Apply turn, its Verify claim and its
1051
+ // Revise.
1001
1052
  promptEl.addEventListener("input", () => {
1002
1053
  if (promptEl.value.trim()) return;
1003
1054
  deliveryControl.release();
1004
1055
  pendingVerifyAnchor = null;
1056
+ setReviseAnchor(null);
1005
1057
  });
1006
1058
 
1007
1059
  async function select(id) {
1008
1060
  clearNotice();
1009
- if (selected !== id) pendingVerifyAnchor = null;
1010
- selected = id;
1011
- setSessionHash(id);
1012
- lists.paintActive();
1013
- historyEl.replaceChildren(hintEl("Loading…"));
1014
- emptyEl.classList.add("hidden");
1015
- closeStream();
1016
- resyncAttempts = 0; // this session has its own re-read budget
1017
- resetSessionState();
1018
- resetTurnView();
1019
- composer.newVisit();
1020
- updateInfoBar();
1021
- contextChips.load(id);
1022
- participantsControl.load(id);
1023
- updateComposerMode();
1061
+ const same = store.selected === id;
1062
+ // Its synchronous part in one batch: the active card, the composer and
1063
+ // the bar redraw once, at its end (never across the await).
1064
+ store.batch(() => {
1065
+ store.update({ selected: id });
1066
+ setSessionHash(id);
1067
+ historyEl.replaceChildren(hintEl("Loading…"));
1068
+ emptyEl.classList.add("hidden");
1069
+ resyncAttempts = 0; // this session has its own re-read budget
1070
+ forgetSession({ keepVerify: same });
1071
+ contextChips.load(id);
1072
+ participantsControl.load(id);
1073
+ });
1024
1074
  await resync();
1025
1075
  // A folded composer stays folded: typing on the page opens it.
1026
1076
  focusPrompt({ open: false });
@@ -1038,19 +1088,19 @@ function resetTurnView() {
1038
1088
  }
1039
1089
 
1040
1090
  // A re-read that failed (the phone still offline, chi web a moment): try it
1041
- // again, backing off, and stop when the tries run out. The turn view is left
1042
- // alone meanwhile: a worker that still owns the turn keeps showing it running,
1091
+ // again, backing off, and stop when the tries run out. The running turn's DOM
1092
+ // is left alone meanwhile: a worker that still owns the turn keeps showing it running,
1043
1093
  // and the hub's next event for the session is the fallback once the tries end.
1044
1094
  const RESYNC_RETRIES = 3;
1045
1095
  let resyncAttempts = 0;
1046
1096
 
1047
1097
  function retryResync(id, { endText = null } = {}) {
1048
- if (id !== selected) return;
1098
+ if (id !== store.selected) return;
1049
1099
  const { attempt, delayMs } = resyncRetry({ attempts: resyncAttempts, max: RESYNC_RETRIES, baseMs: STREAM_RETRY_MS });
1050
1100
  if (delayMs === null) return;
1051
1101
  resyncAttempts = attempt;
1052
1102
  setTimeout(() => {
1053
- if (id !== selected || (streamControl && streamControl.live)) return;
1103
+ if (id !== store.selected || (streamControl && streamControl.live)) return;
1054
1104
  resync({ endText });
1055
1105
  }, delayMs);
1056
1106
  }
@@ -1065,7 +1115,7 @@ function retryResync(id, { endText = null } = {}) {
1065
1115
  // worker left unfinished; shown again after the re-render when no worker
1066
1116
  // took over.
1067
1117
  async function resync({ endText = null } = {}) {
1068
- const id = selected;
1118
+ const id = store.selected;
1069
1119
  if (!id) return;
1070
1120
  resyncsInFlight += 1;
1071
1121
  let data;
@@ -1074,14 +1124,14 @@ async function resync({ endText = null } = {}) {
1074
1124
  data = await getSession(id, { parts: true });
1075
1125
  } catch (e) {
1076
1126
  if (e.status === 404) return leaveGoneSession(id, "not_found");
1077
- if (id === selected) {
1127
+ if (id === store.selected) {
1078
1128
  historyEl.replaceChildren(hintEl(`Error: ${e.message}`));
1079
1129
  retryResync(id, { endText });
1080
1130
  }
1081
1131
  return;
1082
1132
  }
1083
1133
  resyncAttempts = 0;
1084
- if (id !== selected) return;
1134
+ if (id !== store.selected) return;
1085
1135
  // An archived session opens folded: decided once per visit, from its
1086
1136
  // first answer (the list may not hold it yet, or ever under ?dir).
1087
1137
  composer.decideVisit(data.session?.archived);
@@ -1091,14 +1141,14 @@ async function resync({ endText = null } = {}) {
1091
1141
  showPendingClaim();
1092
1142
  // Preview is read-only: a worker is woken only by POST /turn, so attach
1093
1143
  // only when a live bridge exists.
1094
- current.streamSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
1095
- if (current.streamSeq !== null) startStream(id, current.streamSeq);
1144
+ store.update({ current: { streamSeq: typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null } });
1145
+ if (store.current.streamSeq !== null) startStream(id, store.current.streamSeq);
1096
1146
  else if (endText) addEndBubble(endText);
1097
1147
  } finally {
1098
1148
  resyncsInFlight -= 1;
1099
1149
  if (resyncsInFlight === 0 && resyncAgain) {
1100
1150
  resyncAgain = false;
1101
- if (id === selected && !(streamControl && streamControl.live)) resync();
1151
+ if (id === store.selected && !(streamControl && streamControl.live)) resync();
1102
1152
  }
1103
1153
  }
1104
1154
  }
@@ -1113,12 +1163,16 @@ function renderSessionView(data, { kept = [] } = {}) {
1113
1163
  // The saved fill (when there is one) goes in here too, so a reloaded or
1114
1164
  // stopped session shows it before its next turn (a running turn's replay
1115
1165
  // below streams over it).
1116
- Object.assign(current, sessionStateFrom(data));
1117
- liveTurn.timing.ended = current.timing.turnRecords.length;
1118
- const activeTurnId = current.timing.activeTurn?.id || null;
1119
- llmContextControl.refresh();
1120
- thinkingControl.refresh();
1121
- reasoningHistoryControl.refresh();
1166
+ const { status, ...fields } = sessionStateFrom(data);
1167
+ // The fields in one batch (their chips' popovers, the bar); the replay's
1168
+ // own redraws below stay where they run.
1169
+ store.batch(() => {
1170
+ store.update({ current: fields });
1171
+ store.setStatus(status, "payload");
1172
+ featureGates.prime(store.current);
1173
+ });
1174
+ liveTurn.timing.ended = store.current.timing.turnRecords.length;
1175
+ const activeTurnId = store.current.timing.activeTurn?.id || null;
1122
1176
  if (data.messages && data.messages.length) renderHistory(data.messages);
1123
1177
  else renderHistory(data.history || []);
1124
1178
  seedWorkingSet(data);
@@ -1149,7 +1203,7 @@ function renderSessionView(data, { kept = [] } = {}) {
1149
1203
  current_turn: data.current_turn,
1150
1204
  queued: data.queued,
1151
1205
  queued_commands: data.queued_commands,
1152
- started_at: current.timing.activeTurn?.started_at,
1206
+ started_at: store.current.timing.activeTurn?.started_at,
1153
1207
  });
1154
1208
  // Marked replayed: a call's duration isn't timed from the replay's clock.
1155
1209
  replay.forEach((event) => streamHandlers[event.type]?.({ ...event, replayed: true }));
@@ -1157,8 +1211,8 @@ function renderSessionView(data, { kept = [] } = {}) {
1157
1211
  const gen = data.current_turn?.generation;
1158
1212
  liveTurn.serverGen = gen ? { ...gen, at: Date.now() } : null;
1159
1213
  if (!data.current_turn) {
1160
- setTurnRunning(current.status === "running");
1161
- if (liveTurn.running && current.timing.activeTurn?.started_at) liveTurn.timing.start(current.timing.activeTurn.started_at, activeTurnId);
1214
+ setTurnRunning(store.current.status === "running");
1215
+ if (liveTurn.running && store.current.timing.activeTurn?.started_at) liveTurn.timing.start(store.current.timing.activeTurn.started_at, activeTurnId);
1162
1216
  } else if (liveTurn.running) {
1163
1217
  // The replayed turn_started carries no id: the running turn's record's.
1164
1218
  liveTurn.timing.turnId = activeTurnId;
@@ -1169,7 +1223,7 @@ function renderSessionView(data, { kept = [] } = {}) {
1169
1223
  // A question the worker is blocked on (also after a reload).
1170
1224
  if (data.pending_question && data.pending_question.id && data.pending_question.status === "pending") {
1171
1225
  // Its refs on the asking step, as question_requested puts them.
1172
- if (data.pending_question.refs) turnDom.questionRefs?.(data.pending_question.refs);
1226
+ if (data.pending_question.refs) turnDom.questionRefs(data.pending_question.refs);
1173
1227
  questionCards.renderQuestion(data.pending_question);
1174
1228
  // The turn's replay drew it as asked; the snapshot knows whether it is
1175
1229
  // relayed to the parent now.
@@ -1177,8 +1231,12 @@ function renderSessionView(data, { kept = [] } = {}) {
1177
1231
  }
1178
1232
  // A turn ran out of iterations and waits for a yes or no.
1179
1233
  if (data.continue_offer) questionCards.renderContinue(data.continue_offer);
1180
- updateInfoBar();
1181
- updateComposerMode();
1234
+ // A whole view drawn: the composer and the bar once more (the working
1235
+ // set's seed may change the bar's chip).
1236
+ store.batch(() => {
1237
+ store.touch("composer");
1238
+ store.touch("infoBar");
1239
+ });
1182
1240
  }
1183
1241
 
1184
1242
  // The working set's turns from a session's messages, as turnHistoryNodes
@@ -1187,7 +1245,7 @@ function renderSessionView(data, { kept = [] } = {}) {
1187
1245
  function seedWorkingSet(data) {
1188
1246
  if (!workingSet) return;
1189
1247
  const items = data.messages || [];
1190
- const groups = typeof items[0] === "object" && items[0]?.role ? turnGroups(items, current.timing).filter((g) => g.kind === "turn") : [];
1248
+ const groups = typeof items[0] === "object" && items[0]?.role ? turnGroups(items, store.current.timing).filter((g) => g.kind === "turn") : [];
1191
1249
  if (data.current_turn && groups.length) groups.pop();
1192
1250
  workingSet.seed(groups.map((g, i) => {
1193
1251
  const text = String(items[g.user]?.content ?? "").replace(/\s+/g, " ").trim();
@@ -1202,10 +1260,10 @@ function renderHistory(items) {
1202
1260
  return;
1203
1261
  }
1204
1262
  if (typeof items[0] === "object" && items[0].role) {
1205
- historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
1263
+ historyEl.replaceChildren(turnHistoryNodes(items, store.current.timing, { thumbs: (images) => thumbsTemplate(store.selected, images), workingSet: !!workingSet }));
1206
1264
  renderSavedVerdicts();
1207
1265
  // The live dividers count from the history's last activity.
1208
- gapClock.seed(lastActivity(turnGroups(items, current.timing), items));
1266
+ gapClock.seed(lastActivity(turnGroups(items, store.current.timing), items));
1209
1267
  linkTextRefs();
1210
1268
  } else {
1211
1269
  historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
@@ -1242,33 +1300,31 @@ installRefClicks(document.body, pageViewer, {
1242
1300
  });
1243
1301
 
1244
1302
  async function readTurnEnd() {
1245
- if (!selected) return;
1246
- const id = selected;
1303
+ if (!store.selected) return;
1304
+ const id = store.selected;
1247
1305
  // The line and the turn this read is for, before the await: a turn
1248
1306
  // queued behind this one may start (a new live line) before it lands.
1249
1307
  const line = liveTurn.timing.capture();
1308
+ const still = stillOn(openId);
1250
1309
  try {
1251
1310
  const d = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
1252
- if (id !== selected) return;
1311
+ if (!still()) return;
1253
1312
  if (d.session) {
1254
- if (Array.isArray(d.session.used_memory_names)) current.usedMemories = d.session.used_memory_names;
1255
- // The notes chip: the first turn's build (or a /model) records them.
1256
- if (Array.isArray(d.session.prompt_notes)) current.promptNotes = d.session.prompt_notes;
1257
- if (d.session.status) current.status = d.session.status;
1258
1313
  if (!(await mergeTurnTiming(id, d.timing))) return;
1259
1314
  liveTurn.timing.finishLine(line);
1260
- setMarkdownWarning(d.markdown_warning);
1261
- markdownTip.show(d.markdown_tip);
1262
- setCapabilities(d.capabilities, d.editor_url);
1263
- if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1264
- updateInfoBar();
1265
- const sess = allSessions.find(s=>s.id===selected);
1266
- if (sess) {
1267
- sess.used_memory_names = current.usedMemories.slice();
1268
- sess.status = current.status;
1269
- }
1270
- lists.render();
1271
- lists.paintActive();
1315
+ // The rest in one batch: the bar and the lists redraw once, after the
1316
+ // answer's markdown.
1317
+ store.batch(() => {
1318
+ // The notes chip: the first turn's build (or a /model) records them.
1319
+ applySessionPayload(d, { fields: ["usedMemories", "promptNotes"] });
1320
+ turnEndStatus(d);
1321
+ setMarkdownWarning(d.markdown_warning);
1322
+ markdownTip.show(d.markdown_tip);
1323
+ setCapabilities(d.capabilities, d.editor_url);
1324
+ if (Array.isArray(d.messages)) applyFinalMarkdown(d.messages);
1325
+ // Its card, replaced (replaceSelectedCard).
1326
+ replaceSelectedCard((sess) => ({ ...sess, used_memory_names: store.current.usedMemories.slice(), status: store.current.status }));
1327
+ });
1272
1328
  }
1273
1329
  } catch (_) {
1274
1330
  // A failed read (the server or worker gone a moment) leaves the answer
@@ -1286,7 +1342,7 @@ function seedSeenFromDom() {
1286
1342
  // delta), so the gap measured is the one the user was sitting at. While the
1287
1343
  // stage holds a turn its rows go there: nothing it does moves the history.
1288
1344
  function isNearBottom() {
1289
- return !turnDom.holds?.() && atHistoryEnd();
1345
+ return !turnDom.holds() && atHistoryEnd();
1290
1346
  }
1291
1347
 
1292
1348
  function atHistoryEnd() {
@@ -1303,15 +1359,15 @@ function endTurnView(kind, endText = null, { emptyAnswer = null, stopped = false
1303
1359
  // gone mid-turn is re-rendered (resync), which counts again.
1304
1360
  if (kind !== "gone") liveTurn.timing.ended += 1;
1305
1361
  // A completed turn with no answer: its notice where the answer goes
1306
- // (the turn view places it), else after the turn; before the timing.
1362
+ // (the turn block places it), else after the turn; before the timing.
1307
1363
  const endNote = emptyAnswer ? elementOf(emptyAnswerTemplate(emptyAnswer)) : null;
1308
- const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer }) || [];
1364
+ const kept = turnDom.turnEnded({ kind, endNote, emptyAnswer: !!emptyAnswer, turnId: liveTurn.timing.turnId }) || [];
1309
1365
  if (endNote && !endNote.isConnected) appendToHistory(endNote);
1310
1366
  liveTurn.timing.finish({ canceled: kind === "canceled" });
1311
1367
  if (endText) addEndBubble(endText, { stopped });
1312
1368
  if (kept.length) {
1313
1369
  kept.forEach((el) => appendToHistory(el));
1314
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
1370
+ if (!turnDom.holds()) historyEl.scrollTop = historyEl.scrollHeight;
1315
1371
  }
1316
1372
  // The question dies with its turn (the worker stopped waiting).
1317
1373
  if (questionCards.pendingQuestion()) questionCards.resolveQuestion(questionCards.pendingQuestion().id, { cancelled: true, reason: "turn ended" });
@@ -1328,17 +1384,16 @@ infoTextEl.addEventListener("click", (e) => {
1328
1384
  }
1329
1385
  });
1330
1386
  infoTextEl.addEventListener("click", async (e) => {
1331
- if (!e.target.closest(".copy-attach") || !selected) return;
1332
- const id = selected;
1333
- if (!(await copyText(`chi --attach ${id}`)) || id !== selected) return;
1334
- current.attachCopiedAt = Date.now();
1335
- updateInfoBar();
1336
- setTimeout(updateInfoBar, 2000);
1387
+ if (!e.target.closest(".copy-attach") || !store.selected) return;
1388
+ const id = store.selected;
1389
+ if (!(await copyText(`chi --attach ${id}`)) || id !== store.selected) return;
1390
+ store.update({ current: { attachCopiedAt: Date.now() } });
1391
+ setTimeout(() => store.touch("infoBar"), 2000);
1337
1392
  });
1338
1393
 
1339
1394
  // Apply (review_apply.js) under a participant's review turn that just
1340
1395
  // ended live ({turn_id, author, author_name}, from its turn_started), once:
1341
- // a reload draws it from the history (turn_view.js).
1396
+ // a reload draws it from the history (turn_block.js).
1342
1397
  function placeApply(data) {
1343
1398
  const badge = authorBadge(data.author, data.author_name);
1344
1399
  if (!badge || historyEl.querySelector(`.review-apply[data-apply-turn="${CSS.escape(data.turn_id)}"]`)) return;
@@ -1347,10 +1402,12 @@ function placeApply(data) {
1347
1402
 
1348
1403
  function placeVerdict(record) {
1349
1404
  if (!record) return;
1350
- const known = (current.verdicts || []).some((item) => item.check_turn_id === record.check_turn_id);
1351
- if (!known) current.verdicts = [...(current.verdicts || []), record];
1405
+ const known = (store.current.verdicts || []).some((item) => item.check_turn_id === record.check_turn_id);
1406
+ const patch = {};
1407
+ if (!known) patch.verdicts = [...(store.current.verdicts || []), record];
1352
1408
  // A fresh verdict ends its check's "verifying fresh" (the card shows it).
1353
- if (record.fresh) current.verifyFresh = settleRunning(current.verifyFresh, record);
1409
+ if (record.fresh) patch.verifyFresh = settleRunning(store.current.verifyFresh, record);
1410
+ store.update({ current: patch });
1354
1411
  const badge = record.by && authorBadge(record.by);
1355
1412
  placeVerdictWithFresh(historyEl, record, {
1356
1413
  who: badge ? badge.name : "You",
@@ -1358,7 +1415,7 @@ function placeVerdict(record) {
1358
1415
  roots: CLAIM_ROOTS,
1359
1416
  opts: claimOpts(),
1360
1417
  onMissingCheck: (missing) => {
1361
- const hash = ownerCheckHash(allSessions, selected, missing.check_turn_id);
1418
+ const hash = ownerCheckHash(store.sessions, store.selected, missing.check_turn_id);
1362
1419
  if (hash) location.hash = hash;
1363
1420
  else showToast(CHECK_NOT_ON_PAGE);
1364
1421
  },
@@ -1369,12 +1426,11 @@ function placeVerdict(record) {
1369
1426
  // always theirs to start; its running check links to the child's session.
1370
1427
  const verifyFreshErrors = {};
1371
1428
  function verifyFreshState() {
1372
- const id = selected;
1373
- const unavailable = verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp);
1374
- if (!Array.isArray(current.verifyFresh)) current.verifyFresh = [];
1429
+ const id = store.selected;
1430
+ const unavailable = verifyWorkerUnavailable(store.current.workerFeatures, store.current.bridgeUp);
1375
1431
  return {
1376
- verdicts: current.verdicts || [],
1377
- running: current.verifyFresh,
1432
+ verdicts: store.current.verdicts || [],
1433
+ running: store.current.verifyFresh || [],
1378
1434
  errors: verifyFreshErrors,
1379
1435
  canStart: !!id && !unavailable,
1380
1436
  unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
@@ -1391,19 +1447,19 @@ function verifyFreshState() {
1391
1447
  }
1392
1448
 
1393
1449
  function renderSavedVerdicts() {
1394
- for (const record of current.verdicts || []) placeVerdict(record);
1450
+ for (const record of store.current.verdicts || []) placeVerdict(record);
1395
1451
  }
1396
1452
 
1397
1453
  // Apply: the owner's Apply turn into the composer, after what is typed
1398
1454
  // there; never sent from here.
1399
1455
  historyEl.addEventListener("click", async (e) => {
1400
1456
  const button = e.target.closest(".review-apply-btn");
1401
- if (!button || !selected) return;
1402
- const id = selected;
1457
+ if (!button || !store.selected) return;
1458
+ const id = store.selected;
1403
1459
  button.disabled = true;
1404
1460
  try {
1405
1461
  const text = await fetchApply(id, button.dataset.applyTurn);
1406
- if (id === selected) {
1462
+ if (id === store.selected) {
1407
1463
  fillComposer(applyComposerText(promptEl.value, text));
1408
1464
  // The owner's own turn: queued after a running one, never steered in.
1409
1465
  deliveryControl.hold();
@@ -1417,18 +1473,18 @@ historyEl.addEventListener("click", async (e) => {
1417
1473
 
1418
1474
  // The open session's worker badge (info_bar_model.js workerBadgeFor).
1419
1475
  function currentWorkerBadge() {
1420
- return workerBadgeFor(selected, current, versions);
1476
+ return workerBadgeFor(store.selected, store.current, versions);
1421
1477
  }
1422
1478
 
1423
1479
  // Restart: the session moves to a new worker on the newest chi; the page
1424
1480
  // follows it (its bridge_up), the composer's draft stays as it is.
1425
1481
  async function restartWorker() {
1426
1482
  const badge = currentWorkerBadge();
1427
- if (!badge || !badge.restart || restartingIds.has(selected)) return;
1428
- if (!confirm(restartConfirmText(current.workerVersion, badge.newest))) return;
1429
- const id = selected;
1483
+ if (!badge || !badge.restart || restartingIds.has(store.selected)) return;
1484
+ if (!confirm(restartConfirmText(store.current.workerVersion, badge.newest))) return;
1485
+ const id = store.selected;
1430
1486
  restartingIds.add(id);
1431
- updateInfoBar();
1487
+ store.touch("infoBar");
1432
1488
  try {
1433
1489
  const result = await restartSession(id);
1434
1490
  showToast(result.version ? `Restarted on chi ${result.version}` : "Restarting: the new worker is starting");
@@ -1436,7 +1492,7 @@ async function restartWorker() {
1436
1492
  showToast(cantText("restart", e), null, { sticky: true });
1437
1493
  } finally {
1438
1494
  restartingIds.delete(id);
1439
- updateInfoBar();
1495
+ store.touch("infoBar");
1440
1496
  }
1441
1497
  }
1442
1498
 
@@ -1445,7 +1501,7 @@ async function restartWorker() {
1445
1501
  function updateInfoBar() {
1446
1502
  annotateBar?.refresh();
1447
1503
  const { buttons, bar } = sessionBar({
1448
- id: selected, state: current, sessions: allSessions, versions, cardTitle: (s) => lists.cardTitle(s),
1504
+ id: store.selected, state: store.current, sessions: store.sessions, versions, cardTitle: (s) => lists.cardTitle(s),
1449
1505
  continuing: sessionActions.continuing, restarting: restartingIds, streaming: !!streamControl, running: liveTurn.running,
1450
1506
  now: Date.now(), workingSet: workingSet ? workingSet.chip() : undefined, participants: participantsControl.chip(),
1451
1507
  });
@@ -1483,9 +1539,9 @@ function updateInfoBar() {
1483
1539
  // browser tab names it too. It follows the card's (openTitle): a chain link
1484
1540
  // reads its card's title (cardTitle), whatever opener it was sent.
1485
1541
  function shownTitle() {
1486
- const shown = selected ? lists.sessionsById().get(selected) : null;
1487
- const first = current.firstPreview || "";
1488
- const raw = !selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
1542
+ const shown = store.selected ? lists.sessionsById().get(store.selected) : null;
1543
+ const first = store.current.firstPreview || "";
1544
+ const raw = !store.selected ? "" : openTitle({ shown, first, cardTitle: (s) => lists.cardTitle(s) });
1489
1545
  return raw === "\u2014" ? "" : raw; // "—": no first message yet
1490
1546
  }
1491
1547
  // The top bar's ✎ follows it (not a plain terminal's session), and an open
@@ -1500,7 +1556,7 @@ function updateSessionTitle() {
1500
1556
  });
1501
1557
  if (sessionTitleEl.textContent !== text) sessionTitleEl.textContent = text;
1502
1558
  if (sessionTitleRowEl.hidden !== !text) sessionTitleRowEl.hidden = !text;
1503
- const renamable = !!text && renameShown(current.owner);
1559
+ const renamable = !!text && renameShown(store.current.owner);
1504
1560
  if (titleRenameBtn.hidden !== !renamable) titleRenameBtn.hidden = !renamable;
1505
1561
  const tip = text ? title : "";
1506
1562
  if (sessionTitleEl.title !== tip) sessionTitleEl.title = tip;
@@ -1509,7 +1565,7 @@ function updateSessionTitle() {
1509
1565
  tabShown = tab;
1510
1566
  notifications.setBaseTitle(tab);
1511
1567
  }
1512
- rename.follow(selected);
1568
+ rename.follow(store.selected);
1513
1569
  rename.reposition();
1514
1570
  }
1515
1571
 
@@ -1517,7 +1573,7 @@ function updateSessionTitle() {
1517
1573
  // @return true when the stage took +el+ (it scrolls it into its view itself)
1518
1574
  function appendToHistory(el) {
1519
1575
  // (The stage's place first moves a turn still sliding away into the history.)
1520
- if (turnDom.place?.(el)) return true;
1576
+ if (turnDom.place(el)) return true;
1521
1577
  appendAboveLiveTiming(historyEl, el, liveTurn.timing.el);
1522
1578
  return false;
1523
1579
  }
@@ -1528,31 +1584,61 @@ function appendToHistory(el) {
1528
1584
  // turn that started meanwhile keeps its line and its own active turn.
1529
1585
  // @return false when the session changed meanwhile
1530
1586
  async function mergeTurnTiming(id, data) {
1531
- const keepActive = () => (liveTurn.running ? current.timing.activeTurn : null);
1532
- const merged = mergeTiming(current.timing, data);
1533
- current.timing = { ...merged.timing, activeTurn: keepActive() };
1587
+ const keepActive = () => (liveTurn.running ? store.current.timing.activeTurn : null);
1588
+ const merged = mergeTiming(store.current.timing, data);
1589
+ store.update({ current: { timing: { ...merged.timing, activeTurn: keepActive() } } });
1534
1590
  if (merged.short) {
1591
+ const still = stillOn(openId, id);
1535
1592
  try {
1536
1593
  const full = await getSession(id, { timing: true });
1537
- if (id !== selected) return false;
1538
- current.timing = { ...normalizeTiming(full.timing), activeTurn: keepActive() };
1594
+ if (!still()) return false;
1595
+ store.update({ current: { timing: { ...normalizeTiming(full.timing), activeTurn: keepActive() } } });
1539
1596
  } catch (_) {
1540
- if (id !== selected) return false;
1597
+ if (!still()) return false;
1541
1598
  }
1542
1599
  }
1543
- liveTurn.timing.ended = Math.max(liveTurn.timing.ended, current.timing.turnRecords.length);
1600
+ liveTurn.timing.ended = Math.max(liveTurn.timing.ended, store.current.timing.turnRecords.length);
1544
1601
  return true;
1545
1602
  }
1546
1603
 
1547
1604
  function setUsedMemories(names) {
1548
- current.usedMemories = names.slice();
1549
- updateInfoBar();
1550
- const sess = allSessions.find(s=>s.id===selected);
1551
- if (sess) {
1552
- sess.used_memory_names = current.usedMemories.slice();
1553
- lists.render();
1554
- lists.paintActive();
1555
- }
1605
+ store.batch(() => {
1606
+ store.update({ current: { usedMemories: names.slice() } });
1607
+ replaceSelectedCard((sess) => ({ ...sess, used_memory_names: store.current.usedMemories.slice() }));
1608
+ });
1609
+ }
1610
+
1611
+ // The open session's card replaced by +change(card)+'s copy, in a new list
1612
+ // (replaceCard). @return whether the list has it
1613
+ function replaceSelectedCard(change) {
1614
+ return replaceCard(store.selected, change);
1615
+ }
1616
+
1617
+ // Session +id+'s card replaced by +change(card)+'s copy, in a new list
1618
+ // (never changed in place: setStatus and the hub's events replace too).
1619
+ // @return whether the list has it
1620
+ function replaceCard(id, change) {
1621
+ const i = store.sessions.findIndex((s) => s.id === id);
1622
+ if (i < 0) return false;
1623
+ const next = store.sessions.slice();
1624
+ next[i] = change(next[i]);
1625
+ store.update({ sessions: next });
1626
+ return true;
1627
+ }
1628
+
1629
+ // A partial re-read of the open session (readTurnEnd, the turn_canceled
1630
+ // timing re-read): its named +fields+ into the store, read as a full load
1631
+ // reads them (session_payload.js). Not its status: turnEndStatus. Their
1632
+ // redraws follow (session_fields.js FIELDS).
1633
+ function applySessionPayload(data, { fields }) {
1634
+ store.update({ current: sessionFieldsFrom(data, fields) });
1635
+ }
1636
+
1637
+ // A turn end's re-read's status, when it carries one (the "turnEnd" rule:
1638
+ // not over a turn that started meanwhile).
1639
+ function turnEndStatus(data) {
1640
+ const { status } = sessionFieldsFrom(data, ["status"]);
1641
+ if (status) store.setStatus(status, "turnEnd", { running: liveTurn.running });
1556
1642
  }
1557
1643
 
1558
1644
  // A turn or command just woke the session's worker: follow its stream if
@@ -1561,12 +1647,12 @@ function setUsedMemories(names) {
1561
1647
  // worker have been up already, turn_started says so (replaysDrawnTurn).
1562
1648
  function followWokenWorker() {
1563
1649
  if (streamControl?.live) return;
1564
- startStream(selected, current.streamSeq ?? 0);
1565
- current.streamFromGuess = current.streamSeq === null;
1650
+ startStream(store.selected, store.current.streamSeq ?? 0);
1651
+ store.update({ current: { streamFromGuess: store.current.streamSeq === null } });
1566
1652
  }
1567
1653
 
1568
1654
  function startStream(id, fromSeq) {
1569
- current.streamFromGuess = false;
1655
+ store.update({ current: { streamFromGuess: false } });
1570
1656
  if (streamControl) {
1571
1657
  streamControl.close();
1572
1658
  streamControl = null;
@@ -1578,7 +1664,7 @@ function startStream(id, fromSeq) {
1578
1664
  onStreamClosed: () => workerGone(id),
1579
1665
  onStreamError: () => workerGone(id, { opened: false }),
1580
1666
  });
1581
- updateInfoBar();
1667
+ store.touch("infoBar"); // streaming
1582
1668
  }
1583
1669
 
1584
1670
  // +opened+ false: the stream never delivered a frame (the proxy answered
@@ -1587,38 +1673,33 @@ function startStream(id, fromSeq) {
1587
1673
  const STREAM_RETRY_MS = 3000;
1588
1674
  function workerGone(id, { opened = true } = {}) {
1589
1675
  streamControl = null;
1590
- if (id !== selected) return;
1676
+ if (id !== store.selected) return;
1591
1677
  // A turn that was running is over only when the worker really left: a
1592
1678
  // stream that merely dropped (no stop, it was open) waits for the re-read.
1593
1679
  const stopped = sessionActions.stoppingId() === id;
1594
1680
  const { endNow, endText } = streamDropEnd({ turnRunning: liveTurn.running, stopped, opened });
1595
- if (endNow) {
1596
- endTurnView("gone", endText, { stopped });
1597
- if (!stopped) setLiveStatus("idle");
1598
- }
1599
- if (current.owner === "worker") current.owner = null;
1600
- current.bridgeUp = false;
1601
- // Its card says "live" (the list's owner, or setLiveStatus's) until the
1602
- // hub's event says otherwise: the worker is gone now, as far as this
1603
- // tab can tell.
1604
- const i = allSessions.findIndex((s) => s.id === id);
1605
- if (i >= 0 && allSessions[i].owner === "worker") {
1606
- allSessions[i] = withoutWorker(allSessions[i]);
1607
- lists.render();
1608
- }
1609
- updateInfoBar();
1681
+ if (endNow) endTurnView("gone", endText, { stopped });
1682
+ store.batch(() => {
1683
+ if (endNow && !stopped) setLiveStatus("idle");
1684
+ store.update({ current: { ...(store.current.owner === "worker" ? { owner: null } : {}), bridgeUp: false } });
1685
+ // Its card says "live" (the list's owner, or setLiveStatus's) until the
1686
+ // hub's event says otherwise: the worker is gone now, as far as this
1687
+ // tab can tell.
1688
+ if (store.sessions.find((s) => s.id === id)?.owner === "worker") replaceCard(id, withoutWorker);
1689
+ store.touch("infoBar"); // streaming
1690
+ });
1610
1691
  // Once: a worker still alive with a bridge (the stream dropped, not the
1611
1692
  // worker: chi web restarted) reconnects right here. Otherwise the next
1612
1693
  // worker's bridge_up event brings the stream back (followSelectedSession).
1613
1694
  if (opened) resync({ endText });
1614
- else setTimeout(() => { if (id === selected && !(streamControl && streamControl.live)) resync({ endText }); }, STREAM_RETRY_MS);
1695
+ else setTimeout(() => { if (id === store.selected && !(streamControl && streamControl.live)) resync({ endText }); }, STREAM_RETRY_MS);
1615
1696
  }
1616
1697
 
1617
1698
  // The stream's event handlers (stream_handlers.js); renderSessionView
1618
1699
  // replays a snapshot through them too.
1619
1700
  const streamHandlers = createStreamHandlers({
1620
- selected: () => selected,
1621
- state: () => current,
1701
+ selected: () => store.selected,
1702
+ store,
1622
1703
  clientId,
1623
1704
  capabilities: () => pageCapabilities,
1624
1705
  historyEl,
@@ -1627,20 +1708,18 @@ const streamHandlers = createStreamHandlers({
1627
1708
  shownTexts,
1628
1709
  questionCards,
1629
1710
  contextChips,
1630
- llmContextControl,
1631
- thinkingControl,
1632
- reasoningHistoryControl,
1633
1711
  workingSet,
1634
1712
  participantsControl,
1635
1713
  placeApply,
1636
1714
  placeVerdict,
1637
1715
  restoreVerify: (anchor) => { pendingVerifyAnchor = anchor; },
1716
+ restoreRevise,
1638
1717
  sentIds,
1639
1718
  earlyRestores,
1640
1719
  stream: {
1641
1720
  takeGuess: () => {
1642
- const guess = current.streamFromGuess;
1643
- current.streamFromGuess = false;
1721
+ const guess = store.current.streamFromGuess;
1722
+ store.update({ current: { streamFromGuess: false } });
1644
1723
  return guess;
1645
1724
  },
1646
1725
  restart: () => {
@@ -1663,7 +1742,7 @@ const streamHandlers = createStreamHandlers({
1663
1742
  getSession,
1664
1743
  // The page's hooks, passed as they are: function declarations (hoisted)
1665
1744
  // and the views' methods bound above.
1666
- resync, updateInfoBar, setTurnRunning, setLiveStatus, endTurnView, readTurnEnd, refreshTimingFromSession,
1745
+ resync, setTurnRunning, setLiveStatus, endTurnView, readTurnEnd, refreshTimingFromSession,
1667
1746
  setUsedMemories, onInitEvent, findInTurnRoots, applyFinalMarkdown,
1668
1747
  // bubbles.js
1669
1748
  applyPromptOps, showSteers, markRecapStale, appendChunk, makeUserBubble, addEndBubble, commandBubble,
@@ -1675,14 +1754,16 @@ const streamHandlers = createStreamHandlers({
1675
1754
  });
1676
1755
 
1677
1756
  async function refreshTimingFromSession() {
1678
- if (!selected) return;
1679
- const id = selected;
1757
+ if (!store.selected) return;
1758
+ const id = store.selected;
1680
1759
  const line = liveTurn.timing.capture();
1760
+ const still = stillOn(openId);
1681
1761
  try {
1682
1762
  const data = await getSession(id, { tail: true, recent: true, turnId: line.turnId });
1683
- if (id !== selected) return;
1684
- if (data.session?.status) current.status = data.session.status;
1685
- if (await mergeTurnTiming(id, data.timing)) liveTurn.timing.finishLine(line);
1763
+ if (!still()) return;
1764
+ turnEndStatus(data);
1765
+ if (!(await mergeTurnTiming(id, data.timing))) return;
1766
+ liveTurn.timing.finishLine(line);
1686
1767
  } catch (_) {}
1687
1768
  }
1688
1769
 
@@ -1699,14 +1780,13 @@ function closeStream() {
1699
1780
  // put it (they follow, with its owner and bridge_up).
1700
1781
  function addCreatedSession(created) {
1701
1782
  const { bridge_port: _port, ...session } = created;
1702
- allSessions = applySessionEvent(allSessions, "session", { session });
1703
- lists.render();
1783
+ store.update({ sessions: applySessionEvent(store.sessions, "session", { session }) });
1704
1784
  }
1705
1785
 
1706
1786
  // /model, !ls … from the composer, and a control's command (the llm ctx
1707
1787
  // and thinking chips): composer.js.
1708
1788
  const composerCommands = createComposerCommands({
1709
- session: () => selected,
1789
+ session: () => store.selected,
1710
1790
  clientId,
1711
1791
  sendCommand,
1712
1792
  followWokenWorker: () => followWokenWorker(),
@@ -1715,15 +1795,15 @@ const composerCommands = createComposerCommands({
1715
1795
  // As send_turn.js says a turn's failure after a switch.
1716
1796
  failedElsewhere: (line, text, id) => showToast(cantText(`run ${line} in ${id.slice(0, 8)}`, text), null, { sticky: true }),
1717
1797
  ownedByTui: () => {
1718
- current.owner = "tui";
1719
- updateComposerMode();
1798
+ // The composer and the bar (its continue and stop, the title's ✎) follow.
1799
+ store.update({ current: { owner: "tui" } });
1720
1800
  },
1721
1801
  composer: {
1722
1802
  busy: () => { actionBtn.disabled = true; },
1723
1803
  text: () => promptEl.value,
1724
1804
  clear: () => clearComposer(),
1725
1805
  done: () => {
1726
- actionBtn.disabled = current.owner === "tui";
1806
+ actionBtn.disabled = store.current.owner === "tui";
1727
1807
  // A chip's command (the info bar) leaves a folded composer folded.
1728
1808
  focusPrompt({ open: false });
1729
1809
  },
@@ -1733,8 +1813,8 @@ const composerCommands = createComposerCommands({
1733
1813
  // The composer's send (send_turn.js): a line into the open session, or a
1734
1814
  // new session first on the start page.
1735
1815
  const sendTurnControl = createSendTurn({
1736
- session: () => selected,
1737
- state: () => current,
1816
+ session: () => store.selected,
1817
+ store,
1738
1818
  running: () => liveTurn.running,
1739
1819
  clientId,
1740
1820
  sentIds,
@@ -1743,6 +1823,10 @@ const sendTurnControl = createSendTurn({
1743
1823
  verify: () => pendingVerifyAnchor,
1744
1824
  clearVerify: (anchor) => { if (pendingVerifyAnchor === anchor) pendingVerifyAnchor = null; },
1745
1825
  restoreVerify: (anchor) => { pendingVerifyAnchor = anchor; },
1826
+ // Not while the gate refuses (the chip says so; the line goes as a plain message).
1827
+ revise: () => (reviseUnavailable() ? null : pendingReviseAnchor),
1828
+ clearRevise: (anchor) => { if (pendingReviseAnchor === anchor) setReviseAnchor(null); },
1829
+ restoreRevise,
1746
1830
  dir: scope.dir,
1747
1831
  startPage,
1748
1832
  takeDelivery: () => deliveryControl.take(),
@@ -1765,8 +1849,6 @@ const sendTurnControl = createSendTurn({
1765
1849
  addCreatedSession,
1766
1850
  select,
1767
1851
  showNotice,
1768
- updateInfoBar,
1769
- updateComposerMode,
1770
1852
  followWokenWorker,
1771
1853
  refreshHistory,
1772
1854
  leaveGoneSession,
@@ -1792,18 +1874,18 @@ function clearComposer() {
1792
1874
  // A queue (Alt+Enter, or an Apply turn's Enter) to a worker that can't
1793
1875
  // queue (it would steer): not sent, the text kept, a note says why.
1794
1876
  function queueRefused(altKey) {
1795
- if (!selected || !liveTurn.running || current.workerFeatures.includes("delivery")) return false;
1877
+ if (!store.selected || !liveTurn.running || store.current.workerFeatures.includes("delivery")) return false;
1796
1878
  if (!altKey && !deliveryControl.held()) return false;
1797
1879
  addCommandBubble({ label: null, line: promptEl.value.trim(), text: QUEUE_NEEDS_WORKER, failed: true });
1798
1880
  return true;
1799
1881
  }
1800
1882
 
1801
1883
  async function handleCancel() {
1802
- if (!selected) return;
1884
+ if (!store.selected) return;
1803
1885
  cancelBtn.disabled = true;
1804
1886
  infoCancelBtn.disabled = true;
1805
1887
  try {
1806
- await cancelTurn(selected);
1888
+ await cancelTurn(store.selected);
1807
1889
  } catch (e) {
1808
1890
  // The turn ended meanwhile: nothing left to cancel.
1809
1891
  if (e.code !== "not_running") alert(e.message);
@@ -1847,7 +1929,7 @@ const composer = mountComposer({
1847
1929
  // Alt/Option+Enter: queued for after the running turn (D6), only to a
1848
1930
  // worker that queues (else it would steer): the text stays, a note says why.
1849
1931
  send: ({ queue }) => {
1850
- if (!selected) return handleSendTurn();
1932
+ if (!store.selected) return handleSendTurn();
1851
1933
  if (!queueRefused(queue)) handleSendTurn(queue ? { delivery: "queue" } : {});
1852
1934
  },
1853
1935
  keyFirst: (e) => commandList.handleKey(e),
@@ -1856,8 +1938,8 @@ const composer = mountComposer({
1856
1938
  fold: {
1857
1939
  infoCancelBtn,
1858
1940
  infoBarEl: $("#infoBar"),
1859
- session: () => selected,
1860
- waiting: () => !!(waitingOn(allSessions.find((s) => s.id === selected)) || questionCards.pendingQuestion()),
1941
+ session: () => store.selected,
1942
+ waiting: () => !!(waitingOn(store.sessions.find((s) => s.id === store.selected)) || questionCards.pendingQuestion()),
1861
1943
  turnRunning: () => liveTurn.running,
1862
1944
  allViewOpen,
1863
1945
  lightboxOpen: () => !lightboxEl.hidden,
@@ -1878,7 +1960,7 @@ function refreshHistory() {
1878
1960
 
1879
1961
  // The × on a message queued for after the turn takes it back (unqueue.js).
1880
1962
  const unqueue = createUnqueue({
1881
- sessionId: () => selected,
1963
+ sessionId: () => store.selected,
1882
1964
  clientId,
1883
1965
  toast: (text, action, options) => showToast(text, action, options),
1884
1966
  copy: (text) => copyText(text),
@@ -1912,7 +1994,7 @@ infoCancelBtn.addEventListener("click", () => handleCancel());
1912
1994
  const commandList = createCommandList({
1913
1995
  list: $("#commandList"),
1914
1996
  prompt: promptEl,
1915
- commands: () => (selected && current.owner !== "tui" ? current.commands : []),
1997
+ commands: () => (store.selected && store.current.owner !== "tui" ? store.current.commands : []),
1916
1998
  afterPick: () => {
1917
1999
  noteDraft();
1918
2000
  fitPrompt();
@@ -1921,7 +2003,7 @@ const commandList = createCommandList({
1921
2003
 
1922
2004
  $("#infoStopBtn").addEventListener("click", () => sessionActions.stopSelected());
1923
2005
  $("#infoDeleteBtn").addEventListener("click", () => {
1924
- if (selected) confirmDelete(selected);
2006
+ if (store.selected) confirmDelete(store.selected);
1925
2007
  });
1926
2008
  $("#infoContinueBtn").addEventListener("click", () => continueSelected());
1927
2009
  $("#infoArchiveBtn").addEventListener("click", () => sessionActions.toggleArchiveSelected());
@@ -1984,16 +2066,16 @@ function fillComposer(text) {
1984
2066
  annotateBar = createAnnotateBar({
1985
2067
  presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
1986
2068
  disabledPreset: (preset) => preset === VERIFY_PRESET
1987
- && verifyWorkerUnavailable(current.workerFeatures, current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
2069
+ && verifyWorkerUnavailable(store.current.workerFeatures, store.current.bridgeUp) ? VERIFY_UNAVAILABLE : null,
1988
2070
  offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
1989
- canAnnotate: () => !!selected && current.owner !== "tui",
2071
+ canAnnotate: () => !!store.selected && store.current.owner !== "tui",
1990
2072
  roots: turnRoots,
1991
2073
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
1992
2074
  onQuote: (text) => fillComposer(appendQuote(promptEl.value, text)),
1993
2075
  onPreset: (preset, fields) => {
1994
2076
  pendingVerifyAnchor = preset === VERIFY_PRESET ? verifyAnchorFor(fields) : null;
1995
2077
  },
1996
- actions: () => annotationActions(current.commands),
2078
+ actions: () => annotationActions(store.current.commands),
1997
2079
  running: () => liveTurn.running,
1998
2080
  onAction: (action, fields) => {
1999
2081
  const line = actionLine(action, fields);
@@ -2022,8 +2104,30 @@ historyEl.addEventListener("click", (e) => {
2022
2104
  const button = e.target.closest?.("[data-confirm-line]");
2023
2105
  if (button) applyConfirm(button, runSessionCommand);
2024
2106
  });
2025
- turnDom.scrollEl?.addEventListener("scroll", annotateBar.schedule, true);
2107
+ turnDom.scrollEl.addEventListener("scroll", annotateBar.schedule, true);
2026
2108
  turnRoots().forEach((root) => installCopy(root));
2109
+ // Long answers fold by hand (answer_fold.js), per viewer and session.
2110
+ installAnswerFold({
2111
+ historyEl,
2112
+ sessionId: () => store.selected,
2113
+ projectRoot: () => store.sessions.find((s) => s.id === store.selected)?.project_root ?? null,
2114
+ });
2115
+ // Revise stacks (revise_stack.js): the superseded versions fold to stubs,
2116
+ // the top answer gets its version chip, a final answer its Revise button.
2117
+ reviseStacks = installReviseStacks({
2118
+ historyEl,
2119
+ sessionId: () => store.selected,
2120
+ projectRoot: () => store.sessions.find((s) => s.id === store.selected)?.project_root ?? null,
2121
+ running: () => (liveTurn.running ? { turnId: liveTurn.timing.turnId || null } : null),
2122
+ verifyTurn: (turnId) => (store.current.verdicts || []).some((v) => v.check_turn_id === turnId),
2123
+ unavailable: reviseUnavailable,
2124
+ armed: () => pendingReviseAnchor?.turn_id ?? null,
2125
+ onRevise: (anchor) => {
2126
+ setReviseAnchor(anchor);
2127
+ openForVisit();
2128
+ focusPrompt();
2129
+ },
2130
+ });
2027
2131
 
2028
2132
  // Notifications (notifications.js): an OS notification and a title count
2029
2133
  // when a session needs the user while this tab is not in front. On a
@@ -2083,6 +2187,24 @@ document.addEventListener("keydown", (e) => {
2083
2187
  }
2084
2188
  closeAllView();
2085
2189
  });
2190
+ // The redraws the store runs, by name (session_store.js ORDER), after a
2191
+ // write of what they read (session_fields.js FIELDS; sessions, selected) or
2192
+ // a touch(). Registered here, once every piece they call exists: a write
2193
+ // before it (none at load) would redraw nothing.
2194
+ store.on("lists", () => lists.render());
2195
+ store.on("paintActive", () => lists.paintActive());
2196
+ store.on("llmContextChip", () => llmContextControl.refresh());
2197
+ store.on("thinkingChip", () => thinkingControl.refresh());
2198
+ store.on("reasoningChip", () => reasoningHistoryControl.refresh());
2199
+ // The working set's list offers pin only under forget.
2200
+ store.on("workingSetStrategy", () => workingSet?.strategyChanged());
2201
+ store.on("featureGates", () => featureGates.check(store.current));
2202
+ store.on("composer", updateComposerMode);
2203
+ // The answers' Revise buttons follow liveTurn.running too (none on the
2204
+ // running turn's answer), which touches the composer.
2205
+ store.on("composer", () => reviseStacks.decorate());
2206
+ store.on("infoBar", updateInfoBar);
2207
+
2086
2208
  applyView();
2087
2209
 
2088
2210
  // Where sessions live: under the all-sessions search (the server fills it in).
@@ -2092,8 +2214,10 @@ if (document.body.dataset.sessionsDir) {
2092
2214
  }
2093
2215
 
2094
2216
  renderScope();
2095
- updateInfoBar();
2096
- updateComposerMode();
2217
+ store.batch(() => {
2218
+ store.touch("composer");
2219
+ store.touch("infoBar");
2220
+ });
2097
2221
  startPage.loadModels();
2098
2222
  // The list: the hub's stream when the browser has EventSource, else fetched.
2099
2223
  if (typeof globalThis.EventSource === "function") openSessionEvents();