samagotchi 0.134.0 → 0.135.0

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