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