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,9 +1,8 @@
1
- // The participant page's logic, pure (participant.js does the DOM): who
2
- // wrote a message, a participant's colour slot, a tool row's line, a turn
3
- // refusal in words, the stream's URL, and the live turn as filtered events
4
- // build it (ParticipantView on the server decides what arrives at all).
5
-
6
- import { fullTime, gapText } from "./format.js";
1
+ // A participant's author and colour, pure: the one place the web spells a
2
+ // participant's author ("p:<grant id>"), shared by the badges on their
3
+ // prompts (participant_names.js), the owner's participants panel, the
4
+ // question cards, notifications and the viewer's own identity
5
+ // (viewer_self.js).
7
6
 
8
7
  // A participant's author: "p:" and its grant id, as the server writes it
9
8
  // (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
@@ -25,289 +24,3 @@ export function authorSlot(author) {
25
24
  export function authorColor(author) {
26
25
  return isParticipantAuthor(author) ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
27
26
  }
28
-
29
- // Who wrote a user message or started a turn, as the page names them.
30
- export function authorLabel(item) {
31
- if (item?.mine) return "You";
32
- if (isParticipantAuthor(item?.author)) return item.author_name || "A participant";
33
- if (item?.automatic) return "Automatic";
34
- return "Owner";
35
- }
36
-
37
- // "read docs/plan.md", or the tool's name alone.
38
- export function toolLine(row) {
39
- return row?.path ? `${row.tool} ${row.path}` : String(row?.tool || "tool");
40
- }
41
-
42
- // The conversation with each run of text-less tool steps (a reply that
43
- // only called tools) folded into the next reply's rows, or one row group
44
- // when no reply follows: one "3 tools: …" line instead of three.
45
- // A folded run with no reply after it keeps its last step's time (at).
46
- export function withToolSteps(messages) {
47
- const out = [];
48
- let pending = [];
49
- let pendingAt = null;
50
- const flush = () => out.push({ role: "assistant", content: "", tools: pending, ...(pendingAt ? { at: pendingAt } : {}) });
51
- for (const message of messages) {
52
- const toolOnly = message.role === "assistant" && !String(message.content || "").trim() && message.tools?.length;
53
- if (toolOnly) {
54
- pending = pending.concat(message.tools);
55
- pendingAt = message.at || pendingAt;
56
- continue;
57
- }
58
- if (pending.length) {
59
- if (message.role === "assistant") {
60
- out.push({ ...message, tools: pending.concat(message.tools || []) });
61
- pending = [];
62
- pendingAt = null;
63
- continue;
64
- }
65
- flush();
66
- pending = [];
67
- pendingAt = null;
68
- }
69
- out.push(message);
70
- }
71
- if (pending.length) flush();
72
- return out;
73
- }
74
-
75
- // The thread's gap dividers (format.js gapText, the owner's page's words):
76
- // before each message, and before the turn in progress (+currentAt+, its
77
- // turn_started's at), the divider's text or null. A gap is marked only
78
- // where a person's message or a turn starts, measured from the last
79
- // message's time before it: never inside a turn's work.
80
- export function threadGaps(messages, { currentAt = null, now = Date.now() } = {}) {
81
- let last = null;
82
- const before = (messages || []).map((message) => {
83
- const text = message?.role === "user" && last && message.at ? gapText(last, message.at, now) : null;
84
- if (message?.at) last = message.at;
85
- return text;
86
- });
87
- return { before, current: last && currentAt ? gapText(last, currentAt, now) : null };
88
- }
89
-
90
- // The server's words (ParticipantMessages, a relay's detail) as the page
91
- // shows them: a sentence, never reworded; null with none.
92
- export function sentence(detail) {
93
- const text = typeof detail === "string" ? detail.trim() : "";
94
- if (!text) return null;
95
- return `${text[0].toUpperCase()}${text.slice(1)}${/[.!?…]$/.test(text) ? "" : "."}`;
96
- }
97
-
98
- // A comment refused (POST …/turn), in words: the server's detail, else
99
- // the status.
100
- export function refusalText(status, body) {
101
- const said = sentence(body?.detail);
102
- if (said) return said;
103
- if (status === 0) return "Not sent: can't reach the session. Send it again.";
104
- return `Not sent (HTTP ${status}).`;
105
- }
106
-
107
- // A queued comment not taken back (POST …/dequeue): the server's detail,
108
- // else a turn that already started (the Bridge's 409, no detail), else the
109
- // status.
110
- export function dequeueText(status, body) {
111
- const said = sentence(body?.detail);
112
- if (said) return said;
113
- if (body?.error === "already_started") return "Not taken back: it already started.";
114
- if (status === 0) return "Not taken back: can't reach the session.";
115
- return `Not taken back (HTTP ${status}).`;
116
- }
117
-
118
- // A 401 from chi web: the link ended, revoked or expired, in the server's
119
- // words; with none (no session in the link), where to get a new one.
120
- export function endedText(body) {
121
- return sentence(body?.detail) || "Ask the session's owner for a new link.";
122
- }
123
-
124
- // Whether a review read that failed is read again: chi web unreachable
125
- // (status 0) or a 5xx (a worker starting, a proxy); a 404 (gone) or
126
- // another 4xx is final, a 401 the ended page.
127
- export function retries(status) {
128
- return status === 0 || status >= 500;
129
- }
130
-
131
- // The wait before the next read of the review after +failures+ failed in
132
- // a row: 3 s, doubling, at most 30 s.
133
- export function retryDelay(failures) {
134
- return Math.min(3000 * 2 ** Math.max(0, failures), 30000);
135
- }
136
-
137
- // The seq of a stream cursor `<seq>-<epoch>` (or a plain seq); null for none.
138
- export function cursorSeq(eventId) {
139
- const m = /^(\d+)/.exec(String(eventId || ""));
140
- return m ? Number(m[1]) : null;
141
- }
142
-
143
- // The filtered stream's URL: from the review data's cursor, `<seq>-<epoch>`
144
- // as the owner's page sends it (another worker answers it with a reset,
145
- // which the page re-reads at), or (read with no worker) joining with a
146
- // snapshot, which arrives as a bare marker the page re-reads at, never as
147
- // a replay of old turns. The owner's participant view asks for the
148
- // participant filter (?as=participant).
149
- export function streamURL(sessionId, eventId, { owner = false } = {}) {
150
- const params = new URLSearchParams();
151
- if (cursorSeq(eventId) !== null) params.set("from_seq", String(eventId));
152
- else params.set("snapshot", "1");
153
- if (owner) params.set("as", "participant");
154
- const query = params.toString();
155
- return `/api/sessions/${encodeURIComponent(sessionId)}/stream${query ? `?${query}` : ""}`;
156
- }
157
-
158
- // The events after which the page reads its review data again: a turn's
159
- // start or end, the queue, the owner's question, a snapshot or reset.
160
- const RELOAD = new Set(["turn_started", "turn_completed", "turn_canceled", "turn_failed", "turn_enqueued", "turn_dequeued",
161
- "input_merged", "question_requested", "question_answered", "question_cancelled", "continue_offered",
162
- "continue_resolved", "snapshot", "reset"]);
163
- export function reloads(type) {
164
- return RELOAD.has(type);
165
- }
166
-
167
- // The events after which the live turn drawn is no longer the running one
168
- // (it ended, another started, the stream started over): the page draws no
169
- // live event until its re-read, whose stream replays the ones after it.
170
- const TURN_BOUNDARY = new Set(["turn_started", "turn_completed", "turn_canceled", "turn_failed", "snapshot", "reset"]);
171
- export function endsLiveTurn(type) {
172
- return TURN_BOUNDARY.has(type);
173
- }
174
-
175
- // The live turn between reloads: its text by step (a restarted step's
176
- // text is void) and its tool rows. Returns the new live state.
177
- export function liveFrom(current) {
178
- return {
179
- texts: current?.text ? { 0: current.text } : {},
180
- tools: Array.isArray(current?.tools) ? current.tools.map((row) => ({ ...row })) : [],
181
- };
182
- }
183
-
184
- export function applyLive(live, event) {
185
- const next = { texts: { ...live.texts }, tools: live.tools.slice() };
186
- const step = Number.isInteger(event?.iteration) ? event.iteration : 0;
187
- switch (event?.type) {
188
- case "generation_chunk":
189
- next.texts[step] = (next.texts[step] || "") + String(event.text || "");
190
- break;
191
- case "generation_retrying":
192
- if (event.restarted) delete next.texts[step];
193
- break;
194
- case "tool_call_started":
195
- next.tools.push({ tool: event.tool, ...(event.path ? { path: event.path } : {}), status: "running",
196
- iteration: event.iteration, call_index: event.call_index });
197
- break;
198
- case "tool_call_completed": {
199
- const at = next.tools.findLastIndex((row) => row.iteration === event.iteration && row.call_index === event.call_index);
200
- if (at >= 0) next.tools[at] = { ...next.tools[at], status: event.status || "ok" };
201
- break;
202
- }
203
- default:
204
- return live;
205
- }
206
- return next;
207
- }
208
-
209
- // The live turn's reply text so far, its steps in order.
210
- export function liveText(live) {
211
- return Object.keys(live.texts).map(Number).sort((a, b) => a - b).map((k) => live.texts[k]).join("");
212
- }
213
-
214
- // The Plan view's subject: the one picked by path when it is still there,
215
- // else the first; null with no subjects (not a review session).
216
- export function pickSubject(subjects, path) {
217
- const list = Array.isArray(subjects) ? subjects : [];
218
- return list.find((s) => s.path === path) || list[0] || null;
219
- }
220
-
221
- // What the Plan view shows for a subject: rendered markdown, plain text,
222
- // or why it can't (gone, or no longer a project file a participant may
223
- // read); +note+ when the text is cut.
224
- export function subjectBody(subject) {
225
- if (!subject) return { kind: "none" };
226
- if (subject.missing) return { kind: "missing", text: `${subject.path} can't be shown: it is gone, or no longer a project file you may read.` };
227
- const note = subject.truncated ? "Only the start of this file is shown: it is long." : null;
228
- return subject.html ? { kind: "html", html: subject.html, note } : { kind: "text", text: subject.text || "", note };
229
- }
230
-
231
- // Whether the subjects differ from what the Plan view drew (a re-read at
232
- // a turn boundary redraws it only then, keeping its scroll).
233
- export function subjectsKey(subjects) {
234
- return JSON.stringify((subjects || []).map((s) => [s.path, s.missing ? null : s.text, !!s.truncated]));
235
- }
236
-
237
- // Which view a narrow screen shows: "thread" by default, "plan" only when
238
- // there is a subject to show.
239
- export function narrowView(wanted, subjects) {
240
- return wanted === "plan" && (subjects || []).length ? "plan" : "thread";
241
- }
242
-
243
- // A participant's usage row (the review data's viewer.usage: the fields
244
- // their grant's exposure shows, ParticipantExposure): one short item per
245
- // field it has, each {key, text, title, level}; level "" / "warn" (most of
246
- // the cap spent, the queue full) / "over" (the cap reached). Only the
247
- // participant's own figures ever arrive; a field the level doesn't show is
248
- // absent and draws nothing. [] with no usage (the owner's view).
249
- export const USAGE_WARN = 0.8;
250
- const MINUTE_MS = 60 * 1000;
251
- const HOUR_MS = 60 * MINUTE_MS;
252
- const DAY_MS = 24 * HOUR_MS;
253
- const dollars = (n) => `$${(Number(n) || 0).toFixed(2)}`;
254
- const isNum = (n) => typeof n === "number" && Number.isFinite(n);
255
-
256
- // "6 d left", "5 h left", "12 min left", "ended"; "" for no or bad input.
257
- export function leftText(iso, now = Date.now()) {
258
- const at = Date.parse(iso || "");
259
- if (!Number.isFinite(at)) return "";
260
- const left = at - now;
261
- if (left <= 0) return "ended";
262
- if (left >= 2 * DAY_MS) return `${Math.round(left / DAY_MS)} d left`;
263
- if (left >= 2 * HOUR_MS) return `${Math.floor(left / HOUR_MS)} h left`;
264
- return `${Math.max(1, Math.floor(left / MINUTE_MS))} min left`;
265
- }
266
-
267
- // What the page says a participant's turn does, by the session's mode
268
- // (viewer.mode: ParticipantMode.live on the server): a review turn only
269
- // reads; a pair turn (a working session's) acts with the session's tools
270
- // under the owner's guardrails. +tag+ is a message's: its own stamp
271
- // (participant_mode), a message without one a review's.
272
- export const MODE_COPY = {
273
- review: {
274
- note: "You can comment and ask; the model answers in a turn that can only read. The owner sees everything.",
275
- hint: "The model answers in a read-only turn; the owner sees everything.",
276
- tag: "review",
277
- },
278
- pair: {
279
- note: "You can ask the model to work in this session: your turns use its tools, writes included, under the owner's "
280
- + "guardrails, and what needs approval goes to the owner. The owner sees everything.",
281
- hint: "The model works in this session; the owner approves what needs it and sees everything.",
282
- tag: "pair",
283
- },
284
- };
285
- export const modeCopy = (mode) => MODE_COPY[mode] || MODE_COPY.review;
286
-
287
- export function usageItems(usage, now = Date.now()) {
288
- if (!usage || typeof usage !== "object") return [];
289
- const items = [];
290
- if (isNum(usage.spent)) {
291
- const cap = isNum(usage.cap) ? usage.cap : null;
292
- const share = cap ? usage.spent / cap : 0;
293
- const level = cap === null ? "" : usage.spent >= cap ? "over" : share >= USAGE_WARN ? "warn" : "";
294
- items.push({ key: "spent", text: cap === null ? `${dollars(usage.spent)} spent` : `${dollars(usage.spent)} of ${dollars(cap)} cap`,
295
- title: cap === null ? "What your comments have cost the owner so far"
296
- : `What your comments have cost the owner so far, of the ${dollars(cap)} cap they set`, level });
297
- }
298
- if (isNum(usage.queued) && isNum(usage.queue_limit)) {
299
- items.push({ key: "queued", text: `${usage.queued}/${usage.queue_limit} queued`,
300
- title: `Your comments waiting for their turn: up to ${usage.queue_limit} can wait behind a running one`,
301
- level: usage.queued >= usage.queue_limit ? "warn" : "" });
302
- }
303
- const left = leftText(usage.expires_at, now);
304
- if (left) {
305
- const day = Date.parse(usage.expires_at) - now < DAY_MS;
306
- items.push({ key: "expires_at", text: left === "ended" ? "link ended" : `link: ${left}`,
307
- title: `Your link ends ${fullTime(usage.expires_at)}`, level: day ? "warn" : "" });
308
- }
309
- if (typeof usage.model === "string" && usage.model) {
310
- items.push({ key: "model", text: usage.model, title: "The model answering in this session", level: "" });
311
- }
312
- return items;
313
- }
@@ -5,6 +5,7 @@
5
5
  // badge drawn before the list came, with neither, says UNNAMED until
6
6
  // nameUnnamedBadges names it.
7
7
  import { authorColor, isParticipantAuthor, participantAuthor } from "./participant_model.js";
8
+ import { viewerSelf } from "./viewer_self.js";
8
9
 
9
10
  export const UNNAMED = "a participant";
10
11
 
@@ -38,7 +39,11 @@ export function authorBadge(author, name = null, mode = null) {
38
39
  // tools; a review turn, or one whose mode isn't known (a queued prompt, a
39
40
  // message from before the stamp), could only read.
40
41
  export const PAIR = "pair";
41
- export const authorBadgeText = (name, mode = null) => (mode === PAIR ? `${name} · pair` : `${name} · review`);
42
+ // A participant looking (viewer_self.js) reads a pair turn by its name alone.
43
+ export const authorBadgeText = (name, mode = null) => {
44
+ if (mode === PAIR) return viewerSelf() ? name : `${name} · pair`;
45
+ return `${name} · review`;
46
+ };
42
47
  export const authorBadgeTitle = (name, mode = null) => (mode === PAIR
43
48
  ? `A turn from ${name}, a participant: it acted with this session's tools, its asks came to you`
44
49
  : `A review comment from ${name}, a participant: its turn could only read`);
@@ -0,0 +1,66 @@
1
+ // Revise on a participant's page: the answers to their own messages only,
2
+ // as chi web and the worker decide it (ParticipantRevise, from the author
3
+ // the server stamped; this is the page's mirror, so it shows a button only
4
+ // where a send would be taken). revise_stack.js draws the stacks for every
5
+ // viewer; this module answers its mayRevise, and app.js wires it for a
6
+ // participant (viewer.can("revise_own_turn")).
7
+ //
8
+ // A module of its own: static JS is cached at unversioned URLs, and a new
9
+ // export on an old module would blank the page.
10
+ import { viewerSelf } from "./viewer_self.js";
11
+ import { workerPredates } from "./worker_features.js";
12
+
13
+ // The worker feature their Revise needs (ReviseAnchor::PARTICIPANT_FEATURE):
14
+ // a worker that checks it at the stamp.
15
+ export const PARTICIPANT_REVISE_FEATURE = "participant_revise";
16
+
17
+ // The button's title and the chip's words while a live worker lacks it.
18
+ // The copy of ParticipantMessages::REVISE_UNAVAILABLE (its Ruby owner), the
19
+ // only one on the page: a refused send shows the server's own detail.
20
+ export const PARTICIPANT_REVISE_UNAVAILABLE = "this session's worker is too old for you to revise an answer: ask the session's owner to restart it";
21
+
22
+ // The pre-send gate (worker_features.js's rule): a live worker without it.
23
+ export function participantReviseUnavailable(workerFeatures, bridgeUp) {
24
+ return workerPredates(workerFeatures, bridgeUp, PARTICIPANT_REVISE_FEATURE);
25
+ }
26
+
27
+ // Whether a prompt bubble is +author+'s: its data-author (a reload's, a live
28
+ // echo's), or this tab's own echo.
29
+ function ownPrompt(prompt, author) {
30
+ return !!prompt && (prompt.dataset?.author === author || prompt.dataset?.own === "1");
31
+ }
32
+
33
+ // Rules 1–2 on historyTurns' list (+turns+, revise_stack.js), in one
34
+ // backward pass: the turns +author+ may revise, each opening with their
35
+ // prompt, answered, not a Verify turn, and with only their own turns after
36
+ // it (none of the owner's, another participant's or a wake turn's). The
37
+ // owner's `!` lines never reach their page, so they count for nothing here
38
+ // either. @return Set of turns
39
+ export function revisableOwn(turns, author) {
40
+ const ok = new Set();
41
+ if (!author || !Array.isArray(turns)) return ok;
42
+ for (let i = turns.length - 1; i >= 0; i--) {
43
+ const t = turns[i];
44
+ const own = !t.wake && ownPrompt(t.prompt, author);
45
+ if (!own) break;
46
+ if (t.answer && !t.prompt.hasAttribute?.("data-verify")) ok.add(t);
47
+ }
48
+ return ok;
49
+ }
50
+
51
+ // Whether the turn at +index+ is one of revisableOwn's.
52
+ export function mayReviseOwn(turns, index, author) {
53
+ return Array.isArray(turns) && revisableOwn(turns, author).has(turns[index]);
54
+ }
55
+
56
+ // revise_stack.js's mayRevise for the viewer looking (viewer_self.js), false
57
+ // while their composer is closed (+closed+(): cap reached, link ended,
58
+ // session stopped). One pass per decorate: its +turns+ list is read once.
59
+ export function participantMayRevise(closed = () => false) {
60
+ let last = null;
61
+ return (turn, turns) => {
62
+ if (closed()) return false;
63
+ if (last?.turns !== turns) last = { turns, ok: revisableOwn(turns, viewerSelf()?.author) };
64
+ return last.ok.has(turn);
65
+ };
66
+ }
@@ -5,9 +5,7 @@
5
5
  // expiry, cap, and behind an auth proxy that names the signed-in user their
6
6
  // email, which binds the link to that account) gives the link once, to
7
7
  // copy, share (a phone's share sheet)
8
- // or scan (its QR); and the participant view, the participant page as a
9
- // participant sees this session (through the server's filter), in a new
10
- // tab. The panel reads GET /api/sessions/:id/participants, again after
8
+ // or scan (its QR). The panel reads GET /api/sessions/:id/participants, again after
11
9
  // every change and on a participants_changed event.
12
10
  //
13
11
  // The names it learns feed the author badges on participants' prompts
@@ -149,11 +147,6 @@ export function revokeConfirmText(name) {
149
147
  return `Revoke ${name}'s link? Their page and stream stop and their queued comments are dropped. This can't be undone: a new invite gives a new link.`;
150
148
  }
151
149
 
152
- // The participant view's address: the participant page as the owner sees it.
153
- export function viewURL(sessionId) {
154
- return `/?as=participant#s=${encodeURIComponent(sessionId)}`;
155
- }
156
-
157
150
  function rowTemplate(row, { capFor }) {
158
151
  const actions = row.active ? html`<span class="pp-actions">${capFor === row.id
159
152
  ? html`<form class="pp-cap-form" data-id=${row.id}><input name="cap" type="text" inputmode="decimal" autocomplete="off"
@@ -193,7 +186,7 @@ export const EMPTY_REVIEW = "Nobody yet. Invite a person (or an agent) to review
193
186
  export const EMPTY_PAIR = "Nobody yet. Invite a person (or an agent) to work with you here: their turns act with this session's tools under your guardrails, and their asks come to you.";
194
187
 
195
188
  // The popover's content: the participants, the Invite form (or the link it
196
- // gave), the participant view.
189
+ // gave).
197
190
  // +working+: the server's warning for a session that isn't a review session
198
191
  // (shown before the Invite, with Start a review of it), null for one.
199
192
  // +pair+: a participant invited now gets pair turns (the list's mode: a
@@ -229,9 +222,7 @@ export function panelTemplate(grants, { now, sessionId, invite = null, error = n
229
222
  ?selected=${d === DEFAULT_EXPIRY_DAYS}>${d === 1 ? "1 day" : `${d} days`}</option>`)}</select></span></label
230
223
  ><label>Cap $<input name="cap" type="text" inputmode="decimal" placeholder="2" autocomplete="off" aria-label="Spending cap, dollars"></label
231
224
  ><button type="submit" class="pp-invite-btn" ?disabled=${busy}>Invite</button></div></form>`}
232
- <div class="ctx-pop-actions pp-foot">${invite ? html`<button type="button" data-act="another">Invite another</button>` : nothing}<a
233
- class="pp-view" href=${viewURL(sessionId)} target="_blank" rel="noopener"
234
- title="The participant page for this session, as a participant sees it: through the same filter">Participant view ↗</a></div>`;
225
+ ${invite ? html`<div class="ctx-pop-actions pp-foot"><button type="button" data-act="another">Invite another</button></div>` : nothing}`;
235
226
  }
236
227
 
237
228
  // The control: el (#infoText, where the chip is drawn), session() the open
@@ -24,7 +24,8 @@ import { clientLabel, isOwn } from "./turn_events.js";
24
24
  import { continueLine } from "./commands.js";
25
25
  import { plainText } from "./plain_line.js";
26
26
  import { noContactFill } from "./no_contact_fill.js";
27
- import { CONTINUE_KEY, questionKey } from "./stage_waiting.js";
27
+ import { removeSession } from "./storage.js";
28
+ import { CONTINUE_KEY, foldKey, questionKey } from "./stage_waiting.js";
28
29
 
29
30
  // A card's text fields' name: not a contact field (no_contact_fill.js).
30
31
  const CARD_NOTE = "chi-card-note";
@@ -50,6 +51,8 @@ const CARD_NOTE = "chi-card-note";
50
51
  // @param onPending (pending|null) called whenever the open question changes:
51
52
  // the page touches the redraws that read pendingQuestion() (the session
52
53
  // bar's waiting chip, docs/internals/web-frontend.md write -> redraw)
54
+ // @param answers false for a viewer who may not answer (viewer.js
55
+ // answer_cards): no question or continue card is drawn at all
53
56
  // @param onSettled (key) a card stopped waiting (answered, cancelled,
54
57
  // replaced or removed; the continue card resolved or dropped), by its
55
58
  // stage_waiting.js key: the stage's waiting cards as data
@@ -61,7 +64,7 @@ export function createQuestionCards({
61
64
  sessionId, clientId, place, isNearBottom, scrollToEnd, removeHintIfEmpty, askingText = () => "",
62
65
  renderMarkdown = () => Promise.resolve(null), linkRefs = () => {}, pairRefs = () => {},
63
66
  api = { sendAnswer, dismissQuestion, sendCommand }, doc = globalThis.document,
64
- onPending = () => {}, onSettled = () => {},
67
+ onPending = () => {}, onSettled = () => {}, answers = true,
65
68
  }) {
66
69
  // Live ask_user_question card, keyed by pending question id. Re-rendered from
67
70
  // pending_question on session select so a reload mid-question restores it.
@@ -85,6 +88,7 @@ export function createQuestionCards({
85
88
  // +said+: the asking reply when the caller knows it (a reloaded card: its
86
89
  // step's text, "" for none); else the running turn's (askingText).
87
90
  function renderQuestionCard(pq, { said = null } = {}) {
91
+ if (!answers) return;
88
92
  const action = nextCardAction({ cardId: questionEl?.dataset.qid ?? null, pendingId: pending?.id ?? null }, pq);
89
93
  if (action === "same") return;
90
94
  // A resolved card stays where it was; only a pending one is replaced.
@@ -520,6 +524,11 @@ export function createQuestionCards({
520
524
  // After the collapse: a card left open by hand renders at once.
521
525
  if (full) renderSaidOnOpen(questionEl, full, asked, refs);
522
526
  resolvedId = pending.id;
527
+ // Answered or cancelled for good: the fold this tab kept for it is gone
528
+ // (stage_waiting.js). No session id, no key: foldKey(null) is null, and a
529
+ // removeItem(null) would delete the key "null".
530
+ const fold = foldKey(sessionId());
531
+ if (fold) removeSession(fold);
523
532
  onSettled(questionKey(questionEl.dataset.qid));
524
533
  setPending(null);
525
534
  }
@@ -614,7 +623,7 @@ export function createQuestionCards({
614
623
  }
615
624
 
616
625
  function renderContinueCard(offer) {
617
- if (continueEl?.isConnected) return;
626
+ if (!answers || continueEl?.isConnected) return;
618
627
  // A step-limit question is up: it is the card (an older worker has none).
619
628
  if (isContinue(pending)) return;
620
629
  removeHintIfEmpty();
@@ -678,6 +687,8 @@ export function createQuestionCards({
678
687
  continueEl = null;
679
688
  if (!card) return;
680
689
  card.classList.add("resolved");
690
+ const fold = foldKey(sessionId());
691
+ if (fold) removeSession(fold);
681
692
  onSettled(CONTINUE_KEY);
682
693
  card.querySelectorAll("button, input").forEach((el) => { el.disabled = true; });
683
694
  const note = doc.createElement("div");