samagotchi 0.170.0 → 0.171.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -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();