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
@@ -14,6 +14,11 @@ import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
14
14
  import { drawCommandBubble } from "./commands.js";
15
15
  import { TIME_GAP_CLASS, timeGapEl } from "./time_gaps.js";
16
16
 
17
+ // What a session's load places at a turn's prompt from the snapshot
18
+ // (cards_place.js placeSnapshotCards): its cards, answered questions,
19
+ // notices and warnings.
20
+ const SNAPSHOT_PLACED = ".plugin-card, .bubble.question, .bubble.hook-notice, .bubble.guardrail-warning";
21
+
17
22
  export function removeQueuedBubble(bubble) {
18
23
  if (!bubble) return;
19
24
  const slot = bubble.parentElement?.classList.contains("ts-queued") ? bubble.parentElement : null;
@@ -105,7 +110,7 @@ export function setBubbleDelivery(bubble, delivery) {
105
110
  // @param appendToHistory (el) puts +el+ at the history's end (or the stage's,
106
111
  // while it holds a turn), above the live timing line
107
112
  // @param isNearBottom () => whether the history follows its end
108
- // @param turnDom the running turn's view (stage_view.js / turn_view.js)
113
+ // @param turnDom the running turn's DOM (stage_view.js)
109
114
  // @param findInTurnRoots (selector) => the matching elements in the history
110
115
  // and the stage
111
116
  // @param shownTexts the running turn's texts on the page (shown_texts.js)
@@ -149,7 +154,7 @@ export function createBubbles({
149
154
 
150
155
  function addEndBubble(text, { stopped = false } = {}) {
151
156
  appendToHistory(elementOf(endBubbleTemplate(text, { stopped })));
152
- if (!turnDom.holds?.()) historyEl.scrollTop = historyEl.scrollHeight;
157
+ if (!turnDom.holds()) historyEl.scrollTop = historyEl.scrollHeight;
153
158
  }
154
159
 
155
160
  // ── Prompt bubbles ───────────────────────────────────────────────────────
@@ -159,10 +164,11 @@ export function createBubbles({
159
164
 
160
165
  // The bubble a reload draws (turn_html.js), with this page's own fields.
161
166
  // +delivery+: how it went into a running turn (next_step, cut, queue): its
162
- // queued badge says what it waits for.
163
- function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null, author = null, turnId = null } = {}) {
167
+ // queued badge says what it waits for. +source+: a delegate report's
168
+ // "delegate_report" (its fold row, answer_fold.js).
169
+ function makeUserBubble(prompt, { state = null, label = null, enqueuedId = null, own = false, images = null, delivery = null, author = null, turnId = null, source = null } = {}) {
164
170
  removeHint();
165
- const div = elementOf(userBubbleTemplate({ content: prompt, turn_id: turnId }, {
171
+ const div = elementOf(userBubbleTemplate({ content: prompt, turn_id: turnId, source }, {
166
172
  label, state, own, enqueuedId, delivery, author, key: normalize(prompt), thumbs: thumbsTemplate(selected(), images),
167
173
  }));
168
174
  return placeFollowing(div);
@@ -182,10 +188,16 @@ export function createBubbles({
182
188
  return echoes.find((el) => el.dataset.userContent === text) || echoes[0] || null;
183
189
  }
184
190
 
191
+ // The running wake turn's id (a turn chi ran for reports), for its report's bubble.
192
+ let wakeTurnId = null;
193
+
185
194
  // Returns the bubble of the turn a turn_started starts (the stage takes it
186
195
  // into its prompt line), else null.
187
196
  function applyPromptOps(event) {
188
- if (event.type === "turn_started") liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
197
+ if (event.type === "turn_started") {
198
+ liveTurn.wakeStart = !!event.continue && isReportClient(event.origin?.client_id);
199
+ wakeTurnId = liveTurn.wakeStart ? event.turn_id || null : null;
200
+ }
189
201
  const ops = promptOps(event, { myId: clientId, known: (id) => !!bubbleById(id), unmatchedMerge: liveTurn.unmatchedMerge, wakeStart: liveTurn.wakeStart });
190
202
  if (event.type === "input_merged") liveTurn.unmatchedMerge = false;
191
203
  if (event.type === "pending_input_merged") liveTurn.unmatchedMerge = false;
@@ -194,7 +206,10 @@ export function createBubbles({
194
206
  if (op.op === "report") {
195
207
  liveTurn.unmatchedMerge = "report";
196
208
  } else if (op.op === "add") {
197
- const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery, author: op.author });
209
+ // A report that opens a wake turn carries that turn's id, as a
210
+ // reload's does (its fold key); a merged one has none until reload.
211
+ const turnId = op.source && !op.state && liveTurn.wakeStart ? wakeTurnId : null;
212
+ const bubble = makeUserBubble(op.prompt, { state: op.state, label: op.label, enqueuedId: op.enqueuedId, images: op.images, delivery: op.delivery, author: op.author, source: op.source, turnId });
198
213
  if (op.state === "started") started = bubble;
199
214
  if (event.type === "pending_input_merged") liveTurn.wakeStart = false;
200
215
  } else if (op.op === "tag") {
@@ -239,10 +254,15 @@ export function createBubbles({
239
254
  const details = elementOf(recapTemplate(text, turnsSince, createdAt));
240
255
  // A stale one sits where it was written, above the turns it doesn't cover.
241
256
  // Never in the stage: a recap is between turns.
242
- turnDom.settleFold?.();
257
+ turnDom.settleFold();
243
258
  const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
244
- // Above that turn's gap divider too: the recap was written before the quiet stretch ended.
245
- const before = prompt?.previousElementSibling?.matches(`.${TIME_GAP_CLASS}`) ? prompt.previousElementSibling : prompt;
259
+ // Above that turn's gap divider too: the recap was written before the quiet stretch ended. The
260
+ // snapshot's cards placed at that prompt (placed first) sit between the divider and the prompt.
261
+ // A delegate report's fold row (answer_fold.js) heads its prompt.
262
+ const head = prompt?.previousElementSibling?.matches(".answer-fold") ? prompt.previousElementSibling : prompt;
263
+ let back = head?.previousElementSibling;
264
+ while (back?.matches(SNAPSHOT_PLACED)) back = back.previousElementSibling;
265
+ const before = back?.matches(`.${TIME_GAP_CLASS}`) ? back : head;
246
266
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
247
267
  }
248
268
 
@@ -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);
@@ -1,5 +1,5 @@
1
- // The popover shell a session-bar chip opens (llm_context_chip.js,
2
- // level_chip.js, the working set's): a div.ctx-popover on the body, placed
1
+ // The popover shell a session-bar chip opens (settings_chip.js' ⚙ panel,
2
+ // the working set's, the start page's panels): a div.ctx-popover on the body, placed
3
3
  // at the chip, opened by a click on it (delegated on +el+) or by open()
4
4
  // (another opener: the working set's rail and footprint), closed by a second
5
5
  // click, its × (data-act="close"), a click outside or Esc; focus goes back
@@ -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,21 +1,25 @@
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
- // 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.
3
+ // step, a diff), an answer or a delegate report folded (answer_fold.js: its row follows
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;
18
- el.closest?.(".bubble.output.folded")?.classList.remove("folded");
21
+ openStubOver(el);
22
+ el.closest?.(".bubble.folded")?.classList.remove("folded");
19
23
  for (let details = el.parentElement?.closest("details"); details; details = details.parentElement?.closest("details")) {
20
24
  details.open = true;
21
25
  }