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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/internals/participants.md +26 -1
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/session_manager.rb +28 -4
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +34 -6
- data/lib/samagotchi/web/public/app.js +66 -5
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/participant.html +1 -0
- data/lib/samagotchi/web/public/participant.js +46 -4
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/worker.rb +57 -3
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- 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
|
|
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
|
|
1825
|
-
// session's plugins' pills
|
|
1826
|
-
//
|
|
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
|
|
553
|
-
// is sent. No plugin pills: a
|
|
554
|
-
// owner's participant view, nor
|
|
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\/([^/?#]+)
|
|
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
|
+
}
|