samagotchi 0.124.0 → 0.126.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/guardrails.md +22 -0
  6. data/docs/internals/participants.md +96 -3
  7. data/docs/internals/remote-sessions.md +2 -0
  8. data/docs/internals/web-push.md +10 -6
  9. data/docs/sessions.md +5 -2
  10. data/lib/samagotchi/bridge.rb +4 -3
  11. data/lib/samagotchi/bridge_client.rb +8 -1
  12. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
  13. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/child_reports.rb +3 -0
  16. data/lib/samagotchi/children_status.rb +6 -1
  17. data/lib/samagotchi/engine.rb +43 -7
  18. data/lib/samagotchi/guardrail_wiring.rb +10 -1
  19. data/lib/samagotchi/llm/chat_loop.rb +1 -0
  20. data/lib/samagotchi/process_group.rb +10 -1
  21. data/lib/samagotchi/reminder_carry.rb +92 -0
  22. data/lib/samagotchi/reminder_store.rb +49 -0
  23. data/lib/samagotchi/session.rb +40 -3
  24. data/lib/samagotchi/session_inbox.rb +14 -8
  25. data/lib/samagotchi/session_manager.rb +39 -8
  26. data/lib/samagotchi/session_participants.rb +10 -2
  27. data/lib/samagotchi/sessions_command.rb +1 -1
  28. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  30. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  31. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  32. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  34. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  35. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  36. data/lib/samagotchi/verdict.rb +20 -0
  37. data/lib/samagotchi/verify_anchor.rb +28 -0
  38. data/lib/samagotchi/verify_fresh.rb +275 -0
  39. data/lib/samagotchi/verify_fresh_origin.rb +71 -0
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +10 -3
  42. data/lib/samagotchi/web/participant_access.rb +15 -4
  43. data/lib/samagotchi/web/participant_review.rb +7 -1
  44. data/lib/samagotchi/web/participant_view.rb +46 -5
  45. data/lib/samagotchi/web/participants_admin.rb +15 -4
  46. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  47. data/lib/samagotchi/web/public/annotate_bar.js +43 -7
  48. data/lib/samagotchi/web/public/app.js +134 -8
  49. data/lib/samagotchi/web/public/claim_link.js +347 -0
  50. data/lib/samagotchi/web/public/data.js +2 -1
  51. data/lib/samagotchi/web/public/index.html +1 -0
  52. data/lib/samagotchi/web/public/notify.js +13 -2
  53. data/lib/samagotchi/web/public/participant.html +3 -1
  54. data/lib/samagotchi/web/public/participant.js +113 -9
  55. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  56. data/lib/samagotchi/web/public/route.js +3 -2
  57. data/lib/samagotchi/web/public/send_turn.js +6 -1
  58. data/lib/samagotchi/web/public/session_state.js +5 -0
  59. data/lib/samagotchi/web/public/stream_handlers.js +3 -0
  60. data/lib/samagotchi/web/public/tokens.css +2 -1
  61. data/lib/samagotchi/web/public/verdict_card.js +65 -0
  62. data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
  63. data/lib/samagotchi/web/public/verify_card.css +17 -0
  64. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  65. data/lib/samagotchi/web/push/attention.rb +25 -5
  66. data/lib/samagotchi/web/push/notifier.rb +4 -3
  67. data/lib/samagotchi/web/session_control.rb +2 -0
  68. data/lib/samagotchi/web/session_summary.rb +25 -1
  69. data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
  70. data/lib/samagotchi/worker.rb +125 -12
  71. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  72. data/lib/samagotchi/worker_inbound.rb +4 -4
  73. metadata +19 -1
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // A bundle's pills (chi.annotation) come after the presets: each prepares
11
11
  // its command line with the selection (annotate_actions.js), at most 3
12
- // inline, the rest behind "⋯".
12
+ // inline, the rest behind "⋯". Last, "Copy link" where the page has a
13
+ // link for the selection (a claim link, claim_link.js).
13
14
  //
14
15
  // On a touch screen the bar docks above the composer instead: the OS's own
15
16
  // selection menu (Copy, Look Up…) and the handles sit next to the selection,
@@ -21,7 +22,8 @@ import { annotateBarLeft, annotationSource, quoteBlock } from "./annotations.js"
21
22
  import { presetNote } from "./annotate_presets.js";
22
23
  import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline } from "./annotate_actions.js";
23
24
 
24
- // @param presets the preset texts (annotate_presets.js parsePresets)
25
+ // @param presets the presets: each a text (annotate_presets.js
26
+ // parsePresets) or {label, text} (verify_preset.js)
25
27
  // @param canAnnotate () => whether the open session takes annotations (one
26
28
  // is open and the composer can send)
27
29
  // @param roots () => the elements a selection may start in (the history,
@@ -45,12 +47,17 @@ import { actionEnabled, actionTitle, sameActions, selectionFields, splitInline }
45
47
  // passes its own, participant_annotate.js)
46
48
  // @param quote (text, source) => the quote block for the selected +text+
47
49
  // (annotations.js quoteBlock by default), or null for no text
50
+ // @param link (source, range) => the selection's link, {url, title}, or
51
+ // null for no "Copy link" (the default); +range+ the selection clipped
52
+ // to +source+'s root
53
+ // @param onLink (link) runs on "Copy link"; the selection is cleared and
54
+ // the bar hidden first
48
55
  // @param doc, win the page's document and window
49
56
  // @return {schedule(), hide(), el}; schedule() re-reads the selection on the
50
57
  // next frame (call it on scroll)
51
58
  export function createAnnotateBar({
52
- presets, canAnnotate, roots, isLive, onQuote, actions = () => [], running = () => false, onAction = () => {},
53
- dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock,
59
+ presets, canAnnotate, roots, isLive, onQuote, onPreset = () => {}, actions = () => [], running = () => false, onAction = () => {},
60
+ dock = () => null, source: sourceOf = annotationSource, quote = quoteBlock, link = () => null, onLink = () => {},
54
61
  doc = globalThis.document, win = globalThis.window,
55
62
  }) {
56
63
  const bar = doc.createElement("div");
@@ -66,8 +73,9 @@ export function createAnnotateBar({
66
73
  return btn;
67
74
  }
68
75
  bar.appendChild(button("Annotate", "annotate-main"));
69
- presets.forEach((text, i) => {
70
- const btn = button(text, "annotate-preset");
76
+ presets.forEach((preset, i) => {
77
+ const { label, text } = presetParts(preset);
78
+ const btn = button(label, "annotate-preset");
71
79
  btn.dataset.index = String(i);
72
80
  btn.title = text;
73
81
  bar.appendChild(btn);
@@ -78,10 +86,16 @@ export function createAnnotateBar({
78
86
  group.className = "annotate-actions";
79
87
  group.hidden = true;
80
88
  bar.appendChild(group);
89
+ const linkBtn = button("Copy link", "annotate-link");
90
+ linkBtn.hidden = true;
91
+ bar.appendChild(linkBtn);
81
92
  doc.body.appendChild(bar);
82
93
  const touch = win.matchMedia?.("(pointer: coarse)");
83
94
  let pendingQuote = null;
84
95
  let pendingFields = null;
96
+ let pendingSource = null;
97
+ let pendingRange = null;
98
+ let pendingLink = null;
85
99
  let drawn = [];
86
100
  let moreList = null;
87
101
 
@@ -129,6 +143,9 @@ export function createAnnotateBar({
129
143
  function hide() {
130
144
  pendingQuote = null;
131
145
  pendingFields = null;
146
+ pendingSource = null;
147
+ pendingRange = null;
148
+ pendingLink = null;
132
149
  if (moreList) moreList.hidden = true;
133
150
  bar.hidden = true;
134
151
  }
@@ -151,6 +168,11 @@ export function createAnnotateBar({
151
168
  if (!block) return hide();
152
169
  pendingQuote = block;
153
170
  pendingFields = selectionFields(raw, source);
171
+ pendingSource = source;
172
+ pendingRange = clipped;
173
+ pendingLink = link(source, clipped) || null;
174
+ linkBtn.hidden = !pendingLink;
175
+ linkBtn.title = pendingLink?.title || "";
154
176
  const list = actions() || [];
155
177
  if (!sameActions(list, drawn)) drawActions(list);
156
178
  markActions();
@@ -209,6 +231,13 @@ export function createAnnotateBar({
209
231
  moreList.hidden = !moreList.hidden;
210
232
  return;
211
233
  }
234
+ if (btn === linkBtn) {
235
+ const target = pendingLink;
236
+ win.getSelection()?.removeAllRanges();
237
+ hide();
238
+ if (target) onLink(target);
239
+ return;
240
+ }
212
241
  if (btn.dataset.action !== undefined) {
213
242
  const action = drawn[Number(btn.dataset.action)];
214
243
  if (!action || btn.disabled) return;
@@ -219,11 +248,18 @@ export function createAnnotateBar({
219
248
  return;
220
249
  }
221
250
  const preset = btn.dataset.index === undefined ? null : presets[Number(btn.dataset.index)];
222
- const text = preset ? presetNote(pendingQuote, preset) : pendingQuote;
251
+ const text = preset ? presetNote(pendingQuote, presetParts(preset).text) : pendingQuote;
252
+ const fields = { ...pendingFields, source: pendingSource, range: pendingRange };
223
253
  win.getSelection()?.removeAllRanges();
224
254
  hide();
255
+ if (preset) onPreset(preset, fields);
225
256
  onQuote(text);
226
257
  });
227
258
 
228
259
  return { schedule, hide, el: bar };
229
260
  }
261
+
262
+ // A preset's button label and note: a text is both.
263
+ function presetParts(preset) {
264
+ return typeof preset === "string" ? { label: preset, text: preset } : { label: preset.label, text: preset.text };
265
+ }
@@ -30,6 +30,7 @@ import {
30
30
  restartWorkers,
31
31
  restartWeb,
32
32
  onAuthLost,
33
+ api,
33
34
  } from "./data.js";
34
35
  import { authProbe, createAuthLost } from "./auth_lost.js";
35
36
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
@@ -58,9 +59,12 @@ import { createTaskStopper } from "./task_stop.js";
58
59
  import { createSystemOneWrongAction } from "./system_one_wrong.js";
59
60
  import { appendQuote } from "./annotations.js";
60
61
  import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
62
+ import { nameVerdictCards } from "./verdict_card.js";
63
+ import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
61
64
  import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
62
65
  import { parsePresets } from "./annotate_presets.js";
63
66
  import { createAnnotateBar } from "./annotate_bar.js";
67
+ import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
64
68
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
65
69
  import { copyText, installCopy } from "./copy.js";
66
70
  import { createQuestionCards } from "./question_cards.js";
@@ -70,6 +74,9 @@ import { mountComposer } from "./composer_mount.js";
70
74
  import { readStored, writeStored } from "./storage.js";
71
75
  import { createCommandList } from "./command_list.js";
72
76
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
77
+ import {
78
+ claimFromHash, ownerClaimHash, participantClaimLink, selectionClaim, showClaim, turnIdOf,
79
+ } from "./claim_link.js";
73
80
  import { scopeDir, scopeTemplate } from "./scope.js";
74
81
  import { html, render } from "./vendor/lit-html.js";
75
82
  import { shouldFollowScroll } from "./scroll.js";
@@ -175,7 +182,13 @@ const participantsControl = createParticipantsControl({
175
182
  startReview: (opts) => sessionActions.reviewSelected(opts),
176
183
  reviewing: () => sessionActions.reviewing.has(selected),
177
184
  // An older prompt drawn before the list came says "a participant": named now.
178
- onNames: () => nameUnnamedBadges(document),
185
+ onNames: () => {
186
+ nameUnnamedBadges(document);
187
+ nameVerdictCards(historyEl, (author) => {
188
+ const badge = authorBadge(author);
189
+ return badge && !badge.unnamed ? badge.name : null;
190
+ });
191
+ },
179
192
  onChange: () => updateInfoBar(),
180
193
  toast: (text, action, options) => showToast(text, action, options),
181
194
  });
@@ -220,6 +233,7 @@ const infoCancelBtn = $("#infoCancelBtn");
220
233
  const composeHintEl = $("#composeHint");
221
234
 
222
235
  let selected = null;
236
+ let pendingVerifyAnchor = null;
223
237
  // The session the composer's text was last written in ("" the start page,
224
238
  // undefined unknown): the composer is one for every session, and continue →
225
239
  // keeps only a draft of its own (fillOpener).
@@ -719,9 +733,61 @@ function setSessionHash(id) {
719
733
  history.replaceState(null, "", location.pathname + location.search + hash);
720
734
  }
721
735
  // A #/s/<id> typed or pasted into the address bar, or reached by back.
736
+ // A claim link (claim_link.js; #/s/<id>?t=…&q=…, or the participant's
737
+ // #s=<id>&t=… opened in this browser) opens its session and, once drawn,
738
+ // shows the claim.
722
739
  function selectFromHash() {
723
- const id = sessionIdFromHash(location.hash);
740
+ const claim = claimFromHash(location.hash);
741
+ const id = sessionIdFromHash(location.hash) || claim?.sessionId || null;
742
+ if (claim) pendingClaim = claim;
724
743
  if (id && id !== selected) select(id);
744
+ else if (claim) showPendingClaim();
745
+ }
746
+ let pendingClaim = null;
747
+ // Where a claim's text can be on this page: answers, own messages, steps.
748
+ const CLAIM_ROOTS = ".bubble.output, .bubble.user .user-message, .gen-text";
749
+ // The claim link's quote scrolled to and marked, its text read as the
750
+ // participant page shows it (the session's folder relative, home ~); a
751
+ // quote not found marks its turn; neither found: the top, and a toast.
752
+ // The hash is the session's again (select() already set it when it
753
+ // switched), so the same link opened again is a hashchange.
754
+ function showPendingClaim() {
755
+ const claim = pendingClaim;
756
+ if (!claim || claim.sessionId !== selected) return;
757
+ pendingClaim = null;
758
+ if (!allViewOpen() && location.hash !== sessionHash(selected)) {
759
+ history.replaceState(null, "", location.pathname + location.search + sessionHash(selected));
760
+ }
761
+ if (showClaim(historyEl, claim, { roots: CLAIM_ROOTS, opts: claimOpts() })) return;
762
+ historyEl.scrollTop = 0;
763
+ showToast("The linked turn isn't in this session.");
764
+ }
765
+ // How the participant page shows this page's text (claim_link.js).
766
+ function claimOpts() {
767
+ return { home: document.body.dataset.home || "", cwd: current.dir || "" };
768
+ }
769
+ // Annotate's "Copy link" for a selection in a turn's answer, step or own
770
+ // message: the participant page's link when the session has a person
771
+ // active (it opens in the browser their review link was opened in: one
772
+ // link per browser, the cookie), else this page's own.
773
+ function claimLinkFor(source, range) {
774
+ if (!selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
775
+ const turnId = turnIdOf(historyEl, source.root);
776
+ if (!turnId) return null;
777
+ const claim = { turnId, ...selectionClaim(source.root, range, claimOpts()) };
778
+ if (!claim.q) return null;
779
+ const target = participantsControl.claimTarget();
780
+ if (!target) {
781
+ return { url: `${location.origin}${location.pathname}${location.search}${ownerClaimHash(selected, claim)}`,
782
+ title: "A link to this on your page (no person is invited to this session)" };
783
+ }
784
+ const whose = target.names.map((name) => `${name}'s`).join(" or ");
785
+ return { url: participantClaimLink(target.base, selected, claim),
786
+ title: `A link to this for ${whose} browser: it opens where their review link was opened (one link per browser). Opened here, it shows your page.` };
787
+ }
788
+ async function copyClaimLink({ url }) {
789
+ if (await copyText(url)) showToast("Link copied");
790
+ else showToast(`Couldn't copy: ${url}`, null, { sticky: true });
725
791
  }
726
792
  function applyView() {
727
793
  const open = allViewOpen();
@@ -914,13 +980,16 @@ function clearNotice() {
914
980
  }
915
981
  promptEl.addEventListener("input", clearNotice);
916
982
  promptEl.addEventListener("input", () => noteDraft());
917
- // An emptied composer lets go of its Apply turn.
983
+ // An emptied composer lets go of its Apply turn and its Verify claim.
918
984
  promptEl.addEventListener("input", () => {
919
- if (!promptEl.value.trim()) deliveryControl.release();
985
+ if (promptEl.value.trim()) return;
986
+ deliveryControl.release();
987
+ pendingVerifyAnchor = null;
920
988
  });
921
989
 
922
990
  async function select(id) {
923
991
  clearNotice();
992
+ if (selected !== id) pendingVerifyAnchor = null;
924
993
  selected = id;
925
994
  setSessionHash(id);
926
995
  lists.paintActive();
@@ -1002,6 +1071,7 @@ async function resync({ endText = null } = {}) {
1002
1071
  const kept = keptCommandBubbles(historyEl, id);
1003
1072
  resetTurnView();
1004
1073
  renderSessionView(data, { kept });
1074
+ showPendingClaim();
1005
1075
  // Preview is read-only: a worker is woken only by POST /turn, so attach
1006
1076
  // only when a live bridge exists.
1007
1077
  current.streamSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
@@ -1116,6 +1186,7 @@ function renderHistory(items) {
1116
1186
  }
1117
1187
  if (typeof items[0] === "object" && items[0].role) {
1118
1188
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
1189
+ renderSavedVerdicts();
1119
1190
  // The live dividers count from the history's last activity.
1120
1191
  gapClock.seed(lastActivity(turnGroups(items, current.timing), items));
1121
1192
  linkTextRefs();
@@ -1257,6 +1328,44 @@ function placeApply(data) {
1257
1328
  appendToHistory(elementOf(applyRowTemplate(data.turn_id, badge)));
1258
1329
  }
1259
1330
 
1331
+ function placeVerdict(record) {
1332
+ if (!record) return;
1333
+ const known = (current.verdicts || []).some((item) => item.check_turn_id === record.check_turn_id);
1334
+ if (!known) current.verdicts = [...(current.verdicts || []), record];
1335
+ // A fresh verdict ends its check's "verifying fresh" (the card shows it).
1336
+ if (record.fresh) current.verifyFresh = settleRunning(current.verifyFresh, record);
1337
+ const badge = record.by && authorBadge(record.by);
1338
+ placeVerdictWithFresh(historyEl, record, {
1339
+ who: badge ? badge.name : "You",
1340
+ author: record.by || null,
1341
+ }, verifyFreshState());
1342
+ }
1343
+
1344
+ // The owner's Verify fresh on an unclear verdict's card (verdict_fresh.js):
1345
+ // always theirs to start; its running check links to the child's session.
1346
+ function verifyFreshState() {
1347
+ const id = selected;
1348
+ if (!Array.isArray(current.verifyFresh)) current.verifyFresh = [];
1349
+ return {
1350
+ verdicts: current.verdicts || [],
1351
+ running: current.verifyFresh,
1352
+ canStart: !!id,
1353
+ start: async (of) => {
1354
+ try {
1355
+ const body = await api(freshURL(id, of), { method: "POST", body: "{}" });
1356
+ return { ok: true, child_id: body.child_id || null };
1357
+ } catch (error) {
1358
+ return { ok: false, error: error.message };
1359
+ }
1360
+ },
1361
+ childHref: (childId) => sessionHash(childId),
1362
+ };
1363
+ }
1364
+
1365
+ function renderSavedVerdicts() {
1366
+ for (const record of current.verdicts || []) placeVerdict(record);
1367
+ }
1368
+
1260
1369
  // Apply: the owner's Apply turn into the composer, after what is typed
1261
1370
  // there; never sent from here.
1262
1371
  historyEl.addEventListener("click", async (e) => {
@@ -1494,6 +1603,7 @@ const streamHandlers = createStreamHandlers({
1494
1603
  workingSet,
1495
1604
  participantsControl,
1496
1605
  placeApply,
1606
+ placeVerdict,
1497
1607
  sentIds,
1498
1608
  earlyRestores,
1499
1609
  stream: {
@@ -1599,6 +1709,8 @@ const sendTurnControl = createSendTurn({
1599
1709
  sentIds,
1600
1710
  earlyRestores,
1601
1711
  api: { createIdleSession, uploadImage, sendTurn },
1712
+ verify: () => pendingVerifyAnchor,
1713
+ clearVerify: (anchor) => { if (pendingVerifyAnchor === anchor) pendingVerifyAnchor = null; },
1602
1714
  dir: scope.dir,
1603
1715
  startPage,
1604
1716
  takeDelivery: () => deliveryControl.take(),
@@ -1821,9 +1933,11 @@ const updateSheet = createUpdateSheet({
1821
1933
  function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
1822
1934
 
1823
1935
  // Annotate (annotate_bar.js): a selection in the conversation offers
1824
- // "Annotate" and the presets, which append a quote to the composer, and the
1825
- // session's plugins' pills (chi.annotation, annotate_actions.js), which fill
1826
- // the composer with their command line and the quote, or send it at once.
1936
+ // "Annotate" and the presets (then the built-in Verify: verify_preset.js),
1937
+ // which append a quote to the composer, the session's plugins' pills
1938
+ // (chi.annotation, annotate_actions.js), which fill the composer with their
1939
+ // command line and the quote, or send it at once, and Copy link in a turn
1940
+ // (claimLinkFor).
1827
1941
  function fillComposer(text) {
1828
1942
  openForVisit();
1829
1943
  promptEl.value = text;
@@ -1835,11 +1949,14 @@ function fillComposer(text) {
1835
1949
  }
1836
1950
 
1837
1951
  const annotateBar = createAnnotateBar({
1838
- presets: parsePresets(document.body.dataset.annotatePresets),
1952
+ presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
1839
1953
  canAnnotate: () => !!selected && current.owner !== "tui",
1840
1954
  roots: turnRoots,
1841
1955
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
1842
1956
  onQuote: (text) => fillComposer(appendQuote(promptEl.value, text)),
1957
+ onPreset: (preset, fields) => {
1958
+ pendingVerifyAnchor = preset === VERIFY_PRESET ? verifyAnchorFor(fields) : null;
1959
+ },
1843
1960
  actions: () => annotationActions(current.commands),
1844
1961
  running: () => liveTurn.running,
1845
1962
  onAction: (action, fields) => {
@@ -1847,8 +1964,17 @@ const annotateBar = createAnnotateBar({
1847
1964
  if (action.send) runSessionCommand(line);
1848
1965
  else fillComposer(composerWithAction(promptEl.value, action, line));
1849
1966
  },
1967
+ link: claimLinkFor,
1968
+ onLink: copyClaimLink,
1850
1969
  dock: () => $("#dock"),
1851
1970
  });
1971
+
1972
+ function verifyAnchorFor({ source, range }) {
1973
+ if (!source || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
1974
+ const turnId = turnIdOf(historyEl, source.root);
1975
+ const quote = selectionClaim(source.root, range, claimOpts()).q;
1976
+ return turnId && quote ? { turn_id: turnId, quote } : null;
1977
+ }
1852
1978
  historyEl.addEventListener("scroll", annotateBar.schedule);
1853
1979
  // A command bubble's Apply (CacheConfirm): sends the change's --yes line
1854
1980
  // once; its own bubble then says what it did.