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
@@ -105,7 +105,7 @@ export function setBubbleDelivery(bubble, delivery) {
105
105
  // @param appendToHistory (el) puts +el+ at the history's end (or the stage's,
106
106
  // while it holds a turn), above the live timing line
107
107
  // @param isNearBottom () => whether the history follows its end
108
- // @param turnDom the running turn's view (stage_view.js / turn_view.js)
108
+ // @param turnDom the running turn's DOM (stage_view.js)
109
109
  // @param findInTurnRoots (selector) => the matching elements in the history
110
110
  // and the stage
111
111
  // @param shownTexts the running turn's texts on the page (shown_texts.js)
@@ -149,7 +149,7 @@ export function createBubbles({
149
149
 
150
150
  function addEndBubble(text, { stopped = false } = {}) {
151
151
  appendToHistory(elementOf(endBubbleTemplate(text, { stopped })));
152
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
152
+ if (!turnDom.holds()) historyEl.scrollTop = historyEl.scrollHeight;
153
153
  }
154
154
 
155
155
  // ── Prompt bubbles ───────────────────────────────────────────────────────
@@ -239,7 +239,7 @@ export function createBubbles({
239
239
  const details = elementOf(recapTemplate(text, turnsSince, createdAt));
240
240
  // A stale one sits where it was written, above the turns it doesn't cover.
241
241
  // Never in the stage: a recap is between turns.
242
- turnDom.settleFold?.();
242
+ turnDom.settleFold();
243
243
  const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
244
244
  // Above that turn's gap divider too: the recap was written before the quiet stretch ended.
245
245
  const before = prompt?.previousElementSibling?.matches(`.${TIME_GAP_CLASS}`) ? prompt.previousElementSibling : prompt;
@@ -10,9 +10,10 @@
10
10
  import { getSession, sendCommand } from "./data.js";
11
11
  import { cardPlace, drawCard, isCard, isNoticeCard, refreshCardBody, turnNoticePlace } from "./card.js";
12
12
  import { elementOf } from "./turn_html.js";
13
- import { stepText } from "./turn_view.js";
13
+ import { stepText } from "./turn_block.js";
14
14
  import { llmContextNotice, noticeLine } from "./turn_events.js";
15
15
  import { hookNoticeTemplate, TURN_PROMPT, turnPromptBubbles } from "./bubbles.js";
16
+ import { stillOn } from "./latest.js";
16
17
 
17
18
  // The elements of the reloaded turn that ends before +before+ (the next
18
19
  // turn's prompt, or null for the last turn), its prompt first.
@@ -145,10 +146,11 @@ export function createCardsPlace({
145
146
  function refreshCardBodies() {
146
147
  clearTimeout(cardBodiesTimer);
147
148
  const id = selected();
149
+ const still = stillOn(selected, id);
148
150
  cardBodiesTimer = setTimeout(async () => {
149
151
  try {
150
152
  const data = await api.getSession(id, { cards: true });
151
- if (id !== selected()) return;
153
+ if (!still()) return;
152
154
  for (const card of data.cards || []) {
153
155
  const el = card.type === "card" && cardElement(card.id);
154
156
  if (el) refreshCardBody(el, card);
@@ -24,6 +24,14 @@ body.no-glass .composer-card{background:var(--bg-panel);-webkit-backdrop-filter:
24
24
  body.has-session #composer{flex-wrap:wrap;row-gap:6px}
25
25
  #chips{display:flex;flex-wrap:wrap;gap:6px}
26
26
  #chips[hidden]{display:none}
27
+ /* The armed Revise (revise_stack.js reviseChipTemplate): "Revising: ‹first line› ✕". */
28
+ #reviseChip{display:flex;min-width:0}
29
+ #reviseChip[hidden]{display:none}
30
+ .revise-chip{display:inline-flex;align-items:center;gap:6px;max-width:100%;min-width:0;padding:2px 4px 2px 10px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-bg);color:var(--text-2);font-size:12px;line-height:18px}
31
+ .revise-chip.refused{border-color:var(--warn-border);background:var(--warn-bg);color:var(--warn-text)}
32
+ .revise-chip-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
33
+ .revise-chip-clear{flex:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;min-height:0;padding:0;border:0;border-radius:999px;background:transparent;color:var(--text-muted);font-size:11px;cursor:pointer}
34
+ .revise-chip-clear:hover{background:var(--bg-raised);color:var(--text-bright);filter:none}
27
35
  .chip{display:inline-flex;align-items:center;gap:6px;background:var(--accent-bg);border:1px solid var(--border-strong);border-radius:8px;padding:3px 4px 3px 3px;font-size:12px;max-width:220px}
28
36
  .chip img{width:32px;height:32px;object-fit:cover;border-radius:5px}
29
37
  .chip .chip-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -11,6 +11,7 @@ import { html, nothing, render } from "./vendor/lit-html.js";
11
11
  import { keepFocus } from "./keep_focus.js";
12
12
  import { popoverPlace } from "./chip_popover.js";
13
13
  import { cantText } from "./format.js";
14
+ import { createLatest, stillOn } from "./latest.js";
14
15
 
15
16
  // "now", "3m", "2h", "4d" since an ISO time; "" for none.
16
17
  export function ageText(iso, now = Date.now()) {
@@ -106,7 +107,8 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
106
107
  let rows = [];
107
108
  let canAdd = false;
108
109
  let openName = null;
109
- let loadSeq = 0;
110
+ // Only the newest list read for the open session is drawn (latest.js).
111
+ const latest = createLatest(() => sessionId);
110
112
  // The open row's text once read (view text), and the "+ URL" form's
111
113
  // state ({error, busy}).
112
114
  let text = null;
@@ -140,15 +142,15 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
140
142
  async function refresh() {
141
143
  const id = sessionId;
142
144
  if (!id) return;
143
- const seq = ++loadSeq;
145
+ const fresh = latest.begin();
144
146
  try {
145
147
  const data = await api.list(id);
146
- if (seq !== loadSeq || id !== sessionId) return;
148
+ if (!fresh()) return;
147
149
  rows = data.sources || [];
148
150
  canAdd = data.can_add_url === true;
149
151
  } catch {
150
152
  // A session the server doesn't know (gone, or an older chi web): no chips.
151
- if (seq !== loadSeq || id !== sessionId) return;
153
+ if (!fresh()) return;
152
154
  rows = [];
153
155
  canAdd = false;
154
156
  }
@@ -234,15 +236,16 @@ export function createContextChips({ el, api, toast = () => {}, doc = document,
234
236
  const url = form.elements.url.value.trim();
235
237
  const why = form.elements.why.value.trim();
236
238
  if (!id || !url) return;
239
+ const still = stillOn(() => sessionId);
237
240
  drawAdd({ busy: true });
238
241
  try {
239
242
  const data = await api.add(id, { url, why: why || undefined });
240
- if (id !== sessionId) return;
243
+ if (!still()) return;
241
244
  close();
242
245
  await refresh();
243
246
  toast(`Attached ${data.name}: the agent gets a note before your next message`);
244
247
  } catch (e) {
245
- if (id !== sessionId || openName !== ADD) return;
248
+ if (!still() || openName !== ADD) return;
246
249
  // The same form again: what the user typed stays; the URL gets focus.
247
250
  drawAdd({ busy: false, error: e.message });
248
251
  pop.querySelector('input[name="url"]')?.focus();
@@ -130,7 +130,7 @@ export function uploadImage(id, file, name, opts = {}) {
130
130
  // (mergeTiming), and opts.turnId asks for that turn's answer.
131
131
  // opts.timing: the whole timing alone (when the merge came up short).
132
132
  // opts.parts: each saved message with what it did (thinking, tool calls
133
- // and their output), for the turn view's reload.
133
+ // and their output), for the turn block's reload.
134
134
  export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...opts } = {}) {
135
135
  let query = cards ? "?cards=1" : tail ? "?tail=1" : timing ? "?timing=1" : parts ? "?parts=1" : "";
136
136
  if (tail && recent) query += "&recent=1";
@@ -144,12 +144,14 @@ export function getSession(id, { tail, recent, turnId, timing, parts, cards, ...
144
144
  // image-only send, whose text is only its [image: …] placeholders).
145
145
  // delivery: what the message does to a running turn ("cut": /cut TEXT);
146
146
  // none = the server's default (the next step).
147
- export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, verify = null, ...opts } = {}) {
147
+ export function sendTurn(id, prompt, { clientId, images, history = true, delivery = null, verify = null, revises = null, ...opts } = {}) {
148
148
  const body = clientId ? { prompt, client_id: clientId } : { prompt };
149
149
  if (images?.length) body.images = images;
150
150
  if (delivery) body.delivery = delivery;
151
151
  if (history === false) body.history = false;
152
152
  if (verify) body.verify = verify;
153
+ // A Revise turn's draft: {turn_id} of the answer it rewrites.
154
+ if (revises) body.revises = revises;
153
155
  return api(`/api/sessions/${id}/turn`, {
154
156
  method: "POST",
155
157
  body: JSON.stringify(body),
@@ -21,7 +21,7 @@ import { toastBottom } from "./toast_place.js";
21
21
  // model panel (toast_place.js); with no card in sight (the start page, all
22
22
  // sessions) the CSS's 24 px from the bottom (--toast-bottom unset).
23
23
  //
24
- // +stageEl+: the stage (stage view) or null (turn view). +atHistoryEnd()+:
24
+ // +stageEl+: the stage (stage_view.js). +atHistoryEnd()+:
25
25
  // the reader is at the history's end (app.js, scroll.js's threshold).
26
26
  // +reduce()+ / +ease+: prefers-reduced-motion and the eased follow
27
27
  // (scroll.js), defaults the page's.
@@ -37,7 +37,7 @@ export function createDockReserve({
37
37
  const win = doc.defaultView;
38
38
 
39
39
  function dockReserve() {
40
- const shown = stageEl && !stageEl.hidden && !stageEl.classList.contains("leaving");
40
+ const shown = !stageEl.hidden && !stageEl.classList.contains("leaving");
41
41
  return dockEl.offsetHeight + (shown ? stageEl.offsetHeight + STAGE_GAP : 0);
42
42
  }
43
43
  const historyEnd = () => historyEl.scrollHeight - historyEl.clientHeight;
@@ -48,7 +48,7 @@ export function createDockReserve({
48
48
  // counts as at the end.
49
49
  function updateReserve({ fromStage = false } = {}) {
50
50
  const follow = atHistoryEnd() || historyEl.dataset.following !== undefined;
51
- const leaving = !!stageEl?.classList.contains("leaving");
51
+ const leaving = stageEl.classList.contains("leaving");
52
52
  centerEl.classList.toggle("reserve-easing", leaving);
53
53
  const reserve = `${dockReserve()}px`;
54
54
  const changed = centerEl.style.getPropertyValue("--dock-h") !== reserve;
@@ -83,18 +83,16 @@ export function createDockReserve({
83
83
 
84
84
  const dockObserver = new win.ResizeObserver((entries) => updateReserve({ fromStage: entries.some((e) => e.target === stageEl) }));
85
85
  dockObserver.observe(dockEl);
86
- if (stageEl) {
87
- dockObserver.observe(stageEl);
88
- // Only showing, hiding and leaving change the reserve (not the stage's
89
- // other classes: waiting, in-use…).
90
- let stageShown = "";
91
- new win.MutationObserver(() => {
92
- const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
93
- if (shown === stageShown) return;
94
- stageShown = shown;
95
- updateReserve({ fromStage: true });
96
- }).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
97
- }
86
+ dockObserver.observe(stageEl);
87
+ // Only showing, hiding and leaving change the reserve (not the stage's
88
+ // other classes: waiting, in-use…).
89
+ let stageShown = "";
90
+ new win.MutationObserver(() => {
91
+ const shown = `${stageEl.hidden}|${stageEl.classList.contains("leaving")}`;
92
+ if (shown === stageShown) return;
93
+ stageShown = shown;
94
+ updateReserve({ fromStage: true });
95
+ }).observe(stageEl, { attributes: true, attributeFilter: ["class", "hidden"] });
98
96
  win.addEventListener("resize", () => placeToast());
99
97
  // The model panel opening, closing or changing height (its list filters).
100
98
  new win.ResizeObserver(() => placeToast()).observe(modelPanelEl);
@@ -0,0 +1,33 @@
1
+ // The open session's feature gates: a page action that its worker must
2
+ // support (its sidecar features, a live bridge), and the redraw that
3
+ // follows when that flips. Registered with the session store as its
4
+ // `featureGates` redraw (session_store.js ORDER): it runs whenever a field
5
+ // it reads changes, and a gate's own redraw only when its answer flipped.
6
+ //
7
+ // A session switch (forgetSession) and a full load (renderSessionView) draw
8
+ // every card with the gate's answer already: they prime() the gates, so the
9
+ // redraw that follows their write sees no flip. A write anywhere else (a
10
+ // restart's hub event bringing the feature, the worker going) fires it.
11
+ //
12
+ // A new module: the page's modules are cached at unversioned URLs, and a
13
+ // new export of an old module would leave a cached copy without it.
14
+
15
+ // @param entries [{name, unavailable(current) → bool, redraw()}]
16
+ // @return {prime(current), check(current)}: prime takes each gate's answer
17
+ // without redrawing; check redraws the gates whose answer flipped since
18
+ export function createFeatureGates(entries) {
19
+ const last = new Map();
20
+ return {
21
+ prime(current) {
22
+ for (const gate of entries) last.set(gate.name, !!gate.unavailable(current));
23
+ },
24
+ check(current) {
25
+ for (const gate of entries) {
26
+ const now = !!gate.unavailable(current);
27
+ const flipped = last.has(gate.name) && last.get(gate.name) !== now;
28
+ last.set(gate.name, now);
29
+ if (flipped) gate.redraw();
30
+ }
31
+ },
32
+ };
33
+ }
@@ -1,20 +1,24 @@
1
1
  // The one way a jump into the history shows what it lands on: whatever
2
2
  // hides +el+ unfolds: a closed <details> around it (a turn's steps block, a
3
3
  // step, a diff), an answer folded by hand (answer_fold.js: its row follows
4
- // the bubble's class; the jump's unfold isn't saved). Every jump goes through it (claim_link.js
5
- // showClaim, refs.js showRow and so the working set's step tags,
6
- // verdict_card.js "View check"), so a new way to hide history is opened in
7
- // one place.
4
+ // the bubble's class; the jump's unfold isn't saved), a superseded version
5
+ // of a Revise stack (revise_stack.js: its stub opens, not saved). Every
6
+ // jump goes through it (claim_link.js showClaim, refs.js showRow and so the
7
+ // working set's step tags, verdict_card.js "View check"), so a new way to
8
+ // hide history is opened in one place.
8
9
  //
9
10
  // Pure DOM, so it runs under `node --test` with happy-dom. A module of its
10
11
  // own: static JS is cached at unversioned URLs, and a new export on an old
11
12
  // module would blank the page.
12
13
 
14
+ import { openStubOver } from "./revise_stack.js";
15
+
13
16
  // Opens each closed <details> that holds +el+ (not +el+ itself: a fold the
14
17
  // caller means to open it opens itself) and unfolds the folded answer that
15
18
  // is or holds it. +el+, or null.
16
19
  export function revealInHistory(el) {
17
20
  if (!el) return null;
21
+ openStubOver(el);
18
22
  el.closest?.(".bubble.output.folded")?.classList.remove("folded");
19
23
  for (let details = el.parentElement?.closest("details"); details; details = details.parentElement?.closest("details")) {
20
24
  details.open = true;
@@ -57,9 +57,6 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
57
57
  /* A generation opens its answer bubble empty: hidden until text streams (the thinking box shows progress meanwhile). */
58
58
  .bubble.output.streaming:empty{display:none}
59
59
  /* The answer's pop: the promoted bubble is held at the narration box's height (inline max-height, scrolled to its tail) until the rendered markdown lands, then grows to its content while the content fades in; both classes go 400 ms later. */
60
- .bubble.output.boxed{overflow:hidden;transition:max-height 360ms ease}
61
- .bubble.output.revealing>:not(.copy-btn){animation:fadeIn 240ms ease}
62
- @media (prefers-reduced-motion:reduce){.bubble.output.boxed{transition:none}.bubble.output.revealing>:not(.copy-btn){animation:none}}
63
60
  /* A long answer folded by hand (answer_fold.js): the row is the bubble's sibling just before it, styled as a closed turn's steps; folded, the bubble is hidden and the row reads its first line and size; unfolded, a slim "▾ N lines" header close above it. */
64
61
  .bubble.output.folded{display:none}
65
62
  .answer-fold{display:flex;align-items:baseline;gap:0;align-self:flex-start;max-width:100%;min-width:0;margin:0;padding:3px 10px;font:inherit;font-size:12px;font-weight:500;line-height:1.4;color:var(--text-muted);text-align:left;cursor:pointer;user-select:none;background:transparent;border:1px solid transparent;border-left:3px solid var(--accent-strong);border-radius:6px;transition:background .12s ease}
@@ -71,6 +68,37 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
71
68
  .answer-fold-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-2);font-size:13px}
72
69
  .answer-fold-label:empty{display:none}
73
70
  .answer-fold-size{flex:none;white-space:nowrap;font-variant-numeric:tabular-nums}
71
+ /* Revise stacks (revise_stack.js): a superseded version folds to its stub; the Revise row under a final answer
72
+ (zero height: its button sits on the bubble's bottom edge, clear of the copy button, shown on hover) and a stack
73
+ top's version chip. */
74
+ .stack-hidden,.time-gap.stack-gap{display:none!important}
75
+ .stack-stub{display:flex;align-items:baseline;gap:8px;align-self:stretch;max-width:100%;min-width:0;margin:0;padding:4px 10px;font:inherit;font-size:12px;line-height:1.4;color:var(--text-muted);text-align:left;cursor:pointer;user-select:none;background:transparent;border:1px dashed var(--border-strong);border-radius:8px;transition:background .12s ease}
76
+ .stack-stub:hover{background:var(--bg-panel)}
77
+ .stack-stub:focus-visible{outline:2px solid var(--accent-ring);outline-offset:1px}
78
+ .stack-stub::before{content:"▸";color:var(--accent);flex:none}
79
+ .stack-stub.open::before{content:"▾"}
80
+ .stack-stub.open{border-style:solid;border-color:transparent;border-left:3px solid var(--border-strong);border-radius:6px}
81
+ .stack-stub-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
82
+ .stack-stub-gap{flex:none;margin-left:auto;color:var(--time-gap);white-space:nowrap;font-variant-numeric:tabular-nums}
83
+ .revise-row{position:relative;height:0;margin-top:-12px;display:flex;justify-content:flex-end;align-items:center;gap:8px}
84
+ .revise-row.stacked{height:auto;margin-top:-6px;justify-content:flex-start}
85
+ .revise-btn{min-height:0;height:22px;padding:0 8px;border-radius:6px;font-size:11px;line-height:20px;background:var(--bg-raised);border:1px solid var(--border-strong);color:var(--text-muted);cursor:pointer;opacity:0;transition:opacity .12s ease,color .12s ease}
86
+ .revise-row:not(.stacked) .revise-btn{position:absolute;right:14px;top:-10px;z-index:1;height:20px;line-height:18px}
87
+ .revise-btn:hover:not(:disabled){color:var(--text-bright);filter:none}
88
+ .revise-btn:disabled{cursor:default}
89
+ .revise-btn.armed,.revise-btn.armed:hover:not(:disabled){opacity:1;color:var(--on-solid);background:var(--accent-strong);border-color:var(--accent-strong)}
90
+ .bubble.output:hover+.revise-row .revise-btn,.revise-row:hover .revise-btn,.revise-row.stacked .revise-btn,.revise-btn:focus-visible{opacity:1}
91
+ @media (hover:none){.revise-btn{opacity:.55}}
92
+ .stack-versions{position:relative;font-size:12px;color:var(--text-muted)}
93
+ .stack-versions>summary{list-style:none;cursor:pointer;padding:1px 8px;border-radius:999px;border:1px solid var(--accent-border);background:var(--accent-bg);color:var(--text-2);white-space:nowrap;font-variant-numeric:tabular-nums}
94
+ .stack-versions>summary::-webkit-details-marker{display:none}
95
+ .stack-versions>summary::after{content:"\00a0▾";color:var(--accent)}
96
+ .stack-versions[open]>summary::after{content:"\00a0▴"}
97
+ .stack-versions-list{position:absolute;z-index:5;top:calc(100% + 4px);left:0;display:flex;flex-direction:column;min-width:240px;max-width:min(520px,calc(100vw - 32px));padding:4px;border-radius:8px;border:1px solid var(--border-strong);background:var(--bg-raised);box-shadow:var(--toast-shadow)}
98
+ .stack-version{display:block;min-height:0;padding:4px 8px;border:0;border-radius:6px;background:transparent;color:var(--text-2);font:inherit;font-size:12px;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
99
+ .stack-version:hover:not(:disabled){background:var(--bg-hover);filter:none}
100
+ .stack-version.current{color:var(--text-muted);cursor:default}
101
+ .stack-version-n{font-weight:600;color:var(--accent);font-variant-numeric:tabular-nums}
74
102
  /* A gap divider (time_gaps.js): one centred dim line where a quiet stretch passed, never wrapped (a phone at 360 px keeps it on one line). Not a .bubble. */
75
103
  .time-gap{align-self:stretch;text-align:center;color:var(--time-gap);font-size:12px;line-height:1.4;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:2px 0;user-select:none}
76
104
  .turn-timing{margin-top:8px;color:var(--text-muted);font-size:11px;font-variant-numeric:tabular-nums}
@@ -286,7 +314,7 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
286
314
  .bubble.thinking>summary::before{content:"▸ ";color:var(--think)}
287
315
  .bubble.thinking[open]>summary::before{content:"▾ "}
288
316
  .thinking-body{margin-top:6px;font-size:13px;color:var(--text-3);white-space:pre-wrap;word-break:break-word;font-style:italic;max-height:240px;overflow:auto}
289
- /* Turn view (web.view: turn, and inside the stage): one block per turn, its steps as collapsed rows, the live one open at the bottom. */
317
+ /* The turn block (the stage's cloud, the history): one block per turn, its steps as collapsed rows, the live one open on top. */
290
318
  .turn-work{border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;background:var(--bg-panel);padding:8px 12px;font-size:14px}
291
319
  .turn-work>summary{cursor:pointer;color:var(--text-muted);font-size:13px;font-weight:600;user-select:none;list-style:none}
292
320
  .turn-work>summary::-webkit-details-marker{display:none}
@@ -303,7 +331,7 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
303
331
  .gen>summary::-webkit-details-marker{display:none}
304
332
  .gen>summary::before{content:"▸ ";color:var(--text-faint)}
305
333
  .gen[open]>summary::before{content:"▾ "}
306
- /* Open, a step whose label starts with its body's first words (turn_view.js labelParts) says only its calls. */
334
+ /* Open, a step whose label starts with its body's first words (turn_block.js labelParts) says only its calls. */
307
335
  .gen[open]>summary.repeats>:is(.gen-head,.gen-sep){display:none}
308
336
  /* A step with a failed call (status error): its chevron in the danger colour. */
309
337
  .gen.has-failed>summary::before{color:var(--danger)}
@@ -327,7 +355,7 @@ button.system-one-wrong:hover:not(:disabled){background:var(--bg-hover);color:va
327
355
  .gen details.thinking summary.thinking-fade{animation:thinking-fade 120ms ease-out}
328
356
  @keyframes thinking-fade{from{opacity:.4}to{opacity:1}}
329
357
  @media (prefers-reduced-motion:reduce){.gen details.thinking summary.thinking-fade{animation:none}}
330
- /* A finished block's outline (turn_view.js outlineBlock): open on its narrated steps' rows; a run of steps that said
358
+ /* A finished block's outline (turn_block.js outlineBlock): open on its narrated steps' rows; a run of steps that said
331
359
  nothing is one "· N steps" fold, a row like a step's, that shows them. */
332
360
  .turn-work.outline{--step-indent:14px}
333
361
  .turn-work.outline>.gen.folded:not(.unfolded){display:none}
@@ -676,9 +704,9 @@ body.has-session.fresh-link #composer .actions-col .model-pick:not([hidden]){dis
676
704
  #commandList .command-desc{flex:1;min-width:0;font-size:13px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
677
705
  #commandList .command-note{font-size:11px;color:var(--text-faint);white-space:nowrap}
678
706
  @media(max-width:600px){#commandList{left:6px;width:calc(100% - 12px)}#commandList .command-option{flex-wrap:wrap;row-gap:0;padding:8px 10px}#commandList .command-desc{flex-basis:100%;order:3}}
679
- /* The stage view (web.view: stage, stage_view.js): the running turn as the dock card's top section, pinned above the composer.
707
+ /* The stage (stage_view.js): the running turn as the dock card's top section, pinned above the composer.
680
708
  Fixed slots (status row, prompt line, a two-line headline, the tool row, a one-line trail) never change height while the
681
- turn runs; cards and rows that arrive meanwhile go in their own capped scroller (extras); the cloud is the turn view's
709
+ turn runs; cards and rows that arrive meanwhile go in their own capped scroller (extras); the cloud is the turn
682
710
  block behind a chip of ticks. --stage-max (stage_view.js fitCap) keeps it inside the session panel. */
683
711
  /* D2: its own glass card above the composer card (as a section of one card the composer's focus ring wrapped the turn too):
684
712
  in the stage view #dock is a clear column holding the stage and .dock-card (the composer and its footer, stage_view.js). */
@@ -791,7 +819,7 @@ body.stage-view .turn-stage{position:absolute;left:0;right:0;bottom:100%;margin-
791
819
  .ts-answer{display:flex;flex-direction:column;gap:8px}
792
820
  .ts-answer>.bubble.output{border-left:0;background:transparent;border:0;padding:0}
793
821
  .ts-answer>.bubble.output>.copy-btn{top:0;right:0}
794
- /* The cloud: one chip (steps, calls, a tick per call), the turn view's block behind it. */
822
+ /* The cloud: one chip (steps, calls, a tick per call), the turn block behind it. */
795
823
  /* The footer: the chip at the stage's bottom edge (the cloud's steps open above it, the scroller's last slot). */
796
824
  .ts-foot{position:relative;flex:none;padding:0 14px 10px}
797
825
  /* The question room's jump pill (stage_view.js drawPill): at the scroller's bottom edge, above the chip, while the waiting
@@ -1654,6 +1682,7 @@ a{color:var(--accent)}
1654
1682
  </div>
1655
1683
  <div id="deliveryLine" class="composer-line" aria-live="off" hidden></div>
1656
1684
  <div class="compose-col">
1685
+ <div id="reviseChip" hidden></div>
1657
1686
  <div id="chips" hidden></div>
1658
1687
  <textarea id="prompt" placeholder="Ask chi anything…" aria-controls="commandList" aria-autocomplete="list" aria-expanded="false"></textarea>
1659
1688
  </div>
@@ -0,0 +1,46 @@
1
+ // Stale-answer guards: an answer that comes back after an await is applied
2
+ // only while what it was asked for is still what the page shows. The page
3
+ // switches sessions, reopens a popover or asks again while a fetch is out;
4
+ // an answer for the old ask must be dropped, not drawn over the new one.
5
+ //
6
+ // Two shapes (docs/internals/web-frontend.md, "Stale answers"):
7
+ // - stillOn(get): the thing asked about (the open session's id) hasn't
8
+ // changed. For a one-off read: the same session may ask twice and both
9
+ // answers are good.
10
+ // - createLatest(get): also only the newest ask counts. For a list that is
11
+ // re-read (a refresh while a refresh is out): an older answer landing
12
+ // last would draw the older list.
13
+ //
14
+ // const latest = createLatest(() => sessionId);
15
+ // async function refresh() {
16
+ // const fresh = latest.begin();
17
+ // const data = await api.list(sessionId);
18
+ // if (!fresh()) return;
19
+ // …
20
+ // }
21
+
22
+ // @param get () => the key the answer is for (the open session's id)
23
+ // @param at the key asked for (default: get() now)
24
+ // @return () => true while get() is still +at+
25
+ export function stillOn(get, at = get()) {
26
+ return () => get() === at;
27
+ }
28
+
29
+ // @param get () => the key the asks are for (default: none, the newest ask
30
+ // alone decides)
31
+ // @return {begin(), drop()}: begin() starts an ask and gives its guard,
32
+ // () => true while it is the newest ask and get() is unchanged; drop()
33
+ // makes every ask out stale (a reset)
34
+ export function createLatest(get = () => null) {
35
+ let seq = 0;
36
+ return {
37
+ begin() {
38
+ const mine = ++seq;
39
+ const same = stillOn(get);
40
+ return () => mine === seq && same();
41
+ },
42
+ drop() {
43
+ seq += 1;
44
+ },
45
+ };
46
+ }
@@ -19,7 +19,7 @@
19
19
  // refresh() fetch the list (no events stream)
20
20
  // select(id) open a session
21
21
  // confirmDelete(id), setArchived(id, archive), archiveOne(id, archive)
22
- // updateInfoBar(), updateSessionTitle(), showToast(text, action)
22
+ // updateSessionTitle(), showToast(text, action)
23
23
  // allViewOpen(), openAllView(), leaveAllView(hash)
24
24
  // renameShown(owner) whether a card's title has its ✎ (session_title.js)
25
25
  // onRename() the active card's ✎ clicked (session_rename.js open)
@@ -231,7 +231,7 @@ function draw(el, template) {
231
231
  export function createListsView(deps) {
232
232
  const {
233
233
  doc, win, topStripEl, allListEl, sessions, selected, scope, scopeError, eventsLive, refresh, select,
234
- confirmDelete, setArchived, archiveOne, updateInfoBar, updateSessionTitle, showToast,
234
+ confirmDelete, setArchived, archiveOne, updateSessionTitle, showToast,
235
235
  allViewOpen, openAllView, leaveAllView, renameShown = () => false, onRename = () => {}, renaming = () => false,
236
236
  } = deps;
237
237
  const $ = (s) => doc.querySelector(s);
@@ -302,18 +302,25 @@ export function createListsView(deps) {
302
302
  // is the cards drawn, which select mode picks from.
303
303
  const drawnOrder = new Map(); // list element → card ids in the order last drawn
304
304
  const heldLists = new Set(); // lists drawn in a held order
305
+ // Lists whose last draw was hidden (the strip under the all view, which
306
+ // the store's list write draws as the view opens): that order was never
307
+ // seen, so a hover that stuck on the strip doesn't hold it afterwards.
308
+ const drawnHidden = new Set();
305
309
  const canHover = win.matchMedia?.("(hover: hover)").matches ?? false;
306
310
  function listOrder(el, next, idOf = (s) => s.id) {
307
311
  // The strip's popover holds it too (the pointer leaves the strip for it,
308
312
  // and a touch screen has no hover at all), and so does a rename typed
309
313
  // over its active card (the card doesn't jump away under the input).
310
- const holding = el.getClientRects().length > 0
314
+ const shown = el.getClientRects().length > 0;
315
+ const holding = shown
311
316
  && ((canHover && el.matches(":hover")) || (el === topStripEl && (familyPop.isOpen() || renaming())) || chainPopIn(el));
312
- const held = holding ? drawnOrder.get(el) : null;
317
+ const held = holding && !drawnHidden.has(el) ? drawnOrder.get(el) : null;
313
318
  const ordered = held ? heldOrder(held, next, idOf) : next;
314
319
  if (held && ordered.some((x, i) => idOf(x) !== idOf(next[i]))) heldLists.add(el);
315
320
  else heldLists.delete(el);
316
321
  drawnOrder.set(el, ordered.map(idOf));
322
+ if (shown) drawnHidden.delete(el);
323
+ else drawnHidden.add(el);
317
324
  return ordered;
318
325
  }
319
326
 
@@ -498,7 +505,7 @@ export function createListsView(deps) {
498
505
  noteChainFamilies(cards);
499
506
  const refused = scopeError();
500
507
  if (refused) {
501
- draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("", null, win.location.search)}>show every session</a></div>`);
508
+ draw(topStripEl, html`<div class="hint scope-error">${refused} · <a href=${allScopeHref("")}>show every session</a></div>`);
502
509
  lastDrawn.delete(topStripEl);
503
510
  familyPop.close();
504
511
  chainPop.close();
@@ -727,14 +734,9 @@ export function createListsView(deps) {
727
734
  selecting.running = { done: 0, total: ids.length, archive };
728
735
  updateSelectBar();
729
736
  try {
730
- const outcome = await runBatch(ids, async (id) => {
731
- const answer = await archiveOne(id, archive);
732
- if (answer.ok) {
733
- render();
734
- updateInfoBar();
735
- }
736
- return answer;
737
- }, (done) => {
737
+ // An archived one flips in the store's list (archiveOne): the lists
738
+ // and the bar redraw from there.
739
+ const outcome = await runBatch(ids, (id) => archiveOne(id, archive), (done) => {
738
740
  selecting.running.done = done;
739
741
  updateSelectBar();
740
742
  });
@@ -105,6 +105,8 @@ export function createLiveTiming({
105
105
 
106
106
  start(startedAt = new Date(now()).toISOString(), id = null) {
107
107
  stopTicker();
108
+ // In place, on purpose: the running turn's own clock in the session
109
+ // store's timing (session_fields.js, D8).
108
110
  timing().activeTurn = id ? { id, started_at: startedAt } : { started_at: startedAt };
109
111
  turnId = id || null;
110
112
  // The turns ended so far, so this one comes next.
@@ -8,7 +8,7 @@
8
8
  // steps' texts.
9
9
  const TEXTS = ".bubble.output, .gen:not(.live) > .gen-text";
10
10
  // The closed steps' texts still shown raw: a live turn's (a reload's come
11
- // rendered, turn_view.js textTemplate).
11
+ // rendered, turn_block.js textTemplate).
12
12
  const RAW_STEPS = ".gen:not(.live) > .gen-text:not(.markdown)";
13
13
 
14
14
  export function createMarkdownPlace({
@@ -0,0 +1,14 @@
1
+ // chi web's JSON as notifications.js and push_devices.js call it:
2
+ // (method, path, body). Over the page's api (data.js), so a signed-out
3
+ // proxy answer (a 401, a redirect, HTML) reports auth_lost as every other
4
+ // read does, and a refusal throws api()'s Error (the server's words, .status,
5
+ // .code).
6
+
7
+ import { api as pageApi } from "./data.js";
8
+
9
+ // @param body the JSON body (undefined: none)
10
+ // @param opts api()'s fetch options (specs: fetchImpl); api: another client
11
+ // @return a Promise of the answer's JSON ({} for an empty one)
12
+ export function methodApi(method, path, body, { api = pageApi, ...opts } = {}) {
13
+ return api(path, { ...opts, method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
14
+ }
@@ -36,6 +36,7 @@ import {
36
36
  isWaitingTag, openPushKeys, pushSupported, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe,
37
37
  } from "./push.js";
38
38
  import { createPushDevices } from "./push_devices.js";
39
+ import { methodApi } from "./method_api.js";
39
40
 
40
41
  export const NOTIFY_KEY = "chi_notify";
41
42
  // The device id chi web gave this app's subscription (POST subscribe's
@@ -64,17 +65,9 @@ const PUSH_TITLES = {
64
65
  denied: "Notifications are blocked for this app in the phone's settings",
65
66
  };
66
67
 
67
- // fetch's JSON, or an Error with chi web's detail.
68
- async function defaultApi(method, path, body) {
69
- const res = await fetch(path, {
70
- method,
71
- headers: body === undefined ? {} : { "Content-Type": "application/json" },
72
- body: body === undefined ? undefined : JSON.stringify(body),
73
- });
74
- const data = res.status === 204 ? null : await res.json().catch(() => null);
75
- if (!res.ok) throw new Error(data?.detail || `HTTP ${res.status}`);
76
- return data;
77
- }
68
+ // chi web's JSON, or api()'s Error; a signed-out answer reports auth_lost
69
+ // (method_api.js).
70
+ const defaultApi = (method, path, body) => methodApi(method, path, body);
78
71
 
79
72
  // How a presence report goes: a beacon when the page is leaving (it
80
73
  // outlives the page; same origin, so the cookie goes), else the api's
@@ -171,6 +171,16 @@ function scheduleReload() {
171
171
  reloadTimer = setTimeout(load, 120);
172
172
  }
173
173
 
174
+ // The types the participant stream sends: exactly Web::ParticipantView::EVENTS
175
+ // (spec/web/participant_view_spec.rb reads this list), so a type the server
176
+ // starts sending isn't dropped here unseen.
177
+ const STREAM_EVENTS = [
178
+ "generation_chunk", "generation_retrying", "tool_call_started", "tool_call_completed",
179
+ "turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued", "input_merged",
180
+ "question_requested", "question_answered", "question_cancelled", "continue_offered", "continue_resolved",
181
+ "snapshot", "reset", "verdict_recorded",
182
+ ];
183
+
174
184
  // The stream from the review data's cursor; a closed or refused stream is
175
185
  // opened again from fresh data (a worker may have started or restarted).
176
186
  function connect() {
@@ -203,12 +213,7 @@ function connect() {
203
213
  renderLive();
204
214
  }
205
215
  };
206
- for (const type of ["generation_chunk", "generation_retrying", "tool_call_started", "tool_call_completed",
207
- "turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued", "input_merged",
208
- "question_requested", "question_answered", "question_cancelled", "continue_offered", "continue_resolved",
209
- "snapshot", "reset", "verdict_recorded"]) {
210
- source.addEventListener(type, onEvent);
211
- }
216
+ for (const type of STREAM_EVENTS) source.addEventListener(type, onEvent);
212
217
  source.onerror = () => {
213
218
  if (source.readyState !== EventSource.CLOSED) return;
214
219
  scheduleRetry(retryDelay(failures));
@@ -1,6 +1,6 @@
1
1
  // The open session's participants by author ("p:<id>" → name), for the
2
2
  // author badges on their prompts (turn_events.js promptOps, a reload's
3
- // turn_view.js): the participants panel sets it when it reads the list. A
3
+ // turn_block.js): the participants panel sets it when it reads the list. A
4
4
  // prompt's own author_name (a saved message, turn_started) comes first; a
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.