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
@@ -18,6 +18,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
18
18
  import { createChipPopover } from "./chip_popover.js";
19
19
  import { authorColor, participantAuthor } from "./participant_model.js";
20
20
  import { setParticipantNames } from "./participant_names.js";
21
+ import { stillOn } from "./latest.js";
21
22
 
22
23
  // Seen this recently: here now (the chip's dot). The gate writes
23
24
  // last_used_at at most once a minute, so two minutes.
@@ -298,9 +299,10 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
298
299
  onChange();
299
300
  return;
300
301
  }
302
+ const still = stillOn(session, id);
301
303
  try {
302
304
  const body = await api.list(id);
303
- if (id !== session()) return;
305
+ if (!still()) return;
304
306
  grants = Array.isArray(body.participants) ? body.participants : [];
305
307
  working = typeof body.warning === "string" ? body.warning : null;
306
308
  inviteMode = typeof body.mode === "string" ? body.mode : null;
@@ -309,7 +311,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
309
311
  loadedFor = id;
310
312
  if (setParticipantNames(grants)) onNames();
311
313
  } catch {
312
- if (id !== session()) return;
314
+ if (!still()) return;
313
315
  // Another session's list and warning are not this one's.
314
316
  if (loadedFor !== id) {
315
317
  grants = [];
@@ -1,24 +1,20 @@
1
1
  // The project picker opened by the top-bar scope chip. The server supplies
2
- // the project roots and their current/default marks; links carry the view
3
- // without carrying a session hash into another project's list.
2
+ // the project roots and their current/default marks; links never carry a
3
+ // session hash into another project's list.
4
4
 
5
5
  import { html, nothing, render } from "./vendor/lit-html.js";
6
6
  import { allScopeHref, projectScopeHref } from "./scope.js";
7
7
  import { createChipPopover } from "./chip_popover.js";
8
-
9
- function homeHref(search) {
10
- const view = new URLSearchParams(search || "").get("view");
11
- return view ? `/?view=${encodeURIComponent(view)}` : "/";
12
- }
8
+ import { createLatest } from "./latest.js";
13
9
 
14
10
  function sessionCount(count) {
15
11
  const n = Number(count) || 0;
16
12
  return `${n} session${n === 1 ? "" : "s"}`;
17
13
  }
18
14
 
19
- function projectRow(row, search) {
15
+ function projectRow(row) {
20
16
  return html`<a class=${`project-row${row.default ? " default" : ""}${row.current ? " current" : ""}`}
21
- href=${row.default ? homeHref(search) : projectScopeHref(row.dir, "", search)}
17
+ href=${row.default ? "/" : projectScopeHref(row.dir, "")}
22
18
  aria-current=${row.current ? "page" : nothing}>
23
19
  <span class="project-main"><span class="project-name">${row.name}</span
24
20
  >${row.default ? html`<span class="project-default">default</span>` : nothing
@@ -30,14 +26,14 @@ function projectRow(row, search) {
30
26
 
31
27
  // +rows+ is the API's ordered list. When there is no project scope, the
32
28
  // all-projects row is current; a bad ?dir= has no current project to mark.
33
- export function pickerTemplate({ rows = [], loading = false, error = "", search = "", currentDir = null,
29
+ export function pickerTemplate({ rows = [], loading = false, error = "", currentDir = null,
34
30
  allCurrent = false } = {}) {
35
- const allHref = allScopeHref("", currentDir, search);
31
+ const allHref = allScopeHref("", currentDir);
36
32
  return html`<div class="ctx-pop-head"><strong>Projects</strong><button type="button" class="ctx-pop-close"
37
33
  data-act="close" aria-label="Close">×</button></div>
38
34
  ${loading ? html`<div class="project-note" role="status">Loading projects…</div>` : nothing}
39
35
  ${error ? html`<div class="project-note project-error" role="alert">${error}</div>` : nothing}
40
- <nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row, search))}
36
+ <nav class="project-list" aria-label="Projects">${rows.map((row) => projectRow(row))}
41
37
  <a id="projectAll" class=${`project-row project-all${allCurrent ? " current" : ""}`}
42
38
  href=${allHref} aria-current=${allCurrent ? "page" : nothing}>
43
39
  <span class="project-main"><span class="project-name">All projects</span
@@ -47,20 +43,20 @@ export function pickerTemplate({ rows = [], loading = false, error = "", search
47
43
  </nav>`;
48
44
  }
49
45
 
50
- // +scopeError+ returns the page's current folder error, if any. +where+
51
- // supplies the live query string so links retain ?view= without a hash.
52
- export function createProjectPicker({ el, scope, scopeError = () => null, api, where = () => ({ search: "" }),
46
+ // +scopeError+ returns the page's current folder error, if any.
47
+ export function createProjectPicker({ el, scope, scopeError = () => null, api,
53
48
  doc = globalThis.document, win = globalThis.window }) {
54
49
  let rows = [];
55
50
  let loading = false;
56
51
  let error = "";
57
- let loads = 0;
52
+ // Only the newest read is drawn; a close drops the one out (latest.js).
53
+ const latest = createLatest();
58
54
 
59
55
  const currentDir = () => scopeError() ? null : scope.dir || null;
60
56
  const paint = (focus = false) => {
61
57
  const failedScope = !!scopeError();
62
58
  render(pickerTemplate({
63
- rows, loading, error, search: where().search, currentDir: currentDir(),
59
+ rows, loading, error, currentDir: currentDir(),
64
60
  allCurrent: !scope.dir && !failedScope,
65
61
  }), shell.pop);
66
62
  if (focus) {
@@ -71,7 +67,7 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
71
67
  };
72
68
 
73
69
  async function load(focus = false) {
74
- const turn = ++loads;
70
+ const fresh = latest.begin();
75
71
  loading = true;
76
72
  error = "";
77
73
  if (shell.isOpen()) {
@@ -83,14 +79,14 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
83
79
  const dir = currentDir();
84
80
  const answer = await api(`/api/projects${dir ? `?dir=${encodeURIComponent(dir)}` : ""}`);
85
81
  if (!Array.isArray(answer)) throw new Error("the server returned an invalid project list");
86
- if (turn !== loads) return;
82
+ if (!fresh()) return;
87
83
  rows = answer;
88
84
  } catch (e) {
89
- if (turn !== loads) return;
85
+ if (!fresh()) return;
90
86
  error = `Can't read projects: ${e?.message || e}`;
91
87
  rows = [];
92
88
  }
93
- if (turn !== loads) return;
89
+ if (!fresh()) return;
94
90
  loading = false;
95
91
  if (shell.isOpen()) {
96
92
  paint(focus);
@@ -108,7 +104,7 @@ export function createProjectPicker({ el, scope, scopeError = () => null, api, w
108
104
  paint();
109
105
  void load(true);
110
106
  },
111
- onClose: () => { loads += 1; },
107
+ onClose: () => { latest.drop(); },
112
108
  });
113
109
 
114
110
  return { pop: shell.pop, isOpen: shell.isOpen, load };
@@ -91,7 +91,7 @@ export function createQuestionCards({
91
91
  summary.textContent = summaryText(pq);
92
92
  card.appendChild(summary);
93
93
  // A hand toggle: the resolve's auto-collapse then leaves it as the user
94
- // left it (rememberToggle-style, as turn_view.js does).
94
+ // left it (rememberToggle-style, as turn_block.js does).
95
95
  summary.addEventListener("click", () => { card.dataset.toggled = "1"; });
96
96
 
97
97
  // Relayed to the parent: say where else it waits (QuestionDesk#annotate).
@@ -632,7 +632,7 @@ export function createQuestionCards({
632
632
  pendingQuestion: () => pending,
633
633
  renderContinue: renderContinueCard,
634
634
  resolveContinue: resolveContinueCard,
635
- // The turn view was reset: a later offer draws a new card.
635
+ // The running turn's DOM was reset: a later offer draws a new card.
636
636
  forgetContinue() { continueEl = null; },
637
637
  };
638
638
  }
@@ -21,6 +21,8 @@ export function restoreAction(event, { myId, sentIds, composerEmpty }) {
21
21
  own,
22
22
  label: own ? null : clientLabel(origin.client_id),
23
23
  ...(back && event.verify ? { verify: event.verify } : {}),
24
+ // A Revise turn's draft ({turn_id}): its composer chip comes back.
25
+ ...(back && event.revises?.turn_id ? { revises: { turn_id: event.revises.turn_id } } : {}),
24
26
  }, back ? event.images : null);
25
27
  }
26
28
 
@@ -0,0 +1,372 @@
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
+ const STOPPED = ".bubble.cancel";
45
+ const LABEL_CHARS = 120;
46
+ // The stored key of a stub unfolded by hand (answer_fold.js's list).
47
+ export const stubKey = (turnId) => `stub:${turnId}`;
48
+
49
+ // The chains of revisions among +messages+ (the history's openings, in
50
+ // order: {turn_id, revises?: {turn_id}}), each [v1, v2, …, top] by turn id;
51
+ // only chains of two or more. A revises whose target isn't an earlier
52
+ // opening is ignored (a stale page, a rolled-back turn); one whose target
53
+ // was revised already goes on that stack's top (as the server re-targets).
54
+ export function stacks(messages) {
55
+ const order = new Map();
56
+ const revisesOf = new Map();
57
+ for (const m of Array.isArray(messages) ? messages : []) {
58
+ const id = m?.turn_id;
59
+ if (typeof id !== "string" || !id || order.has(id) || m.merged) continue;
60
+ order.set(id, order.size);
61
+ const target = m.revises?.turn_id;
62
+ if (typeof target === "string" && target) revisesOf.set(id, target);
63
+ }
64
+ const next = new Map();
65
+ const prev = new Map();
66
+ for (const [id, at] of order) {
67
+ let target = revisesOf.get(id);
68
+ if (!target || !order.has(target) || order.get(target) >= at) continue;
69
+ while (next.has(target)) target = next.get(target);
70
+ next.set(target, id);
71
+ prev.set(id, target);
72
+ }
73
+ const chains = [];
74
+ for (const id of order.keys()) {
75
+ if (prev.has(id) || !next.has(id)) continue;
76
+ const chain = [id];
77
+ while (next.has(chain.at(-1))) chain.push(next.get(chain.at(-1)));
78
+ chains.push(chain);
79
+ }
80
+ return chains;
81
+ }
82
+
83
+ // A stub's words: "v1 · "‹label›" · 84 lines — superseded by v3".
84
+ export function stubText({ version, label = "", lines = 0, top }) {
85
+ const said = label ? ` · “${label}”` : "";
86
+ const size = lines ? ` · ${lines} line${lines === 1 ? "" : "s"}` : "";
87
+ return `v${version}${said}${size} — superseded by v${top}`;
88
+ }
89
+
90
+ // The version chip's words: "v3 · 2 earlier".
91
+ export function versionChipText(count) {
92
+ return `v${count} · ${count - 1} earlier`;
93
+ }
94
+
95
+ // The composer's chip: "Revising: ‹label›", or the refusal (+unavailable+:
96
+ // the worker predates Revise turns).
97
+ export const REVISE_UNAVAILABLE = "restart this session to use Revise";
98
+
99
+ // The feature gate (app.js featureGates): a live worker without the
100
+ // "revise" feature predates Revise turns (chi web would answer 409
101
+ // revise_unavailable). A worker that isn't up isn't asked: the send wakes a
102
+ // new one, which has it (as Verify's gate, verify_preset.js).
103
+ export function reviseWorkerUnavailable(workerFeatures, bridgeUp) {
104
+ return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("revise");
105
+ }
106
+ export function reviseChipText(label, { unavailable = false } = {}) {
107
+ if (unavailable) return `Revise: ${REVISE_UNAVAILABLE}`;
108
+ return label ? `Revising: ${label}` : "Revising the answer";
109
+ }
110
+
111
+ // The composer's chip (app.js renders it into its own element): the armed
112
+ // Revise's words and ✕ (+onClear+), or nothing when +anchor+ is null.
113
+ export function reviseChipTemplate(anchor, { unavailable = false, onClear } = {}) {
114
+ if (!anchor) return nothing;
115
+ 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>`;
116
+ }
117
+
118
+ // An answer's label (the stub's, the chip's): its first line.
119
+ export function answerLabel(bubble) {
120
+ if (!bubble) return "";
121
+ return foldLabel(bubbleCopyText(bubble), { display: bubble.hasAttribute?.("data-answer-display") });
122
+ }
123
+
124
+ // A prompt bubble's first text line (a version's label in the chip's list).
125
+ function promptLabel(opening) {
126
+ if (!opening) return "";
127
+ const text = opening.matches?.(".bubble.user") ? bubbleCopyText(opening) : opening.querySelector?.(".note-text")?.textContent || "";
128
+ const line = String(text).split("\n").map((l) => l.trim()).find(Boolean) || "";
129
+ return clip(line, LABEL_CHARS);
130
+ }
131
+
132
+ // The history's turns, in order (one pass over #history's children):
133
+ // {id, opening, els (after the opening, up to the next opening or note, a
134
+ // trailing gap divider left to the next), answer (its final answer), wake
135
+ // (a note opened it), stopped (it was canceled or failed)}. Our own rows
136
+ // aren't part of any.
137
+ export function historyTurns(historyEl) {
138
+ const turns = [];
139
+ let turn = null;
140
+ const close = () => {
141
+ if (!turn) return;
142
+ while (turn.els.at(-1)?.classList.contains("time-gap")) turn.els.pop();
143
+ };
144
+ for (const el of historyEl.children) {
145
+ if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS)) continue;
146
+ const opening = el.matches(OPENING) ? el : el.querySelector(OPENING);
147
+ if (opening || el.matches(NOTE_BOUNDARY)) {
148
+ close();
149
+ turn = { id: opening?.dataset.turnId || null, opening: el, prompt: opening || null, els: [], answer: null, wake: !opening };
150
+ turns.push(turn);
151
+ continue;
152
+ }
153
+ if (!turn) continue;
154
+ turn.els.push(el);
155
+ if (el.matches(ANSWERS)) turn.answer = el;
156
+ // A canceled or failed turn's end line (timing.js STOPPED_LINE_CLASS,
157
+ // a live end's too): its partial answer isn't a completed one.
158
+ if (el.matches(STOPPED)) turn.stopped = true;
159
+ }
160
+ close();
161
+ // A wake turn's id is its answer's (timing.js turnGroups keys it).
162
+ for (const t of turns) if (t.wake && t.answer) t.id = t.answer.getAttribute("data-answer-turn") || null;
163
+ return turns;
164
+ }
165
+
166
+ // @param historyEl #history (also its scroller)
167
+ // @param sessionId () => the open session's id
168
+ // @param projectRoot () => its project root (the fold store's sweep), or null
169
+ // @param running () => null when no turn runs, else {turnId} (null id: the
170
+ // newest turn counts as the running one)
171
+ // @param verifyTurn (turnId) => whether that turn was a Verify turn (beyond
172
+ // its prompt's data-verify: a verdict's check_turn_id)
173
+ // @param unavailable () => the worker predates Revise (the feature gate)
174
+ // @param armed () => the turn id the composer's Revise is armed with, or null
175
+ // @param onRevise ({turn_id, label}) the Revise button
176
+ // @return {decorate()} (the observer runs it; app.js after a change it reads)
177
+ export function installReviseStacks({
178
+ historyEl, sessionId, projectRoot = () => null, running = () => null, verifyTurn = () => false,
179
+ unavailable = () => false, armed = () => null, onRevise = () => {},
180
+ }) {
181
+ const stubs = new Map();
182
+ const rows = new Map();
183
+ // Each row's answer bubble (its label at a click).
184
+ const rowAnswer = new WeakMap();
185
+ let observer = null;
186
+
187
+ function stubFor(id, opening, stored) {
188
+ let stub = stubs.get(id);
189
+ if (!stub) {
190
+ stub = historyEl.ownerDocument.createElement("button");
191
+ stub.type = "button";
192
+ stub.className = STUB_CLASS;
193
+ stub.dataset.stackStub = id;
194
+ // The stored choice, once: after that the stub's class is the state
195
+ // (a jump's reveal isn't saved).
196
+ if (stored().includes(stubKey(id))) stub.classList.add("open");
197
+ stubs.set(id, stub);
198
+ }
199
+ if (stub.parentElement !== historyEl || stub.nextElementSibling !== opening) historyEl.insertBefore(stub, opening);
200
+ return stub;
201
+ }
202
+
203
+ function drawStub(stub, { version, top, answer, opening, gap }) {
204
+ const open = stub.classList.contains("open");
205
+ stub.setAttribute("aria-expanded", open ? "true" : "false");
206
+ stub.title = `${open ? "Fold" : "Show"} this earlier version${opening.title ? ` (${opening.title})` : ""}`;
207
+ const text = stubText({ version, label: answerLabel(answer), lines: answer ? lineCount(bubbleCopyText(answer)) : 0, top });
208
+ render(html`<span class="stack-stub-text">${text}</span>${gap ? html`<span class="stack-stub-gap">${gap}</span>` : nothing}`, stub);
209
+ }
210
+
211
+ function rowFor(id, answer) {
212
+ let row = rows.get(id);
213
+ if (!row) {
214
+ row = historyEl.ownerDocument.createElement("div");
215
+ row.className = ROW_CLASS;
216
+ row.dataset.reviseFor = id;
217
+ rows.set(id, row);
218
+ }
219
+ if (answer.nextElementSibling !== row) answer.after(row);
220
+ return row;
221
+ }
222
+
223
+ function drawRow(row, { id, answer, chain, openings }) {
224
+ const off = unavailable();
225
+ const isArmed = armed() === id;
226
+ const versions = chain
227
+ ? 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>`
228
+ : nothing;
229
+ 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>`;
230
+ row.classList.toggle("stacked", !!chain);
231
+ render(html`${versions}${button}`, row);
232
+ rowAnswer.set(row, answer);
233
+ }
234
+
235
+ function decorate() {
236
+ let folds = null;
237
+ const stored = () => (folds ??= readFolded(sessionId()));
238
+ const turns = historyTurns(historyEl);
239
+ const openings = new Map(turns.filter((t) => t.id).map((t) => [t.id, t.prompt || t.opening]));
240
+ const entries = turns.filter((t) => t.id).map((t) => {
241
+ const revises = t.prompt?.getAttribute("data-revises");
242
+ return revises ? { turn_id: t.id, revises: { turn_id: revises } } : { turn_id: t.id };
243
+ });
244
+ const superseded = new Map();
245
+ const tops = new Map();
246
+ for (const chain of stacks(entries)) {
247
+ chain.slice(0, -1).forEach((id, k) => superseded.set(id, { version: k + 1, top: chain.length }));
248
+ tops.set(chain.at(-1), chain);
249
+ }
250
+ const run = running();
251
+ const lastOpening = turns.filter((t) => !t.wake).at(-1);
252
+ const keepStubs = new Set();
253
+ const keepRows = new Set();
254
+ for (const turn of turns) {
255
+ const range = [turn.opening, ...turn.els];
256
+ const place = turn.id && superseded.get(turn.id);
257
+ if (place) {
258
+ const stub = stubFor(turn.id, turn.opening, stored);
259
+ keepStubs.add(turn.id);
260
+ const open = stub.classList.contains("open");
261
+ for (const el of range) {
262
+ const hide = !open && !el.matches(KEPT);
263
+ el.classList.toggle(HIDDEN_CLASS, hide);
264
+ if (el.dataset.stackOf !== turn.id) el.dataset.stackOf = turn.id;
265
+ }
266
+ turn.stub = stub;
267
+ turn.place = place;
268
+ continue;
269
+ }
270
+ for (const el of range) {
271
+ if (el.classList.contains(HIDDEN_CLASS)) el.classList.remove(HIDDEN_CLASS);
272
+ if (el.dataset.stackOf) delete el.dataset.stackOf;
273
+ }
274
+ // The Revise row: an ended turn's final answer, not a Verify turn's,
275
+ // not the running turn's.
276
+ const answer = turn.answer;
277
+ if (!turn.id || turn.wake || !answer || answer.matches(UNFINISHED) || turn.stopped) continue;
278
+ if (turn.prompt?.hasAttribute("data-verify") || verifyTurn(turn.id)) continue;
279
+ if (run && (turn === lastOpening || (run.turnId && run.turnId === turn.id))) continue;
280
+ const row = rowFor(turn.id, answer);
281
+ keepRows.add(turn.id);
282
+ drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
283
+ }
284
+ // A divider between two folded stubs folds into the later one.
285
+ for (const turn of turns) {
286
+ if (!turn.stub) continue;
287
+ const gap = turn.stub.previousElementSibling;
288
+ let gapText = "";
289
+ if (gap?.classList.contains("time-gap")) {
290
+ let before = gap.previousElementSibling;
291
+ while (before?.classList.contains(HIDDEN_CLASS)) before = before.previousElementSibling;
292
+ const fold = !turn.stub.classList.contains("open") && !!before?.matches(`.${STUB_CLASS}:not(.open)`);
293
+ gap.classList.toggle(GAP_HIDDEN_CLASS, fold);
294
+ if (fold) gapText = gap.textContent.replace(/[─\s]+$/u, "").replace(/^[─\s]+/u, "");
295
+ }
296
+ drawStub(turn.stub, { ...turn.place, answer: turn.answer, opening: turn.prompt || turn.opening, gap: gapText });
297
+ }
298
+ for (const gap of historyEl.querySelectorAll(`:scope > .${GAP_HIDDEN_CLASS}`)) {
299
+ if (!gap.nextElementSibling?.matches(`.${STUB_CLASS}:not(.open)`)) gap.classList.remove(GAP_HIDDEN_CLASS);
300
+ }
301
+ for (const [id, stub] of stubs) if (!keepStubs.has(id) || stub.parentElement !== historyEl) { stub.remove(); stubs.delete(id); }
302
+ for (const [id, row] of rows) if (!keepRows.has(id) || row.parentElement !== historyEl) { row.remove(); rows.delete(id); }
303
+ // A stale row left by a re-render (its map entry gone with the nodes).
304
+ for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}`)) {
305
+ if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el) el.remove();
306
+ }
307
+ // What we changed isn't news.
308
+ observer?.takeRecords();
309
+ }
310
+
311
+ function setOpen(stub, open, { save }) {
312
+ keepRowInView(historyEl, stub, () => {
313
+ stub.classList.toggle("open", open);
314
+ decorate();
315
+ });
316
+ if (save) writeFold(sessionId(), stubKey(stub.dataset.stackStub), open, { root: projectRoot() });
317
+ }
318
+
319
+ // Changes from elsewhere: a direct child added or removed, a stamp, an
320
+ // answer's key or its end (its class).
321
+ observer = new MutationObserver((records) => {
322
+ const news = records.some((r) => (r.type === "childList" ? r.target === historyEl
323
+ && [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && !n.classList.contains(STUB_CLASS) && !n.classList.contains(ROW_CLASS))
324
+ : r.attributeName !== "class" || r.target.matches?.(`.bubble.output, .${STUB_CLASS}`)));
325
+ if (news) decorate();
326
+ });
327
+ observer.observe(historyEl, { childList: true, subtree: true, attributes: true, attributeFilter: ["class", "data-revises", "data-answer-turn", "data-verify"] });
328
+ decorate();
329
+
330
+ historyEl.addEventListener("click", (e) => {
331
+ const stub = e.target.closest?.(`.${STUB_CLASS}`);
332
+ if (stub && stub.parentElement === historyEl) {
333
+ e.preventDefault();
334
+ setOpen(stub, !stub.classList.contains("open"), { save: true });
335
+ return;
336
+ }
337
+ const pick = e.target.closest?.("[data-stack-pick]");
338
+ if (pick) {
339
+ e.preventDefault();
340
+ pick.closest("details")?.removeAttribute("open");
341
+ const target = stubs.get(pick.dataset.stackPick);
342
+ if (!target) return;
343
+ if (!target.classList.contains("open")) setOpen(target, true, { save: false });
344
+ target.scrollIntoView?.({ block: "start", behavior: "smooth" });
345
+ target.focus?.({ preventScroll: true });
346
+ return;
347
+ }
348
+ const button = e.target.closest?.("[data-revise]");
349
+ const row = button?.closest(`.${ROW_CLASS}`);
350
+ if (!button || !row || button.disabled) return;
351
+ e.preventDefault();
352
+ onRevise({ turn_id: button.dataset.revise, label: answerLabel(rowAnswer.get(row)) });
353
+ });
354
+
355
+ return { decorate };
356
+ }
357
+
358
+ // The stub that hides +el+ (a superseded turn's element, or inside one),
359
+ // opened (not saved); +el+ or null. history_reveal.js runs it on a jump.
360
+ export function openStubOver(el) {
361
+ const top = el?.closest?.(`.${HIDDEN_CLASS}`);
362
+ const id = top?.dataset.stackOf;
363
+ const parent = top?.parentElement;
364
+ if (!id || !parent) return el ?? null;
365
+ const stub = [...parent.children].find((c) => c.classList.contains(STUB_CLASS) && c.dataset.stackStub === id);
366
+ stub?.classList.add("open");
367
+ stub?.setAttribute("aria-expanded", "true");
368
+ for (const hidden of parent.querySelectorAll(`:scope > .${HIDDEN_CLASS}`)) {
369
+ if (hidden.dataset.stackOf === id) hidden.classList.remove(HIDDEN_CLASS);
370
+ }
371
+ return el;
372
+ }
@@ -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}`;