samagotchi 0.124.0 → 0.125.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +4 -2
  4. data/docs/cli.md +20 -8
  5. data/docs/internals/participants.md +26 -1
  6. data/docs/internals/remote-sessions.md +2 -0
  7. data/docs/sessions.md +2 -2
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/process_group.rb +10 -1
  10. data/lib/samagotchi/reminder_carry.rb +92 -0
  11. data/lib/samagotchi/reminder_store.rb +49 -0
  12. data/lib/samagotchi/session_manager.rb +28 -4
  13. data/lib/samagotchi/sessions_command.rb +1 -1
  14. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
  15. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
  16. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
  17. data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
  18. data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
  19. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
  20. data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
  21. data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
  22. data/lib/samagotchi/version.rb +1 -1
  23. data/lib/samagotchi/web/app.rb +7 -3
  24. data/lib/samagotchi/web/participants_admin.rb +15 -4
  25. data/lib/samagotchi/web/public/annotate_bar.css +8 -4
  26. data/lib/samagotchi/web/public/annotate_bar.js +34 -6
  27. data/lib/samagotchi/web/public/app.js +66 -5
  28. data/lib/samagotchi/web/public/claim_link.js +347 -0
  29. data/lib/samagotchi/web/public/participant.html +1 -0
  30. data/lib/samagotchi/web/public/participant.js +46 -4
  31. data/lib/samagotchi/web/public/participants_panel.js +15 -0
  32. data/lib/samagotchi/web/public/route.js +3 -2
  33. data/lib/samagotchi/web/public/tokens.css +2 -1
  34. data/lib/samagotchi/web/public/verify_preset.js +18 -0
  35. data/lib/samagotchi/worker.rb +57 -3
  36. data/lib/samagotchi/worker_idle_exit.rb +7 -6
  37. metadata +11 -1
@@ -61,6 +61,7 @@ import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.
61
61
  import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
62
62
  import { parsePresets } from "./annotate_presets.js";
63
63
  import { createAnnotateBar } from "./annotate_bar.js";
64
+ import { withVerify } from "./verify_preset.js";
64
65
  import { actionLine, annotationActions, composerWithAction } from "./annotate_actions.js";
65
66
  import { copyText, installCopy } from "./copy.js";
66
67
  import { createQuestionCards } from "./question_cards.js";
@@ -70,6 +71,9 @@ import { mountComposer } from "./composer_mount.js";
70
71
  import { readStored, writeStored } from "./storage.js";
71
72
  import { createCommandList } from "./command_list.js";
72
73
  import { ALL_SESSIONS_HASH, sessionHash, sessionIdFromHash } from "./route.js";
74
+ import {
75
+ claimFromHash, ownerClaimHash, participantClaimLink, selectionClaim, showClaim, turnIdOf,
76
+ } from "./claim_link.js";
73
77
  import { scopeDir, scopeTemplate } from "./scope.js";
74
78
  import { html, render } from "./vendor/lit-html.js";
75
79
  import { shouldFollowScroll } from "./scroll.js";
@@ -719,9 +723,61 @@ function setSessionHash(id) {
719
723
  history.replaceState(null, "", location.pathname + location.search + hash);
720
724
  }
721
725
  // A #/s/<id> typed or pasted into the address bar, or reached by back.
726
+ // A claim link (claim_link.js; #/s/<id>?t=…&q=…, or the participant's
727
+ // #s=<id>&t=… opened in this browser) opens its session and, once drawn,
728
+ // shows the claim.
722
729
  function selectFromHash() {
723
- const id = sessionIdFromHash(location.hash);
730
+ const claim = claimFromHash(location.hash);
731
+ const id = sessionIdFromHash(location.hash) || claim?.sessionId || null;
732
+ if (claim) pendingClaim = claim;
724
733
  if (id && id !== selected) select(id);
734
+ else if (claim) showPendingClaim();
735
+ }
736
+ let pendingClaim = null;
737
+ // Where a claim's text can be on this page: answers, own messages, steps.
738
+ const CLAIM_ROOTS = ".bubble.output, .bubble.user .user-message, .gen-text";
739
+ // The claim link's quote scrolled to and marked, its text read as the
740
+ // participant page shows it (the session's folder relative, home ~); a
741
+ // quote not found marks its turn; neither found: the top, and a toast.
742
+ // The hash is the session's again (select() already set it when it
743
+ // switched), so the same link opened again is a hashchange.
744
+ function showPendingClaim() {
745
+ const claim = pendingClaim;
746
+ if (!claim || claim.sessionId !== selected) return;
747
+ pendingClaim = null;
748
+ if (!allViewOpen() && location.hash !== sessionHash(selected)) {
749
+ history.replaceState(null, "", location.pathname + location.search + sessionHash(selected));
750
+ }
751
+ if (showClaim(historyEl, claim, { roots: CLAIM_ROOTS, opts: claimOpts() })) return;
752
+ historyEl.scrollTop = 0;
753
+ showToast("The linked turn isn't in this session.");
754
+ }
755
+ // How the participant page shows this page's text (claim_link.js).
756
+ function claimOpts() {
757
+ return { home: document.body.dataset.home || "", cwd: current.dir || "" };
758
+ }
759
+ // Annotate's "Copy link" for a selection in a turn's answer, step or own
760
+ // message: the participant page's link when the session has a person
761
+ // active (it opens in the browser their review link was opened in: one
762
+ // link per browser, the cookie), else this page's own.
763
+ function claimLinkFor(source, range) {
764
+ if (!selected || !["answer", "step", "user"].includes(source.kind) || !historyEl.contains(source.root)) return null;
765
+ const turnId = turnIdOf(historyEl, source.root);
766
+ if (!turnId) return null;
767
+ const claim = { turnId, ...selectionClaim(source.root, range, claimOpts()) };
768
+ if (!claim.q) return null;
769
+ const target = participantsControl.claimTarget();
770
+ if (!target) {
771
+ return { url: `${location.origin}${location.pathname}${location.search}${ownerClaimHash(selected, claim)}`,
772
+ title: "A link to this on your page (no person is invited to this session)" };
773
+ }
774
+ const whose = target.names.map((name) => `${name}'s`).join(" or ");
775
+ return { url: participantClaimLink(target.base, selected, claim),
776
+ 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.` };
777
+ }
778
+ async function copyClaimLink({ url }) {
779
+ if (await copyText(url)) showToast("Link copied");
780
+ else showToast(`Couldn't copy: ${url}`, null, { sticky: true });
725
781
  }
726
782
  function applyView() {
727
783
  const open = allViewOpen();
@@ -1002,6 +1058,7 @@ async function resync({ endText = null } = {}) {
1002
1058
  const kept = keptCommandBubbles(historyEl, id);
1003
1059
  resetTurnView();
1004
1060
  renderSessionView(data, { kept });
1061
+ showPendingClaim();
1005
1062
  // Preview is read-only: a worker is woken only by POST /turn, so attach
1006
1063
  // only when a live bridge exists.
1007
1064
  current.streamSeq = typeof data.last_event_seq === "number" ? (data.last_event_id || data.last_event_seq) : null;
@@ -1821,9 +1878,11 @@ const updateSheet = createUpdateSheet({
1821
1878
  function showToast(text, action = null, options = {}) { toasts.show(text, action, options); }
1822
1879
 
1823
1880
  // 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.
1881
+ // "Annotate" and the presets (then the built-in Verify: verify_preset.js),
1882
+ // which append a quote to the composer, the session's plugins' pills
1883
+ // (chi.annotation, annotate_actions.js), which fill the composer with their
1884
+ // command line and the quote, or send it at once, and Copy link in a turn
1885
+ // (claimLinkFor).
1827
1886
  function fillComposer(text) {
1828
1887
  openForVisit();
1829
1888
  promptEl.value = text;
@@ -1835,7 +1894,7 @@ function fillComposer(text) {
1835
1894
  }
1836
1895
 
1837
1896
  const annotateBar = createAnnotateBar({
1838
- presets: parsePresets(document.body.dataset.annotatePresets),
1897
+ presets: withVerify(parsePresets(document.body.dataset.annotatePresets)),
1839
1898
  canAnnotate: () => !!selected && current.owner !== "tui",
1840
1899
  roots: turnRoots,
1841
1900
  isLive: (root) => liveTurn.running && turnDom.containsLive(root),
@@ -1847,6 +1906,8 @@ const annotateBar = createAnnotateBar({
1847
1906
  if (action.send) runSessionCommand(line);
1848
1907
  else fillComposer(composerWithAction(promptEl.value, action, line));
1849
1908
  },
1909
+ link: claimLinkFor,
1910
+ onLink: copyClaimLink,
1850
1911
  dock: () => $("#dock"),
1851
1912
  });
1852
1913
  historyEl.addEventListener("scroll", annotateBar.schedule);
@@ -0,0 +1,347 @@
1
+ // A link to a claim (docs/internals/participants.md, Deep links): one turn
2
+ // of a session and a quote in it, on either page.
3
+ //
4
+ // participant page: <chi web>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>
5
+ // owner's page: #/s/<id>?t=<turn8>&q=<quote>&p=<prefix>
6
+ //
7
+ // t is the turn's id, its first 8 characters (a turn-opening message's
8
+ // turn_id, data-turn-id in the DOM); q the selection's rendered text,
9
+ // normalized and at most QUOTE_MAX characters; p the PREFIX_MAX characters
10
+ // before it in the same bubble, to tell two equal quotes apart (left out
11
+ // when there are none). URLSearchParams encodes both, so a link is
12
+ // URL-safe and a space is one "+".
13
+ //
14
+ // Normalized: whitespace collapsed, and what the participant page shows
15
+ // instead (ParticipantView::Redact): secret shapes "[redacted]" (so a link
16
+ // never carries one, and matches the participant's redacted text), and on
17
+ // the owner's page the session's folder relative ("<cwd>/x" → "x",
18
+ // "<cwd>" → "."), the home folder "~". Not the owner's token (Redact's
19
+ // viewer secrets): the page doesn't hold it.
20
+ //
21
+ // Opening one: the quote's bubble scrolled to and marked (the quote itself
22
+ // by the CSS Highlight API, where the browser has it); a quote not found
23
+ // marks the turn; a turn not found (one the page doesn't stamp) is looked
24
+ // for in the whole page, and then is the caller's (top + a toast).
25
+ //
26
+ // The text work is pure; the DOM part reads only Element/Range/TreeWalker,
27
+ // so it runs under `node --test` with happy-dom. A module of its own:
28
+ // static JS is cached at unversioned URLs, and a new export on an old
29
+ // module would blank the page.
30
+
31
+ export const QUOTE_MAX = 200;
32
+ export const PREFIX_MAX = 32;
33
+ export const TURN_CHARS = 8;
34
+ // The CSS highlight's name (::highlight(chi-claim)) and the marked
35
+ // element's class.
36
+ export const HIGHLIGHT = "chi-claim";
37
+ export const HIT_CLASS = "claim-hit";
38
+
39
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40
+
41
+ export const MARK = "[redacted]";
42
+ // ParticipantView::Redact's PATTERNS and VALUES
43
+ // (lib/samagotchi/web/participant_view.rb), as JS: keep them in step.
44
+ // spec/fixtures/redact_cases.json holds cases both sides read the same
45
+ // (participant_view_spec.rb, claim_link.test.js).
46
+ const SECRET_PATTERNS = [
47
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
48
+ /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{16,}/g,
49
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
50
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
51
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
52
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
53
+ /\bchp_[A-Za-z0-9_-]{20,}/g,
54
+ /\bchc_[A-Za-z0-9_-]{43}/g,
55
+ /\bAIza[0-9A-Za-z_-]{30,}/g,
56
+ /\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/g,
57
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
58
+ /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/_-]+/g,
59
+ ];
60
+ // The value after group 1 is the secret.
61
+ const SECRET_VALUES = [
62
+ /(\b(?:Authorization|X-Chi-Bridge):\s*(?:Bearer\s+|Basic\s+|Token\s+)?)[^\s"'`]+/gi,
63
+ /((?<![A-Za-z0-9])[A-Za-z0-9_-]*?(?:token|api[_-]?key|access[_-]?key|secret|password|passwd)["']?\s*[=:]\s*["']?)[^\s"'&`,;}]+/gi,
64
+ /(\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:)[^@\s/]+(?=@)/gi,
65
+ ];
66
+
67
+ // The secrets in +raw+ as [{start, end, text: MARK}]; overlapping ones
68
+ // (a token= value holding a key shape) one span, as Redact's passes in a
69
+ // row leave one mark.
70
+ function secretSpans(raw) {
71
+ const found = [];
72
+ for (const re of SECRET_PATTERNS) for (const m of raw.matchAll(re)) found.push([m.index, m.index + m[0].length]);
73
+ for (const re of SECRET_VALUES) {
74
+ for (const m of raw.matchAll(re)) found.push([m.index + m[1].length, m.index + m[0].length]);
75
+ }
76
+ found.sort((a, b) => a[0] - b[0]);
77
+ const spans = [];
78
+ for (const [start, end] of found) {
79
+ const last = spans[spans.length - 1];
80
+ if (last && start < last.end) last.end = Math.max(last.end, end);
81
+ else spans.push({ start, end, text: MARK });
82
+ }
83
+ return spans;
84
+ }
85
+
86
+ // The spans of +raw+ that read differently on the participant page:
87
+ // [{start, end, text}], in order, not overlapping. Secrets first, then
88
+ // the session's folder (it is usually under home), as Redact.text.
89
+ function replacements(raw, { home = "", cwd = "" } = {}) {
90
+ const spans = secretSpans(raw);
91
+ const taken = (s, e) => spans.some((r) => s < r.end && e > r.start);
92
+ if (cwd.length > 1) {
93
+ for (const m of raw.matchAll(new RegExp(`${escapeRegExp(cwd)}(/|(?=\\b|$))`, "g"))) {
94
+ const end = m.index + m[0].length;
95
+ if (!taken(m.index, end)) spans.push({ start: m.index, end, text: m[1] === "/" ? "" : "." });
96
+ }
97
+ }
98
+ if (home.length > 1) {
99
+ for (const m of raw.matchAll(new RegExp(`${escapeRegExp(home)}(?=/|\\b|$)`, "g"))) {
100
+ const end = m.index + m[0].length;
101
+ if (!taken(m.index, end)) spans.push({ start: m.index, end, text: "~" });
102
+ }
103
+ }
104
+ return spans.sort((a, b) => a.start - b.start);
105
+ }
106
+
107
+ // +raw+ normalized, with where each of its characters came from: text,
108
+ // and starts[i]/ends[i] the raw offsets character i covers.
109
+ export function normalizeWithMap(raw, opts = {}) {
110
+ const source = String(raw ?? "");
111
+ const spans = replacements(source, opts);
112
+ let text = "";
113
+ const starts = [];
114
+ const ends = [];
115
+ let space = null;
116
+ const put = (ch, start, end) => {
117
+ if (/\s/.test(ch)) {
118
+ if (space) space.end = end;
119
+ else if (text) space = { start, end };
120
+ return;
121
+ }
122
+ if (space) {
123
+ text += " ";
124
+ starts.push(space.start);
125
+ ends.push(space.end);
126
+ space = null;
127
+ }
128
+ text += ch;
129
+ starts.push(start);
130
+ ends.push(end);
131
+ };
132
+ let i = 0;
133
+ let next = 0;
134
+ while (i < source.length) {
135
+ const span = spans[next];
136
+ if (span && span.start === i) {
137
+ // A folder shown "." or "" (its "/" gone) maps to the whole span.
138
+ for (const ch of span.text) put(ch, span.start, span.end);
139
+ i = span.end;
140
+ next += 1;
141
+ continue;
142
+ }
143
+ put(source[i], i, i + 1);
144
+ i += 1;
145
+ }
146
+ return { text, starts, ends };
147
+ }
148
+
149
+ export function normalizeClaim(raw, opts = {}) {
150
+ return normalizeWithMap(raw, opts).text;
151
+ }
152
+
153
+ // The first +max+ characters of +text+ (or the last, +fromEnd+), never
154
+ // half a surrogate pair.
155
+ function clip(text, max, fromEnd = false) {
156
+ const chars = Array.from(text);
157
+ if (chars.length <= max) return text;
158
+ return (fromEnd ? chars.slice(-max) : chars.slice(0, max)).join("");
159
+ }
160
+
161
+ // The claim's q and p for a selection: +selected+ its text, +before+ and
162
+ // +after+ the bubble's text around it. Normalized together, so a secret
163
+ // the selection cuts into is read whole and marked whole (never a piece
164
+ // of it in the link), and the prefix ends as the page reads it (one space
165
+ // between it and the quote, or none). q is "" when the selection holds no
166
+ // text.
167
+ export function claimQuote(selected, before = "", opts = {}, after = "") {
168
+ const pre = String(before ?? "");
169
+ const from = pre.length;
170
+ const to = from + String(selected ?? "").length;
171
+ const { text, starts, ends } = normalizeWithMap(`${pre}${selected ?? ""}${after ?? ""}`, opts);
172
+ let a = 0;
173
+ while (a < text.length && ends[a] <= from) a += 1;
174
+ let b = text.length;
175
+ while (b > a && starts[b - 1] >= to) b -= 1;
176
+ while (a < b && text[a] === " ") a += 1;
177
+ while (b > a && text[b - 1] === " ") b -= 1;
178
+ const q = text.slice(a, b);
179
+ if (!q) return { q: "", p: "" };
180
+ return { q: clip(q, QUOTE_MAX), p: clip(text.slice(0, a), PREFIX_MAX, true) };
181
+ }
182
+
183
+ // "t=…&q=…&p=…" for a claim {turnId, q, p}; empty parts left out.
184
+ export function claimParams({ turnId = "", q = "", p = "" } = {}) {
185
+ const params = new URLSearchParams();
186
+ if (turnId) params.set("t", String(turnId).slice(0, TURN_CHARS));
187
+ if (q) params.set("q", q);
188
+ if (q && p) params.set("p", p);
189
+ return params.toString();
190
+ }
191
+
192
+ // The participant page's link: +base+ the participant link's origin
193
+ // ("https://chi.example/", "http://192.168.1.5:4567/"), no token.
194
+ export function participantClaimLink(base, sessionId, claim) {
195
+ const params = claimParams(claim);
196
+ const root = String(base || "").replace(/#.*$/, "").replace(/\/?$/, "/");
197
+ return `${root}#s=${encodeURIComponent(sessionId)}${params ? `&${params}` : ""}`;
198
+ }
199
+
200
+ // The owner's page's hash for the claim.
201
+ export function ownerClaimHash(sessionId, claim) {
202
+ const params = claimParams(claim);
203
+ return `#/s/${encodeURIComponent(sessionId)}${params ? `?${params}` : ""}`;
204
+ }
205
+
206
+ // {sessionId, turn, q, p} from either page's hash, or null when it names
207
+ // no turn (a bare session link). The owner's page reads both forms: the
208
+ // participant link opened in the owner's browser shows the owner's page.
209
+ export function claimFromHash(hash) {
210
+ const raw = String(hash || "");
211
+ let id = null;
212
+ let query = "";
213
+ const owner = /^#\/s\/([^/?#]+)\?(.*)$/.exec(raw);
214
+ if (owner) {
215
+ try { id = decodeURIComponent(owner[1]); } catch { return null; }
216
+ query = owner[2];
217
+ } else if (raw.startsWith("#") && !raw.startsWith("#/")) {
218
+ query = raw.slice(1);
219
+ } else {
220
+ return null;
221
+ }
222
+ let params;
223
+ try { params = new URLSearchParams(query); } catch { return null; }
224
+ id ||= params.get("s");
225
+ const turn = params.get("t") || "";
226
+ if (!id || !/^[0-9a-f-]{1,36}$/i.test(turn)) return null;
227
+ return { sessionId: id, turn: turn.toLowerCase(), q: params.get("q") || "", p: params.get("p") || "" };
228
+ }
229
+
230
+ // Where the claim's text is in +texts+ (each root's raw text): {at, start,
231
+ // end} raw offsets in texts[at], or null. The prefix and the quote
232
+ // together first, then the quote alone (the bubble's text before it may
233
+ // read differently on the other page).
234
+ export function findClaim(texts, { q = "", p = "" } = {}, opts = {}) {
235
+ if (!q) return null;
236
+ const maps = texts.map((t) => normalizeWithMap(t, opts));
237
+ const needles = p ? [[`${p}${q}`, p.length], [q, 0]] : [[q, 0]];
238
+ for (const [needle, skip] of needles) {
239
+ for (let at = 0; at < maps.length; at += 1) {
240
+ const found = maps[at].text.indexOf(needle);
241
+ if (found < 0) continue;
242
+ const first = found + skip;
243
+ const last = found + needle.length - 1;
244
+ return { at, start: maps[at].starts[first], end: maps[at].ends[last] };
245
+ }
246
+ }
247
+ return null;
248
+ }
249
+
250
+ // ---- The DOM ----
251
+
252
+ // The turn-opening element whose data-turn-id starts with +turn+ in
253
+ // +container+, and the roots (+rootSelector+) from it to the next turn's
254
+ // opening: {anchor, roots}, or null.
255
+ export function turnRoots(container, turn, rootSelector) {
256
+ const anchors = [...container.querySelectorAll("[data-turn-id]")];
257
+ const at = anchors.findIndex((a) => turn && String(a.dataset.turnId || "").toLowerCase().startsWith(turn));
258
+ if (at < 0) return null;
259
+ const anchor = anchors[at];
260
+ const next = anchors[at + 1] || null;
261
+ const FOLLOWING = 4;
262
+ const roots = [...container.querySelectorAll(rootSelector)].filter((el) =>
263
+ (anchor === el || anchor.contains(el) || (anchor.compareDocumentPosition(el) & FOLLOWING))
264
+ && (!next || (!next.contains(el) && (el.compareDocumentPosition(next) & FOLLOWING))));
265
+ return { anchor, roots };
266
+ }
267
+
268
+ // The turn id of the turn +node+ is in (the last turn opening at or before
269
+ // it in +container+), or null.
270
+ export function turnIdOf(container, node) {
271
+ let found = null;
272
+ const PRECEDING = 2;
273
+ for (const anchor of container.querySelectorAll("[data-turn-id]")) {
274
+ if (anchor === node || anchor.contains(node) || (node.compareDocumentPosition(anchor) & PRECEDING)) found = anchor;
275
+ else break;
276
+ }
277
+ return found?.dataset.turnId || null;
278
+ }
279
+
280
+ // +root+'s text nodes and their text joined: {text, nodes: [{node, start}]}.
281
+ function textOf(root, doc) {
282
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
283
+ const nodes = [];
284
+ let text = "";
285
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
286
+ nodes.push({ node: n, start: text.length });
287
+ text += n.data;
288
+ }
289
+ return { text, nodes };
290
+ }
291
+
292
+ // The text node and offset of raw offset +at+ in a textOf result.
293
+ function pointAt({ nodes }, at, end = false) {
294
+ for (let i = nodes.length - 1; i >= 0; i -= 1) {
295
+ const { node, start } = nodes[i];
296
+ if (at > start || (at === start && !end) || i === 0) return { node, offset: Math.min(Math.max(at - start, 0), node.data.length) };
297
+ }
298
+ return null;
299
+ }
300
+
301
+ // The claim for a selection in +root+ (+range+ in it), as claimQuote's.
302
+ export function selectionClaim(root, range, opts = {}, doc = globalThis.document) {
303
+ const before = doc.createRange();
304
+ before.setStart(root, 0);
305
+ before.setEnd(range.startContainer, range.startOffset);
306
+ const after = doc.createRange();
307
+ after.setStart(range.endContainer, range.endOffset);
308
+ after.setEnd(root, root.childNodes.length);
309
+ return claimQuote(range.toString(), before.toString(), opts, after.toString());
310
+ }
311
+
312
+ // Finds the claim +claim+ ({turn, q, p}) in +container+ and shows it: the
313
+ // match's root (or the turn's opening) scrolled to the middle and marked
314
+ // HIT_CLASS, its closed <details> opened, the quote highlighted (CSS
315
+ // Highlight API). Without the turn (no opening carries its id: a
316
+ // non-person turn), the quote in any root of the page. @return "quote",
317
+ // "turn" or null (neither the turn nor the quote).
318
+ export function showClaim(container, claim, { roots: rootSelector, opts = {}, doc = globalThis.document, win = globalThis.window } = {}) {
319
+ clearClaim(container, { doc, win });
320
+ const turn = turnRoots(container, claim.turn, rootSelector);
321
+ const roots = turn ? turn.roots : [...container.querySelectorAll(rootSelector)];
322
+ const texts = roots.map((root) => textOf(root, doc));
323
+ const hit = findClaim(texts.map((t) => t.text), claim, opts);
324
+ if (!hit && !turn) return null;
325
+ const el = hit ? roots[hit.at] : turn.anchor;
326
+ for (let d = el.parentElement?.closest("details"); d; d = d.parentElement?.closest("details")) d.open = true;
327
+ el.classList.add(HIT_CLASS);
328
+ el.scrollIntoView?.({ block: "center" });
329
+ if (hit) {
330
+ const start = pointAt(texts[hit.at], hit.start);
331
+ const end = pointAt(texts[hit.at], hit.end, true);
332
+ const highlights = win?.CSS?.highlights;
333
+ if (start && end && highlights && typeof win.Highlight === "function") {
334
+ const range = doc.createRange();
335
+ range.setStart(start.node, start.offset);
336
+ range.setEnd(end.node, end.offset);
337
+ highlights.set(HIGHLIGHT, new win.Highlight(range));
338
+ }
339
+ }
340
+ return hit ? "quote" : "turn";
341
+ }
342
+
343
+ // The mark and highlight showClaim left, gone.
344
+ export function clearClaim(container, { win = globalThis.window } = {}) {
345
+ for (const el of container.querySelectorAll(`.${HIT_CLASS}`)) el.classList.remove(HIT_CLASS);
346
+ win?.CSS?.highlights?.delete?.(HIGHLIGHT);
347
+ }
@@ -171,6 +171,7 @@ button.small{font-size:12px;padding:2px 8px}
171
171
  <div class="p-note" id="note"></div>
172
172
  </div>
173
173
  <div class="p-offline" id="offline" role="status" hidden>Can't reach the session — retrying…</div>
174
+ <div class="p-offline" id="claimNote" role="status" hidden></div>
174
175
  </header>
175
176
  <div class="p-body">
176
177
  <aside class="p-plan" id="plan" hidden aria-label="Under review">
@@ -22,8 +22,10 @@ import { createAuthLost, lostAnswer } from "./auth_lost.js";
22
22
  import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
23
23
  import { createAnnotateBar } from "./annotate_bar.js";
24
24
  import { parsePresets } from "./annotate_presets.js";
25
+ import { withVerify } from "./verify_preset.js";
25
26
  import { appendQuote } from "./annotations.js";
26
27
  import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
28
+ import { claimFromHash, showClaim } from "./claim_link.js";
27
29
  import { mountComposer } from "./composer_mount.js";
28
30
  import { PROMPT_LINE_PX } from "./composer_size.js";
29
31
  import {
@@ -68,6 +70,10 @@ let wantedView = "thread";
68
70
  // a message's node while it is unchanged (its open tool list, a text
69
71
  // selection in it).
70
72
  let drawnMessages = [];
73
+ // A claim link's turn and quote (claim_link.js: #s=…&t=…&q=…), shown once
74
+ // the thread is drawn; a later hash change shows another.
75
+ let pendingClaim = claimFromHash(globalThis.location?.hash);
76
+ let claimNoteTimer = null;
71
77
 
72
78
  function el(tag, cls, text) {
73
79
  const node = document.createElement(tag);
@@ -141,6 +147,7 @@ async function load() {
141
147
  live = liveFrom(data.current_turn);
142
148
  liveStale = false;
143
149
  render();
150
+ showPendingClaim();
144
151
  // The live turn is the data's now: the events after its cursor come
145
152
  // again from there (the open stream may have delivered some of them to
146
153
  // the live turn just replaced). With no cursor (no worker) an open
@@ -220,6 +227,40 @@ function render() {
220
227
  renderDock();
221
228
  }
222
229
 
230
+ // The claim link's quote in the thread, scrolled to and marked; a quote
231
+ // not found marks its turn; neither found (before a continue) leaves the
232
+ // thread at the top and says so, as does a link to another session (this
233
+ // browser's cookie is one grant's). The hash then drops the claim, so the
234
+ // same link opened again shows it again (a hashchange).
235
+ function showPendingClaim() {
236
+ const claim = pendingClaim;
237
+ if (!claim || !data) return;
238
+ pendingClaim = null;
239
+ const { pathname = "", search = "" } = globalThis.location || {};
240
+ globalThis.history?.replaceState?.(null, "", `${pathname}${search}#s=${encodeURIComponent(sessionId)}`);
241
+ if (claim.sessionId !== sessionId) {
242
+ const title = data.session?.title ? `"${data.session.title}"` : "this session";
243
+ return showClaimNote(`This link is for another session; this browser holds your link for ${title}.`);
244
+ }
245
+ setView("thread");
246
+ if (showClaim($("thread"), claim, { roots: ".p-msg > .p-bubble" })) return;
247
+ $("main").scrollTop = 0;
248
+ showClaimNote("The linked turn isn't in this session.");
249
+ }
250
+
251
+ function showClaimNote(text) {
252
+ const note = $("claimNote");
253
+ note.textContent = text;
254
+ note.hidden = false;
255
+ clearTimeout(claimNoteTimer);
256
+ claimNoteTimer = setTimeout(() => { note.hidden = true; }, 6000);
257
+ }
258
+
259
+ window.addEventListener("hashchange", () => {
260
+ pendingClaim = claimFromHash(globalThis.location?.hash);
261
+ showPendingClaim();
262
+ });
263
+
223
264
  // The review's subjects: beside the thread on a wide screen, behind the
224
265
  // Plan | Thread toggle on a narrow one; drawn again only when they changed.
225
266
  function renderPlan() {
@@ -549,11 +590,12 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
549
590
 
550
591
  // Annotate (annotate_bar.js, participant_annotate.js): a selection in the
551
592
  // Plan or the thread offers "Annotate" and the presets
552
- // (web.annotate_presets), which append a quote to the comment box; nothing
553
- // is sent. No plugin pills: a review turn refuses `/` lines. Not in the
554
- // owner's participant view, nor while this participant can't comment.
593
+ // (web.annotate_presets, then the built-in Verify: verify_preset.js), which
594
+ // append a quote to the comment box; nothing is sent. No plugin pills: a
595
+ // review turn refuses `/` lines. Not in the owner's participant view, nor
596
+ // while this participant can't comment.
555
597
  const annotateBar = createAnnotateBar({
556
- presets: parsePresets(body.annotatePresets),
598
+ presets: withVerify(parsePresets(body.annotatePresets)),
557
599
  canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
558
600
  roots: () => [$("planDoc"), $("thread")],
559
601
  isLive: (root) => !!root.closest?.(".p-msg.live"),
@@ -247,6 +247,9 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
247
247
  // The header chi web's proxy names the user in (GET …/participants), null
248
248
  // without one: the Invite's email field.
249
249
  let identityHeader = null;
250
+ // Where a participant's link opens, no token (GET …/participants
251
+ // link_base): a claim link's base (claimTarget).
252
+ let linkBase = null;
250
253
  let loadedFor = null;
251
254
  let invite = null;
252
255
  let error = null;
@@ -289,6 +292,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
289
292
  grants = null;
290
293
  working = null;
291
294
  inviteMode = null;
295
+ linkBase = null;
292
296
  loadedFor = null;
293
297
  setParticipantNames([]);
294
298
  onChange();
@@ -301,6 +305,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
301
305
  working = typeof body.warning === "string" ? body.warning : null;
302
306
  inviteMode = typeof body.mode === "string" ? body.mode : null;
303
307
  identityHeader = typeof body.identity_header === "string" && body.identity_header ? body.identity_header : null;
308
+ linkBase = typeof body.link_base === "string" && body.link_base ? body.link_base : null;
304
309
  loadedFor = id;
305
310
  if (setParticipantNames(grants)) onNames();
306
311
  } catch {
@@ -310,6 +315,7 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
310
315
  grants = [];
311
316
  working = null;
312
317
  inviteMode = null;
318
+ linkBase = null;
313
319
  setParticipantNames([]);
314
320
  }
315
321
  }
@@ -446,6 +452,15 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
446
452
  // A participants_changed event, or a session's own refresh.
447
453
  reload: () => load(),
448
454
  chip: () => (session() && loadedFor === session() ? chipModel(grants, clock()) : null),
455
+ // A claim link's target (claim_link.js): {base, names} the open
456
+ // session's participant link base and its active people's names (an
457
+ // agent has no browser to open it in); null without either (an older
458
+ // chi web, no person invited).
459
+ claimTarget: () => {
460
+ if (!session() || loadedFor !== session() || !linkBase) return null;
461
+ const names = (grants || []).filter((g) => g.state === "active" && g.kind === "human").map((g) => g.name);
462
+ return names.length ? { base: linkBase, names } : null;
463
+ },
449
464
  close: () => shell.close(),
450
465
  isOpen: () => shell.isOpen(),
451
466
  };
@@ -1,5 +1,6 @@
1
1
  // The page's place in its URL hash: #/s/<session id> for an open session,
2
- // #/sessions for the all-sessions view, nothing for the empty start.
2
+ // #/sessions for the all-sessions view, nothing for the empty start. A
3
+ // session's hash may carry a claim link's ?t=…&q=… (claim_link.js).
3
4
  export const ALL_SESSIONS_HASH = "#/sessions";
4
5
 
5
6
  export function sessionHash(id) {
@@ -7,7 +8,7 @@ export function sessionHash(id) {
7
8
  }
8
9
 
9
10
  export function sessionIdFromHash(hash) {
10
- const m = /^#\/s\/([^/?#]+)$/.exec(hash || "");
11
+ const m = /^#\/s\/([^/?#]+)(?:\?[^#]*)?$/.exec(hash || "");
11
12
  if (!m) return null;
12
13
  try {
13
14
  return decodeURIComponent(m[1]) || null;
@@ -3,7 +3,8 @@
3
3
  --glass-bg:color-mix(in srgb,var(--bg-panel) 70%,transparent);--glass-border:rgba(255,255,255,.08);--glass-highlight:rgba(255,255,255,.05);--glass-shadow:0 8px 30px rgba(0,0,0,.35);
4
4
  --diff-add-bg:rgba(63,185,80,.14);--diff-add-text:#aff5b4;--diff-del-bg:rgba(248,81,73,.14);--diff-del-text:#ffc1bc;
5
5
  --code-text:#c9d1d9;--code-keyword:#ff7b72;--code-string:#a5d6ff;--code-comment:#8b949e;--code-constant:#79c0ff;--code-func:#d2a8ff;--code-type:#ffa657;
6
- --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint)}
6
+ --card-hover-border:rgba(255,255,255,.14);--on-solid:#fff;--on-warn:#fff;--text-hover:#fff;--badge-bg:rgba(255,255,255,.08);--badge-ring:rgba(255,255,255,.18);--shadow-strong:rgba(0,0,0,.5);--backdrop:rgba(0,0,0,.85);--toast-shadow:0 6px 24px rgba(0,0,0,.25);--ws-stale-opacity:.4;--ws-rule:var(--border-strong);--ws-muted:var(--text-3);--ws-faint:var(--text-muted);--ws-act-border:#444c56;--author-1:#79c0ff;--author-2:#d2a8ff;--author-3:#7ee787;--author-4:#ffa657;--author-5:#ff7b72;--author-6:#f2cc60;--author-owner:var(--text-3);--time-gap:var(--text-faint);
7
+ --claim-mark:color-mix(in srgb,var(--warn-bright) 32%,transparent);--claim-ring:var(--warn)}
7
8
  /* Light palette: follows the system setting. :not([data-theme="dark"]) leaves room for a later in-page toggle (data-theme on <html>); there is none yet. Each palette sets color-scheme, so native radios, inputs and scrollbars follow it. */
8
9
  @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){color-scheme:light;--bg:#f5f6f8;--bg-panel:#ffffff;--bg-inset:#f1f3f6;--bg-raised:#e8ebf0;--bg-hover:#eceff4;--border:#dde1e7;--border-strong:#c3c9d2;--text:#1f2328;--text-bright:#0d1117;--text-2:#2f363d;--text-3:#475160;--text-muted:#57606a;--text-faint:#636c76;--accent:#0066cc;--accent-strong:#0058b0;--accent-bg:#e3f0ff;--accent-ring:rgba(0,102,204,.2);--primary:#0071e3;--ok:#1a7f37;--ok-text:#116329;--ok-bg:#dafbe1;--warn:#9a6700;--warn-text:#7d4e00;--warn-bright:#875c00;--warn-bg:#fff6cc;--warn-border:#d4a72c;--danger:#cf222e;--danger-bg:#cf222e;--danger-text:#a40e26;--danger-bg-dim:#ffebe9;--think:#8250df;--think-text:#6639ba;--accent-border:#b3d4f7;--pick-border:#0969da;
9
10
  --glass-border:rgba(31,35,40,.10);--glass-highlight:rgba(255,255,255,.7);--glass-shadow:0 8px 30px rgba(31,35,40,.10);
@@ -0,0 +1,18 @@
1
+ // The built-in Verify preset, after web.annotate_presets' on both pages
2
+ // (docs/internals/participants.md, Deep links): the quote, then the ask to
3
+ // check it against the code and end on a verdict line a later card can
4
+ // read. Like a preset of the config's, it fills the box and never sends.
5
+ // A preset is a text (its label) or {label, text} (annotate_bar.js). A
6
+ // module of its own: static JS is cached at unversioned URLs, and a new
7
+ // export on an old module would blank the page.
8
+
9
+ export const VERIFY_PRESET = Object.freeze({
10
+ label: "Verify",
11
+ text: "Verify this claim against the code. If this session's context doesn't settle it, say so. "
12
+ + "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`.",
13
+ });
14
+
15
+ // +presets+ (annotate_presets.js parsePresets) with Verify after them.
16
+ export function withVerify(presets) {
17
+ return [...(presets || []), VERIFY_PRESET];
18
+ }