samagotchi 0.170.0 → 0.171.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -169,8 +169,9 @@ export function editorUrl(template, path, line) {
169
169
 
170
170
  // A ref's targets, per kind, in order: the first available one is a plain
171
171
  // click's (the main target), all of them are the menu's. Each says when
172
- // it's available for +viewer+ (what this page's viewer can do: {editor,
173
- // editorUrl}, the server's capabilities.editor and editor_url) and
172
+ // it's available for +refEditor+ (where this page can open a ref: {editor,
173
+ // editorUrl}, the server's capabilities.editor and editor_url; not
174
+ // viewer.js's viewer, which says what the viewer may do at all) and
174
175
  // +target+ (refTarget's). A secondary one (copy) joins a ref that has
175
176
  // another, never alone. A line (a diff or read line) has its click only.
176
177
  // A link whose text is the ref (github-pr's PR diff): its href stays the
@@ -181,8 +182,8 @@ const LINK = Object.freeze({
181
182
  id: "link",
182
183
  label: (target) => target.actions?.find((action) => sameUrl(action.url, target.href))?.label ||
183
184
  (/\/pull\/\d+\/files\b/.test(target.href) ? "PR diff" : "Open link"),
184
- available: (_viewer, target) => !!target.href,
185
- run: (target, _viewer, env) => env.openLink(target.href),
185
+ available: (_refEditor, target) => !!target.href,
186
+ run: (target, _refEditor, env) => env.openLink(target.href),
186
187
  });
187
188
 
188
189
  // The ref's own actions (a plugin's, bindRef), one target each, for every
@@ -196,8 +197,8 @@ const URL_ACTIONS = Object.freeze({
196
197
  .map((action) => Object.freeze({
197
198
  id: `url:${action.id}`,
198
199
  label: () => action.label,
199
- available: (_viewer, target) => !target.isLine,
200
- run: (_target, _viewer, env) => env.openLink(action.url),
200
+ available: (_refEditor, target) => !target.isLine,
201
+ run: (_target, _refEditor, env) => env.openLink(action.url),
201
202
  hint: () => action.url,
202
203
  })),
203
204
  });
@@ -226,8 +227,8 @@ const MORE_REFS = Object.freeze({
226
227
  items.push(Object.freeze({
227
228
  id: `more:${at}:editor`,
228
229
  label: () => `${name} · Open in editor`,
229
- available: (viewer, target) => !target.isLine && viewer?.editor === true && !!viewer.editorUrl,
230
- run: (_target, viewer, env) => env.open(editorUrl(viewer.editorUrl, ref.target.path, ref.target.line)),
230
+ available: (refEditor, target) => !target.isLine && refEditor?.editor === true && !!refEditor.editorUrl,
231
+ run: (_target, refEditor, env) => env.open(editorUrl(refEditor.editorUrl, ref.target.path, ref.target.line)),
231
232
  hint: () => `Open the current file in your editor${ref.target.line ? ` at line ${ref.target.line}` : ""}`,
232
233
  }));
233
234
  }
@@ -237,8 +238,8 @@ const MORE_REFS = Object.freeze({
237
238
  items.push(Object.freeze({
238
239
  id: `more:${at}:${action.id}`,
239
240
  label: () => `${name} · ${action.label}`,
240
- available: (_viewer, target) => !target.isLine,
241
- run: (_target, _viewer, env) => env.openLink(action.url),
241
+ available: (_refEditor, target) => !target.isLine,
242
+ run: (_target, _refEditor, env) => env.openLink(action.url),
242
243
  hint: () => action.url,
243
244
  }));
244
245
  }
@@ -253,10 +254,10 @@ export const REF_ACTIONS = Object.freeze({
253
254
  Object.freeze({
254
255
  id: "editor",
255
256
  label: () => "Open in editor",
256
- available: (viewer) => viewer?.editor === true && !!viewer.editorUrl,
257
- url: (target, viewer) => editorUrl(viewer.editorUrl, target.path, target.line),
258
- run(target, viewer, env) {
259
- const url = this.url(target, viewer);
257
+ available: (refEditor) => refEditor?.editor === true && !!refEditor.editorUrl,
258
+ url: (target, refEditor) => editorUrl(refEditor.editorUrl, target.path, target.line),
259
+ run(target, refEditor, env) {
260
+ const url = this.url(target, refEditor);
260
261
  if (url) env.open(url);
261
262
  },
262
263
  // The hover hint: it opens the file as it is now.
@@ -268,17 +269,29 @@ export const REF_ACTIONS = Object.freeze({
268
269
  Object.freeze({
269
270
  id: "show",
270
271
  label: () => "Show in turn",
271
- available: (_viewer, target) => !target.isLine && !target.owner?.matches?.(".activity-row") && !!turnRowFor(target),
272
- run: (target, _viewer, env) => env.show(target),
272
+ available: (_refEditor, target) => !target.isLine && !target.owner?.matches?.(".activity-row") && !!turnRowFor(target),
273
+ run: (target, _refEditor, env) => env.show(target),
273
274
  hint: () => "Show the row that touched it in the turn",
274
275
  }),
275
276
  MORE_REFS,
277
+ // What chi has of the file (the working set's reader, ws_reader.js):
278
+ // an owner's page with the reader, for a file of the set (env's
279
+ // whatChiHas). Secondary and after a row's other refs: never a ref's main
280
+ // target, so a click does what it did; it is in the ▾ menu.
281
+ Object.freeze({
282
+ id: "has",
283
+ label: () => "What chi has",
284
+ secondary: true,
285
+ available: (_refEditor, target, env) => !target.isLine && !!target.path && !!env?.whatChiHas?.has(target.path),
286
+ run: (target, _refEditor, env) => env.whatChiHas.open(target.path),
287
+ hint: () => "What chi has of it: its reads and edits, newest first",
288
+ }),
276
289
  Object.freeze({
277
290
  id: "copy",
278
291
  label: () => "Copy path",
279
292
  secondary: true,
280
- available: (_viewer, target) => !target.isLine && !!target.path,
281
- run: (target, _viewer, env) => env.copy(target.path),
293
+ available: (_refEditor, target) => !target.isLine && !!target.path,
294
+ run: (target, _refEditor, env) => env.copy(target.path),
282
295
  }),
283
296
  ]),
284
297
  // Any other kind (a plugin's issue, commit, …): its link, then its
@@ -286,14 +299,15 @@ export const REF_ACTIONS = Object.freeze({
286
299
  "*": Object.freeze([LINK, URL_ACTIONS, MORE_REFS]),
287
300
  });
288
301
 
289
- // The targets +viewer+ has for +target+ (refTarget's), in order; none
290
- // when only a secondary one is.
291
- export function refActions(target, viewer, actions = REF_ACTIONS) {
302
+ // The targets +refEditor+ has for +target+ (refTarget's), in order; none
303
+ // when only a secondary one is. +env+: installRefClicks' (an action's
304
+ // availability may ask it: whatChiHas).
305
+ export function refActions(target, refEditor, actions = REF_ACTIONS, env = null) {
292
306
  if (!target) return [];
293
307
  // Own keys only: a kind named like an Object property ("constructor") is
294
308
  // any other kind.
295
309
  const table = (Object.hasOwn(actions, target.kind) ? actions[target.kind] : actions["*"]) || [];
296
- const found = table.flatMap((action) => (action.expand ? action.expand(target) : [action])).filter((action) => action.available(viewer, target));
310
+ const found = table.flatMap((action) => (action.expand ? action.expand(target) : [action])).filter((action) => action.available(refEditor, target, env));
297
311
  return found.some((action) => !action.secondary) ? found : [];
298
312
  }
299
313
 
@@ -475,16 +489,19 @@ function defaultOpenLink(href) {
475
489
  }
476
490
 
477
491
  // One delegated listener set on +root+ for every ref on the page.
478
- // +viewer+() is read per click, key and hover, so a capabilities change
492
+ // +refEditor+() is read per click, key and hover, so a capabilities change
479
493
  // (another session, a reload) is seen. The targets run through: +open+(url)
480
494
  // (the editor's URL), +openLink+(href), +show+(target) and +copy+(text).
481
- // +menu+ is the ▾ and its menu (ref_menu.js).
482
- export function installRefClicks(root, viewer, {
495
+ // +menu+ is the ▾ and its menu (ref_menu.js). +whatChiHas+: the working
496
+ // set's reader ({has(path), open(path)}; null: none, the "What chi has"
497
+ // target never shows).
498
+ export function installRefClicks(root, refEditor, {
483
499
  open = defaultOpen, openLink = defaultOpenLink, show = (target) => showInTurn(target), copy = copyText,
484
- selection = () => window.getSelection?.(), menu = createRefMenu(root.ownerDocument),
500
+ selection = () => window.getSelection?.(), menu = createRefMenu(root.ownerDocument), whatChiHas = null,
485
501
  } = {}) {
486
- const env = { open, openLink, show, copy };
487
- const run = (action, target) => action.run(target, viewer(), env);
502
+ const env = { open, openLink, show, copy, whatChiHas };
503
+ const actionsOf = (target) => refActions(target, refEditor(), REF_ACTIONS, env);
504
+ const run = (action, target) => action.run(target, refEditor(), env);
488
505
  const items = (actions, target) => actions.map((action) => ({
489
506
  id: action.id, label: action.label(target), hint: action.hint?.(target) || "", run: () => run(action, target),
490
507
  }));
@@ -497,14 +514,14 @@ export function installRefClicks(root, viewer, {
497
514
  // the link's own click); Tab works out the tab stops before it moves.
498
515
  root.addEventListener("keydown", (e) => {
499
516
  pointer = "keyboard";
500
- if (e.key === "Tab") syncRefTabs(root, viewer());
517
+ if (e.key === "Tab") syncRefTabs(root, refEditor());
501
518
  }, true);
502
519
 
503
520
  root.addEventListener("click", (e) => {
504
521
  if (e.button !== 0 || e.defaultPrevented) return;
505
522
  const target = refTarget(e.target);
506
523
  if (!target) return;
507
- const actions = refActions(target, viewer());
524
+ const actions = actionsOf(target);
508
525
  if (!actions.length) return;
509
526
  if (selectingIn(target.owner, selection())) return;
510
527
  if (pointer === "touch" && !target.isLine && actions.length > 1) {
@@ -529,7 +546,7 @@ export function installRefClicks(root, viewer, {
529
546
  if (e.key === "Escape") menu.hideMore();
530
547
  if (e.key !== "Enter" && e.key !== "ArrowDown") return;
531
548
  const target = refTarget(el);
532
- const actions = refActions(target, viewer());
549
+ const actions = actionsOf(target);
533
550
  if (!actions.length) return;
534
551
  if (e.key === "Enter") {
535
552
  if (actions[0].id === "link") return;
@@ -547,13 +564,13 @@ export function installRefClicks(root, viewer, {
547
564
  const mark = (e) => {
548
565
  const target = refTarget(e.target);
549
566
  if (!target) return;
550
- const actions = refActions(target, viewer());
567
+ const actions = actionsOf(target);
551
568
  const el = target.el;
552
569
  if (actions.length) el.dataset.refMain = actions[0].id;
553
570
  else delete el.dataset.refMain;
554
571
  if (!target.isLine) setRefRole(el, actions);
555
572
  hint(el, target, actions[0]);
556
- if (!target.isLine && actions.length > 1 && pointer !== "touch") menu.showMore(el, () => openMenu(refTarget(el), refActions(refTarget(el), viewer()), { focus: true }));
573
+ if (!target.isLine && actions.length > 1 && pointer !== "touch") menu.showMore(el, () => openMenu(refTarget(el), actionsOf(refTarget(el)), { focus: true }));
557
574
  };
558
575
  root.addEventListener("mouseover", mark);
559
576
  root.addEventListener("focusin", mark);
@@ -565,12 +582,12 @@ export function installRefClicks(root, viewer, {
565
582
  root.addEventListener("focusout", leave);
566
583
  }
567
584
 
568
- // The tab stops under +root+ for +viewer+: a ref is one (a button, with
585
+ // The tab stops under +root+ for +refEditor+: a ref is one (a button, with
569
586
  // aria-haspopup when it has a menu) only while it has a target, so a
570
587
  // phone's or an editorless viewer's row titles aren't. Worked out before a
571
588
  // Tab moves the focus and on a ref's hover or focus; a line never is one.
572
- export function syncRefTabs(root, viewer) {
573
- for (const el of root.querySelectorAll("[data-ref-kind]")) setRefRole(el, refActions(refTarget(el), viewer));
589
+ export function syncRefTabs(root, refEditor) {
590
+ for (const el of root.querySelectorAll("[data-ref-kind]")) setRefRole(el, refActions(refTarget(el), refEditor));
574
591
  }
575
592
 
576
593
  function setRefRole(el, actions) {
@@ -13,11 +13,20 @@
13
13
  // fallback, data-undrawn): 0 unless chi web missed one. Fire-and-forget: no toast, no
14
14
  // store write, a failure is dropped.
15
15
  //
16
- // Cards and answered question cards are left out on both sides: a reload
17
- // places them after the render (CardStore's stamps and the snapshot's
18
- // questions, cards_place.js placeResolvedQuestion), which the offscreen
19
- // render doesn't run; the e2e reload checks compare them.
16
+ // Compared: the turn's own cards and questions too. A drawn turn already
17
+ // renders its plugin cards (turn_block.js), and the snapshot's questions and
18
+ // an undrawn turn's entries are placed into the offscreen holder by
19
+ // cards_offscreen.js, the same placer the page uses with inert hooks. Only
20
+ // entries CardStore attributes to this turn (of_turn) are compared; a card
21
+ // the turn doesn't own (another turn's, a between-turn notice) is dropped on
22
+ // both sides, and a question the read attributes to no turn and stamps to no
23
+ // opening (an older worker) is dropped as well, since nothing places it.
24
+ // What a card's equality means: its id and title, or a question's summary line
25
+ // — not its body, its buttons, its fold or the tag it is drawn in.
26
+ // Between-turn cards and notices are out of scope (the e2e reload checks
27
+ // cover them).
20
28
  import { compareShapes, historyShape } from "./history_shape.js";
29
+ import { isCard, splitSnapshotCards } from "./card.js";
21
30
  import { turnElements } from "./turn_range.js";
22
31
 
23
32
  // The opening of a turn: its prompt, a wake's note, /continue no's line.
@@ -29,8 +38,42 @@ function openingId(el) {
29
38
  return el?.dataset?.turnId || el?.dataset?.openingId || null;
30
39
  }
31
40
 
32
- function withoutCards(shape) {
33
- return shape.filter((item) => item.kind !== "card" && item.kind !== "question");
41
+ // The snapshot entries this turn owns (CardStore's of_turn): a drawn turn
42
+ // already renders its cards, so only its questions come from the placer; an
43
+ // undrawn turn has all of them placed, as a reload places them. Entries the
44
+ // page shows elsewhere, the running turn's and the ones that came during it
45
+ // are out (splitSnapshotCards), as in session_view.js.
46
+ function turnEntries(cards, capabilities, turnId, drawn) {
47
+ const placed = splitSnapshotCards(cards, capabilities).placed.filter((entry) => entry?.of_turn === turnId);
48
+ return drawn ? placed.filter((entry) => entry?.type === "question") : placed;
49
+ }
50
+
51
+ // A question the read attributes to no turn (an older worker: no of_turn, and
52
+ // no opening it came after, or one stamped to this very turn) has nothing to
53
+ // place it from, so its card is left out of the compare. Only entries that
54
+ // could belong to this turn count: one stamped to another turn does not
55
+ // silence questions here.
56
+ function looseQuestions(cards, turnId) {
57
+ return (Array.isArray(cards) ? cards : []).some((entry) => entry?.type === "question"
58
+ && entry?.of_turn == null && (entry?.opening_id == null || String(entry.opening_id) === String(turnId)));
59
+ }
60
+
61
+ // The ids of the snapshot's cards that are not this turn's.
62
+ function foreignIds(cards, turnId) {
63
+ return new Set((Array.isArray(cards) ? cards : [])
64
+ .filter((entry) => isCard(entry) && entry?.of_turn !== turnId)
65
+ .map((entry) => String(entry.id ?? "")));
66
+ }
67
+
68
+ // This turn's items only: a card of another turn or between turns is dropped
69
+ // by id; a question the worker didn't attribute to a turn (an older worker)
70
+ // has nothing to place it from, so questions are dropped on both sides.
71
+ function turnOnly(shape, foreign, questions) {
72
+ return shape.filter((item) => {
73
+ if (item.kind === "card") return !foreign.has(item.id);
74
+ if (item.kind === "question") return questions;
75
+ return true;
76
+ });
34
77
  }
35
78
 
36
79
  // @param enabled () => whether the check is on (the body's attribute)
@@ -39,8 +82,13 @@ function withoutCards(shape) {
39
82
  // turn started streaming)
40
83
  // @param read (id) => the session as a reload reads it (the full GET)
41
84
  // @param render (data) => that read drawn as a reload draws it (a fragment)
85
+ // @param place (holder, entries) puts the turn's snapshot entries into that
86
+ // holder as a reload's placement would (cards_offscreen.js); without it,
87
+ // cards and questions are dropped from both sides
88
+ // @param capabilities () => the page's capabilities (which notices it shows
89
+ // elsewhere)
42
90
  // @param post (id, body) => the counts to chi web
43
- export function createReloadCheck({ enabled, sessionId, running, read, render, post }) {
91
+ export function createReloadCheck({ enabled, sessionId, running, read, render, place = null, capabilities = () => null, post }) {
44
92
  // The hand-off's pieces (stage_view.js onHandedOff), in their order;
45
93
  // +immediate+: another turn took the stage at once.
46
94
  async function handedOff({ pieces = [], immediate = false } = {}) {
@@ -56,15 +104,24 @@ export function createReloadCheck({ enabled, sessionId, running, read, render, p
56
104
  // failed turn hands off at once, and its timing line takes the turn
57
105
  // record's figure when its own re-read lands (refreshTimingFromSession),
58
106
  // which started at the turn's end, before this one.
59
- const live = withoutCards(historyShape(rest.filter((el) => !el.matches?.(NOT_THE_TURNS))));
107
+ // Whether the read had a drawing for the turn (turn drawings, P2): a
108
+ // drawn one already renders its own cards, an undrawn one has them
109
+ // placed after the render.
110
+ const drawn = !!data?.turn_drawings?.[turnId];
111
+ const entries = place ? turnEntries(data.cards, capabilities(), turnId, drawn) : [];
112
+ // A question the read can't place for this turn (see looseQuestions) is
113
+ // left out of the compare on both sides.
114
+ const questions = !!place && !looseQuestions(data.cards, turnId);
115
+ const shapeOf = (shape) => turnOnly(shape, foreignIds(data.cards, turnId), questions);
116
+ const live = shapeOf(historyShape(rest.filter((el) => !el.matches?.(NOT_THE_TURNS))));
60
117
  const holder = document.createElement("div");
61
118
  holder.append(render(data));
119
+ // The turn's own cards and questions, where a reload would put them.
120
+ if (place) place(holder, entries);
62
121
  const reopened = [...holder.querySelectorAll(OPENING)].find((el) => openingId(el) === turnId);
63
- const reloaded = reopened ? withoutCards(historyShape(turnElements(reopened, { container: holder }))) : [];
122
+ const reloaded = reopened ? shapeOf(historyShape(turnElements(reopened, { container: holder }))) : [];
64
123
  const { same, kinds, excused, explained } = reopened ? compareShapes(live, reloaded)
65
124
  : { same: false, kinds: ["prompt"], excused: [], explained: false };
66
- // Whether the read had a drawing for the turn (turn drawings, P2).
67
- const drawn = !!data?.turn_drawings?.[turnId];
68
125
  const undrawn = holder.querySelectorAll("[data-undrawn]").length;
69
126
  await post(id, { turn_id: turnId, same, kinds, excused, explained, drawn, undrawn });
70
127
  } catch (_) {
@@ -16,7 +16,9 @@
16
16
  // answer's place (revise_switch.js; view only, not stored);
17
17
  // - the final answer of an ended turn (verify_target.js's rule, a delegate
18
18
  // report's reply excluded), unstacked or a stack's top, and not a Verify
19
- // turn's, gets a Revise button: it arms the composer (app.js).
19
+ // turn's, gets a Revise button: it arms the composer (app.js). A
20
+ // participant's page asks mayRevise too (participant_revise.js: their own
21
+ // answers only); the stubs, the chip and the switcher are everyone's.
20
22
  //
21
23
  // The stamps live on the history's prompt bubbles (data-revises: a reload's
22
24
  // render, a live turn_completed's revises), so the stacks are read from the
@@ -111,7 +113,8 @@ export function versionChipText(count, at = count - 1) {
111
113
  }
112
114
 
113
115
  // The composer's chip: "Revising: ‹label›", or the refusal (+unavailable+:
114
- // the worker predates Revise turns).
116
+ // the worker predates Revise turns; +text+ its words, a participant's page
117
+ // gives its own: participant_revise.js).
115
118
  export const REVISE_UNAVAILABLE = "restart this session to use Revise";
116
119
 
117
120
  // The feature gate (app.js featureGates): a live worker without the "revise"
@@ -121,16 +124,16 @@ export const REVISE_UNAVAILABLE = "restart this session to use Revise";
121
124
  export function reviseWorkerUnavailable(workerFeatures, bridgeUp) {
122
125
  return workerPredates(workerFeatures, bridgeUp, "revise");
123
126
  }
124
- export function reviseChipText(label, { unavailable = false } = {}) {
125
- if (unavailable) return `Revise: ${REVISE_UNAVAILABLE}`;
127
+ export function reviseChipText(label, { unavailable = false, text = REVISE_UNAVAILABLE } = {}) {
128
+ if (unavailable) return `Revise: ${text}`;
126
129
  return label ? `Revising: ${label}` : "Revising the answer";
127
130
  }
128
131
 
129
132
  // The composer's chip (app.js renders it into its own element): the armed
130
133
  // Revise's words and ✕ (+onClear+), or nothing when +anchor+ is null.
131
- export function reviseChipTemplate(anchor, { unavailable = false, onClear } = {}) {
134
+ export function reviseChipTemplate(anchor, { unavailable = false, text = REVISE_UNAVAILABLE, onClear } = {}) {
132
135
  if (!anchor) return nothing;
133
- return html`<span class=${unavailable ? "revise-chip refused" : "revise-chip"} title=${unavailable ? REVISE_UNAVAILABLE : `This message rewrites that answer: ${anchor.label || ""}`}><span class="revise-chip-text">${reviseChipText(anchor.label, { unavailable })}</span><button type="button" class="revise-chip-clear" aria-label="Don't revise" title="Don't revise (send as a plain message)" @click=${onClear}>✕</button></span>`;
136
+ return html`<span class=${unavailable ? "revise-chip refused" : "revise-chip"} title=${unavailable ? text : `This message rewrites that answer: ${anchor.label || ""}`}><span class="revise-chip-text">${reviseChipText(anchor.label, { unavailable, text })}</span><button type="button" class="revise-chip-clear" aria-label="Don't revise" title="Don't revise (send as a plain message)" @click=${onClear}>✕</button></span>`;
134
137
  }
135
138
 
136
139
  // An answer's label (the stub's, the chip's): its first line.
@@ -190,12 +193,18 @@ export function historyTurns(historyEl) {
190
193
  // @param verifyTurn (turnId) => whether that turn was a Verify turn (beyond
191
194
  // its prompt's data-verify: a verdict's check_turn_id)
192
195
  // @param unavailable () => the worker predates Revise (the feature gate)
196
+ // @param unavailableText the button's title while +unavailable+ (a
197
+ // participant's page: its own words, participant_revise.js)
193
198
  // @param armed () => the turn id the composer's Revise is armed with, or null
194
199
  // @param onRevise ({turn_id, label}) the Revise button
200
+ // @param mayRevise (turn, turns) => whether this viewer may revise +turn+
201
+ // (one of historyTurns' +turns+): false draws no button, the stack's
202
+ // switcher and chip stay
195
203
  // @return {decorate()} (the observer runs it; app.js after a change it reads)
196
204
  export function installReviseStacks({
197
205
  historyEl, sessionId, projectRoot = () => null, running = () => null, verifyTurn = () => false,
198
- unavailable = () => false, armed = () => null, onRevise = () => {},
206
+ unavailable = () => false, unavailableText = REVISE_UNAVAILABLE, armed = () => null, onRevise = () => {},
207
+ mayRevise = () => true,
199
208
  }) {
200
209
  const stubs = new Map();
201
210
  const rows = new Map();
@@ -244,14 +253,14 @@ export function installReviseStacks({
244
253
  return row;
245
254
  }
246
255
 
247
- function drawRow(row, { id, answer, chain, openings }) {
256
+ function drawRow(row, { id, answer, chain, openings, may }) {
248
257
  const off = unavailable();
249
258
  const isArmed = armed() === id;
250
259
  const at = chain ? (shown.get(id) ?? chain.length - 1) : 0;
251
260
  const versions = chain
252
261
  ? html`<details class="stack-versions"><summary>${versionChipText(chain.length, at)}</summary><div class="stack-versions-list" role="list">${chain.map((vid, k) => html`<button type="button" role="listitem" class=${vid === id ? "stack-version current" : "stack-version"} data-stack-pick=${vid} ?disabled=${vid === id}><span class="stack-version-n">v${k + 1}</span> ${promptLabel(openings.get(vid))}</button>`)}</div></details>`
253
262
  : nothing;
254
- 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>`;
263
+ const button = may ? html`<button type="button" class=${isArmed ? "revise-btn armed" : "revise-btn"} data-revise=${id} ?disabled=${off} title=${off ? unavailableText : "Rewrite this answer: your next message revises it, and the earlier version folds away"}>${isArmed ? "Revising…" : "Revise"}</button>` : nothing;
255
264
  row.classList.toggle("stacked", !!chain);
256
265
  render(html`${chain ? switcherTemplate(id, at, chain.length) : nothing}${versions}${button}`, row);
257
266
  rowAnswer.set(row, answer);
@@ -302,9 +311,11 @@ export function installReviseStacks({
302
311
  if (!turn.id || turn.wake || !answer || answer.matches(UNFINISHED) || turn.stopped) continue;
303
312
  if (turn.prompt?.hasAttribute("data-verify") || verifyTurn(turn.id)) continue;
304
313
  if (run && (turn === lastOpening || (run.turnId && run.turnId === turn.id))) continue;
314
+ const may = !!mayRevise(turn, turns);
315
+ if (!may && !tops.has(turn.id)) continue;
305
316
  const row = rowFor(turn.id, answer);
306
317
  keepRows.add(turn.id);
307
- drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings });
318
+ drawRow(row, { id: turn.id, answer, chain: tops.get(turn.id) || null, openings, may });
308
319
  }
309
320
  syncViews(tops, keepRows, new Map(turns.filter((t) => t.id).map((t) => [t.id, t.answer])));
310
321
  // A divider between two folded stubs folds into the later one.
@@ -26,15 +26,22 @@ import { composerAfterAck, dropEarlyRestores, restoreOnAck } from "./restore.js"
26
26
  // +addCommandBubble(view)+; +addCreatedSession(created)+, +select(id)+,
27
27
  // +showNotice(text)+,
28
28
  // +followWokenWorker()+, +refreshHistory()+, +leaveGoneSession(id, why)+,
29
- // +runCommand(command)+ (composer.js send), +toast(text, action, options)+;
29
+ // +runCommand(command)+ (composer.js send; null: a viewer without session commands, the line goes as a turn), +toast(text, action, options)+;
30
+ // +closesOn(error)+: a participant's page takes a refusal that closes its
31
+ // composer (participant_main.js; true: taken, the line stays typed); null else;
30
32
  // +alert+: the browser's.
33
+ // The refused Revise sends whose text and chip stay (a failed line says why):
34
+ // a worker from before Revise turns, and a participant's of an answer that
35
+ // isn't theirs or that someone else's turn came after.
36
+ const REVISE_REFUSALS = ["revise_unavailable", "revise_not_own", "revise_overtaken"];
37
+
31
38
  export function createSendTurn({
32
39
  session, store, running, clientId, sentIds, earlyRestores, api, dir, startPage, takeDelivery, composer,
33
40
  verify = () => null, clearVerify = () => {}, restoreVerify = () => {},
34
41
  revise = () => null, clearRevise = () => {}, restoreRevise = () => {},
35
42
  historyEl, turnDom, makeUserBubble, bubbleById, addCommandBubble,
36
43
  addCreatedSession, select, showNotice, followWokenWorker, refreshHistory,
37
- leaveGoneSession, runCommand, toast, alert = (text) => globalThis.alert(text),
44
+ leaveGoneSession, runCommand, toast, closesOn = null, alert = (text) => globalThis.alert(text),
38
45
  }) {
39
46
  const promptEl = composer.el;
40
47
  const actionBtn = composer.button;
@@ -64,6 +71,10 @@ export function createSendTurn({
64
71
  alert(e.message);
65
72
  }
66
73
 
74
+ // The line being sent ({to, line}) until its /turn answers: a second
75
+ // Enter on a slow link (the text still in the box) doesn't send it again.
76
+ let inFlight = null;
77
+
67
78
  async function send({ delivery: chosen = null } = {}) {
68
79
  if (store.current.owner === "tui") return;
69
80
  // A fresh link's pick first: its /model is queued in the worker once
@@ -110,8 +121,10 @@ export function createSendTurn({
110
121
  composer.clear();
111
122
  return;
112
123
  }
113
- if (route.command) return runCommand(route.command);
114
- prompt = route.send;
124
+ if (route.command && runCommand) return runCommand(route.command);
125
+ // A viewer without session commands (viewer.js): the line goes as a turn, and the worker decides.
126
+ prompt = route.command ?? route.send;
127
+ if (inFlight && inFlight.to === session() && inFlight.line === line) return;
115
128
  const isRunning = running() && !!session();
116
129
  const delivery = route.delivery || (isRunning ? chosen || armed : null);
117
130
  const hint = historyEl.querySelector(".hint");
@@ -134,6 +147,8 @@ export function createSendTurn({
134
147
  // The session the line goes to: the page may switch to another while an
135
148
  // image uploads or the turn is sent, and what follows stays with this one.
136
149
  const sentTo = session();
150
+ const flight = { to: sentTo, line };
151
+ inFlight = flight;
137
152
  try {
138
153
  await uploadChips(sending, sentTo, { upload: api.uploadImage });
139
154
  const verifyAnchor = verify();
@@ -208,13 +223,16 @@ export function createSendTurn({
208
223
  // A stale tab sent into a session deleted meanwhile: the start page
209
224
  // says so, and the line stays in the composer.
210
225
  if (e.code === "not_found") return leaveGoneSession(sentTo, "not_found");
226
+ if (sentTo === session() && closesOn?.(e)) return;
211
227
  // A /queue to a worker that can't queue (it would steer): its words, the text kept.
212
228
  if (e.code === "delivery_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
213
229
  // A Revise to a worker from before Revise turns (the page's gate saw
214
- // none yet): its words, the text and the chip kept.
215
- if (e.code === "revise_unavailable" && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
230
+ // none yet), or a participant's of an answer not theirs or overtaken
231
+ // since the tap: the server's words, the text and the chip kept.
232
+ if (REVISE_REFUSALS.includes(e.code) && sentTo === session()) return addCommandBubble({ label: null, line, text: e.body?.detail || e.message, failed: true });
216
233
  toast(cantText("send", e), null, { sticky: true });
217
234
  } finally {
235
+ if (inFlight === flight) inFlight = null;
218
236
  actionBtn.disabled = store.current.owner === "tui";
219
237
  composer.focus();
220
238
  }
@@ -54,7 +54,7 @@ export const FIELDS = {
54
54
  writers: "owner_verify.js placeVerdict (settleRunning); verdict_fresh.js startFresh pushes in place (D8)",
55
55
  },
56
56
  createdAt: { redraws: ["infoBar"], writers: "the payload" },
57
- ctxPct: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_chunk, context_status)" },
57
+ ctxPct: { redraws: ["infoBar"], writers: "session_state.js sessionStateFrom (the payload's context_status, else the saved fill), stream_handlers.js (generation_chunk, context_status)" },
58
58
  ctxWindow: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
59
59
  ctxWindowSource: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
60
60
  ctxUnknownAfterSwitch: { redraws: ["infoBar"], writers: "stream_handlers.js (generation_started, context_status)" },
@@ -109,10 +109,18 @@ export function sessionStateFrom(data) {
109
109
  bridgeUp: s.bridge_up === true,
110
110
  timing: normalizeTiming(data.timing),
111
111
  };
112
- // The saved fill, so a reloaded or stopped session shows it before its
113
- // next turn.
114
- const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
115
- if (savedPct !== null) state.ctxPct = savedPct;
112
+ // The ctx fill: the live worker's newest estimate (shown in the session
113
+ // JSON as context_status, kept per request by the Engine) beats the saved
114
+ // fill, which is only the last counted turn's; without it, the saved fill.
115
+ const livePct = s.context_status?.est_pct;
116
+ if (typeof livePct === "number" && Number.isFinite(livePct)) {
117
+ state.ctxPct = livePct;
118
+ } else {
119
+ // The saved fill, so a reloaded or stopped session shows it before its
120
+ // next turn.
121
+ const savedPct = savedCtxPct(data.timing?.context, s.llm_context?.budget_tokens);
122
+ if (savedPct !== null) state.ctxPct = savedPct;
123
+ }
116
124
  if (data.timing?.context?.window_tokens) {
117
125
  state.ctxWindow = data.timing.context.window_tokens;
118
126
  state.ctxWindowSource = data.timing.context.window_source || null;
@@ -16,6 +16,7 @@
16
16
  // historyEl, emptyEl the history and the start page's empty box
17
17
  // getSession(id, opts), openStream(id, fromSeq, handlers, opts) data.js
18
18
  // liveTurn, turnDom, questionCards, contextChips, participantsControl the page's views
19
+ // (contextChips, participantsControl: null for a viewer without them, viewer.js)
19
20
  // view: {forgetSession(opts), renderSessionView(data, opts), endTurnView(kind, endText, opts)}
20
21
  // the session view (session_view.js)
21
22
  // page: {clearNotice(), setSessionHash(id), setTurnRunning(running), setLiveStatus(status),
@@ -37,6 +38,8 @@ import { withoutWorker } from "./format.js";
37
38
  import { resyncRetry, streamDropEnd } from "./turn_events.js";
38
39
  import { createResyncReach } from "./resync_reach.js";
39
40
  import { keptCommandBubbles } from "./commands.js";
41
+ import { foldKey } from "./stage_waiting.js";
42
+ import { removeSession } from "./storage.js";
40
43
 
41
44
  // +opened+ false: the stream never delivered a frame (the proxy answered
42
45
  // 503, or the bridge refused it), so a retry waits a moment rather than
@@ -83,6 +86,10 @@ export function createSessionSync(deps) {
83
86
  async function select(id) {
84
87
  page.clearNotice();
85
88
  const same = store.selected === id;
89
+ // Leaving for another session: the fold this tab kept for the one left
90
+ // has no page to come back to (stage_waiting.js). A reselect of the same
91
+ // session keeps it: the page is redrawn, not left.
92
+ if (!same) removeSession(foldKey(store.selected));
86
93
  // Its synchronous part in one batch: the active card, the composer and
87
94
  // the bar redraw once, at its end (never across the await).
88
95
  store.batch(() => {
@@ -93,8 +100,8 @@ export function createSessionSync(deps) {
93
100
  emptyEl.classList.add("hidden");
94
101
  resyncAttempts = 0; // this session has its own re-read budget
95
102
  deps.view.forgetSession({ keepVerify: same });
96
- contextChips.load(id);
97
- participantsControl.load(id);
103
+ contextChips?.load(id);
104
+ participantsControl?.load(id);
98
105
  });
99
106
  await resync();
100
107
  // A folded composer stays folded: typing on the page opens it.