samagotchi 0.170.0 → 0.171.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -1
- data/docs/architecture.md +2 -2
- data/docs/cli.md +37 -23
- data/docs/configuration.md +1 -1
- data/docs/internals/block-renderers.md +2 -2
- data/docs/internals/bridge.md +5 -4
- data/docs/internals/context-telemetry.md +13 -1
- data/docs/internals/engine.md +4 -4
- data/docs/internals/inline-widgets.md +1 -1
- data/docs/internals/objectives.md +1 -1
- data/docs/internals/participants.md +235 -119
- data/docs/internals/web-frontend.md +164 -33
- data/docs/internals/working-set.md +69 -5
- data/docs/releasing.md +2 -1
- data/docs/sessions.md +6 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
- data/lib/samagotchi/bridge.rb +39 -12
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/delivery.rb +13 -0
- data/lib/samagotchi/engine.rb +5 -1
- data/lib/samagotchi/event_fanout.rb +22 -1
- data/lib/samagotchi/grants.rb +5 -0
- data/lib/samagotchi/guardrails/review_paths.rb +2 -2
- data/lib/samagotchi/image_store.rb +51 -12
- data/lib/samagotchi/kernel_loop.rb +7 -1
- data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
- data/lib/samagotchi/llm_context_stale.rb +4 -1
- data/lib/samagotchi/participant_messages.rb +39 -0
- data/lib/samagotchi/participant_revise.rb +61 -0
- data/lib/samagotchi/participant_uploads.rb +98 -0
- data/lib/samagotchi/reply_wait/poll.rb +5 -1
- data/lib/samagotchi/revise_anchor.rb +9 -0
- data/lib/samagotchi/send_command.rb +5 -0
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager/delivery.rb +7 -0
- data/lib/samagotchi/session_stores.rb +2 -0
- data/lib/samagotchi/tool_runner.rb +39 -5
- data/lib/samagotchi/tools/memory.rb +26 -7
- data/lib/samagotchi/turn_anchors.rb +0 -3
- data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
- data/lib/samagotchi/verify_anchor.rb +6 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +42 -77
- data/lib/samagotchi/web/participant_access.rb +129 -26
- data/lib/samagotchi/web/participant_images.rb +185 -0
- data/lib/samagotchi/web/participant_review.rb +19 -138
- data/lib/samagotchi/web/participant_show.rb +240 -0
- data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
- data/lib/samagotchi/web/participant_view.rb +331 -24
- data/lib/samagotchi/web/public/annotate_bar.css +1 -2
- data/lib/samagotchi/web/public/annotate_bar.js +1 -2
- data/lib/samagotchi/web/public/app.js +124 -88
- data/lib/samagotchi/web/public/block_host.js +7 -4
- data/lib/samagotchi/web/public/bubbles.js +9 -1
- data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
- data/lib/samagotchi/web/public/chip_popover.js +5 -4
- data/lib/samagotchi/web/public/claim_link.js +4 -4
- data/lib/samagotchi/web/public/composer.css +4 -6
- data/lib/samagotchi/web/public/composer_mode.js +11 -6
- data/lib/samagotchi/web/public/composer_mount.js +4 -4
- data/lib/samagotchi/web/public/ctx.js +12 -3
- data/lib/samagotchi/web/public/delivery_line.js +34 -5
- data/lib/samagotchi/web/public/history_shape.js +5 -1
- data/lib/samagotchi/web/public/images.js +7 -1
- data/lib/samagotchi/web/public/index.html +40 -4
- data/lib/samagotchi/web/public/info_bar_model.js +2 -1
- data/lib/samagotchi/web/public/markdown_place.js +5 -4
- data/lib/samagotchi/web/public/owner_claim.js +3 -3
- data/lib/samagotchi/web/public/owner_verify.js +0 -2
- data/lib/samagotchi/web/public/participant_bar.js +108 -0
- data/lib/samagotchi/web/public/participant_main.js +289 -0
- data/lib/samagotchi/web/public/participant_model.js +5 -292
- data/lib/samagotchi/web/public/participant_names.js +6 -1
- data/lib/samagotchi/web/public/participant_revise.js +66 -0
- data/lib/samagotchi/web/public/participants_panel.js +3 -12
- data/lib/samagotchi/web/public/question_cards.js +14 -3
- data/lib/samagotchi/web/public/refs.js +53 -36
- data/lib/samagotchi/web/public/reload_check.js +68 -11
- data/lib/samagotchi/web/public/revise_stack.js +21 -10
- data/lib/samagotchi/web/public/send_turn.js +24 -6
- data/lib/samagotchi/web/public/session_fields.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +12 -4
- data/lib/samagotchi/web/public/session_sync.js +9 -2
- data/lib/samagotchi/web/public/session_view.js +17 -7
- data/lib/samagotchi/web/public/stage_asking.js +6 -4
- data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
- data/lib/samagotchi/web/public/stage_view.js +18 -4
- data/lib/samagotchi/web/public/stage_waiting.js +74 -6
- data/lib/samagotchi/web/public/storage.js +22 -0
- data/lib/samagotchi/web/public/stream_handlers.js +2 -1
- data/lib/samagotchi/web/public/tokens.css +1 -1
- data/lib/samagotchi/web/public/turn_block.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +15 -6
- data/lib/samagotchi/web/public/turn_html.js +9 -2
- data/lib/samagotchi/web/public/turn_range.js +1 -2
- data/lib/samagotchi/web/public/verify_target.js +2 -4
- data/lib/samagotchi/web/public/viewer.js +59 -0
- data/lib/samagotchi/web/public/viewer_self.js +53 -0
- data/lib/samagotchi/web/public/working_set.js +11 -5
- data/lib/samagotchi/web/public/working_set_view.js +53 -8
- data/lib/samagotchi/web/public/ws_cards.js +89 -0
- data/lib/samagotchi/web/public/ws_peek.js +106 -0
- data/lib/samagotchi/web/public/ws_preview.css +126 -0
- data/lib/samagotchi/web/public/ws_reader.js +319 -0
- data/lib/samagotchi/web/public/ws_timeline.js +200 -0
- data/lib/samagotchi/web/public/ws_way_in.js +25 -0
- data/lib/samagotchi/web/session_control.rb +39 -14
- data/lib/samagotchi/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +75 -28
- data/lib/samagotchi/web/viewer.rb +70 -0
- data/lib/samagotchi/web/working_set_routes.rb +132 -0
- data/lib/samagotchi/worker.rb +19 -14
- data/lib/samagotchi/working_set.rb +260 -8
- metadata +20 -6
- data/lib/samagotchi/web/public/participant.html +0 -218
- data/lib/samagotchi/web/public/participant.js +0 -719
- data/lib/samagotchi/web/public/participant_annotate.js +0 -76
- data/lib/samagotchi/web/public/review_history.js +0 -72
- data/lib/samagotchi/web/public/tools_overview.js +0 -42
|
@@ -1,719 +0,0 @@
|
|
|
1
|
-
// The participant page (participant.html): a participant's review of one
|
|
2
|
-
// session, or the owner's participant view of it (/?as=participant#s=<id>,
|
|
3
|
-
// no composer). Everything comes through chi web's participant filter:
|
|
4
|
-
// GET /api/sessions/:id/review (the data) and the filtered stream; the
|
|
5
|
-
// page re-reads its data at every turn boundary and draws the turn in
|
|
6
|
-
// progress from the stream in between. A participant's comment is
|
|
7
|
-
// POST …/turn (text only, always queued: a participant never steers); their
|
|
8
|
-
// queued ones come back with Dequeue. The comment box is the owner's
|
|
9
|
-
// composer (composer_mount.js) with a subset on: its height, grip and fold,
|
|
10
|
-
// ↑/↓ over this grant's own comments (review_history.js); no `/` list, no
|
|
11
|
-
// delivery choices, no images yet. Under it, the participant's own usage
|
|
12
|
-
// (spend vs cap, queue, link expiry, model). The owner's participant view
|
|
13
|
-
// mounts none.
|
|
14
|
-
import {
|
|
15
|
-
authorColor, participantAuthor, isParticipantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
16
|
-
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
|
|
17
|
-
modeCopy,
|
|
18
|
-
} from "./participant_model.js";
|
|
19
|
-
import { toolsOverview } from "./tools_overview.js";
|
|
20
|
-
import { fullTime } from "./format.js";
|
|
21
|
-
import { createAuthLost, lostAnswer } from "./auth_lost.js";
|
|
22
|
-
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
23
|
-
import { createAnnotateBar } from "./annotate_bar.js";
|
|
24
|
-
import { parsePresets } from "./annotate_presets.js";
|
|
25
|
-
import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
|
|
26
|
-
import { PARTICIPANT_FINAL, verifyTurnOf } from "./verify_target.js";
|
|
27
|
-
import { appendQuote } from "./annotations.js";
|
|
28
|
-
import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
|
|
29
|
-
import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
|
|
30
|
-
import { CHECK_NOT_ON_PAGE } from "./verdict_card.js";
|
|
31
|
-
import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
|
|
32
|
-
import { mountComposer } from "./composer_mount.js";
|
|
33
|
-
import { PROMPT_LINE_PX } from "./composer_size.js";
|
|
34
|
-
import {
|
|
35
|
-
forgetReviewHistory, pruneReviewHistory, readReviewHistory, recordReviewComment, rememberReviewHistory,
|
|
36
|
-
} from "./review_history.js";
|
|
37
|
-
|
|
38
|
-
// A link's #code= opened in a browser that already has a cookie: traded
|
|
39
|
-
// first, the page reloads without it.
|
|
40
|
-
// This page has a session already: the trade would replace it (a link
|
|
41
|
-
// someone sent to swap this browser's session for theirs), so ask first.
|
|
42
|
-
if (fragmentCode(globalThis.location?.hash)
|
|
43
|
-
&& await tradeFragmentCode({ ask: () => globalThis.confirm(REPLACE_SESSION_TEXT) })) await new Promise(() => {});
|
|
44
|
-
const authLost = createAuthLost();
|
|
45
|
-
|
|
46
|
-
const $ = (id) => document.getElementById(id);
|
|
47
|
-
const body = document.body.dataset;
|
|
48
|
-
const ownerView = body.viewer === "owner";
|
|
49
|
-
const sessionId = body.session || new URLSearchParams(location.hash.slice(1)).get("s") || "";
|
|
50
|
-
|
|
51
|
-
let data = null;
|
|
52
|
-
let pendingVerifyAnchor = null;
|
|
53
|
-
let live = null;
|
|
54
|
-
// A turn boundary came after the data drawn: live events wait for the
|
|
55
|
-
// re-read (its stream replays them).
|
|
56
|
-
let liveStale = false;
|
|
57
|
-
let source = null;
|
|
58
|
-
let reloadTimer = null;
|
|
59
|
-
let retryTimer = null;
|
|
60
|
-
let usageTimer = null;
|
|
61
|
-
// Reads of the review that failed in a row (the retry's backoff), and the
|
|
62
|
-
// latest read asked for (an older one answering after it is dropped).
|
|
63
|
-
let failures = 0;
|
|
64
|
-
let loadSeq = 0;
|
|
65
|
-
let ended = false;
|
|
66
|
-
// The other links' ↑/↓ lists went at this page's first read (pruneReviewHistory).
|
|
67
|
-
let historyPruned = false;
|
|
68
|
-
// The Plan view: the subject picked, what it last drew, and which view a
|
|
69
|
-
// narrow screen shows (the thread first).
|
|
70
|
-
let subjectPath = null;
|
|
71
|
-
let planDrawn = null;
|
|
72
|
-
let wantedView = "thread";
|
|
73
|
-
// The thread's messages as drawn, by place: {sig, node}. A re-read keeps
|
|
74
|
-
// a message's node while it is unchanged (its open tool list, a text
|
|
75
|
-
// selection in it).
|
|
76
|
-
let drawnMessages = [];
|
|
77
|
-
// A claim link's turn and quote (claim_link.js: #s=…&t=…&q=…), shown once
|
|
78
|
-
// the thread is drawn; a later hash change shows another.
|
|
79
|
-
let pendingClaim = claimFromHash(globalThis.location?.hash);
|
|
80
|
-
// Where a claim's text can be on this page (a claim link, a verdict's card).
|
|
81
|
-
const CLAIM_ROOTS = ".p-msg > .p-bubble";
|
|
82
|
-
let claimNoteTimer = null;
|
|
83
|
-
|
|
84
|
-
function el(tag, cls, text) {
|
|
85
|
-
const node = document.createElement(tag);
|
|
86
|
-
if (cls) node.className = cls;
|
|
87
|
-
if (text !== undefined) node.textContent = text;
|
|
88
|
-
return node;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// A request's answer; a network failure is status 0 (the page says it
|
|
92
|
-
// can't reach the session), never a throw. A 401 with chi's words is the
|
|
93
|
-
// link ending (revoked, expired); one without (an auth proxy's own 401 {}),
|
|
94
|
-
// a redirect or an HTML answer is the sign-in ending (auth_lost.js).
|
|
95
|
-
async function api(path, options = {}) {
|
|
96
|
-
let res;
|
|
97
|
-
try {
|
|
98
|
-
res = await fetch(path, { credentials: "same-origin", ...options,
|
|
99
|
-
headers: { Accept: "application/json", ...(options.headers || {}) } });
|
|
100
|
-
} catch {
|
|
101
|
-
return { status: 0, ok: false, body: null };
|
|
102
|
-
}
|
|
103
|
-
let json = null;
|
|
104
|
-
try { json = await res.json(); } catch { json = null; }
|
|
105
|
-
if (lostAnswer(res) && !(res.status === 401 && json?.error)) authLost(`${res.status} ${path}`);
|
|
106
|
-
else if (res.status === 401) showEnded(json);
|
|
107
|
-
return { status: res.status, ok: res.ok, body: json };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function showEnded(json) {
|
|
111
|
-
if (ended) return;
|
|
112
|
-
ended = true;
|
|
113
|
-
source?.close();
|
|
114
|
-
clearTimeout(usageTimer);
|
|
115
|
-
usageTimer = null;
|
|
116
|
-
// The link is over: its comments' ↑/↓ list goes with it.
|
|
117
|
-
forgetReviewHistory(data?.viewer?.id, ownerView ? null : sessionId);
|
|
118
|
-
for (const id of ["head", "main", "dock", "plan"]) $(id).hidden = true;
|
|
119
|
-
$("endedText").textContent = endedText(json);
|
|
120
|
-
$("ended").hidden = false;
|
|
121
|
-
document.title = "chi · review link ended";
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const OFFLINE_TEXT = "Can't reach the session — retrying…";
|
|
125
|
-
|
|
126
|
-
// Reads the review data, draws it and streams from it. One that can't be
|
|
127
|
-
// read (unreachable, a 5xx) keeps what the page shows, says it is
|
|
128
|
-
// retrying, and reads again with a backoff.
|
|
129
|
-
async function load() {
|
|
130
|
-
if (!sessionId) return showEnded(null);
|
|
131
|
-
const seq = ++loadSeq;
|
|
132
|
-
const { ok, status, body: json } = await api(`/api/sessions/${encodeURIComponent(sessionId)}/review`);
|
|
133
|
-
if (ended || seq !== loadSeq) return;
|
|
134
|
-
if (!ok) {
|
|
135
|
-
$("head").hidden = false;
|
|
136
|
-
if (!data) $("title").textContent = "Review";
|
|
137
|
-
// Said over the thread (#offline), not in the header's rows: the
|
|
138
|
-
// thread stays where it is.
|
|
139
|
-
$("offline").textContent = retries(status) ? OFFLINE_TEXT
|
|
140
|
-
: status === 404 ? "This session is gone." : `The review couldn't be read (HTTP ${status}).`;
|
|
141
|
-
$("offline").hidden = false;
|
|
142
|
-
if (retries(status)) scheduleRetry(retryDelay(failures++));
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
failures = 0;
|
|
146
|
-
$("offline").hidden = true;
|
|
147
|
-
data = json;
|
|
148
|
-
if (!ownerView) {
|
|
149
|
-
rememberReviewHistory(sessionId, data?.viewer?.id);
|
|
150
|
-
if (!historyPruned) pruneReviewHistory(sessionId, data?.viewer?.id);
|
|
151
|
-
historyPruned = true;
|
|
152
|
-
}
|
|
153
|
-
live = liveFrom(data.current_turn);
|
|
154
|
-
liveStale = false;
|
|
155
|
-
render();
|
|
156
|
-
showPendingClaim();
|
|
157
|
-
// The live turn is the data's now: the events after its cursor come
|
|
158
|
-
// again from there (the open stream may have delivered some of them to
|
|
159
|
-
// the live turn just replaced). With no cursor (no worker) an open
|
|
160
|
-
// stream stays.
|
|
161
|
-
if (data.last_event_id || !source || source.readyState === EventSource.CLOSED) connect();
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function scheduleRetry(delay) {
|
|
165
|
-
clearTimeout(retryTimer);
|
|
166
|
-
retryTimer = setTimeout(load, delay);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function scheduleReload() {
|
|
170
|
-
clearTimeout(reloadTimer);
|
|
171
|
-
reloadTimer = setTimeout(load, 120);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// The types the participant stream sends: exactly Web::ParticipantView::EVENTS
|
|
175
|
-
// (spec/web/participant_view_spec.rb reads this list), so a type the server
|
|
176
|
-
// starts sending isn't dropped here unseen.
|
|
177
|
-
const STREAM_EVENTS = [
|
|
178
|
-
"generation_chunk", "generation_retrying", "tool_call_started", "tool_call_completed",
|
|
179
|
-
"turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued", "input_merged",
|
|
180
|
-
"question_requested", "question_answered", "question_cancelled", "continue_offered", "continue_resolved",
|
|
181
|
-
"snapshot", "reset", "verdict_recorded",
|
|
182
|
-
];
|
|
183
|
-
|
|
184
|
-
// The stream from the review data's cursor; a closed or refused stream is
|
|
185
|
-
// opened again from fresh data (a worker may have started or restarted).
|
|
186
|
-
function connect() {
|
|
187
|
-
if (ended || !data) return;
|
|
188
|
-
clearTimeout(retryTimer);
|
|
189
|
-
source?.close();
|
|
190
|
-
source = new EventSource(streamURL(sessionId, data.last_event_id, { owner: ownerView }));
|
|
191
|
-
source.onmessage = () => {};
|
|
192
|
-
const onEvent = (event) => {
|
|
193
|
-
let parsed;
|
|
194
|
-
try { parsed = JSON.parse(event.data); } catch { return; }
|
|
195
|
-
if (parsed.verdict) {
|
|
196
|
-
const known = (data.verdicts || []).some((record) => record.check_turn_id === parsed.verdict.check_turn_id);
|
|
197
|
-
if (!known) {
|
|
198
|
-
data.verdicts = [...(data.verdicts || []), parsed.verdict];
|
|
199
|
-
// A fresh verdict ends its check's "verifying fresh".
|
|
200
|
-
data.fresh_running = settleRunning(data.fresh_running, parsed.verdict);
|
|
201
|
-
renderThread();
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
if (parsed.type === "verdict_recorded") return;
|
|
205
|
-
if (reloads(parsed.type)) {
|
|
206
|
-
if (endsLiveTurn(parsed.type)) liveStale = true;
|
|
207
|
-
return scheduleReload();
|
|
208
|
-
}
|
|
209
|
-
if (liveStale) return;
|
|
210
|
-
const next = applyLive(live, parsed);
|
|
211
|
-
if (next !== live) {
|
|
212
|
-
live = next;
|
|
213
|
-
renderLive();
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
for (const type of STREAM_EVENTS) source.addEventListener(type, onEvent);
|
|
217
|
-
source.onerror = () => {
|
|
218
|
-
if (source.readyState !== EventSource.CLOSED) return;
|
|
219
|
-
scheduleRetry(retryDelay(failures));
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function render() {
|
|
224
|
-
const viewer = data.viewer || {};
|
|
225
|
-
const session = data.session || {};
|
|
226
|
-
// A pair participant works in the session: their page says so, not "review".
|
|
227
|
-
const whose = viewer.mode === "pair" ? `${viewer.name} · pairing` : `${viewer.name}'s review`;
|
|
228
|
-
document.title = ownerView ? `chi · participant view · ${session.title}` : `chi · ${whose}`;
|
|
229
|
-
const title = $("title");
|
|
230
|
-
title.replaceChildren(ownerView ? "Participant view" : whose, el("span", "p-sub", ` · ${session.title}`));
|
|
231
|
-
title.title = session.title || "";
|
|
232
|
-
const status = $("status");
|
|
233
|
-
const waiting = data.waiting_for_owner;
|
|
234
|
-
status.textContent = waiting ? "waiting for the owner" : (session.status || "");
|
|
235
|
-
status.className = `p-status ${waiting ? "waiting" : session.status === "running" ? "running" : ""}`;
|
|
236
|
-
$("people").replaceChildren(...(data.participants || []).map((p) => {
|
|
237
|
-
const chip = el("span", "p-person");
|
|
238
|
-
chip.style.setProperty("--author-color", authorColor(participantAuthor(p.id)));
|
|
239
|
-
chip.append(el("span", "p-dot"), `${p.name}${p.kind === "agent" ? " (agent)" : ""}`);
|
|
240
|
-
return chip;
|
|
241
|
-
}));
|
|
242
|
-
$("viewBanner").hidden = !ownerView;
|
|
243
|
-
const copy = modeCopy(viewer.mode);
|
|
244
|
-
$("note").textContent = ownerView ? "" : copy.note;
|
|
245
|
-
$("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
|
|
246
|
-
$("hint").title = $("hint").textContent; // at most two lines in the card: the whole text on hover
|
|
247
|
-
$("head").hidden = false;
|
|
248
|
-
$("main").hidden = false;
|
|
249
|
-
renderPlan();
|
|
250
|
-
renderThread();
|
|
251
|
-
renderDock();
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// The claim link's quote in the thread, scrolled to and marked; a quote
|
|
255
|
-
// not found marks its turn; neither found (before a continue) leaves the
|
|
256
|
-
// thread at the top and says so, as does a link to another session (this
|
|
257
|
-
// browser's cookie is one grant's). The hash then drops the claim, so the
|
|
258
|
-
// same link opened again shows it again (a hashchange).
|
|
259
|
-
function showPendingClaim() {
|
|
260
|
-
const claim = pendingClaim;
|
|
261
|
-
if (!claim || !data) return;
|
|
262
|
-
pendingClaim = null;
|
|
263
|
-
const { pathname = "", search = "" } = globalThis.location || {};
|
|
264
|
-
globalThis.history?.replaceState?.(null, "", `${pathname}${search}#s=${encodeURIComponent(sessionId)}`);
|
|
265
|
-
if (claim.sessionId !== sessionId) {
|
|
266
|
-
const title = data.session?.title ? `"${data.session.title}"` : "this session";
|
|
267
|
-
return showClaimNote(`This link is for another session; this browser holds your link for ${title}.`);
|
|
268
|
-
}
|
|
269
|
-
setView("thread");
|
|
270
|
-
if (showClaim($("thread"), claim, { roots: CLAIM_ROOTS })) return;
|
|
271
|
-
$("main").scrollTop = 0;
|
|
272
|
-
showClaimNote("The linked turn isn't in this session.");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function showClaimNote(text) {
|
|
276
|
-
const note = $("claimNote");
|
|
277
|
-
note.textContent = text;
|
|
278
|
-
note.hidden = false;
|
|
279
|
-
clearTimeout(claimNoteTimer);
|
|
280
|
-
claimNoteTimer = setTimeout(() => { note.hidden = true; }, 6000);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
window.addEventListener("hashchange", () => {
|
|
284
|
-
pendingClaim = claimFromHash(globalThis.location?.hash);
|
|
285
|
-
showPendingClaim();
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// The review's subjects: beside the thread on a wide screen, behind the
|
|
289
|
-
// Plan | Thread toggle on a narrow one; drawn again only when they changed.
|
|
290
|
-
function renderPlan() {
|
|
291
|
-
const subjects = data.subjects || [];
|
|
292
|
-
document.body.classList.toggle("has-plan", subjects.length > 0);
|
|
293
|
-
$("plan").hidden = subjects.length === 0;
|
|
294
|
-
setView(wantedView);
|
|
295
|
-
if (!subjects.length) return;
|
|
296
|
-
const subject = pickSubject(subjects, subjectPath);
|
|
297
|
-
const key = `${subject.path}\n${subjectsKey(subjects)}`;
|
|
298
|
-
if (key === planDrawn) return;
|
|
299
|
-
const scrolled = subject.path === subjectPath ? $("planScroll").scrollTop : 0;
|
|
300
|
-
subjectPath = subject.path;
|
|
301
|
-
planDrawn = key;
|
|
302
|
-
const files = $("planFiles");
|
|
303
|
-
files.hidden = subjects.length < 2;
|
|
304
|
-
files.replaceChildren(...subjects.map((s) => {
|
|
305
|
-
const button = el("button", "", s.path.split("/").pop());
|
|
306
|
-
button.type = "button";
|
|
307
|
-
button.title = s.path;
|
|
308
|
-
button.setAttribute("aria-pressed", String(s.path === subject.path));
|
|
309
|
-
button.addEventListener("click", () => {
|
|
310
|
-
subjectPath = s.path;
|
|
311
|
-
planDrawn = null;
|
|
312
|
-
renderPlan();
|
|
313
|
-
});
|
|
314
|
-
return button;
|
|
315
|
-
}));
|
|
316
|
-
$("planPath").textContent = subject.path;
|
|
317
|
-
const doc = $("planDoc");
|
|
318
|
-
// An annotation's label names it (participant_annotate.js).
|
|
319
|
-
doc.dataset.path = subject.path;
|
|
320
|
-
const shown = subjectBody(subject);
|
|
321
|
-
doc.className = `p-doc${shown.kind === "html" ? "" : " plain"}`;
|
|
322
|
-
// The server's rendered markdown (MarkdownRenderer: raw HTML escaped,
|
|
323
|
-
// sanitised) of the redacted text; else the text itself.
|
|
324
|
-
if (shown.kind === "html") doc.innerHTML = shown.html;
|
|
325
|
-
else doc.textContent = shown.text;
|
|
326
|
-
if (shown.note) doc.prepend(el("div", "p-plan-note", shown.note));
|
|
327
|
-
$("planScroll").scrollTop = scrolled;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// A narrow screen's view: "plan" or "thread" (body.view-*; the wide
|
|
331
|
-
// layout shows both and ignores it).
|
|
332
|
-
function setView(view) {
|
|
333
|
-
wantedView = view;
|
|
334
|
-
const shown = narrowView(view, data?.subjects);
|
|
335
|
-
document.body.classList.toggle("view-plan", shown === "plan");
|
|
336
|
-
document.body.classList.toggle("view-thread", shown === "thread");
|
|
337
|
-
$("segPlan").setAttribute("aria-selected", String(shown === "plan"));
|
|
338
|
-
$("segThread").setAttribute("aria-selected", String(shown === "thread"));
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const AUTOMATIC_TURN = "An automatic turn";
|
|
342
|
-
|
|
343
|
-
function messageNode(message) {
|
|
344
|
-
const user = message.role === "user";
|
|
345
|
-
const node = el("div", `p-msg ${user ? "user" : "reply"}${message.mine ? " mine" : ""}`);
|
|
346
|
-
// When it happened, on hover (the owner's page's bubbles do the same).
|
|
347
|
-
const when = fullTime(message.at);
|
|
348
|
-
if (when) node.title = when;
|
|
349
|
-
if (user) {
|
|
350
|
-
if (message.turn_id) node.dataset.turnId = message.turn_id;
|
|
351
|
-
node.style.setProperty("--author-color", authorColor(message.author));
|
|
352
|
-
// Whose it is, for an annotation's label (participant_annotate.js).
|
|
353
|
-
Object.assign(node.dataset, messageAuthorData(message));
|
|
354
|
-
const who = el("div", "p-who");
|
|
355
|
-
who.append(el("b", "", authorLabel(message)));
|
|
356
|
-
if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
|
|
357
|
-
if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
|
|
358
|
-
node.append(who);
|
|
359
|
-
// An automatic turn (a task's, a wake turn's: a delegate report or a
|
|
360
|
-
// context note started it) opens its turn without its prompt.
|
|
361
|
-
const content = message.automatic && !message.content ? AUTOMATIC_TURN : message.content;
|
|
362
|
-
const text = content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
|
|
363
|
-
node.append(el("div", "p-bubble", text));
|
|
364
|
-
return node;
|
|
365
|
-
}
|
|
366
|
-
if (message.tools?.length) node.append(toolsNode(message.tools));
|
|
367
|
-
if (message.content?.trim()) {
|
|
368
|
-
const bubble = el("div", "p-bubble");
|
|
369
|
-
// The server's rendered markdown (MarkdownRenderer, sanitised, from
|
|
370
|
-
// the filtered text); the text itself without it.
|
|
371
|
-
if (message.html) bubble.innerHTML = message.html;
|
|
372
|
-
else {
|
|
373
|
-
bubble.classList.add("plain");
|
|
374
|
-
bubble.textContent = message.content;
|
|
375
|
-
}
|
|
376
|
-
node.append(bubble);
|
|
377
|
-
}
|
|
378
|
-
return node;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// +open+: the overview's state as the reader left it.
|
|
382
|
-
function toolsNode(rows, open = null) {
|
|
383
|
-
const details = el("details", "p-tools");
|
|
384
|
-
details.open = open ?? false;
|
|
385
|
-
details.append(el("summary", "", toolsOverview(rows)));
|
|
386
|
-
const list = el("ul");
|
|
387
|
-
for (const row of rows) list.append(el("li", row.status === "error" ? "error" : row.status === "running" ? "running" : "", toolLine(row)));
|
|
388
|
-
details.append(list);
|
|
389
|
-
return details;
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// A gap divider (the owner's page's .time-gap line), kept while its text is.
|
|
393
|
-
function gapNode(text, old) {
|
|
394
|
-
if (!text) return null;
|
|
395
|
-
if (old?.textContent === text) return old;
|
|
396
|
-
const node = el("div", "time-gap", text);
|
|
397
|
-
node.setAttribute("role", "separator");
|
|
398
|
-
return node;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function renderThread() {
|
|
402
|
-
const thread = $("thread");
|
|
403
|
-
const messages = withToolSteps(data.messages || []);
|
|
404
|
-
const gaps = threadGaps(messages, { currentAt: data.current_turn?.at || null });
|
|
405
|
-
drawnMessages = messages.map((message, at) => {
|
|
406
|
-
const sig = JSON.stringify(message);
|
|
407
|
-
const old = drawnMessages[at];
|
|
408
|
-
const gap = gapNode(gaps.before[at], old?.gap);
|
|
409
|
-
if (old?.sig === sig) return { ...old, gap };
|
|
410
|
-
return { sig, gap, node: keepToolsOpen(messageNode(message), old?.node) };
|
|
411
|
-
});
|
|
412
|
-
const nodes = drawnMessages.flatMap((entry) => (entry.gap ? [entry.gap, entry.node] : [entry.node]));
|
|
413
|
-
if (data.current_turn) {
|
|
414
|
-
const gap = gapNode(gaps.current, thread.querySelector(":scope > .time-gap.current"));
|
|
415
|
-
if (gap) {
|
|
416
|
-
gap.classList.add("current");
|
|
417
|
-
nodes.push(gap);
|
|
418
|
-
}
|
|
419
|
-
nodes.push(...currentTurnNodes());
|
|
420
|
-
}
|
|
421
|
-
else if (data.waiting_for_owner) nodes.push(el("div", "p-waiting", "Waiting for the session's owner"));
|
|
422
|
-
if (nodes.length === 0) nodes.push(el("div", "p-empty", "Nothing here yet. Comments and replies show here."));
|
|
423
|
-
const main = $("main");
|
|
424
|
-
const atBottom = main.scrollHeight - main.scrollTop - main.clientHeight < 80;
|
|
425
|
-
syncChildren(thread, nodes);
|
|
426
|
-
for (const record of data.verdicts || []) {
|
|
427
|
-
const author = record.by || null;
|
|
428
|
-
const viewer = data.viewer || {};
|
|
429
|
-
let who = ownerView ? "You" : "Owner";
|
|
430
|
-
if (isParticipantAuthor(author)) {
|
|
431
|
-
if (!ownerView && author === participantAuthor(viewer.id)) who = "You";
|
|
432
|
-
else {
|
|
433
|
-
who = (data.participants || []).find((participant) => participantAuthor(participant.id) === author)?.name || "A participant";
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
placeVerdictWithFresh(thread, record, {
|
|
437
|
-
who,
|
|
438
|
-
author,
|
|
439
|
-
roots: CLAIM_ROOTS,
|
|
440
|
-
onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
|
|
441
|
-
}, verifyFreshState());
|
|
442
|
-
}
|
|
443
|
-
if (atBottom || !thread.dataset.drawn) main.scrollTop = main.scrollHeight;
|
|
444
|
-
thread.dataset.drawn = "1";
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// Verify fresh on an unclear verdict's card (verdict_fresh.js): a
|
|
448
|
-
// participant starts one while their grant could run a turn now
|
|
449
|
-
// (can_comment); the owner looking through the participant view never
|
|
450
|
-
// does, and no one here gets a link to the child's session.
|
|
451
|
-
const freshErrors = {};
|
|
452
|
-
function verifyFreshState() {
|
|
453
|
-
if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
|
|
454
|
-
const unavailable = data.session?.verify_unavailable === true;
|
|
455
|
-
return {
|
|
456
|
-
verdicts: data.verdicts || [],
|
|
457
|
-
running: data.fresh_running,
|
|
458
|
-
errors: freshErrors,
|
|
459
|
-
canStart: !unavailable && !ownerView && !ended && !!data.viewer?.can_comment,
|
|
460
|
-
unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
|
|
461
|
-
start: async (of) => {
|
|
462
|
-
const { ok, body } = await api(freshURL(sessionId, of), {
|
|
463
|
-
method: "POST", body: "{}", headers: { "Content-Type": "application/json" },
|
|
464
|
-
});
|
|
465
|
-
return ok ? { ok: true } : { ok: false, error: body?.detail || "Couldn't start a fresh check." };
|
|
466
|
-
},
|
|
467
|
-
childHref: () => null,
|
|
468
|
-
};
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// +node+'s tool list open or closed as +old+'s was (the same place drawn
|
|
472
|
-
// again with new content).
|
|
473
|
-
function keepToolsOpen(node, old) {
|
|
474
|
-
const was = old?.querySelector("details.p-tools");
|
|
475
|
-
const now = node.querySelector("details.p-tools");
|
|
476
|
-
if (was && now) now.open = was.open;
|
|
477
|
-
return node;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// +parent+'s children made +nodes+, leaving in place the ones already
|
|
481
|
-
// there (a node taken out loses the reader's selection in it).
|
|
482
|
-
function syncChildren(parent, nodes) {
|
|
483
|
-
nodes.forEach((node, at) => {
|
|
484
|
-
if (parent.children[at] !== node) parent.insertBefore(node, parent.children[at] || null);
|
|
485
|
-
});
|
|
486
|
-
while (parent.children.length > nodes.length) parent.lastElementChild.remove();
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// The turn in progress: its prompt (the turn's start) and the live reply.
|
|
490
|
-
function currentTurnNodes() {
|
|
491
|
-
const turn = data.current_turn;
|
|
492
|
-
const nodes = [];
|
|
493
|
-
if (turn.prompt !== undefined || turn.automatic) {
|
|
494
|
-
nodes.push(messageNode({ role: "user", content: turn.automatic ? AUTOMATIC_TURN : turn.prompt, author: turn.author,
|
|
495
|
-
author_name: turn.author_name, mine: turn.mine, automatic: turn.automatic, at: turn.at, turn_id: turn.turn_id }));
|
|
496
|
-
}
|
|
497
|
-
const reply = el("div", "p-msg reply live");
|
|
498
|
-
fillLive(reply, $("liveReply"));
|
|
499
|
-
reply.id = "liveReply";
|
|
500
|
-
nodes.push(reply);
|
|
501
|
-
return nodes;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// The live reply's parts, its tool list open or closed as +before+ (the
|
|
505
|
-
// reply drawn last) had it.
|
|
506
|
-
function fillLive(reply, before = reply) {
|
|
507
|
-
const parts = [];
|
|
508
|
-
const open = before?.querySelector("details.p-tools")?.open ?? null;
|
|
509
|
-
if (live.tools.length) parts.push(toolsNode(live.tools, open));
|
|
510
|
-
const text = liveText(live);
|
|
511
|
-
if (text) parts.push(el("div", "p-bubble plain", text));
|
|
512
|
-
if (data.current_turn?.waiting_for_owner) parts.push(el("div", "p-waiting", "Waiting for the session's owner"));
|
|
513
|
-
else if (!text) parts.push(el("div", "p-who", "Working…"));
|
|
514
|
-
reply.replaceChildren(...parts);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function renderLive() {
|
|
518
|
-
const reply = $("liveReply");
|
|
519
|
-
if (!reply) return scheduleReload();
|
|
520
|
-
const main = $("main");
|
|
521
|
-
const atBottom = main.scrollHeight - main.scrollTop - main.clientHeight < 80;
|
|
522
|
-
fillLive(reply);
|
|
523
|
-
if (atBottom) main.scrollTop = main.scrollHeight;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function renderDock() {
|
|
527
|
-
const viewer = data.viewer || {};
|
|
528
|
-
if (ownerView) {
|
|
529
|
-
$("dock").hidden = true;
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
$("dock").hidden = false;
|
|
533
|
-
const queue = $("queue");
|
|
534
|
-
const rows = (data.queued?.mine || []).map((entry) => {
|
|
535
|
-
const row = el("div", "p-queued");
|
|
536
|
-
row.append(el("span", "", `Queued: ${entry.prompt}`));
|
|
537
|
-
const take = el("button", "small", "Take back");
|
|
538
|
-
take.type = "button";
|
|
539
|
-
take.addEventListener("click", () => dequeue(entry.enqueued_id, take));
|
|
540
|
-
row.append(take);
|
|
541
|
-
return row;
|
|
542
|
-
});
|
|
543
|
-
const others = data.queued?.others || 0;
|
|
544
|
-
if (others) rows.push(el("div", "p-queue-note", `${others} other turn${others > 1 ? "s" : ""} waiting before yours`));
|
|
545
|
-
queue.replaceChildren(...rows);
|
|
546
|
-
const send = $("send");
|
|
547
|
-
const comment = $("comment");
|
|
548
|
-
comment.disabled = !viewer.can_comment;
|
|
549
|
-
send.disabled = !viewer.can_comment || sending;
|
|
550
|
-
comment.placeholder = viewer.cap_reached ? "Your spending cap is reached: ask the owner to raise it" : "Comment or ask";
|
|
551
|
-
renderUsage(viewer.usage);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
// The participant's own usage, one line under the comment box (only what
|
|
555
|
-
// their grant's exposure shows; the server sends nothing else). Spent and
|
|
556
|
-
// queued change on re-reads; the link's time redraws each minute without one.
|
|
557
|
-
function renderUsage(usage) {
|
|
558
|
-
clearTimeout(usageTimer);
|
|
559
|
-
usageTimer = null;
|
|
560
|
-
const items = usageItems(usage);
|
|
561
|
-
const row = $("usage");
|
|
562
|
-
row.hidden = items.length === 0;
|
|
563
|
-
row.replaceChildren(...items.map((item) => {
|
|
564
|
-
const span = el("span", [item.key === "model" ? "model" : "", item.level].filter(Boolean).join(" "), item.text);
|
|
565
|
-
span.dataset.key = item.key;
|
|
566
|
-
span.title = item.title;
|
|
567
|
-
return span;
|
|
568
|
-
}));
|
|
569
|
-
if (!ended && Number.isFinite(Date.parse(usage?.expires_at || ""))) {
|
|
570
|
-
usageTimer = setTimeout(() => renderUsage(usage), 60_000);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function showError(text) {
|
|
575
|
-
const box = $("error");
|
|
576
|
-
box.textContent = text || "";
|
|
577
|
-
box.hidden = !text;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// One comment in flight at a time: Enter submits with no submitter, so the
|
|
581
|
-
// disabled Send button alone doesn't stop a second Enter.
|
|
582
|
-
let sending = false;
|
|
583
|
-
|
|
584
|
-
async function sendComment() {
|
|
585
|
-
if (sending || !data?.viewer?.can_comment) return;
|
|
586
|
-
const comment = $("comment");
|
|
587
|
-
const prompt = comment.value.trim();
|
|
588
|
-
if (!prompt) return;
|
|
589
|
-
const verify = pendingVerifyAnchor;
|
|
590
|
-
sending = true;
|
|
591
|
-
$("send").disabled = true;
|
|
592
|
-
showError("");
|
|
593
|
-
let reply;
|
|
594
|
-
try {
|
|
595
|
-
reply = await api(`/api/sessions/${encodeURIComponent(sessionId)}/turn`, {
|
|
596
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
597
|
-
body: JSON.stringify({ prompt, ...(verify ? { verify } : {}) }),
|
|
598
|
-
});
|
|
599
|
-
} finally {
|
|
600
|
-
sending = false;
|
|
601
|
-
if (!ended) $("send").disabled = !data?.viewer?.can_comment;
|
|
602
|
-
}
|
|
603
|
-
const { ok, status, body: json } = reply;
|
|
604
|
-
if (ended) return;
|
|
605
|
-
if (ok) {
|
|
606
|
-
if (pendingVerifyAnchor === verify) pendingVerifyAnchor = null;
|
|
607
|
-
recordReviewComment(data?.viewer?.id, prompt);
|
|
608
|
-
// What was typed while the comment was in flight stays in the box.
|
|
609
|
-
if (comment.value.trim() === prompt) comment.value = "";
|
|
610
|
-
composer.fit();
|
|
611
|
-
composer.drawFoldControls();
|
|
612
|
-
composer.refreshHistory();
|
|
613
|
-
showError("");
|
|
614
|
-
await load();
|
|
615
|
-
} else {
|
|
616
|
-
showError(refusalText(status, json));
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
async function dequeue(enqueuedId, button) {
|
|
621
|
-
button.disabled = true;
|
|
622
|
-
const { ok, status, body: json } = await api(
|
|
623
|
-
`/api/sessions/${encodeURIComponent(sessionId)}/turn/${encodeURIComponent(enqueuedId)}/dequeue`, { method: "POST" });
|
|
624
|
-
if (ended) return;
|
|
625
|
-
if (!ok) {
|
|
626
|
-
button.disabled = false;
|
|
627
|
-
showError(dequeueText(status, json));
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
showError("");
|
|
631
|
-
load();
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// The comment box (composer_mount.js), a participant's only: the owner's
|
|
635
|
-
// participant view mounts none (its dock stays hidden). Enter or Send posts
|
|
636
|
-
// the comment: always a queued review turn, so Alt+Enter is Enter here.
|
|
637
|
-
const composer = ownerView ? null : mountComposer({
|
|
638
|
-
els: {
|
|
639
|
-
composer: $("composer"), prompt: $("comment"), button: $("send"), chips: $("chips"),
|
|
640
|
-
attachBtn: $("attachBtn"), attachInput: $("attachInput"), grip: $("composeGrip"),
|
|
641
|
-
},
|
|
642
|
-
minPx: () => PROMPT_LINE_PX,
|
|
643
|
-
toast: showError,
|
|
644
|
-
loadHistory: () => Promise.resolve(readReviewHistory(data?.viewer?.id)),
|
|
645
|
-
send: () => sendComment(),
|
|
646
|
-
imagesOff: "Images can't go in a review comment yet: describe what it shows, or ask the owner.",
|
|
647
|
-
fold: { session: () => sessionId || null },
|
|
648
|
-
});
|
|
649
|
-
if (composer) {
|
|
650
|
-
// An error under the box (a refusal, the images toast) goes at the next edit.
|
|
651
|
-
// An emptied box lets go of its Verify claim.
|
|
652
|
-
$("comment").addEventListener("input", (e) => {
|
|
653
|
-
showError("");
|
|
654
|
-
if (!e.target.value.trim()) pendingVerifyAnchor = null;
|
|
655
|
-
});
|
|
656
|
-
// The session composer's layout (composer.css), and the fold saved in this browser.
|
|
657
|
-
document.body.classList.add("has-session");
|
|
658
|
-
composer.newVisit();
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
for (const button of $("viewSeg").querySelectorAll("button")) {
|
|
662
|
-
button.addEventListener("click", () => {
|
|
663
|
-
setView(button.dataset.view);
|
|
664
|
-
if (button.dataset.view === "thread") $("main").scrollTop = $("main").scrollHeight;
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Annotate (annotate_bar.js, participant_annotate.js): a selection in the
|
|
669
|
-
// Plan or the thread offers "Annotate" and the presets
|
|
670
|
-
// (web.annotate_presets, then the built-in Verify: verify_preset.js, only
|
|
671
|
-
// in an ended turn's final reply: verify_target.js), which
|
|
672
|
-
// append a quote to the comment box; nothing is sent. No plugin pills: a
|
|
673
|
-
// review turn refuses `/` lines. Not in the owner's participant view, nor
|
|
674
|
-
// while this participant can't comment.
|
|
675
|
-
const annotateBar = createAnnotateBar({
|
|
676
|
-
presets: withVerify(parsePresets(body.annotatePresets)),
|
|
677
|
-
disabledPreset: (preset) => preset === VERIFY_PRESET
|
|
678
|
-
&& data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
|
|
679
|
-
offeredPreset: (preset, source, range) => preset !== VERIFY_PRESET || !!verifyAnchorFor({ source, range }),
|
|
680
|
-
canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
|
|
681
|
-
roots: () => [$("planDoc"), $("thread")],
|
|
682
|
-
isLive: (root) => !!root.closest?.(".p-msg.live"),
|
|
683
|
-
onQuote: quoteIntoComment,
|
|
684
|
-
onPreset: (preset, fields) => { pendingVerifyAnchor = preset === VERIFY_PRESET ? verifyAnchorFor(fields) : null; },
|
|
685
|
-
source: participantSource,
|
|
686
|
-
quote: participantQuote,
|
|
687
|
-
dock: () => $("dock"),
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
// Verify's claim: a selection in an ended turn's final reply (the last
|
|
691
|
-
// reply bubble after the turn's opening, as the owner's page and
|
|
692
|
-
// placeVerdict count turns: verify_target.js), the only text
|
|
693
|
-
// TurnStages::VerifyAnchors#verified takes; else null and no Verify on the bar. Not an
|
|
694
|
-
// earlier reply, a line merged mid-turn, a prompt, the running turn or the
|
|
695
|
-
// Plan.
|
|
696
|
-
function verifyAnchorFor({ source, range }) {
|
|
697
|
-
if (source?.kind !== "reply" || !range) return null;
|
|
698
|
-
const running = data?.current_turn ? { turnId: data.current_turn.turn_id || null } : null;
|
|
699
|
-
const turnId = verifyTurnOf($("thread"), source.root, PARTICIPANT_FINAL, { running });
|
|
700
|
-
if (!turnId) return null;
|
|
701
|
-
const quote = selectionClaim(source.root, range).q;
|
|
702
|
-
return quote ? { turn_id: turnId, quote } : null;
|
|
703
|
-
}
|
|
704
|
-
$("main").addEventListener("scroll", annotateBar.schedule);
|
|
705
|
-
$("planScroll").addEventListener("scroll", annotateBar.schedule);
|
|
706
|
-
|
|
707
|
-
function quoteIntoComment(text) {
|
|
708
|
-
const comment = $("comment");
|
|
709
|
-
// A folded box opens for this visit (nothing saved).
|
|
710
|
-
composer.openForVisit();
|
|
711
|
-
comment.value = appendQuote(comment.value, text);
|
|
712
|
-
composer.fit();
|
|
713
|
-
composer.drawFoldControls();
|
|
714
|
-
comment.focus();
|
|
715
|
-
comment.setSelectionRange(comment.value.length, comment.value.length);
|
|
716
|
-
comment.scrollTop = comment.scrollHeight;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
load();
|