samagotchi 0.136.0 → 0.137.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +15 -6
  5. data/docs/guardrails.md +12 -5
  6. data/docs/internals/engine.md +4 -4
  7. data/docs/internals/web-frontend.md +20 -10
  8. data/docs/sessions.md +1 -1
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge.rb +4 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/desktop/macos.rb +3 -2
  13. data/lib/samagotchi/engine.rb +6 -1
  14. data/lib/samagotchi/guardrail_wiring.rb +3 -1
  15. data/lib/samagotchi/guardrails/chi_dirs.rb +2 -2
  16. data/lib/samagotchi/guardrails/outside.rb +5 -5
  17. data/lib/samagotchi/guardrails/pair_boundary.rb +11 -4
  18. data/lib/samagotchi/guardrails/pair_command.rb +3 -19
  19. data/lib/samagotchi/guardrails/protected_paths.rb +5 -2
  20. data/lib/samagotchi/guardrails/review_paths.rb +32 -2
  21. data/lib/samagotchi/guardrails/shell_paths.rb +0 -4
  22. data/lib/samagotchi/guardrails/verdict.rb +1 -1
  23. data/lib/samagotchi/version.rb +1 -1
  24. data/lib/samagotchi/web/message_display.rb +6 -3
  25. data/lib/samagotchi/web/participant_review.rb +2 -1
  26. data/lib/samagotchi/web/public/answer_fold.js +44 -15
  27. data/lib/samagotchi/web/public/app.js +12 -1
  28. data/lib/samagotchi/web/public/chip_popover.js +20 -42
  29. data/lib/samagotchi/web/public/context_chips.js +16 -6
  30. data/lib/samagotchi/web/public/copy.js +8 -5
  31. data/lib/samagotchi/web/public/details_motion.js +74 -0
  32. data/lib/samagotchi/web/public/families_view.js +15 -6
  33. data/lib/samagotchi/web/public/index.html +67 -49
  34. data/lib/samagotchi/web/public/info_bar.js +1 -1
  35. data/lib/samagotchi/web/public/lists_view.js +1 -0
  36. data/lib/samagotchi/web/public/participants_panel.js +1 -1
  37. data/lib/samagotchi/web/public/popover_modal.js +57 -0
  38. data/lib/samagotchi/web/public/revise_stack.js +76 -6
  39. data/lib/samagotchi/web/public/revise_switch.js +55 -0
  40. data/lib/samagotchi/web/public/scope.js +3 -3
  41. data/lib/samagotchi/web/public/send_turn.js +4 -2
  42. data/lib/samagotchi/web/public/settings_chip.js +2 -2
  43. data/lib/samagotchi/web/public/stale_params.js +26 -0
  44. data/lib/samagotchi/web/public/start_settings.js +1 -1
  45. data/lib/samagotchi/web/public/start_toolkit.js +1 -1
  46. data/lib/samagotchi/web/public/stream_handlers.js +10 -5
  47. data/lib/samagotchi/web/public/working_set_rail.js +2 -0
  48. data/lib/samagotchi/web/public/working_set_view.js +2 -2
  49. metadata +5 -1
@@ -11,7 +11,9 @@
11
11
  // verdict cards, the recap. A time-gap divider between two folded stubs
12
12
  // folds into the later stub (it shows the divider's words);
13
13
  // - the top answer gets a version chip, "v3 · 2 earlier ▾", listing the
14
- // versions by their prompts; picking one reveals its stub;
14
+ // versions by their prompts; picking one reveals its stub; and a ‹ 3/3 ›
15
+ // switcher before it shows an earlier version's answer in the top
16
+ // answer's place (revise_switch.js; view only, not stored);
15
17
  // - the final answer of an ended turn (verify_target.js's rule, a delegate
16
18
  // report's reply excluded), unstacked or a stack's top, and not a Verify
17
19
  // turn's, gets a Revise button: it arms the composer (app.js).
@@ -28,6 +30,7 @@ import { bubbleCopyText } from "./copy.js";
28
30
  import { foldLabel, keepRowInView, lineCount, readFolded, writeFold } from "./answer_fold.js";
29
31
  import { clip } from "./format.js";
30
32
  import { NOTE_BOUNDARY } from "./turn_range.js";
33
+ import { SWAPPED_CLASS, VIEW_CLASS, stepVersion, switcherTemplate, viewOf } from "./revise_switch.js";
31
34
 
32
35
  // The classes and attributes this module puts on the history.
33
36
  export const HIDDEN_CLASS = "stack-hidden";
@@ -146,7 +149,7 @@ export function historyTurns(historyEl) {
146
149
  while (turn.els.at(-1)?.classList.contains("time-gap")) turn.els.pop();
147
150
  };
148
151
  for (const el of historyEl.children) {
149
- if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS)) continue;
152
+ if (el.classList.contains(STUB_CLASS) || el.classList.contains(ROW_CLASS) || el.classList.contains(VIEW_CLASS)) continue;
150
153
  if (el.classList.contains(FOLD_ROW) && el.nextElementSibling?.matches(OPENING)) continue;
151
154
  const opening = el.matches(OPENING) ? el : el.querySelector(OPENING);
152
155
  if (opening || el.matches(NOTE_BOUNDARY)) {
@@ -187,6 +190,10 @@ export function installReviseStacks({
187
190
  const rows = new Map();
188
191
  // Each row's answer bubble (its label at a click).
189
192
  const rowAnswer = new WeakMap();
193
+ // The switcher: each stack's version on view (0-based, by its top's id;
194
+ // none: the latest), and the copy shown for it ({el, source, hidden}).
195
+ const shown = new Map();
196
+ const views = new Map();
190
197
  let observer = null;
191
198
 
192
199
  function stubFor(id, opening, stored) {
@@ -234,7 +241,8 @@ export function installReviseStacks({
234
241
  : nothing;
235
242
  const button = html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? REVISE_UNAVAILABLE : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>`;
236
243
  row.classList.toggle("stacked", !!chain);
237
- render(html`${versions}${button}`, row);
244
+ const at = chain ? (shown.get(id) ?? chain.length - 1) : 0;
245
+ render(html`${chain ? switcherTemplate(id, at, chain.length) : nothing}${versions}${button}`, row);
238
246
  rowAnswer.set(row, answer);
239
247
  }
240
248
 
@@ -287,6 +295,7 @@ export function installReviseStacks({
287
295
  keepRows.add(turn.id);
288
296
  drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
289
297
  }
298
+ syncViews(tops, keepRows, new Map(turns.filter((t) => t.id).map((t) => [t.id, t.answer])));
290
299
  // A divider between two folded stubs folds into the later one.
291
300
  for (const turn of turns) {
292
301
  if (!turn.stub) continue;
@@ -307,13 +316,67 @@ export function installReviseStacks({
307
316
  for (const [id, stub] of stubs) if (!keepStubs.has(id) || stub.parentElement !== historyEl) { stub.remove(); stubs.delete(id); }
308
317
  for (const [id, row] of rows) if (!keepRows.has(id) || row.parentElement !== historyEl) { row.remove(); rows.delete(id); }
309
318
  // A stale row left by a re-render (its map entry gone with the nodes).
310
- for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}`)) {
311
- if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el) el.remove();
319
+ const viewEls = new Set([...views.values()].map((v) => v.el));
320
+ for (const el of historyEl.querySelectorAll(`:scope > .${STUB_CLASS}, :scope > .${ROW_CLASS}, :scope > .${VIEW_CLASS}`)) {
321
+ if (stubs.get(el.dataset.stackStub) !== el && rows.get(el.dataset.reviseFor) !== el && !viewEls.has(el)) el.remove();
312
322
  }
313
323
  // What we changed isn't news.
314
324
  observer?.takeRecords();
315
325
  }
316
326
 
327
+ // The switcher's copies: a stack on an earlier version (its top's row
328
+ // drawn, that version's answer there) shows a copy of that answer just
329
+ // before the top answer and its fold row, which hide; any other stack's
330
+ // copy goes and its top shows again.
331
+ function syncViews(tops, keepRows, answers) {
332
+ const wanted = (id) => {
333
+ const chain = tops.get(id);
334
+ const k = shown.get(id);
335
+ if (!chain || k === undefined || k >= chain.length - 1 || !keepRows.has(id)) return null;
336
+ const source = answers.get(chain[k]);
337
+ const top = answers.get(id);
338
+ return source && top ? { source, top, version: k + 1 } : null;
339
+ };
340
+ for (const [id, view] of views) {
341
+ const want = wanted(id);
342
+ if (want && view.source === want.source && view.top === want.top && view.el.isConnected) continue;
343
+ view.el.remove();
344
+ for (const el of view.hidden) el.classList.remove(SWAPPED_CLASS);
345
+ views.delete(id);
346
+ }
347
+ for (const id of [...shown.keys()]) {
348
+ const want = wanted(id);
349
+ if (!want) {
350
+ shown.delete(id);
351
+ continue;
352
+ }
353
+ let view = views.get(id);
354
+ if (!view) {
355
+ view = { el: viewOf(want.source, want.version), source: want.source, top: want.top, hidden: [] };
356
+ views.set(id, view);
357
+ }
358
+ const head = want.top.previousElementSibling?.classList.contains(FOLD_ROW) ? want.top.previousElementSibling : null;
359
+ for (const el of view.hidden) if (el !== want.top && el !== head) el.classList.remove(SWAPPED_CLASS);
360
+ view.hidden = head ? [want.top, head] : [want.top];
361
+ for (const el of view.hidden) el.classList.add(SWAPPED_CLASS);
362
+ const before = head || want.top;
363
+ if (before.previousElementSibling !== view.el) historyEl.insertBefore(view.el, before);
364
+ }
365
+ }
366
+
367
+ // The switcher's ‹ or › on its row's stack.
368
+ function step(row, delta) {
369
+ const id = row.dataset.reviseFor;
370
+ const count = row.querySelectorAll("[data-stack-pick]").length;
371
+ if (!id || count < 2) return;
372
+ const was = shown.get(id) ?? count - 1;
373
+ const k = stepVersion(was, count, delta);
374
+ if (k === was) return;
375
+ if (k === count - 1) shown.delete(id);
376
+ else shown.set(id, k);
377
+ keepRowInView(historyEl, row, decorate);
378
+ }
379
+
317
380
  function setOpen(stub, open, { save }) {
318
381
  keepRowInView(historyEl, stub, () => {
319
382
  stub.classList.toggle("open", open);
@@ -326,7 +389,7 @@ export function installReviseStacks({
326
389
  // answer's key or its end (its class).
327
390
  observer = new MutationObserver((records) => {
328
391
  const news = records.some((r) => (r.type === "childList" ? r.target === historyEl
329
- && [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && !n.classList.contains(STUB_CLASS) && !n.classList.contains(ROW_CLASS))
392
+ && [...r.addedNodes, ...r.removedNodes].some((n) => n.nodeType === 1 && ![STUB_CLASS, ROW_CLASS, VIEW_CLASS].some((c) => n.classList.contains(c)))
330
393
  : r.attributeName !== "class" || r.target.matches?.(`.bubble.output, .${STUB_CLASS}`)));
331
394
  if (news) decorate();
332
395
  });
@@ -340,6 +403,13 @@ export function installReviseStacks({
340
403
  setOpen(stub, !stub.classList.contains("open"), { save: true });
341
404
  return;
342
405
  }
406
+ const stepper = e.target.closest?.("[data-stack-step]");
407
+ if (stepper) {
408
+ e.preventDefault();
409
+ const row = stepper.closest(`.${ROW_CLASS}`);
410
+ if (row && stepper.getAttribute("aria-disabled") !== "true") step(row, Number(stepper.dataset.stackStep));
411
+ return;
412
+ }
343
413
  const pick = e.target.closest?.("[data-stack-pick]");
344
414
  if (pick) {
345
415
  e.preventDefault();
@@ -0,0 +1,55 @@
1
+ // The ‹ N/M › switcher on a Revise stack's top answer (revise_stack.js draws
2
+ // it in the top answer's row, before the version chip): the owner reads an
3
+ // earlier version's answer in place of the top one. View only: no server
4
+ // call, nothing stored (a reload shows the latest again).
5
+ //
6
+ // An earlier version is shown as a copy of its answer bubble (the bubble
7
+ // itself stays hidden in its superseded turn): the copy goes just before the
8
+ // top answer (its fold row, answer_fold.js, kept with it), and the top answer
9
+ // and its fold row are hidden by a class while it shows. The copy keeps its
10
+ // kept source (data-copy-source) and its copy buttons, so Copy copies the
11
+ // version on view; it has no answer key (data-answer-turn), so no fold row,
12
+ // claim key or Revise row is drawn for it, and the top answer stays its
13
+ // turn's last answer (verify_target.js, cards_place.js).
14
+ //
15
+ // A module of its own: static JS is cached at unversioned URLs, and a new
16
+ // export on an old module would blank the page.
17
+ import { html } from "./vendor/lit-html.js";
18
+
19
+ // The copy of an earlier answer on view, and what hides the top one.
20
+ export const VIEW_CLASS = "stack-view";
21
+ export const SWAPPED_CLASS = "stack-swapped";
22
+
23
+ // The switcher's words for version +k+ (0-based) of +count+: the counter
24
+ // ("2/3") and its spoken label ("version 2 of 3").
25
+ export function switchText(k, count) {
26
+ return { text: `${k + 1}/${count}`, label: `version ${k + 1} of ${count}` };
27
+ }
28
+
29
+ // Version +k+ moved by +delta+, kept in 0…count-1.
30
+ export function stepVersion(k, count, delta) {
31
+ return Math.min(Math.max(k + delta, 0), Math.max(count - 1, 0));
32
+ }
33
+
34
+ // The switcher for the stack +id+ (its top turn's id) showing version +k+
35
+ // of +count+. The buttons stay focusable at either end (aria-disabled, not
36
+ // disabled: a press there keeps the focus where it is).
37
+ export function switcherTemplate(id, k, count) {
38
+ const { text, label } = switchText(k, count);
39
+ const first = k <= 0;
40
+ const last = k >= count - 1;
41
+ return html`<span class="stack-switch" role="group" aria-label="Answer versions" data-stack-switch=${id}><button type="button" class="stack-switch-btn" data-stack-step="-1" aria-label="Previous version" title="Read the previous version" aria-disabled=${first ? "true" : "false"}>‹</button><span class="stack-switch-n" title=${label}><span aria-hidden="true">${text}</span><span class="stack-switch-sr" aria-live="polite">${label}</span></span><button type="button" class="stack-switch-btn" data-stack-step="1" aria-label="Next version" title="Read the next version" aria-disabled=${last ? "true" : "false"}>›</button></span>`;
42
+ }
43
+
44
+ // A copy of +answer+ (an earlier version's answer bubble) to show in the top
45
+ // answer's place, titled as version +version+: no answer key,
46
+ // none of the history's state classes.
47
+ export function viewOf(answer, version) {
48
+ const view = answer.cloneNode(true);
49
+ for (const attr of ["id", "data-answer-turn", "data-reply-key", "data-stack-of", "data-line-key"]) view.removeAttribute(attr);
50
+ view.classList.remove("stack-hidden", "folded", SWAPPED_CLASS);
51
+ view.classList.add(VIEW_CLASS);
52
+ view.dataset.stackView = String(version);
53
+ for (const btn of view.querySelectorAll(".copy-btn[data-copied]")) delete btn.dataset.copied;
54
+ return view;
55
+ }
@@ -50,13 +50,13 @@ export function scopeTemplate(scope, { error = null, where = () => ({ hash: "" }
50
50
  const at = where();
51
51
  const allHref = () => allScopeHref(where().hash, scope.dir);
52
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>`;
53
- if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" title=${error}>folder not found</button>${all}`;
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}`;
53
+ if (error) return html`<button id="scopePick" class="scope-name error" type="button" aria-haspopup="dialog" aria-expanded="false" title=${error}>folder not found</button>${all}`;
54
+ if (scope.projectName) return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" aria-expanded="false" title=${`Sessions of ${scope.projectDir}`}>${scope.projectName}</button>${all}`;
55
55
  const dir = scope.dir || scope.serverDir;
56
56
  const backHref = () => projectScopeHref(scope.backDir, where().hash);
57
57
  const back = scope.backName
58
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>`
59
59
  : nothing;
60
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;
61
- return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" title="Sessions of every project">all projects</button>${back}${newSession}`;
61
+ return html`<button id="scopePick" class="scope-name" type="button" aria-haspopup="dialog" aria-expanded="false" title="Sessions of every project">all projects</button>${back}${newSession}`;
62
62
  }
@@ -121,8 +121,10 @@ export function createSendTurn({
121
121
  turnDom.handOff({ immediate: true });
122
122
  const sending = composer.chips().slice();
123
123
  // Into a running turn the echo says what it waits for (its delivery); a
124
- // line with images always waits for the turn's end (the worker queues it).
125
- const shownDelivery = isRunning ? (sending.length ? "queue" : delivery || "next_step") : null;
124
+ // line with images, or a Verify or a Revise, always waits for the turn's
125
+ // end (the worker queues it).
126
+ const anchored = !!verify() || !!revise();
127
+ const shownDelivery = isRunning ? (sending.length || anchored ? "queue" : delivery || "next_step") : null;
126
128
  const echo = makeUserBubble(prompt, { state: "queued", own: true, images: sending, delivery: shownDelivery });
127
129
  if (!turnDom.holds()) historyEl.scrollTop = historyEl.scrollHeight;
128
130
  if (!store.current.firstPreview || store.current.firstPreview === "—") {
@@ -77,7 +77,7 @@ export function chipTemplate(summaries) {
77
77
  const chip = summaries && chipModel(summaries);
78
78
  if (!chip) return nothing;
79
79
  return html`<button type="button" class=${`model settings-chip${chip.own ? " own" : ""}`} title=${chip.title}
80
- aria-label=${chip.label} aria-haspopup="dialog" data-settings-chip><span class="chip-icon sc-gear" aria-hidden="true">⚙</span>${
80
+ aria-label=${chip.label} aria-haspopup="dialog" aria-expanded="false" data-settings-chip><span class="chip-icon sc-gear" aria-hidden="true">⚙</span>${
81
81
  chip.text ? html`<span class="sc-own"> ${chip.text}</span>` : nothing}</button>`;
82
82
  }
83
83
 
@@ -197,7 +197,7 @@ export function createSettingsControl({ el, summaries, session = () => null, run
197
197
  };
198
198
  const shell = createChipPopover({
199
199
  id, className: "ctx-popover start-settings session-settings", width: 440, el, anchor, summary: present, session,
200
- show, sheet: true, sheetShare: SHEET_TALL, modal: true, label: PANEL_LABEL, onOpen: focusInside,
200
+ show, sheet: true, sheetShare: SHEET_TALL, label: PANEL_LABEL, onOpen: focusInside,
201
201
  onClose: () => { shown = null; error = null; }, onSubmit: submit, onAct: act, doc, win,
202
202
  });
203
203
  // An edit makes the disclosure Set's error stale.
@@ -0,0 +1,26 @@
1
+ // Query parameters the page no longer reads, dropped from the address bar
2
+ // as it loads (app.js, history.replaceState), so a bookmark or a link kept
3
+ // from an older chi stops carrying them: ?view= (the turn view, removed in
4
+ // 0.135.0). The rest of the query stays as written (?dir='s readable
5
+ // slashes too), in its order.
6
+ //
7
+ // A module of its own: static JS is cached at unversioned URLs, and a new
8
+ // export on an old module would blank the page.
9
+
10
+ export const STALE_PARAMS = ["view"];
11
+
12
+ // A query part's name ("view" of "view=turn"), decoded as URLSearchParams
13
+ // reads it.
14
+ function nameOf(part) {
15
+ const raw = part.split("=", 1)[0].replace(/\+/g, " ");
16
+ try { return decodeURIComponent(raw); } catch { return raw; }
17
+ }
18
+
19
+ // +search+ (a location.search) without the stale parameters, "?…" or "";
20
+ // null when it has none (nothing to replace).
21
+ export function withoutStaleParams(search) {
22
+ const parts = String(search || "").replace(/^\?/, "").split("&").filter(Boolean);
23
+ const kept = parts.filter((part) => !STALE_PARAMS.includes(nameOf(part)));
24
+ if (kept.length === parts.length) return null;
25
+ return kept.length ? `?${kept.join("&")}` : "";
26
+ }
@@ -190,7 +190,7 @@ export function createStartSettings({ el, button, openers = `#${button.id}`, thi
190
190
  id: "startSettingsPopover", className: "ctx-popover start-settings", width: 440, el, anchor: `#${button.id}`, openers,
191
191
  // Taller than a plain sheet: the Apply and budget fields or the review
192
192
  // form would push the note under the edge of a 60 % one.
193
- summary: () => ({}), sheet: true, sheetShare: SHEET_TALL, expands: true, modal: true, label: PANEL_LABEL, doc, win,
193
+ summary: () => ({}), sheet: true, sheetShare: SHEET_TALL, label: PANEL_LABEL, doc, win,
194
194
  show: (_data, opening) => {
195
195
  if (opening) {
196
196
  const choice = llmContext.choice();
@@ -109,7 +109,7 @@ export function createStartToolkit({ el, button, openers = `#${button.id}`, tool
109
109
 
110
110
  const shell = createChipPopover({
111
111
  id: "startToolkitPopover", className: "ctx-popover start-toolkit", width: 380, el, anchor: `#${button.id}`, openers,
112
- summary: () => toolkits(), sheet: true, expands: true, modal: true, label: LIST_LABEL, doc, win,
112
+ summary: () => toolkits(), sheet: true, label: LIST_LABEL, doc, win,
113
113
  show: () => draw(),
114
114
  onOpen: focusInside,
115
115
  onClose: () => { error = null; },
@@ -185,7 +185,8 @@ export function createStreamHandlers(deps) {
185
185
  liveTurn.timing.start(data.started_at, data.turn_id || null);
186
186
  }),
187
187
  turn_completed: async (data) => {
188
- // The turn's id: the event names none, its turn_started did.
188
+ // The turn's id: the event names it (an older worker names
189
+ // none: its turn_started did).
189
190
  const turnId = data.turn_id || liveTurn.timing.turnId || null;
190
191
  activityEnded();
191
192
  // A participant's turn: their spend and turns in the panel moved.
@@ -194,11 +195,15 @@ export function createStreamHandlers(deps) {
194
195
  const out = turnOutput(data);
195
196
  if (out) appendChunk(out);
196
197
  if (data.verdict) deps.placeVerdict?.(data.verdict);
197
- // A Revise turn that revised: its prompt takes the stamp, as a reload
198
- // draws it, and the stack folds (revise_stack.js follows the DOM).
199
- if (data.revises?.turn_id && turnId) {
198
+ // Its prompt takes the turn's stamps, as a reload draws them
199
+ // (revise_stack.js follows the DOM): a Revise turn that revised
200
+ // (the stack folds), and a Verify turn (no Revise on its answer, for
201
+ // every viewer, verdict or not; the sender's echo had it already).
202
+ if ((data.revises?.turn_id || data.verify) && turnId) {
200
203
  for (const prompt of findInTurnRoots(".bubble.user[data-turn-id]")) {
201
- if (prompt.dataset.turnId === turnId) prompt.dataset.revises = data.revises.turn_id;
204
+ if (prompt.dataset.turnId !== turnId) continue;
205
+ if (data.revises?.turn_id) prompt.dataset.revises = data.revises.turn_id;
206
+ if (data.verify) prompt.dataset.verify = "1";
202
207
  }
203
208
  }
204
209
  // after_turn hooks are running and may present the answer: its
@@ -100,6 +100,8 @@ export function createRing(parent) {
100
100
  export function createWorkingSetRail({ live, stage, onOpen, onChange }) {
101
101
  const rail = el("div", "ts-ws", live);
102
102
  rail.setAttribute("role", "button");
103
+ rail.setAttribute("aria-haspopup", "dialog");
104
+ rail.setAttribute("aria-expanded", "false");
103
105
  rail.tabIndex = 0;
104
106
  const head = el("div", "ws-head", rail);
105
107
  const name = el("span", "ws-name", head);
@@ -105,7 +105,7 @@ export function footprintTemplate(items, ordinal) {
105
105
  const sep = html` <span class="sep">·</span> `;
106
106
  const parts = fp.labels.map((l, i) => html`${i ? sep : nothing}<span class=${`l ${l.kind}${l.state && l.state !== "live" ? ` ${l.state}` : ""}`} title=${labelTitle(l)}>${l.glyph ? html`<span class="k">${l.glyph}</span> ` : nothing}${labelText({ ...l, glyph: "" })}</span>${footTag(l)}`);
107
107
  const text = footprintText(items, BUDGETS.footprint);
108
- return html`<div class="ws-foot" role="button" tabindex="0" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.stale ? html`${sep}<span class="s">${fp.stale} stale</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}${fp.dropped ? html`${sep}<span class="f">${fp.dropped} dropped</span>` : nothing}</div>`;
108
+ return html`<div class="ws-foot" role="button" tabindex="0" aria-haspopup="dialog" aria-expanded="false" data-ws-turn=${ordinal} data-ws-sig=${footSig(items)} aria-label=${`Working set of this turn: ${text.replace(/^touched /, "")}. Open the list.`}><span class="w">touched</span> ${parts}${fp.tail ? html`${sep}<span class="w">${fp.tail}</span>` : nothing}${fp.edited ? html`${sep}<span class="e">edited ${fp.edited}</span>` : nothing}${fp.stale ? html`${sep}<span class="s">${fp.stale} stale</span>` : nothing}${fp.forgotten ? html`${sep}<span class="f">${fp.forgotten} forgotten</span>` : nothing}${fp.dropped ? html`${sep}<span class="f">${fp.dropped} dropped</span>` : nothing}</div>`;
109
109
  }
110
110
 
111
111
  // What a footprint shows that can change after it is drawn: its items'
@@ -186,7 +186,7 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
186
186
 
187
187
  const popover = createChipPopover({
188
188
  id: "workingSetPopover", className: "ctx-popover ws-popover", width: 420, el: infoEl, anchor: ".ws-chip",
189
- openers: ".ws-chip, .ts-ws, .ws-foot", sheet: true,
189
+ openers: ".ws-chip, .ts-ws, .ws-foot", sheet: true, label: "Working set",
190
190
  // The chip opens the session's scope; the rail and a footprint set
191
191
  // theirs before they open it.
192
192
  summary: (anchor) => {
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.136.0
4
+ version: 0.137.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -802,6 +802,7 @@ files:
802
802
  - lib/samagotchi/web/public/ctx.js
803
803
  - lib/samagotchi/web/public/data.js
804
804
  - lib/samagotchi/web/public/delivery_line.js
805
+ - lib/samagotchi/web/public/details_motion.js
805
806
  - lib/samagotchi/web/public/diff_view.js
806
807
  - lib/samagotchi/web/public/dock_reserve.js
807
808
  - lib/samagotchi/web/public/families_view.js
@@ -842,6 +843,7 @@ files:
842
843
  - lib/samagotchi/web/public/participant_names.js
843
844
  - lib/samagotchi/web/public/participants_panel.js
844
845
  - lib/samagotchi/web/public/plain_line.js
846
+ - lib/samagotchi/web/public/popover_modal.js
845
847
  - lib/samagotchi/web/public/project_picker.js
846
848
  - lib/samagotchi/web/public/prompt_history.js
847
849
  - lib/samagotchi/web/public/push.js
@@ -858,6 +860,7 @@ files:
858
860
  - lib/samagotchi/web/public/review_history.js
859
861
  - lib/samagotchi/web/public/review_start.js
860
862
  - lib/samagotchi/web/public/revise_stack.js
863
+ - lib/samagotchi/web/public/revise_switch.js
861
864
  - lib/samagotchi/web/public/route.js
862
865
  - lib/samagotchi/web/public/scope.js
863
866
  - lib/samagotchi/web/public/scroll.js
@@ -880,6 +883,7 @@ files:
880
883
  - lib/samagotchi/web/public/stage_slots.js
881
884
  - lib/samagotchi/web/public/stage_view.js
882
885
  - lib/samagotchi/web/public/stage_watch.js
886
+ - lib/samagotchi/web/public/stale_params.js
883
887
  - lib/samagotchi/web/public/start_page.js
884
888
  - lib/samagotchi/web/public/start_settings.js
885
889
  - lib/samagotchi/web/public/start_toolkit.js