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
@@ -0,0 +1,378 @@
1
+ // Revise stacks (web): a Revise turn rewrites an earlier turn's answer, and
2
+ // the server stamps its prompt with `revises: {turn_id}` once it completed
3
+ // with an answer (Engine#stamp_opening; never derived here). The page draws
4
+ // each stack v1 ← v2 ← … ← top in place:
5
+ //
6
+ // - every superseded turn (its opening, its work, its answer: turn_range.js's
7
+ // rule) is hidden by a class and replaced, where it is, by one stub row,
8
+ // "v1 · ‹first line› · 84 lines — superseded by v3"; a click unfolds the
9
+ // turn in place (per viewer: answer_fold.js's store, the key "stub:<id>").
10
+ // Not hidden: the cards a turn's end keeps out of its block (warn cards),
11
+ // verdict cards, the recap. A time-gap divider between two folded stubs
12
+ // folds into the later stub (it shows the divider's words);
13
+ // - the top answer gets a version chip, "v3 · 2 earlier ▾", listing the
14
+ // versions by their prompts; picking one reveals its stub;
15
+ // - the final answer of an ended turn (verify_target.js's rule, a delegate
16
+ // report's reply excluded), unstacked or a stack's top, and not a Verify
17
+ // turn's, gets a Revise button: it arms the composer (app.js).
18
+ //
19
+ // The stamps live on the history's prompt bubbles (data-revises: a reload's
20
+ // render, a live turn_completed's revises), so the stacks are read from the
21
+ // DOM again on every change (stacks(), pure). One observer on #history, as
22
+ // answer_fold.js has; history_reveal.js unfolds a stub a jump lands in.
23
+ //
24
+ // A module of its own: static JS is cached at unversioned URLs, and a new
25
+ // export on an old module would blank the page.
26
+ import { html, nothing, render } from "./vendor/lit-html.js";
27
+ import { bubbleCopyText } from "./copy.js";
28
+ import { foldLabel, keepRowInView, lineCount, readFolded, writeFold } from "./answer_fold.js";
29
+ import { clip } from "./format.js";
30
+ import { NOTE_BOUNDARY } from "./turn_range.js";
31
+
32
+ // The classes and attributes this module puts on the history.
33
+ export const HIDDEN_CLASS = "stack-hidden";
34
+ export const STUB_CLASS = "stack-stub";
35
+ export const ROW_CLASS = "revise-row";
36
+ const GAP_HIDDEN_CLASS = "stack-gap";
37
+ // What a superseded turn keeps on view (card.js leavesBlock's cards, a
38
+ // verdict, the recap).
39
+ export const KEPT = ".plugin-card.warn, .verify-verdict, .bubble.recap";
40
+ // A turn's final answer (verify_target.js OWNER_FINAL), and not done yet.
41
+ const ANSWERS = ".bubble.output:not(.report-reply)";
42
+ const UNFINISHED = ".streaming";
43
+ const OPENING = "[data-turn-id]";
44
+ // A fold row (answer_fold.js) just before an opening: a delegate report's
45
+ // that opens its turn, the opening's header (in its range, the stub above it).
46
+ const FOLD_ROW = "answer-fold";
47
+ const headOf = (opening) => (opening.previousElementSibling?.classList.contains(FOLD_ROW) ? opening.previousElementSibling : null);
48
+ const STOPPED = ".bubble.cancel";
49
+ const LABEL_CHARS = 120;
50
+ // The stored key of a stub unfolded by hand (answer_fold.js's list).
51
+ export const stubKey = (turnId) => `stub:${turnId}`;
52
+
53
+ // The chains of revisions among +messages+ (the history's openings, in
54
+ // order: {turn_id, revises?: {turn_id}}), each [v1, v2, …, top] by turn id;
55
+ // only chains of two or more. A revises whose target isn't an earlier
56
+ // opening is ignored (a stale page, a rolled-back turn); one whose target
57
+ // was revised already goes on that stack's top (as the server re-targets).
58
+ export function stacks(messages) {
59
+ const order = new Map();
60
+ const revisesOf = new Map();
61
+ for (const m of Array.isArray(messages) ? messages : []) {
62
+ const id = m?.turn_id;
63
+ if (typeof id !== "string" || !id || order.has(id) || m.merged) continue;
64
+ order.set(id, order.size);
65
+ const target = m.revises?.turn_id;
66
+ if (typeof target === "string" && target) revisesOf.set(id, target);
67
+ }
68
+ const next = new Map();
69
+ const prev = new Map();
70
+ for (const [id, at] of order) {
71
+ let target = revisesOf.get(id);
72
+ if (!target || !order.has(target) || order.get(target) >= at) continue;
73
+ while (next.has(target)) target = next.get(target);
74
+ next.set(target, id);
75
+ prev.set(id, target);
76
+ }
77
+ const chains = [];
78
+ for (const id of order.keys()) {
79
+ if (prev.has(id) || !next.has(id)) continue;
80
+ const chain = [id];
81
+ while (next.has(chain.at(-1))) chain.push(next.get(chain.at(-1)));
82
+ chains.push(chain);
83
+ }
84
+ return chains;
85
+ }
86
+
87
+ // A stub's words: "v1 · "‹label›" · 84 lines — superseded by v3".
88
+ export function stubText({ version, label = "", lines = 0, top }) {
89
+ const said = label ? ` · “${label}”` : "";
90
+ const size = lines ? ` · ${lines} line${lines === 1 ? "" : "s"}` : "";
91
+ return `v${version}${said}${size} — superseded by v${top}`;
92
+ }
93
+
94
+ // The version chip's words: "v3 · 2 earlier".
95
+ export function versionChipText(count) {
96
+ return `v${count} · ${count - 1} earlier`;
97
+ }
98
+
99
+ // The composer's chip: "Revising: ‹label›", or the refusal (+unavailable+:
100
+ // the worker predates Revise turns).
101
+ export const REVISE_UNAVAILABLE = "restart this session to use Revise";
102
+
103
+ // The feature gate (app.js featureGates): a live worker without the
104
+ // "revise" feature predates Revise turns (chi web would answer 409
105
+ // revise_unavailable). A worker that isn't up isn't asked: the send wakes a
106
+ // new one, which has it (as Verify's gate, verify_preset.js).
107
+ export function reviseWorkerUnavailable(workerFeatures, bridgeUp) {
108
+ return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("revise");
109
+ }
110
+ export function reviseChipText(label, { unavailable = false } = {}) {
111
+ if (unavailable) return `Revise: ${REVISE_UNAVAILABLE}`;
112
+ return label ? `Revising: ${label}` : "Revising the answer";
113
+ }
114
+
115
+ // The composer's chip (app.js renders it into its own element): the armed
116
+ // Revise's words and ✕ (+onClear+), or nothing when +anchor+ is null.
117
+ export function reviseChipTemplate(anchor, { unavailable = false, onClear } = {}) {
118
+ if (!anchor) return nothing;
119
+ return html`<span class=${unavailable ? "revise-chip refused" : "revise-chip"} title=${unavailable ? REVISE_UNAVAILABLE : `This message rewrites that answer: ${anchor.label || ""}`}><span class="revise-chip-text">${reviseChipText(anchor.label, { unavailable })}</span><button type="button" class="revise-chip-clear" aria-label="Don't revise" title="Don't revise (send as a plain message)" @click=${onClear}>✕</button></span>`;
120
+ }
121
+
122
+ // An answer's label (the stub's, the chip's): its first line.
123
+ export function answerLabel(bubble) {
124
+ if (!bubble) return "";
125
+ return foldLabel(bubbleCopyText(bubble), { display: bubble.hasAttribute?.("data-answer-display") });
126
+ }
127
+
128
+ // A prompt bubble's first text line (a version's label in the chip's list).
129
+ function promptLabel(opening) {
130
+ if (!opening) return "";
131
+ const text = opening.matches?.(".bubble.user") ? bubbleCopyText(opening) : opening.querySelector?.(".note-text")?.textContent || "";
132
+ const line = String(text).split("\n").map((l) => l.trim()).find(Boolean) || "";
133
+ return clip(line, LABEL_CHARS);
134
+ }
135
+
136
+ // The history's turns, in order (one pass over #history's children):
137
+ // {id, opening, els (after the opening, up to the next opening or note, a
138
+ // trailing gap divider left to the next), answer (its final answer), wake
139
+ // (a note opened it), stopped (it was canceled or failed)}. Our own rows
140
+ // aren't part of any.
141
+ export function historyTurns(historyEl) {
142
+ const turns = [];
143
+ let turn = null;
144
+ const close = () => {
145
+ if (!turn) return;
146
+ while (turn.els.at(-1)?.classList.contains("time-gap")) turn.els.pop();
147
+ };
148
+ for (const el of historyEl.children) {
149
+ if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS)) continue;
150
+ if (el.classList.contains(FOLD_ROW) && el.nextElementSibling?.matches(OPENING)) continue;
151
+ const opening = el.matches(OPENING) ? el : el.querySelector(OPENING);
152
+ if (opening || el.matches(NOTE_BOUNDARY)) {
153
+ close();
154
+ turn = { id: opening?.dataset.turnId || null, opening: el, head: headOf(el), prompt: opening || null, els: [], answer: null, wake: !opening };
155
+ turns.push(turn);
156
+ continue;
157
+ }
158
+ if (!turn) continue;
159
+ turn.els.push(el);
160
+ if (el.matches(ANSWERS)) turn.answer = el;
161
+ // A canceled or failed turn's end line (timing.js STOPPED_LINE_CLASS,
162
+ // a live end's too): its partial answer isn't a completed one.
163
+ if (el.matches(STOPPED)) turn.stopped = true;
164
+ }
165
+ close();
166
+ // A wake turn's id is its answer's (timing.js turnGroups keys it).
167
+ for (const t of turns) if (t.wake && t.answer) t.id = t.answer.getAttribute("data-answer-turn") || null;
168
+ return turns;
169
+ }
170
+
171
+ // @param historyEl #history (also its scroller)
172
+ // @param sessionId () => the open session's id
173
+ // @param projectRoot () => its project root (the fold store's sweep), or null
174
+ // @param running () => null when no turn runs, else {turnId} (null id: the
175
+ // newest turn counts as the running one)
176
+ // @param verifyTurn (turnId) => whether that turn was a Verify turn (beyond
177
+ // its prompt's data-verify: a verdict's check_turn_id)
178
+ // @param unavailable () => the worker predates Revise (the feature gate)
179
+ // @param armed () => the turn id the composer's Revise is armed with, or null
180
+ // @param onRevise ({turn_id, label}) the Revise button
181
+ // @return {decorate()} (the observer runs it; app.js after a change it reads)
182
+ export function installReviseStacks({
183
+ historyEl, sessionId, projectRoot = () => null, running = () => null, verifyTurn = () => false,
184
+ unavailable = () => false, armed = () => null, onRevise = () => {},
185
+ }) {
186
+ const stubs = new Map();
187
+ const rows = new Map();
188
+ // Each row's answer bubble (its label at a click).
189
+ const rowAnswer = new WeakMap();
190
+ let observer = null;
191
+
192
+ function stubFor(id, opening, stored) {
193
+ let stub = stubs.get(id);
194
+ if (!stub) {
195
+ stub = historyEl.ownerDocument.createElement("button");
196
+ stub.type = "button";
197
+ stub.className = STUB_CLASS;
198
+ stub.dataset.stackStub = id;
199
+ // The stored choice, once: after that the stub's class is the state
200
+ // (a jump's reveal isn't saved).
201
+ if (stored().includes(stubKey(id))) stub.classList.add("open");
202
+ stubs.set(id, stub);
203
+ }
204
+ const top = headOf(opening) || opening;
205
+ if (stub.parentElement !== historyEl || stub.nextElementSibling !== top) historyEl.insertBefore(stub, top);
206
+ return stub;
207
+ }
208
+
209
+ function drawStub(stub, { version, top, answer, opening, gap }) {
210
+ const open = stub.classList.contains("open");
211
+ stub.setAttribute("aria-expanded", open ? "true" : "false");
212
+ stub.title = `${open ? "Fold" : "Show"} this earlier version${opening.title ? ` (${opening.title})` : ""}`;
213
+ const text = stubText({ version, label: answerLabel(answer), lines: answer ? lineCount(bubbleCopyText(answer)) : 0, top });
214
+ render(html`<span class="stack-stub-text">${text}</span>${gap ? html`<span class="stack-stub-gap">${gap}</span>` : nothing}`, stub);
215
+ }
216
+
217
+ function rowFor(id, answer) {
218
+ let row = rows.get(id);
219
+ if (!row) {
220
+ row = historyEl.ownerDocument.createElement("div");
221
+ row.className = ROW_CLASS;
222
+ row.dataset.reviseFor = id;
223
+ rows.set(id, row);
224
+ }
225
+ if (answer.nextElementSibling !== row) answer.after(row);
226
+ return row;
227
+ }
228
+
229
+ function drawRow(row, { id, answer, chain, openings }) {
230
+ const off = unavailable();
231
+ const isArmed = armed() === id;
232
+ const versions = chain
233
+ ? html`<details class="stack-versions"><summary>${versionChipText(chain.length)}</summary><div class="stack-versions-list" role="list">${chain.map((vid, k) => html`<button type="button" role="listitem" class=${vid === id ? "stack-version current" : "stack-version"} data-stack-pick=${vid} ?disabled=${vid === id}><span class="stack-version-n">v${k + 1}</span> ${promptLabel(openings.get(vid))}</button>`)}</div></details>`
234
+ : nothing;
235
+ const button = html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? REVISE_UNAVAILABLE : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>`;
236
+ row.classList.toggle("stacked", !!chain);
237
+ render(html`${versions}${button}`, row);
238
+ rowAnswer.set(row, answer);
239
+ }
240
+
241
+ function decorate() {
242
+ let folds = null;
243
+ const stored = () => (folds ??= readFolded(sessionId()));
244
+ const turns = historyTurns(historyEl);
245
+ const openings = new Map(turns.filter((t) => t.id).map((t) => [t.id, t.prompt || t.opening]));
246
+ const entries = turns.filter((t) => t.id).map((t) => {
247
+ const revises = t.prompt?.getAttribute("data-revises");
248
+ return revises ? { turn_id: t.id, revises: { turn_id: revises } } : { turn_id: t.id };
249
+ });
250
+ const superseded = new Map();
251
+ const tops = new Map();
252
+ for (const chain of stacks(entries)) {
253
+ chain.slice(0, -1).forEach((id, k) => superseded.set(id, { version: k + 1, top: chain.length }));
254
+ tops.set(chain.at(-1), chain);
255
+ }
256
+ const run = running();
257
+ const lastOpening = turns.filter((t) => !t.wake).at(-1);
258
+ const keepStubs = new Set();
259
+ const keepRows = new Set();
260
+ for (const turn of turns) {
261
+ const range = [turn.head, turn.opening, ...turn.els].filter(Boolean);
262
+ const place = turn.id && superseded.get(turn.id);
263
+ if (place) {
264
+ const stub = stubFor(turn.id, turn.opening, stored);
265
+ keepStubs.add(turn.id);
266
+ const open = stub.classList.contains("open");
267
+ for (const el of range) {
268
+ const hide = !open && !el.matches(KEPT);
269
+ el.classList.toggle(HIDDEN_CLASS, hide);
270
+ if (el.dataset.stackOf !== turn.id) el.dataset.stackOf = turn.id;
271
+ }
272
+ turn.stub = stub;
273
+ turn.place = place;
274
+ continue;
275
+ }
276
+ for (const el of range) {
277
+ if (el.classList.contains(HIDDEN_CLASS)) el.classList.remove(HIDDEN_CLASS);
278
+ if (el.dataset.stackOf) delete el.dataset.stackOf;
279
+ }
280
+ // The Revise row: an ended turn's final answer, not a Verify turn's,
281
+ // not the running turn's.
282
+ const answer = turn.answer;
283
+ if (!turn.id || turn.wake || !answer || answer.matches(UNFINISHED) || turn.stopped) continue;
284
+ if (turn.prompt?.hasAttribute("data-verify") || verifyTurn(turn.id)) continue;
285
+ if (run && (turn === lastOpening || (run.turnId && run.turnId === turn.id))) continue;
286
+ const row = rowFor(turn.id, answer);
287
+ keepRows.add(turn.id);
288
+ drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
289
+ }
290
+ // A divider between two folded stubs folds into the later one.
291
+ for (const turn of turns) {
292
+ if (!turn.stub) continue;
293
+ const gap = turn.stub.previousElementSibling;
294
+ let gapText = "";
295
+ if (gap?.classList.contains("time-gap")) {
296
+ let before = gap.previousElementSibling;
297
+ while (before?.classList.contains(HIDDEN_CLASS)) before = before.previousElementSibling;
298
+ const fold = !turn.stub.classList.contains("open") && !!before?.matches(`.${STUB_CLASS}:not(.open)`);
299
+ gap.classList.toggle(GAP_HIDDEN_CLASS, fold);
300
+ if (fold) gapText = gap.textContent.replace(/[─\s]+$/u, "").replace(/^[─\s]+/u, "");
301
+ }
302
+ drawStub(turn.stub, { ...turn.place, answer: turn.answer, opening: turn.prompt || turn.opening, gap: gapText });
303
+ }
304
+ for (const gap of historyEl.querySelectorAll(`:scope > .${GAP_HIDDEN_CLASS}`)) {
305
+ if (!gap.nextElementSibling?.matches(`.${STUB_CLASS}:not(.open)`)) gap.classList.remove(GAP_HIDDEN_CLASS);
306
+ }
307
+ for (const [id, stub] of stubs) if (!keepStubs.has(id) || stub.parentElement !== historyEl) { stub.remove(); stubs.delete(id); }
308
+ for (const [id, row] of rows) if (!keepRows.has(id) || row.parentElement !== historyEl) { row.remove(); rows.delete(id); }
309
+ // A stale row left by a re-render (its map entry gone with the nodes).
310
+ for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}`)) {
311
+ if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el) el.remove();
312
+ }
313
+ // What we changed isn't news.
314
+ observer?.takeRecords();
315
+ }
316
+
317
+ function setOpen(stub, open, { save }) {
318
+ keepRowInView(historyEl, stub, () => {
319
+ stub.classList.toggle("open", open);
320
+ decorate();
321
+ });
322
+ if (save) writeFold(sessionId(), stubKey(stub.dataset.stackStub), open, { root: projectRoot() });
323
+ }
324
+
325
+ // Changes from elsewhere: a direct child added or removed, a stamp, an
326
+ // answer's key or its end (its class).
327
+ observer = new MutationObserver((records) => {
328
+ const news = records.some((r) => (r.type === "childList" ? r.target === historyEl
329
+ && [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && !n.classList.contains(STUB_CLASS) && !n.classList.contains(ROW_CLASS))
330
+ : r.attributeName !== "class" || r.target.matches?.(`.bubble.output, .${STUB_CLASS}`)));
331
+ if (news) decorate();
332
+ });
333
+ observer.observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-revises", "data-answer-turn", "data-verify"] });
334
+ decorate();
335
+
336
+ historyEl.addEventListener("click", (e) => {
337
+ const stub = e.target.closest?.(`.${STUB_CLASS}`);
338
+ if (stub && stub.parentElement === historyEl) {
339
+ e.preventDefault();
340
+ setOpen(stub, !stub.classList.contains("open"), { save: true });
341
+ return;
342
+ }
343
+ const pick = e.target.closest?.("[data-stack-pick]");
344
+ if (pick) {
345
+ e.preventDefault();
346
+ pick.closest("details")?.removeAttribute("open");
347
+ const target = stubs.get(pick.dataset.stackPick);
348
+ if (!target) return;
349
+ if (!target.classList.contains("open")) setOpen(target, true, { save: false });
350
+ target.scrollIntoView?.({ block: "start", behavior: "smooth" });
351
+ target.focus?.({ preventScroll: true });
352
+ return;
353
+ }
354
+ const button = e.target.closest?.("[data-revise]");
355
+ const row = button?.closest(`.${ROW_CLASS}`);
356
+ if (!button || !row || button.disabled) return;
357
+ e.preventDefault();
358
+ onRevise({ turn_id: button.dataset.revise, label: answerLabel(rowAnswer.get(row)) });
359
+ });
360
+
361
+ return { decorate };
362
+ }
363
+
364
+ // The stub that hides +el+ (a superseded turn's element, or inside one),
365
+ // opened (not saved); +el+ or null. history_reveal.js runs it on a jump.
366
+ export function openStubOver(el) {
367
+ const top = el?.closest?.(`.${HIDDEN_CLASS}`);
368
+ const id = top?.dataset.stackOf;
369
+ const parent = top?.parentElement;
370
+ if (!id || !parent) return el ?? null;
371
+ const stub = [...parent.children].find((c) => c.classList.contains(STUB_CLASS) && c.dataset.stackStub === id);
372
+ stub?.classList.add("open");
373
+ stub?.setAttribute("aria-expanded", "true");
374
+ for (const hidden of parent.querySelectorAll(`:scope > .${HIDDEN_CLASS}`)) {
375
+ if (hidden.dataset.stackOf === id) hidden.classList.remove(HIDDEN_CLASS);
376
+ }
377
+ return el;
378
+ }
@@ -15,23 +15,15 @@ export function scopeDir(search) {
15
15
  // The same place in the all view: ?scope=all without ?dir (a bare page
16
16
  // may be the start folder's project), keeping the hash. +fromDir+ (the
17
17
  // project view's folder) rides along as ?from=, so the all view can link
18
- // back to it. +search+ (the page's location.search) keeps its ?view=
19
- // (VIEW_PARAM) on the new page.
20
- export function allScopeHref(hash, fromDir = null, search = "") {
21
- const params = ["scope=all", fromDir ? `from=${dirParam(fromDir)}` : "", viewParam(search)].filter(Boolean);
18
+ // back to it.
19
+ export function allScopeHref(hash, fromDir = null) {
20
+ const params = ["scope=all", fromDir ? `from=${dirParam(fromDir)}` : ""].filter(Boolean);
22
21
  return `/?${params.join("&")}${hash || ""}`;
23
22
  }
24
23
 
25
- // A folder's project view, keeping the hash and +search+'s ?view=.
26
- export function projectScopeHref(dir, hash, search = "") {
27
- const view = viewParam(search);
28
- return `/?dir=${dirParam(dir)}${view ? `&${view}` : ""}${hash || ""}`;
29
- }
30
-
31
- // "view=<v>" from a location.search string, or "" without one.
32
- function viewParam(search) {
33
- const view = new URLSearchParams(search || "").get("view");
34
- return view ? `view=${encodeURIComponent(view)}` : "";
24
+ // A folder's project view, keeping the hash.
25
+ export function projectScopeHref(dir, hash) {
26
+ return `/?dir=${dirParam(dir)}${hash || ""}`;
35
27
  }
36
28
 
37
29
  // A folder for the query string, its slashes left readable (as chi web
@@ -52,18 +44,18 @@ export function cardFolder(workingDirectory, projectName) {
52
44
  // The header's project chip opens the picker. The "all" link remains the
53
45
  // direct way to every session from a project view. +scope+: { dir,
54
46
  // projectName, projectDir, serverDir, backDir, backName }; +error+: why
55
- // the ?dir= folder can't be shown; +where+: () => { hash, search } (the
47
+ // the ?dir= folder can't be shown; +where+: () => { hash } (the
56
48
  // page's location, read again at a click).
57
- export function scopeTemplate(scope, { error = null, where = () => ({ hash: "", search: "" }) } = {}) {
49
+ export function scopeTemplate(scope, { error = null, where = () => ({ hash: "" }) } = {}) {
58
50
  const at = where();
59
- const allHref = () => allScopeHref(where().hash, scope.dir, where().search);
60
- const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir, at.search)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
51
+ const allHref = () => allScopeHref(where().hash, scope.dir);
52
+ const all = html`<a class="scope-all" href=${allScopeHref(at.hash, scope.dir)} title=${"Sessions of every project"} @click=${(e) => { e.currentTarget.href = allHref(); }}>all</a>`;
61
53
  if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" title=${error}>folder not found</button>${all}`;
62
54
  if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
63
55
  const dir = scope.dir || scope.serverDir;
64
- const backHref = () => projectScopeHref(scope.backDir, where().hash, where().search);
56
+ const backHref = () => projectScopeHref(scope.backDir, where().hash);
65
57
  const back = scope.backName
66
- ? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash, at.search)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
58
+ ? html`<a class="scope-all scope-back" href=${projectScopeHref(scope.backDir, at.hash)} title=${`Sessions of ${scope.backName} only`} @click=${(e) => { e.currentTarget.href = backHref(); }}>← ${scope.backName}</a>`
67
59
  : nothing;
68
60
  const newSession = dir ? html`<span class="scope-where" title=${`New sessions start in ${dir}`}>new session in<span class="path"><bdi>${dir}</bdi></span></span>` : nothing;
69
61
  return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title="Sessions of every project">all projects</button>${back}${newSession}`;
@@ -11,8 +11,9 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
11
11
  // /turn and its ack, a fast failure's restore (restore.js) and the error's
12
12
  // words.
13
13
  //
14
- // Hooks: +session()+ the open session's id or null; +state()+ its fields
15
- // (session_state.js: owner, commands, firstPreview); +running()+ a turn
14
+ // Hooks: +session()+ the open session's id or null; +store+ the page's
15
+ // session store (session_store.js; store.current: owner, commands,
16
+ // firstPreview, written through store.update); +running()+ a turn
16
17
  // runs; +clientId+; +sentIds+ / +earlyRestores+ (restore.js' bookkeeping);
17
18
  // +api+: {createIdleSession, uploadImage, sendTurn} (data.js); +dir+: the
18
19
  // page's scope folder (new sessions start there); +startPage+
@@ -23,15 +24,16 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
23
24
  // text was written in session id)}; +historyEl+,
24
25
  // +turnDom+; +makeUserBubble(prompt, opts)+, +bubbleById(id)+,
25
26
  // +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
26
- // +showNotice(text)+, +updateInfoBar()+, +updateComposerMode()+,
27
+ // +showNotice(text)+,
27
28
  // +followWokenWorker()+, +refreshHistory()+, +leaveGoneSession(id, why)+,
28
29
  // +runCommand(command)+ (composer.js send), +toast(text, action, options)+;
29
30
  // +alert+: the browser's.
30
31
  export function createSendTurn({
31
- session, state, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
32
+ session, store, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
32
33
  verify = () => null, clearVerify = () => {}, restoreVerify = () => {},
34
+ revise = () => null, clearRevise = () => {}, restoreRevise = () => {},
33
35
  historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
34
- addCreatedSession, select, showNotice, updateInfoBar, updateComposerMode, followWokenWorker, refreshHistory,
36
+ addCreatedSession, select, showNotice, followWokenWorker, refreshHistory,
35
37
  leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
36
38
  }) {
37
39
  const promptEl = composer.el;
@@ -63,7 +65,7 @@ export function createSendTurn({
63
65
  }
64
66
 
65
67
  async function send({ delivery: chosen = null } = {}) {
66
- if (state().owner === "tui") return;
68
+ if (store.current.owner === "tui") return;
67
69
  // A fresh link's pick first: its /model is queued in the worker once
68
70
  // acked, and the worker runs queued commands before a turn's input.
69
71
  const pick = startPage.pendingPick();
@@ -81,7 +83,7 @@ export function createSendTurn({
81
83
  // Where the line goes (composer.js): the page's own reply, a session to
82
84
  // make first (the start page), a command, or a turn.
83
85
  let route = routeComposerLine(prompt, {
84
- session: !!session(), running: running(), commands: state().commands, images: composer.chips().length,
86
+ session: !!session(), running: running(), commands: store.current.commands, images: composer.chips().length,
85
87
  });
86
88
  if (route.create) {
87
89
  actionBtn.disabled = true;
@@ -99,7 +101,7 @@ export function createSendTurn({
99
101
  actionBtn.disabled = false;
100
102
  }
101
103
  // In the new session, by its command list (select read it).
102
- route = routeSessionLine(route.send, { commands: state().commands, images: composer.chips().length, delivery: route.delivery });
104
+ route = routeSessionLine(route.send, { commands: store.current.commands, images: composer.chips().length, delivery: route.delivery });
103
105
  }
104
106
  if (route.reply) {
105
107
  // The start page's reply is a notice: no session is made for it (2.29).
@@ -116,16 +118,15 @@ export function createSendTurn({
116
118
  if (hint) hint.remove();
117
119
  // Sending moves a finished turn out of the stage at once (else the echo
118
120
  // would land among its rows); into a running one it is a queued row there.
119
- turnDom.handOff?.({ immediate: true });
121
+ turnDom.handOff({ immediate: true });
120
122
  const sending = composer.chips().slice();
121
123
  // Into a running turn the echo says what it waits for (its delivery); a
122
124
  // line with images always waits for the turn's end (the worker queues it).
123
125
  const shownDelivery = isRunning ? (sending.length ? "queue" : delivery || "next_step") : null;
124
126
  const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
125
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
126
- if (!state().firstPreview || state().firstPreview === "—") {
127
- state().firstPreview = firstPreview(prompt);
128
- updateInfoBar();
127
+ if (!turnDom.holds()) historyEl.scrollTop = historyEl.scrollHeight;
128
+ if (!store.current.firstPreview || store.current.firstPreview === "—") {
129
+ store.update({ current: { firstPreview: firstPreview(prompt) } });
129
130
  }
130
131
  actionBtn.disabled = true;
131
132
  // The session the line goes to: the page may switch to another while an
@@ -134,8 +135,14 @@ export function createSendTurn({
134
135
  try {
135
136
  await uploadChips(sending, sentTo, { upload: api.uploadImage });
136
137
  const verifyAnchor = verify();
138
+ // The armed Revise ({turn_id, label}; null while its gate refuses).
139
+ const reviseAnchor = revise();
140
+ // A Verify turn's prompt: no Revise on its answer (revise_stack.js),
141
+ // as a reload's data-verify says.
142
+ if (verifyAnchor) echo.dataset.verify = "1";
137
143
  const ack = await api.sendTurn(sentTo, prompt, {
138
144
  clientId, images: turnRefs(sending), history: typed, delivery, ...(verifyAnchor ? { verify: verifyAnchor } : {}),
145
+ ...(reviseAnchor ? { revises: { turn_id: reviseAnchor.turn_id } } : {}),
139
146
  });
140
147
  if (ack?.command_id) {
141
148
  // The worker knew it as a command (its list was newer than ours):
@@ -146,6 +153,7 @@ export function createSendTurn({
146
153
  return;
147
154
  }
148
155
  if (verifyAnchor) clearVerify(verifyAnchor);
156
+ if (reviseAnchor) clearRevise(reviseAnchor);
149
157
  if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
150
158
  // A cut the worker didn't take (no thinking-only step: cut off) goes in
151
159
  // at the next step; a queue whose turn ended meanwhile is the next turn.
@@ -161,6 +169,8 @@ export function createSendTurn({
161
169
  if (restored?.refill && sentTo === session()) composer.openForVisit();
162
170
  // Its Verify claim too, so the resend still records a verdict.
163
171
  if (restored?.refill && restored.verify && sentTo === session()) restoreVerify(restored.verify);
172
+ // And its Revise draft, so the resend still stacks.
173
+ if (restored?.refill && restored.revises && sentTo === session()) restoreRevise(restored.revises);
164
174
  const before = promptEl.value;
165
175
  promptEl.value = composerAfterAck({ sent: line, current: before, refill: restored?.refill || "" });
166
176
  // A line back from sentTo is a draft of that session, wherever the page is now.
@@ -189,8 +199,8 @@ export function createSendTurn({
189
199
  // image refused (bad_image, too_large, bad_images) says why below.
190
200
  if (e.code === "owned_by_tui") {
191
201
  if (sentTo !== session()) return toast(cantText(`send to ${sentTo.slice(0, 8)}`, e), null, { sticky: true });
192
- state().owner = "tui";
193
- updateComposerMode();
202
+ // The composer and the bar (its continue and stop, the title's ✎) follow.
203
+ store.update({ current: { owner: "tui" } });
194
204
  return;
195
205
  }
196
206
  // A stale tab sent into a session deleted meanwhile: the start page
@@ -198,9 +208,12 @@ export function createSendTurn({
198
208
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
199
209
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
200
210
  if (e.code === "delivery_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
211
+ // A Revise to a worker from before Revise turns (the page's gate saw
212
+ // none yet): its words, the text and the chip kept.
213
+ if (e.code === "revise_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
201
214
  toast(cantText("send", e), null, { sticky: true });
202
215
  } finally {
203
- actionBtn.disabled = state().owner === "tui";
216
+ actionBtn.disabled = store.current.owner === "tui";
204
217
  composer.focus();
205
218
  }
206
219
  }