bom-pi 0.0.0-stage → 0.0.2

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 (131) hide show
  1. package/README.md +33 -2
  2. package/apps/README.md +7 -0
  3. package/apps/web/README.md +21 -0
  4. package/apps/web/pocket-adapter.mjs +323 -0
  5. package/apps/web/public/LICENSE.pocket +21 -0
  6. package/apps/web/public/activity-fold.js +28 -0
  7. package/apps/web/public/agent-now.js +37 -0
  8. package/apps/web/public/agent-tile.js +79 -0
  9. package/apps/web/public/app.js +975 -0
  10. package/apps/web/public/apple-touch-icon.png +0 -0
  11. package/apps/web/public/artifact-bridge.js +687 -0
  12. package/apps/web/public/avatar.js +87 -0
  13. package/apps/web/public/back.js +554 -0
  14. package/apps/web/public/box-keys.js +216 -0
  15. package/apps/web/public/browser-panel.js +1097 -0
  16. package/apps/web/public/browser.js +68 -0
  17. package/apps/web/public/calls.js +139 -0
  18. package/apps/web/public/chat.js +919 -0
  19. package/apps/web/public/clock.js +34 -0
  20. package/apps/web/public/commands.js +578 -0
  21. package/apps/web/public/composer.js +2068 -0
  22. package/apps/web/public/diff-parse.js +672 -0
  23. package/apps/web/public/diff.js +1646 -0
  24. package/apps/web/public/dock.js +299 -0
  25. package/apps/web/public/extension-ui.js +2734 -0
  26. package/apps/web/public/files-panel.js +775 -0
  27. package/apps/web/public/files.js +368 -0
  28. package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
  29. package/apps/web/public/fonts/Geist.woff2 +0 -0
  30. package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
  31. package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
  32. package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
  33. package/apps/web/public/fonts/InterVariable.woff2 +0 -0
  34. package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
  35. package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
  36. package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
  37. package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
  38. package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
  39. package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
  40. package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
  41. package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
  42. package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
  43. package/apps/web/public/fonts/SOURCES.md +69 -0
  44. package/apps/web/public/gestures.js +610 -0
  45. package/apps/web/public/highlight.js +480 -0
  46. package/apps/web/public/history.js +70 -0
  47. package/apps/web/public/home.js +169 -0
  48. package/apps/web/public/icon-192.png +0 -0
  49. package/apps/web/public/icon-512.png +0 -0
  50. package/apps/web/public/icon-maskable-512.png +0 -0
  51. package/apps/web/public/icon.svg +16 -0
  52. package/apps/web/public/index.html +71 -0
  53. package/apps/web/public/launcher.js +1293 -0
  54. package/apps/web/public/linked-sessions.js +55 -0
  55. package/apps/web/public/live.js +39 -0
  56. package/apps/web/public/manifest.webmanifest +32 -0
  57. package/apps/web/public/model-refresh.js +73 -0
  58. package/apps/web/public/notify.js +251 -0
  59. package/apps/web/public/peeks.js +920 -0
  60. package/apps/web/public/pip-attachments.js +182 -0
  61. package/apps/web/public/pip-window.js +141 -0
  62. package/apps/web/public/pip.js +1217 -0
  63. package/apps/web/public/quote-reply.js +109 -0
  64. package/apps/web/public/quote-text.js +31 -0
  65. package/apps/web/public/rich.js +403 -0
  66. package/apps/web/public/run-frame.js +106 -0
  67. package/apps/web/public/safe-mode.js +71 -0
  68. package/apps/web/public/seen.js +169 -0
  69. package/apps/web/public/session-tree.js +117 -0
  70. package/apps/web/public/sessions.js +2695 -0
  71. package/apps/web/public/share.js +126 -0
  72. package/apps/web/public/sheets/appearance.js +820 -0
  73. package/apps/web/public/sheets/branch.js +353 -0
  74. package/apps/web/public/sheets/extensions.js +818 -0
  75. package/apps/web/public/sheets/file.js +318 -0
  76. package/apps/web/public/sheets/find.js +250 -0
  77. package/apps/web/public/sheets/folder.js +680 -0
  78. package/apps/web/public/sheets/gallery.js +2340 -0
  79. package/apps/web/public/sheets/hooks.js +350 -0
  80. package/apps/web/public/sheets/memory.js +531 -0
  81. package/apps/web/public/sheets/menu.js +580 -0
  82. package/apps/web/public/sheets/message.js +220 -0
  83. package/apps/web/public/sheets/model.js +412 -0
  84. package/apps/web/public/sheets/people.js +414 -0
  85. package/apps/web/public/sheets/pi-sessions.js +264 -0
  86. package/apps/web/public/sheets/providers.js +525 -0
  87. package/apps/web/public/sheets/running.js +130 -0
  88. package/apps/web/public/sheets/schedules.js +71 -0
  89. package/apps/web/public/sheets/session.js +249 -0
  90. package/apps/web/public/sheets/shortcuts.js +101 -0
  91. package/apps/web/public/sheets/spend.js +150 -0
  92. package/apps/web/public/sheets/text.js +53 -0
  93. package/apps/web/public/sheets/viewers.js +363 -0
  94. package/apps/web/public/sheets/worktree.js +50 -0
  95. package/apps/web/public/sheets.js +410 -0
  96. package/apps/web/public/signin.js +57 -0
  97. package/apps/web/public/slots.js +208 -0
  98. package/apps/web/public/store.js +1760 -0
  99. package/apps/web/public/style.css +18625 -0
  100. package/apps/web/public/subagents-board.js +879 -0
  101. package/apps/web/public/subagents.js +273 -0
  102. package/apps/web/public/sw.js +169 -0
  103. package/apps/web/public/taps.js +26 -0
  104. package/apps/web/public/theme-surface.d.ts +4 -0
  105. package/apps/web/public/theme-surface.js +266 -0
  106. package/apps/web/public/theme.js +845 -0
  107. package/apps/web/public/themes/lantern/colors.toml +25 -0
  108. package/apps/web/public/themes/lantern/direction.css +60 -0
  109. package/apps/web/public/themes/lantern/frame.css +254 -0
  110. package/apps/web/public/themes/lantern/theme.css +10 -0
  111. package/apps/web/public/themes/lantern/theme.json +7 -0
  112. package/apps/web/public/themes/pocket/theme.css +189 -0
  113. package/apps/web/public/themes/pocket/theme.json +6 -0
  114. package/apps/web/public/themes.js +556 -0
  115. package/apps/web/public/transcript.js +3348 -0
  116. package/apps/web/public/trust.js +295 -0
  117. package/apps/web/public/turns.js +507 -0
  118. package/apps/web/public/ui.js +1910 -0
  119. package/apps/web/public/voice.js +345 -0
  120. package/apps/web/server.mjs +774 -0
  121. package/package.json +43 -4
  122. package/packages/cli/src/bom-pi.mjs +452 -0
  123. package/packages/cli/templates/blank.factory.json +7 -0
  124. package/packages/cli/templates/dev.factory.json +11 -0
  125. package/packages/cli/templates/docs.factory.json +12 -0
  126. package/packages/cli/templates/ops.factory.json +14 -0
  127. package/packages/cli/templates/triage.factory.json +10 -0
  128. package/packages/core/src/daemon.mjs +2047 -0
  129. package/packages/core/src/limit-gate.mjs +192 -0
  130. package/packages/core/src/rpc-client.mjs +66 -0
  131. package/systemd/README.md +11 -0
@@ -0,0 +1,3348 @@
1
+ // The conversation, as a column of turns: a person's message, then what Pi did (its thinking and tool calls folded
2
+ // into one line that opens into a timeline), then Pi's reply, any approval, and the live line while Pi works.
3
+ import { Component } from "preact";
4
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
5
+ import { activityOpen } from "./activity-fold.js";
6
+ import { initials, personColor } from "./avatar.js";
7
+ import { routeSliding } from "./back.js";
8
+ import { browserAvailable, setBrowserOpen } from "./browser.js";
9
+ import { describeCall, describersRevision } from "./calls.js";
10
+ import { DiffBlock } from "./diff.js";
11
+ import { LinkedSessions } from "./linked-sessions.js";
12
+ import { splitQuote } from "./quote-text.js";
13
+ import { fromServer } from "./seen.js";
14
+ import { defineItem, slotItems, slotKey } from "./slots.js";
15
+ import { Highlighted, InlineArtifact } from "./rich.js";
16
+ import {
17
+ actions,
18
+ attempt,
19
+ canSteer,
20
+ collab,
21
+ discuss,
22
+ isRow,
23
+ loadEarlier,
24
+ loadedFrom,
25
+ navigate,
26
+ notify,
27
+ openSheet,
28
+ store,
29
+ TRANSCRIPT_ROWS,
30
+ } from "./store.js";
31
+ import { openSubagent, stateOf } from "./subagents.js";
32
+ import { prefs } from "./theme.js";
33
+ import {
34
+ diffCounts,
35
+ INLINE_TOOL,
36
+ itemsOf,
37
+ lineCount,
38
+ matchCount,
39
+ plural,
40
+ rowsBefore,
41
+ says,
42
+ short,
43
+ shortError,
44
+ STOPPED,
45
+ summarize,
46
+ testOutcome,
47
+ withoutNotes,
48
+ } from "./turns.js";
49
+ import {
50
+ Boot,
51
+ clockTime,
52
+ copyText,
53
+ entryImageUrl,
54
+ fileUrl,
55
+ html,
56
+ Icon,
57
+ Markdown,
58
+ openFile,
59
+ plainText,
60
+ readable,
61
+ replyText,
62
+ Spinner,
63
+ splitAttachments,
64
+ Thumb,
65
+ } from "./ui.js";
66
+ import { LessonCall, memoryAvailable } from "./sheets/memory.js";
67
+
68
+ const REPORT = /^\[subagent (\S+) (answered|failed)([^\]]*)\]\s?([\s\S]*)$/;
69
+ /** Where the next report starts, in a message of several that arrived together (`extensions/subagents.ts`). */
70
+ const NEXT_REPORT = /\n\n(?=\[subagent \S+ (?:answered|failed))/;
71
+
72
+ /**
73
+ * A background task's message to Pi (`background.ts`): its head line, its command, and the end of its output fenced.
74
+ * Its time is short ("2m 04s"), which keeps a long line that is not one quick to turn down.
75
+ */
76
+ const BACKGROUND =
77
+ /^\[background\] Task (\S+) (.+?) after ([^.\n]{1,20})\.\nCommand: (.*)\n([\s\S]*)$/;
78
+ const OUTPUT_OPEN = "<background-output>\n";
79
+ const OUTPUT_CLOSE = "\n</background-output>";
80
+
81
+ /** The output fenced in a background task's message, or undefined when it printed nothing. */
82
+ function fencedOutput(rest) {
83
+ const start = rest.indexOf(OUTPUT_OPEN);
84
+ const end = start < 0 ? -1 : rest.indexOf(OUTPUT_CLOSE, start + OUTPUT_OPEN.length);
85
+
86
+ return end < 0 ? undefined : rest.slice(start + OUTPUT_OPEN.length, end);
87
+ }
88
+
89
+ /** How a scheduled message starts (see src/server/schedules.ts). */
90
+ const SCHEDULED = "[scheduled] ";
91
+
92
+ /** A message the app sent for a person, and what sent it: PiP, a PR watch, a webhook (`submit` in src/server/commands.ts). */
93
+ const VIA = /^\[via ([^\]\n]{1,300})\] /;
94
+ /** What came from outside in such a message, fenced as data for Pi: a check's log, a webhook's post, GitHub's events. */
95
+ const FENCED =
96
+ /<(ci-log|webhook-payload|github-event)((?: [a-z]+="[^"\n]*")*)>\n([\s\S]*?)\n<\/\1>/g;
97
+ const FENCE_LABELS = { "webhook-payload": "What was posted", "github-event": "On GitHub" };
98
+
99
+ /** A message from the app with what it fenced for Pi shown as blocks of its own, named after what each is. */
100
+ function FencedText({ text }) {
101
+ const parts = [];
102
+ let last = 0;
103
+
104
+ for (const match of text.matchAll(FENCED)) {
105
+ const name = /"([^"]*)"/.exec(match[2])?.[1];
106
+ const label =
107
+ match[1] === "ci-log"
108
+ ? `Log of ${name ?? "a check"}`
109
+ : match[1] === "github-event"
110
+ ? `On GitHub${name ? `: ${name}` : ""}`
111
+ : (name ?? FENCE_LABELS[match[1]]);
112
+
113
+ parts.push(
114
+ html`<${MentionedText} text=${text.slice(last, match.index).replace(/\n$/, "")} />`,
115
+ );
116
+ parts.push(html`<div class="fenced-data">
117
+ <div class="fenced-label">${label}</div>
118
+ <pre>${readable(match[3])}</pre>
119
+ </div>`);
120
+ last = match.index + match[0].length;
121
+ }
122
+
123
+ parts.push(html`<${MentionedText} text=${text.slice(last).replace(/^\n\n?/, "")} />`);
124
+
125
+ return parts;
126
+ }
127
+
128
+ /** A goal's check that did not pass, as Pi is told about it (see src/server/extensions/goals.ts): what, then its output. */
129
+ const GOAL_CHECK =
130
+ /^\[goal\] `([\s\S]*?)` (still fails|was cut off by a server restart) \(([^)]*)\)\. ([\s\S]*)$/;
131
+
132
+ /** A skill run with `/skill:name`, as Pi gets it (see `expandSkillCommand` in src/server/prompts.ts): its name, file, and the request. */
133
+ const SKILL = /^<skill name="([^"]+)" location="([^"]+)">\n[\s\S]*?\n<\/skill>(?:\n\n([\s\S]+))?$/;
134
+
135
+ /** An @mention of a file or folder in a message, as the message box writes it. */
136
+ const MENTION = /(^|[\s([{])@(?:"([^"\n]+)"|([^\s"]+))/g;
137
+
138
+ /** One line of the attachment list the server adds to a message: `- path (name, mime, size bytes)`. */
139
+ const ATTACHED = /^- (.*) \(([^,]*), ([^,]*), (\d+) bytes\)$/;
140
+
141
+ function parseAttachments(block) {
142
+ return block
143
+ .split("\n")
144
+ .filter((line) => line.trim() !== "")
145
+ .map((line) => {
146
+ const match = ATTACHED.exec(line);
147
+
148
+ return match
149
+ ? { path: match[1], name: match[2], mime: match[3] }
150
+ : { name: line.replace(/^- /, ""), mime: "" };
151
+ });
152
+ }
153
+
154
+ function EntryImages({ entryId, count, label }) {
155
+ return html`<div class="thumbs">
156
+ ${Array.from(
157
+ { length: count },
158
+ (_, index) => html`<${Thumb}
159
+ src=${entryImageUrl(entryId, index)}
160
+ alt=${`${label} ${index + 1}`}
161
+ />`,
162
+ )}
163
+ </div>`;
164
+ }
165
+
166
+ function authorName(entryId, view, users) {
167
+ const userId = view.authors?.[entryId];
168
+
169
+ if (userId) {
170
+ const user = users.find((each) => each.id === userId);
171
+
172
+ return user ? user.name : "Someone";
173
+ }
174
+
175
+ return undefined;
176
+ }
177
+
178
+ /** Tapping a message opens what can be done with it, unless the tap was on a link, an image, or a button in it, or ended a text selection. */
179
+ function openMessage(event, entryId) {
180
+ if (event.target.closest("a, button") || getSelection()?.toString()) {
181
+ return;
182
+ }
183
+
184
+ openSheet({ type: "message", entryId });
185
+ }
186
+
187
+ /**
188
+ * The conversation whose first rows have rendered: rows that mount after that are new (a message sent, a report
189
+ * arriving) and slide into place. Rows already there when a session opens just appear.
190
+ */
191
+ let settledFor = null;
192
+
193
+ /** A message's text with its @mentions of files as buttons that open them. Mentions of people stay text. */
194
+ function MentionedText({ text }) {
195
+ const parts = [];
196
+ let last = 0;
197
+
198
+ for (const match of text.matchAll(MENTION)) {
199
+ const quoted = match[2] !== undefined;
200
+ const path = quoted ? match[2] : match[3].replace(/[.,;:!?)\]}'`]+$/, "");
201
+
202
+ if (!quoted && !/[./]/.test(path)) {
203
+ continue;
204
+ }
205
+
206
+ const at = match.index + match[1].length;
207
+ const written = quoted ? `@"${path}"` : `@${path}`;
208
+
209
+ parts.push(text.slice(last, at));
210
+ parts.push(
211
+ html`<button
212
+ class="file-mention"
213
+ title=${`Open ${path}`}
214
+ onClick=${() => openFile(path)}
215
+ >
216
+ ${written}
217
+ </button>`,
218
+ );
219
+ last = at + written.length;
220
+ }
221
+
222
+ parts.push(text.slice(last));
223
+
224
+ return parts;
225
+ }
226
+
227
+ /** A subagent's report: who, whether it answered or failed, and what it said. */
228
+ function Report({ report, view, fresh }) {
229
+ const [, name, verb, after, text] = report;
230
+ const child = view.subagents.find((agent) => agent.name === name);
231
+ // A failed one says why: "failed: stopped before it answered", "failed: Bad request".
232
+ const why = verb === "failed" ? after.replace(/^:\s*/, "") : "";
233
+
234
+ return html`<div
235
+ class=${`report ${verb === "failed" ? "failed" : ""} ${fresh ? "enter" : ""}`}
236
+ >
237
+ <div class="report-head">
238
+ <span class="report-name">${name}</span> ${verb}
239
+ ${
240
+ child &&
241
+ html`<button class="link" onClick=${() => openSubagent(child.conversationId)}>
242
+ Open →
243
+ </button>`
244
+ }
245
+ </div>
246
+ ${why && html`<div class="report-why">${why}</div>`}
247
+ ${text && html`<${Collapsible} text=${text} />`}
248
+ </div>`;
249
+ }
250
+
251
+ /** A background task that ended, as a card for people: its id and outcome, its command, and its output folded away. */
252
+ function BackgroundReport({ match }) {
253
+ const [, id, outcome, took, command, rest] = match;
254
+ const output = fencedOutput(rest);
255
+
256
+ return html`<div class="report background-report">
257
+ <div class="report-head">
258
+ <span class="report-name">${id}</span> ${outcome} · ${took}
259
+ </div>
260
+ ${command !== undefined && html`<div class="background-command muted small mono">${command}</div>`}
261
+ ${
262
+ output === undefined
263
+ ? html`<div class="muted small">It printed nothing.</div>`
264
+ : html`<${BackgroundOutput}
265
+ text=${output}
266
+ clipped=${rest.startsWith("The end of its output")}
267
+ />`
268
+ }
269
+ </div>`;
270
+ }
271
+
272
+ /** Lines of a background task's output shown while it is folded: its last ones, where an error usually is. */
273
+ const OUTPUT_LINES = 8;
274
+
275
+ /** A background task's output as it printed it, folded to its last lines; `clipped` when the task printed more. */
276
+ function BackgroundOutput({ text, clipped }) {
277
+ const [open, setOpen] = useState(false);
278
+ const lines = text.split("\n");
279
+ const folds = lines.length > OUTPUT_LINES;
280
+ const shown = folds && !open ? lines.slice(-OUTPUT_LINES).join("\n") : text;
281
+
282
+ return html`<div class="background-output">
283
+ ${clipped && html`<div class="muted small">The end of its output:</div>`}
284
+ <pre>${shown}</pre>
285
+ ${
286
+ folds &&
287
+ html`<button class="link" onClick=${() => setOpen(!open)}>
288
+ ${open ? "Show less" : `Show all ${lines.length} lines`}
289
+ </button>`
290
+ }
291
+ </div>`;
292
+ }
293
+
294
+ /** A span of time: "18s", "2m 04s", "1h 02m". */
295
+ function span(ms) {
296
+ const seconds = Math.max(0, Math.round(ms / 1000));
297
+
298
+ if (seconds < 60) {
299
+ return `${seconds}s`;
300
+ }
301
+
302
+ if (seconds < 3600) {
303
+ return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`;
304
+ }
305
+
306
+ return `${Math.floor(seconds / 3600)}h ${String(Math.floor(seconds / 60) % 60).padStart(2, "0")}m`;
307
+ }
308
+
309
+ /** Who a wait for a limit is for: the provider by the name people know it by, and the model when only it waits. */
310
+ function limitWho({ provider, modelId, scope }) {
311
+ const names = {
312
+ anthropic: "Claude",
313
+ "openai-codex": "ChatGPT",
314
+ openai: "OpenAI",
315
+ "opencode-go": "OpenCode Go",
316
+ openrouter: "OpenRouter",
317
+ };
318
+ const name = names[provider] ?? provider;
319
+
320
+ return scope === "model" && modelId ? `${name} ${modelId}'s limit` : `${name}'s limit`;
321
+ }
322
+
323
+ /** When a message was sent, from the server's time: "10:42" today, "Oct 3 10:42" before. */
324
+ const clock = (at) => clockTime(new Date(fromServer(at)));
325
+
326
+ /**
327
+ * A person's message: a filled row with a prompt mark, the accent's for your own and the author's color for someone
328
+ * else's, then who sent it and when, and what they wrote.
329
+ */
330
+ function UserEntry({ entry, view, users }) {
331
+ const [fresh] = useState(() => settledFor !== null && settledFor === view.conversation?.id);
332
+
333
+ // A report has nobody's name on it: a person's message that looks like one is theirs.
334
+ if (REPORT.test(entry.text) && entry.from === undefined && !view.authors?.[entry.id]) {
335
+ // Several that arrived together came as one message: a card each. A part that is not a report's start is
336
+ // more of the report before it.
337
+ const parts = [];
338
+
339
+ for (const part of entry.text.split(NEXT_REPORT)) {
340
+ if (parts.length > 0 && !REPORT.test(part)) {
341
+ parts[parts.length - 1] += `\n\n${part}`;
342
+ } else {
343
+ parts.push(part);
344
+ }
345
+ }
346
+
347
+ const reports = parts.map((part) => REPORT.exec(part)).filter(Boolean);
348
+
349
+ return reports.length === 1
350
+ ? html`<${Report} report=${reports[0]} view=${view} fresh=${fresh} />`
351
+ : html`<div class="report-group">
352
+ ${reports.map((report) => html`<${Report} report=${report} view=${view} fresh=${fresh} />`)}
353
+ </div>`;
354
+ }
355
+
356
+ // A background task's message: nobody typed it, so a person writing "[background]" themselves keeps it theirs.
357
+ const background =
358
+ entry.from === undefined && !view.authors?.[entry.id] ? BACKGROUND.exec(entry.text) : null;
359
+
360
+ if (background) {
361
+ return html`<${BackgroundReport} match=${background} />`;
362
+ }
363
+
364
+ const check = GOAL_CHECK.exec(entry.text);
365
+
366
+ if (check) {
367
+ const [, command, what, how, output] = check;
368
+
369
+ return html`<div class="report failed">
370
+ <div class="report-head">
371
+ <span class="report-name mono">${command}</span> ${what} · ${how}
372
+ </div>
373
+ <${Collapsible} text=${output} limit=${300} />
374
+ </div>`;
375
+ }
376
+
377
+ const authorId = view.authors?.[entry.id];
378
+ const author =
379
+ authorName(entry.id, view, users) ??
380
+ entry.from ??
381
+ (view.conversation?.kind === "subagent" ? "Main agent" : undefined);
382
+ const [written, attachments] = splitAttachments(entry.text);
383
+ // Set up earlier to go out now: by a person (who shows as its author) or by Pi.
384
+ // Only a message nobody typed: someone writing "[via …]" themselves gets no label.
385
+ const via = authorId === undefined ? VIA.exec(written) : null;
386
+ const sent = via ? written.slice(via[0].length) : written;
387
+ const scheduled = sent.startsWith(SCHEDULED);
388
+ // A linked session's message: shown by its label and its fence alone, so the words before the fence (which tell Pi
389
+ // how to read it) can change without changing how older messages show.
390
+ const peerText =
391
+ via && /^linked session \S+$/.test(via[1])
392
+ ? /\n<linked-session-message>\n([\s\S]*)\n<\/linked-session-message>$/.exec(sent)?.[1]
393
+ : undefined;
394
+ const body = peerText ?? (scheduled ? sent.slice(SCHEDULED.length) : sent);
395
+ const files = attachments ? parseAttachments(attachments) : [];
396
+ // `/skill:name request`: the skill by name, and the request.
397
+ const skill = SKILL.exec(body);
398
+ const pictures = files.filter((file) => file.path && file.mime.startsWith("image/"));
399
+ const others = files.filter((file) => !pictures.includes(file));
400
+ const name = author ?? (scheduled ? "Pi" : undefined);
401
+ // Your own messages take the accent; someone else's, their color; one from someone the app does not know (a name
402
+ // the message carries, the main agent, Pi's own schedule), a quiet one.
403
+ const mark =
404
+ authorId === store.state.me?.id || (authorId === undefined && name === undefined)
405
+ ? "var(--accent)"
406
+ : authorId === undefined
407
+ ? "var(--muted)"
408
+ : personColor(authorId);
409
+
410
+ // Who it is from, for themes: you, someone else, a linked session, or no one the app knows (Pi's schedule, the main
411
+ // agent, a watch or a webhook).
412
+ const from =
413
+ peerText !== undefined
414
+ ? "linked"
415
+ : authorId === undefined
416
+ ? "other"
417
+ : authorId === store.state.me?.id
418
+ ? "me"
419
+ : "person";
420
+
421
+ return html`<div
422
+ class=${`user-row ${fresh ? "enter" : ""}`}
423
+ data-part="prompt-row"
424
+ id=${`entry-${entry.id}`}
425
+ >
426
+ <div
427
+ class="prompt tappable"
428
+ data-part="prompt"
429
+ data-from=${from}
430
+ title="Edit, send again, or copy"
431
+ tabindex="0"
432
+ onClick=${(event) => openMessage(event, entry.id)}
433
+ onKeyDown=${(event) => {
434
+ if (
435
+ (event.key === "Enter" || event.key === " ") &&
436
+ event.target === event.currentTarget
437
+ ) {
438
+ event.preventDefault();
439
+ openMessage(event, entry.id);
440
+ }
441
+ }}
442
+ >
443
+ <span class="prompt-head" data-part="prompt-head">
444
+ <span
445
+ class="prompt-avatar"
446
+ data-part="author-mark"
447
+ style=${`--who:${mark}`}
448
+ aria-hidden="true"
449
+ >
450
+ ${name ? initials(name) : "›"}
451
+ </span>
452
+ ${name && html`<b class="prompt-author" data-part="prompt-author">${name}</b>`}
453
+ ${entry.at && html`<span class="prompt-time">${clock(entry.at)}</span>`}
454
+ ${scheduled && html`<span class="prompt-time">· scheduled</span>`}
455
+ ${via && html`<span class="prompt-time">· via ${peerText === undefined ? via[1] : "a linked session"}</span>`}
456
+ </span>
457
+ <div class="prompt-body" data-part="prompt-text">
458
+ ${
459
+ skill &&
460
+ html`<button
461
+ class="chip skill-chip"
462
+ title=${skill[2]}
463
+ onClick=${() => openFile(skill[2])}
464
+ >
465
+ ⚡ ${skill[1]}
466
+ </button>`
467
+ }
468
+ ${
469
+ (skill ? skill[3] : body) &&
470
+ html`<div class="user-text">
471
+ ${
472
+ via
473
+ ? html`<${FencedText} text=${body} />`
474
+ : html`<${QuotedText} text=${skill ? skill[3] : body} />`
475
+ }
476
+ </div>`
477
+ }
478
+ ${
479
+ entry.files?.length > 0 &&
480
+ html`<div class="attachments">
481
+ ${entry.files.map(
482
+ (path) =>
483
+ html`<button
484
+ class="chip"
485
+ title=${`Sent with the message: ${path}`}
486
+ onClick=${() => openFile(path)}
487
+ >
488
+ 📄 ${path.split("/").pop()}
489
+ </button>`,
490
+ )}
491
+ </div>`
492
+ }
493
+ ${
494
+ pictures.length > 0 &&
495
+ html`<div class="thumbs">
496
+ ${pictures.map(
497
+ (file) => html`<${Thumb} src=${fileUrl(file.path)} alt=${file.name} />`,
498
+ )}
499
+ </div>`
500
+ }
501
+ ${
502
+ others.length > 0 &&
503
+ html`<div class="attachments">
504
+ ${others.map((file) => html`<span class="chip">📎 ${file.name}</span>`)}
505
+ </div>`
506
+ }
507
+ ${
508
+ entry.images > 0 &&
509
+ !attachments &&
510
+ html`<${EntryImages} entryId=${entry.id} count=${entry.images} label="Image" />`
511
+ }
512
+ </div>
513
+ </div>
514
+ </div>`;
515
+ }
516
+
517
+ function QuotedText({ text }) {
518
+ const quote = splitQuote(text);
519
+
520
+ if (!quote) {
521
+ return html`<${MentionedText} text=${text} />`;
522
+ }
523
+
524
+ return html`<blockquote class="reply-quote" data-part="reply-quote">${quote.passage}</blockquote>
525
+ ${quote.rest && html`<${MentionedText} text=${quote.rest} />`}`;
526
+ }
527
+
528
+ function Collapsible({ text, limit = 600 }) {
529
+ const [open, setOpen] = useState(false);
530
+
531
+ if (text.length <= limit) {
532
+ return html`<${Markdown} text=${text} />`;
533
+ }
534
+
535
+ return html`<div class=${`collapsible ${open ? "open" : ""}`}>
536
+ <${Markdown} text=${open ? text : `${text.slice(0, limit)}…`} />
537
+ <button class="link" onClick=${() => setOpen(!open)}>
538
+ ${open ? "Show less" : "Show more"}
539
+ </button>
540
+ </div>`;
541
+ }
542
+
543
+ // ─── What Pi did: its thinking and tool calls, a line each on a timeline ───────────────
544
+
545
+ /** The tools that publish artifacts: `artifact_inline` also shows its page as a card in the reply. */
546
+ const isArtifactCall = (name) => name === "artifact" || name === INLINE_TOOL;
547
+
548
+ /** What a step's mark says, by how the call went; a call still running shows a spinner instead. */
549
+ const MARKS = { done: "✓", error: "×", denied: "×", stopped: "–", approval: "!" };
550
+
551
+ /**
552
+ * The waiting and running calls' statuses as the mark's own look names: `approval` and `running` are also the card
553
+ * classes in `style.css`, whose padding, border, and layout must not sit on the mark.
554
+ */
555
+ const MARK_LOOKS = { approval: "asking", running: "spinning" };
556
+
557
+ /** A step's mark: a small circle that says how it went. A thought is ✦, and a subagent's call ⑂ unless it failed. */
558
+ function StepMark({ status, kind }) {
559
+ const agent = kind === "agent" && (status === "done" || status === "running");
560
+ const spinning = kind !== "thought" && !agent && status === "running";
561
+ const look = kind === "thought" ? "thought" : agent ? "agent" : (MARK_LOOKS[status] ?? status);
562
+ const mark = kind === "thought" ? "✦" : agent ? "⑂" : MARKS[status];
563
+
564
+ return html`<span
565
+ class=${`step-mark ${look}`}
566
+ data-part="step-mark"
567
+ aria-hidden=${spinning ? undefined : "true"}
568
+ >
569
+ ${spinning ? html`<${Spinner} />` : mark}
570
+ </span>`;
571
+ }
572
+
573
+ /** What Pi did with its memory tool, as a step's label. */
574
+ const MEMORY_WORDS = {
575
+ add: "saved a lesson",
576
+ update: "changed a lesson",
577
+ remove: "forgot a lesson",
578
+ list: "read its lessons",
579
+ };
580
+
581
+ /** A call's line: its label (the tool, or a subagent's name) and what it acts on. */
582
+ function stepWords(call) {
583
+ const args = call.args ?? {};
584
+
585
+ switch (call.name) {
586
+ case "grep":
587
+ case "find":
588
+ return {
589
+ label: call.name,
590
+ subject: [args.pattern, args.path].filter(Boolean).join(" "),
591
+ };
592
+ case "subagent":
593
+ return {
594
+ label: args.name ?? "subagent",
595
+ subject: args.message ? short(args.message) : (args.action ?? ""),
596
+ };
597
+ case "browser":
598
+ return {
599
+ label: "browser",
600
+ subject: `${args.action ?? ""} ${describeCall(call).subject}`.trim(),
601
+ };
602
+ case "memory":
603
+ return {
604
+ label: MEMORY_WORDS[args.action] ?? "memory",
605
+ subject: args.text ? short(args.text) : (args.id ?? ""),
606
+ };
607
+ case "watch_pr":
608
+ return { label: "watch PR", subject: [args.action, args.pr].filter(Boolean).join(" ") };
609
+
610
+ default: {
611
+ const described = describeCall(call);
612
+
613
+ // An extension's own words for its tool, or the tool's name.
614
+ return {
615
+ label: described.own ? described.label : call.name,
616
+ subject: described.subject,
617
+ };
618
+ }
619
+ }
620
+ }
621
+
622
+ /**
623
+ * What a call came to, on the right of its line: `{ text, tone }`, or null while it runs or when there is nothing to
624
+ * say.
625
+ */
626
+ function outcome({ call, result, details, status }) {
627
+ if (status === "approval") {
628
+ return { text: "needs approval", tone: "warn" };
629
+ }
630
+
631
+ if (status === "denied" || status === "stopped") {
632
+ return {
633
+ text: /was interrupted/.test(result?.text ?? "") ? "cut off by a restart" : status,
634
+ tone: "",
635
+ };
636
+ }
637
+
638
+ if (status === "error") {
639
+ return { text: shortError(result?.text ?? ""), tone: "err" };
640
+ }
641
+
642
+ // A call whose result is not stored yet has nothing to count, nor one whose input or output came light, as a session
643
+ // opens (`partial`): it is counted when it comes whole, an instant later.
644
+ if (status !== "done" || !result || result.partial || call.partial) {
645
+ return null;
646
+ }
647
+
648
+ const text = result.text ?? "";
649
+ const args = call.args ?? {};
650
+
651
+ switch (call.name) {
652
+ case "edit": {
653
+ const counts = diffCounts(details?.diff);
654
+
655
+ return counts && { text: `+${counts.added} −${counts.removed}`, tone: "ok", counts };
656
+ }
657
+
658
+ case "write":
659
+ return typeof args.content === "string" && !call.clipped?.content
660
+ ? { text: `+${lineCount(args.content)}`, tone: "ok" }
661
+ : null;
662
+
663
+ case "read": {
664
+ // Without the notes a long file's read ends with: "[120 more lines in file. Use offset=…]".
665
+ const read = withoutNotes(text).replace(
666
+ /\n*\[[^\]\n]*(?:more lines|offset=)[^\]\n]*\]\s*$/,
667
+ "",
668
+ );
669
+
670
+ return result.images > 0
671
+ ? { text: "image", tone: "" }
672
+ : !result.clipped && { text: plural(lineCount(read), "line"), tone: "" };
673
+ }
674
+
675
+ // A long result reaches the browser cut short (its start and end): its lines cannot be counted.
676
+ case "grep":
677
+ return /^No matches/.test(text)
678
+ ? { text: "no matches", tone: "" }
679
+ : {
680
+ text: result.clipped
681
+ ? "many matches"
682
+ : plural(matchCount(withoutNotes(text)), "match").replace(
683
+ "matchs",
684
+ "matches",
685
+ ),
686
+ tone: "",
687
+ };
688
+ case "find":
689
+ case "ls":
690
+ return /^(?:No files|\(empty directory\))/.test(text)
691
+ ? { text: "none", tone: "" }
692
+ : {
693
+ text: result.clipped
694
+ ? "many"
695
+ : plural(
696
+ lineCount(withoutNotes(text)),
697
+ call.name === "ls" ? "entry" : "file",
698
+ ).replace("entrys", "entries"),
699
+ tone: "",
700
+ };
701
+
702
+ case "bash": {
703
+ const tests = testOutcome(text, args.command);
704
+
705
+ if (tests) {
706
+ return { text: tests, tone: "ok" };
707
+ }
708
+
709
+ const lines = lineCount(withoutNotes(text).trim());
710
+
711
+ return {
712
+ text: result.clipped
713
+ ? "long output"
714
+ : lines === 0
715
+ ? "no output"
716
+ : plural(lines, "line"),
717
+ tone: "",
718
+ };
719
+ }
720
+
721
+ case "artifact":
722
+ case "artifact_inline":
723
+ return details?.version !== undefined
724
+ ? { text: `v${details.version}`, tone: "" }
725
+ : null;
726
+ case "codemode":
727
+ return Array.isArray(details?.calls)
728
+ ? { text: plural(details.calls.length, "call"), tone: "" }
729
+ : null;
730
+
731
+ case "subagent": {
732
+ const agent = store.state.view.subagents?.find((each) => each.name === args.name);
733
+
734
+ if (
735
+ !agent ||
736
+ (args.action !== undefined && args.action !== "spawn" && args.action !== "send")
737
+ ) {
738
+ return null;
739
+ }
740
+
741
+ const state = stateOf(agent);
742
+ const took =
743
+ state !== "working" && agent.askedAt && agent.answeredAt
744
+ ? ` ${span(agent.answeredAt - agent.askedAt)}`
745
+ : "";
746
+
747
+ return { text: `${state}${took}`, tone: state === "failed" ? "err" : "" };
748
+ }
749
+
750
+ default:
751
+ return null;
752
+ }
753
+ }
754
+
755
+ /** A call's state for themes (`data-state`), by its status: documented names that stay when the statuses change. */
756
+ const CALL_STATES = {
757
+ approval: "waiting",
758
+ denied: "denied",
759
+ running: "running",
760
+ done: "ok",
761
+ error: "failed",
762
+ stopped: "stopped",
763
+ };
764
+
765
+ /**
766
+ * Who allowed or denied a call. A script's own calls are asked about one by one (`<call>/<n>`): for the script, the one
767
+ * denied when it failed, or else the last one allowed.
768
+ */
769
+ function decisionOf(decisions, call, result) {
770
+ const own = decisions?.[call.id];
771
+
772
+ if (own !== undefined || call.name !== "codemode") {
773
+ return own;
774
+ }
775
+
776
+ const inner = Object.entries(decisions ?? {}).flatMap(([id, each]) =>
777
+ id.startsWith(`${call.id}/`) ? [each] : [],
778
+ );
779
+
780
+ return (
781
+ (result?.isError ? inner.find((each) => each.allow === false) : undefined) ??
782
+ inner.findLast((each) => each.allow !== false)
783
+ );
784
+ }
785
+
786
+ /**
787
+ * Where a call is: waiting for approval, denied, running, done, failed, or stopped before it finished. A call with
788
+ * nothing to say for it yet runs while it is being written, or while Pi works on the answer that asked for it
789
+ * (`canRun`); any other never will.
790
+ */
791
+ function callStatus({ result, slot, approval, decision, streaming, canRun, busy }) {
792
+ if (approval) {
793
+ return "approval";
794
+ }
795
+
796
+ if (decision?.allow === false) {
797
+ return "denied";
798
+ }
799
+
800
+ if (result) {
801
+ // Stopped with the run, or cut off by a restart: the harness says so in its own words.
802
+ if (result.isError && STOPPED.test(result.text ?? "")) {
803
+ return "stopped";
804
+ }
805
+
806
+ return result.isError ? "error" : "done";
807
+ }
808
+
809
+ if (slot) {
810
+ return slot.status === "done" ? "done" : "running";
811
+ }
812
+
813
+ return streaming || (busy && canRun) ? "running" : "stopped";
814
+ }
815
+
816
+ /**
817
+ * `unrolling` for what a person opened just now (`unrolled`, when they tapped it): it unrolls (style.css). What opens by
818
+ * itself, as a session opens or a group goes live, shows at once. Never tapped is -Infinity, not 0: the page's clock
819
+ * starts at 0 as it loads, so 0 would read as a tap just now.
820
+ */
821
+ const unrolling = (unrolled) => (performance.now() - unrolled.current < 600 ? "unrolling" : "");
822
+
823
+ /** A thought, on the timeline: open it to read it. */
824
+ function ThoughtStep({ block, streaming, reading }) {
825
+ // With Show thinking on, a thought once written shows its words (the live line shows the one being written).
826
+ const [chosen, setChosen] = useState(null);
827
+ const unrolled = useRef(-Infinity);
828
+ // One whose words have not come yet (`partial`, as a session opens) opens once they have, if it was asked to.
829
+ const open =
830
+ !block.partial && (chosen ?? (reading && !streaming && Boolean(block.text?.trim())));
831
+
832
+ return html`<div
833
+ class=${`step ${open ? "open" : ""}`}
834
+ data-part="thinking"
835
+ data-status=${streaming ? "running" : "done"}
836
+ >
837
+ <div class="step-line">
838
+ <button
839
+ class="step-head"
840
+ data-part="step-head"
841
+ aria-expanded=${open}
842
+ onClick=${() => {
843
+ unrolled.current = open ? -Infinity : performance.now();
844
+ setChosen(!open);
845
+ }}
846
+ >
847
+ <${StepMark} status=${streaming ? "running" : "done"} kind="thought" />
848
+ <span class="step-label" data-part="step-label">
849
+ ${streaming ? "Thinking" : block.redacted ? "Thought (redacted)" : "Thought"}
850
+ </span>
851
+ <span class="step-subject"></span>
852
+ ${streaming && html`<${Spinner} label="Thinking" />`}
853
+ </button>
854
+ </div>
855
+ ${
856
+ open &&
857
+ html`<div class=${`step-body ${unrolling(unrolled)}`} data-part="step-detail">
858
+ <div class="thought-body">${block.text}</div>
859
+ </div>`
860
+ }
861
+ </div>`;
862
+ }
863
+
864
+ /** One tool call on the timeline: what it did and how it went, and tapped, its input and output. */
865
+ function ToolStep({
866
+ call,
867
+ result,
868
+ slot,
869
+ approval,
870
+ entryId,
871
+ streaming,
872
+ canRun,
873
+ busy,
874
+ conversationId,
875
+ }) {
876
+ const [open, setOpen] = useState(false);
877
+ const unrolled = useRef(-Infinity);
878
+ const [full, setFull] = useState(null);
879
+ const view = store.state.view;
880
+ const decision = decisionOf(view.decisions, call, result);
881
+ const status = callStatus({ result, slot, approval, decision, streaming, canRun, busy });
882
+ // A call that finishes while on screen flashes once.
883
+ const [settled, setSettled] = useState(false);
884
+ const was = useRef(status);
885
+
886
+ useEffect(() => {
887
+ const before = was.current;
888
+
889
+ was.current = status;
890
+
891
+ if (status !== "done" || before !== "running") {
892
+ return;
893
+ }
894
+
895
+ setSettled(true);
896
+ const timer = setTimeout(() => setSettled(false), 900);
897
+
898
+ return () => clearTimeout(timer);
899
+ }, [status]);
900
+ const details = full?.result?.details ?? result?.details ?? slot?.details;
901
+ const args = (full?.call ?? call).args ?? {};
902
+ const resultText = full?.result?.text ?? result?.text;
903
+ // A long edit's diff reaches the browser cut short, as its start and end (`projection.ts`), with no other sign.
904
+ const diffClipped =
905
+ typeof details?.diff === "string" &&
906
+ /\n\n… \d+ characters not shown …\n\n/.test(details.diff);
907
+ const loadFull = () =>
908
+ attempt(async () => {
909
+ const assistant = await actions.fullEntry(entryId);
910
+ const fullCall = assistant.blocks?.find(
911
+ (block) => block.type === "toolCall" && block.id === call.id,
912
+ );
913
+ const fullResult =
914
+ (result?.clipped || diffClipped) && result
915
+ ? await actions.fullEntry(result.id)
916
+ : null;
917
+
918
+ setFull({ call: fullCall ?? call, result: fullResult });
919
+ });
920
+
921
+ // An image a read returned is already on screen elsewhere: it shows only when the call is opened.
922
+ const imagesInside = call.name === "read";
923
+ let body = null;
924
+
925
+ if (open) {
926
+ const clipped = (call.clipped || result?.clipped || diffClipped) && !full;
927
+ const parts = [];
928
+
929
+ if (
930
+ (call.name === "read" || call.name === "write" || call.name === "edit") &&
931
+ typeof args.path === "string" &&
932
+ args.path !== ""
933
+ ) {
934
+ const at = call.name === "read" && args.offset ? `:${args.offset}` : "";
935
+
936
+ parts.push(
937
+ html`<button
938
+ class="link small tool-open"
939
+ onClick=${() => openFile(`${args.path}${at}`)}
940
+ >
941
+ Open ${args.path.split("/").pop()} →
942
+ </button>`,
943
+ );
944
+ }
945
+
946
+ if (imagesInside && result?.images > 0) {
947
+ parts.push(
948
+ html`<${EntryImages}
949
+ entryId=${result.id}
950
+ count=${result.images}
951
+ label=${`${call.name} image`}
952
+ />`,
953
+ );
954
+ }
955
+
956
+ if (call.name === "bash") {
957
+ parts.push(html`<pre class="cmd">$ ${args.command}</pre>`);
958
+ }
959
+
960
+ if (call.name === "write" && args.content) {
961
+ parts.push(
962
+ html`<${Highlighted} class="output" text=${args.content} lang=${args.path} />`,
963
+ );
964
+ }
965
+
966
+ if (call.name === "edit") {
967
+ if (details?.diff) {
968
+ parts.push(
969
+ html`<${DiffBlock} text=${details.diff} path=${args.path ?? ""} bare=${true} />`,
970
+ );
971
+ } else if (Array.isArray(args.edits)) {
972
+ parts.push(
973
+ html`<pre class="output">
974
+ ${args.edits.map((edit) => `- ${edit.oldText}\n+ ${edit.newText}`).join("\n\n")}
975
+ </pre>`,
976
+ );
977
+ }
978
+ }
979
+
980
+ if (call.name === "subagent" && args.message) {
981
+ parts.push(html`<div class="tool-note">${args.message}</div>`);
982
+ }
983
+
984
+ if (call.name === "codemode") {
985
+ if (args.code) {
986
+ parts.push(html`<pre class="cmd">${args.code}</pre>`);
987
+ }
988
+
989
+ // The script's own tool calls, live while it runs.
990
+ const nested = Array.isArray(details?.calls) ? details.calls : [];
991
+
992
+ if (nested.length > 0) {
993
+ parts.push(
994
+ html`<div class="nested-calls">
995
+ ${nested.map(
996
+ (each) => html`<div class=${`nested-call ${each.status}`}>
997
+ <span class="nested-status">
998
+ ${each.status === "ok" ? "✓" : each.status === "running" ? "…" : each.status === "cancelled" ? "–" : "!"}
999
+ </span>
1000
+ <span class="mono">${each.name}</span>
1001
+ ${
1002
+ each.durationMs !== undefined &&
1003
+ html`<span class="muted">${each.durationMs} ms</span>`
1004
+ }
1005
+ ${
1006
+ each.error &&
1007
+ html`<span class="nested-error">${each.error}</span>`
1008
+ }
1009
+ </div>`,
1010
+ )}
1011
+ </div>`,
1012
+ );
1013
+ }
1014
+ }
1015
+
1016
+ if (isArtifactCall(call.name) && (args.content || args.edits)) {
1017
+ parts.push(
1018
+ html`<pre class="output">
1019
+ ${args.content ?? JSON.stringify(args.edits, null, 2)}
1020
+ </pre>`,
1021
+ );
1022
+ }
1023
+
1024
+ if (call.name === "browser") {
1025
+ parts.push(
1026
+ args.script
1027
+ ? html`<pre class="cmd">${args.script}</pre>`
1028
+ : html`<pre class="output">${JSON.stringify(args, null, 2)}</pre>`,
1029
+ );
1030
+ }
1031
+
1032
+ if (
1033
+ ![
1034
+ "read",
1035
+ "write",
1036
+ "edit",
1037
+ "bash",
1038
+ "subagent",
1039
+ "artifact",
1040
+ "artifact_inline",
1041
+ "codemode",
1042
+ "browser",
1043
+ ].includes(call.name)
1044
+ ) {
1045
+ parts.push(html`<pre class="output">${JSON.stringify(args, null, 2)}</pre>`);
1046
+ }
1047
+
1048
+ // The harness's own errors come wrapped in its tags, after the output if any: what it says is inside them.
1049
+ const output = (resultText ?? slot?.output)
1050
+ ?.replace(
1051
+ /(^|\n)<harness>([\s\S]*?)<\/harness>\s*$/,
1052
+ (_, before, note) => before + note.trim(),
1053
+ )
1054
+ .replace(/^<harness>\s*/, "");
1055
+
1056
+ if (output && !(call.name === "edit" && details?.diff && !result?.isError)) {
1057
+ parts.push(
1058
+ html`<pre class=${`output ${result?.isError ? "error" : ""}`}>${output}</pre>`,
1059
+ );
1060
+ }
1061
+
1062
+ // A call still streaming has no stored entry to load yet.
1063
+ if (clipped && entryId !== undefined) {
1064
+ parts.push(html`<button class="link" onClick=${loadFull}>Load everything</button>`);
1065
+ }
1066
+
1067
+ body = html`<div class=${`step-body ${unrolling(unrolled)}`} data-part="step-detail">
1068
+ ${parts}
1069
+ </div>`;
1070
+ }
1071
+
1072
+ const artifact = isArtifactCall(call.name) && details?.id ? details : null;
1073
+ const artifactType = artifact
1074
+ ? view.artifacts.find((each) => each.id === artifact.id)?.type
1075
+ : undefined;
1076
+ const artifactSrc = artifact
1077
+ ? `/a/${view.conversation.id}/${encodeURIComponent(artifact.id)}/${artifact.version}`
1078
+ : null;
1079
+ const child =
1080
+ call.name === "subagent"
1081
+ ? (details?.conversationId ??
1082
+ view.subagents.find((agent) => agent.name === args.name)?.conversationId)
1083
+ : undefined;
1084
+ const { label, subject } = stepWords(call);
1085
+ // Counted from everything once it is loaded: a long write's lines, a long edit's whole diff.
1086
+ const said = outcome({
1087
+ call: full?.call ?? call,
1088
+ result: full?.result ?? result,
1089
+ details,
1090
+ status,
1091
+ });
1092
+ const watch = call.name === "browser" && browserAvailable() && !store.state.browserOpen;
1093
+ // What a call made shows under its line, as on today's cards: what it returned as images, an artifact, the browser.
1094
+ // A lesson Pi saved, changed, or forgot: its state, and approving it where it happened.
1095
+ const lesson =
1096
+ call.name === "memory" &&
1097
+ typeof details?.lesson === "string" &&
1098
+ memoryAvailable(conversationId)
1099
+ ? details.lesson
1100
+ : null;
1101
+ const imagesUnder = result?.images > 0 && !imagesInside;
1102
+ const answers =
1103
+ call.name === "ask" && !result?.isError && Array.isArray(details?.answers)
1104
+ ? details.answers
1105
+ : null;
1106
+ const extras = imagesUnder || artifact || watch || lesson || answers;
1107
+
1108
+ return html`<div
1109
+ class=${`step ${open ? "open" : ""} ${settled ? "settled" : ""}`}
1110
+ data-part="tool-call"
1111
+ data-state=${CALL_STATES[status]}
1112
+ data-status=${status}
1113
+ >
1114
+ <div class="step-line">
1115
+ <button
1116
+ class="step-head"
1117
+ data-part="step-head"
1118
+ aria-expanded=${open}
1119
+ onClick=${() => {
1120
+ unrolled.current = open ? -Infinity : performance.now();
1121
+ setOpen(!open);
1122
+ }}
1123
+ >
1124
+ <${StepMark} status=${status} kind=${call.name === "subagent" ? "agent" : ""} />
1125
+ <span class="step-label" data-part="step-label">${label}</span>
1126
+ <span class="step-subject">${subject}</span>
1127
+ ${
1128
+ said &&
1129
+ html`<span class=${`step-meta ${said.tone}`}>
1130
+ ${
1131
+ said.counts
1132
+ ? html`<span class="ok">+${said.counts.added}</span>${" "}<span class="err">−${said.counts.removed}</span>`
1133
+ : said.text
1134
+ }
1135
+ </span>`
1136
+ }
1137
+ </button>
1138
+ ${
1139
+ child !== undefined &&
1140
+ html`<button
1141
+ class="step-open"
1142
+ title=${`Open ${args.name ?? "the subagent"}`}
1143
+ onClick=${() => openSubagent(child)}
1144
+ >
1145
+ Open →
1146
+ </button>`
1147
+ }
1148
+ </div>
1149
+ ${
1150
+ decision &&
1151
+ html`<div
1152
+ class=${`step-decision ${decision.allow ? "allowed" : "denied"}`}
1153
+ title=${new Date(decision.at).toLocaleString()}
1154
+ >
1155
+ <${Icon} name="shield" size=${13} /> ${decided(decision)} by ${decision.by}
1156
+ </div>`
1157
+ }
1158
+ ${
1159
+ extras &&
1160
+ html`<div class="step-extras" data-part="step-detail">
1161
+ ${answers && html`<${AskAnswers} answers=${answers} />`}
1162
+ ${
1163
+ lesson &&
1164
+ html`<${LessonCall} lessonId=${lesson} conversationId=${conversationId} />`
1165
+ }
1166
+ ${
1167
+ imagesUnder &&
1168
+ html`<${EntryImages}
1169
+ entryId=${result.id}
1170
+ count=${result.images}
1171
+ label=${`${call.name} image`}
1172
+ />`
1173
+ }
1174
+ ${
1175
+ artifactType === "svg" &&
1176
+ html`<button
1177
+ class="artifact-preview"
1178
+ type="button"
1179
+ onClick=${() => openSheet({ type: "image", src: artifactSrc, alt: artifact.title })}
1180
+ >
1181
+ <img
1182
+ src=${artifactSrc}
1183
+ alt=${artifact.title}
1184
+ loading="lazy"
1185
+ decoding="async"
1186
+ />
1187
+ </button>`
1188
+ }
1189
+ ${
1190
+ artifact &&
1191
+ html`<button
1192
+ class="artifact-link"
1193
+ onClick=${() => openSheet({ type: "viewer", id: artifact.id, version: artifact.version })}
1194
+ >
1195
+ Open ${artifact.title} · version ${artifact.version} →
1196
+ </button>`
1197
+ }
1198
+ ${
1199
+ watch &&
1200
+ html`<button class="artifact-link" onClick=${() => setBrowserOpen(true)}>
1201
+ Watch in the browser${details?.address ? ` · ${details.address}` : ""} →
1202
+ </button>`
1203
+ }
1204
+ </div>`
1205
+ }
1206
+ ${body}
1207
+ </div>`;
1208
+ }
1209
+
1210
+ /** Groups opened or closed by hand, per conversation and group, for as long as the page is open. */
1211
+ const openedGroups = new Map();
1212
+ /**
1213
+ * An open group draws its newest steps, this many, and a button for the rest: a long run of Pi's alone can be hundreds of
1214
+ * steps, which made opening its session freeze on a phone. Groups shown in full, by hand or by a jump, are kept here.
1215
+ */
1216
+ const GROUP_STEPS = 24;
1217
+ const wholeGroups = new Map();
1218
+ /**
1219
+ * The answer a jump asked to see last (`jumpToEntry` in chat.js says so with a `pocket:reveal` event), and when: a group
1220
+ * that shows only after the jump moved the rows shown opens when it first draws. Groups already shown hear the event.
1221
+ */
1222
+ let revealing = { entryId: null, at: 0 };
1223
+
1224
+ document.addEventListener("pocket:reveal", (event) => {
1225
+ revealing = { entryId: event.detail, at: Date.now() };
1226
+ });
1227
+ /** When this page first saw a group that began before any of its entries was stored: the start of its clock. */
1228
+ const groupSeen = new Map();
1229
+ /** Where a group's clock stood when it last ticked: the end of a group whose stored times do not say. */
1230
+ const groupTicked = new Map();
1231
+
1232
+ /** Keep `value` in one of the maps above, the oldest going once there are many. */
1233
+ function keep(map, key, value) {
1234
+ map.delete(key);
1235
+ map.set(key, value);
1236
+
1237
+ if (map.size > 500) {
1238
+ map.delete(map.keys().next().value);
1239
+ }
1240
+ }
1241
+
1242
+ /**
1243
+ * How long a group took, from when its first answer began to its last result; ticking while it is live, without
1244
+ * drawing the group again. A group whose stored times do not say how long (a thought, then words) keeps what its clock
1245
+ * showed, or shows nothing.
1246
+ */
1247
+ function GroupTime({ id, start, end, live }) {
1248
+ const [, tick] = useState(0);
1249
+
1250
+ useEffect(() => {
1251
+ if (!live) {
1252
+ return;
1253
+ }
1254
+
1255
+ const timer = setInterval(() => tick((n) => n + 1), 1000);
1256
+
1257
+ return () => clearInterval(timer);
1258
+ }, [live]);
1259
+
1260
+ if (start === undefined) {
1261
+ return null;
1262
+ }
1263
+
1264
+ let until = end;
1265
+
1266
+ if (live) {
1267
+ until = Date.now();
1268
+ keep(groupTicked, id, until);
1269
+ } else if (!(until > start)) {
1270
+ until = groupTicked.get(id);
1271
+ }
1272
+
1273
+ if (until === undefined || !Number.isFinite(until) || until < start) {
1274
+ return null;
1275
+ }
1276
+
1277
+ return html`<span class=${`activity-time ${live ? "live" : ""}`}>${span(until - start)}</span>`;
1278
+ }
1279
+
1280
+ /**
1281
+ * Pi's thinking and tool calls between two of its words, as one line: what they did, and how long they took. It opens
1282
+ * into a timeline, a line per call, each drawn again only when what it shows changes. Open while it is live or in the
1283
+ * newest turn, closed before, unless opened or closed by hand; Appearance's Keep activity folded starts every group
1284
+ * closed.
1285
+ */
1286
+ function ActivityGroup({
1287
+ item,
1288
+ live,
1289
+ newest,
1290
+ after,
1291
+ busy,
1292
+ results,
1293
+ slots,
1294
+ approvals,
1295
+ conversationId,
1296
+ }) {
1297
+ const id = `${conversationId}:${item.key}`;
1298
+ const [, redraw] = useState(0);
1299
+ const [fresh] = useState(() => settledFor !== null && settledFor === conversationId);
1300
+ // When a click opened it: it unrolls, and with Fluid motion its first steps drop in one after another (style.css).
1301
+ const unrolled = useRef(-Infinity);
1302
+
1303
+ if (
1304
+ revealing.entryId !== null &&
1305
+ Date.now() - revealing.at < 2000 &&
1306
+ item.anchors.includes(revealing.entryId)
1307
+ ) {
1308
+ keep(openedGroups, id, true);
1309
+ keep(wholeGroups, id, true);
1310
+ }
1311
+
1312
+ const all = item.before.length === 0 ? item.steps : [...item.before, ...item.steps];
1313
+ // A thought alone is the group's own line, not a step under a line that says the same: it opens to its words,
1314
+ // which stay folded until asked for, as a thought on the timeline does.
1315
+ const lone = all.length === 1 && all[0].block.type === "thinking" ? all[0] : null;
1316
+ // Show thinking on: thoughts show their words once written.
1317
+ const reading = prefs().showThinking === true;
1318
+ const open = activityOpen({
1319
+ chosen: openedGroups.get(id),
1320
+ lone: lone && {
1321
+ partial: lone.block.partial,
1322
+ streaming: lone.streaming,
1323
+ text: lone.block.text,
1324
+ },
1325
+ live,
1326
+ newest,
1327
+ reading,
1328
+ // Appearance's Keep activity folded.
1329
+ fold: prefs().foldActivity === true,
1330
+ });
1331
+ // Calls that failed, said on the line too: folded, "edited 1" alone would read as done. A call someone denied did
1332
+ // not fail, nor a script stopped by a denial: its line says who denied it.
1333
+ const failed = all.filter((step) => {
1334
+ const result = step.block.type === "toolCall" ? results.get(step.block.id) : undefined;
1335
+
1336
+ return (
1337
+ result?.isError === true &&
1338
+ !STOPPED.test(result.text ?? "") &&
1339
+ decisionOf(store.state.view.decisions, step.block, result)?.allow !== false
1340
+ );
1341
+ }).length;
1342
+ const first = all[0];
1343
+ let start = first.entry?.at === undefined ? undefined : fromServer(first.entry.at);
1344
+
1345
+ if (start === undefined && first.entry === undefined) {
1346
+ if (!groupSeen.has(id)) {
1347
+ keep(
1348
+ groupSeen,
1349
+ id,
1350
+ after === undefined ? Date.now() : Math.min(Date.now(), fromServer(after)),
1351
+ );
1352
+ }
1353
+
1354
+ start = groupSeen.get(id);
1355
+ }
1356
+
1357
+ // The last result, or the last answer when the group ends with a thought.
1358
+ const times = all
1359
+ .flatMap((step) => [
1360
+ step.entry?.at,
1361
+ step.block.type === "toolCall" ? results.get(step.block.id)?.at : undefined,
1362
+ ])
1363
+ .filter((at) => at !== undefined);
1364
+ const end = times.length === 0 ? undefined : fromServer(Math.max(...times));
1365
+ const thinking =
1366
+ live && item.steps.at(-1).streaming && item.steps.at(-1).block.type === "thinking";
1367
+ // The steps' names, as the timeline draws them (below).
1368
+ const keys = new Set();
1369
+ // Begun in rows not shown, it goes on from their last call: a thought keeps its name when they show.
1370
+ const earlier = item.before.findLast((step) => step.block.type === "toolCall");
1371
+ let lastCall = earlier ? `c:${earlier.block.id || "#"}` : "start";
1372
+ let thoughts = 0;
1373
+ // A jump to one of its answers (Find, a pin, Changes) opens it: `jumpToEntry` says which.
1374
+ const anchors = item.anchors.join();
1375
+ // The steps not drawn yet, the oldest: each still names the steps after it (below), so keys stay as they were.
1376
+ const hidden = wholeGroups.get(id) ? 0 : Math.max(0, item.steps.length - GROUP_STEPS);
1377
+
1378
+ useEffect(() => {
1379
+ if (anchors === "") {
1380
+ return;
1381
+ }
1382
+
1383
+ const reveal = (event) => {
1384
+ if (item.anchors.includes(event.detail)) {
1385
+ keep(openedGroups, id, true);
1386
+ keep(wholeGroups, id, true);
1387
+ redraw((n) => n + 1);
1388
+ }
1389
+ };
1390
+
1391
+ document.addEventListener("pocket:reveal", reveal);
1392
+
1393
+ return () => document.removeEventListener("pocket:reveal", reveal);
1394
+ }, [anchors, id]);
1395
+
1396
+ return html`<div
1397
+ class=${`activity ${open ? "open" : ""} ${fresh ? "enter" : ""}`}
1398
+ data-part="activity"
1399
+ data-state=${open ? "open" : undefined}
1400
+ >
1401
+ ${item.anchors.map(
1402
+ (entryId) => html`<span class="entry-anchor" id=${`entry-${entryId}`}></span>`,
1403
+ )}
1404
+ <button
1405
+ class="activity-head"
1406
+ data-part="activity-head"
1407
+ aria-expanded=${open}
1408
+ onClick=${() => {
1409
+ unrolled.current = open ? -Infinity : performance.now();
1410
+ keep(openedGroups, id, !open);
1411
+ redraw((n) => n + 1);
1412
+ }}
1413
+ >
1414
+ <${Icon} name="chevron" size=${14} class="chev" part="activity-chevron" />
1415
+ <span class="activity-summary">${summarize(all, thinking)}</span>
1416
+ ${failed > 0 && html`<span class="activity-failed">${failed} failed</span>`}
1417
+ <${GroupTime} id=${id} start=${start} end=${end} live=${live} />
1418
+ </button>
1419
+ ${
1420
+ open &&
1421
+ lone &&
1422
+ html`<div class=${`activity-thought ${unrolling(unrolled)}`}>
1423
+ <div class="thought-body">${lone.block.text}</div>
1424
+ </div>`
1425
+ }
1426
+ ${
1427
+ open &&
1428
+ !lone &&
1429
+ html`<div
1430
+ class=${`timeline ${unrolling(unrolled)}`}
1431
+ data-part="timeline"
1432
+ >
1433
+ ${
1434
+ hidden > 0 &&
1435
+ html`<button
1436
+ class="link timeline-earlier"
1437
+ data-part="timeline-earlier"
1438
+ onClick=${() => {
1439
+ keep(wholeGroups, id, true);
1440
+ redraw((n) => n + 1);
1441
+ }}
1442
+ >
1443
+ Show ${hidden} earlier ${hidden === 1 ? "step" : "steps"}
1444
+ </button>`
1445
+ }
1446
+ ${item.steps.map(({ block, entry, streaming, canRun }, index) => {
1447
+ // A call is named by its id, the same streamed and stored; a thought by the call before it. Not by
1448
+ // place: earlier steps join the front when earlier messages show.
1449
+ if (block.type === "thinking") {
1450
+ const key = `t:${lastCall}:${thoughts++}`;
1451
+
1452
+ return (
1453
+ index >= hidden &&
1454
+ html`<${Memo} key=${key} deps=${[block, streaming, reading]}>
1455
+ <${ThoughtStep} block=${block} streaming=${streaming} reading=${reading} />
1456
+ <//>`
1457
+ );
1458
+ }
1459
+
1460
+ const slot = slots.get(block.id);
1461
+ const approval =
1462
+ slot?.taskId === undefined
1463
+ ? undefined
1464
+ : approvals.find((each) => each.taskId === slot.taskId);
1465
+ let key = `c:${block.id || `#${index}`}`;
1466
+
1467
+ // A provider can use an id twice, or none.
1468
+ if (keys.has(key)) {
1469
+ key = `${key}#${index}`;
1470
+ }
1471
+
1472
+ keys.add(key);
1473
+ lastCall = key;
1474
+ thoughts = 0;
1475
+
1476
+ if (index < hidden) {
1477
+ return null;
1478
+ }
1479
+
1480
+ return html`<${Memo}
1481
+ key=${key}
1482
+ deps=${[
1483
+ block,
1484
+ entry,
1485
+ canRun,
1486
+ busy,
1487
+ conversationId,
1488
+ ...callDeps(block, results, slots, approvals),
1489
+ ]}
1490
+ >
1491
+ <${ToolStep}
1492
+ call=${block}
1493
+ result=${results.get(block.id)}
1494
+ slot=${slot}
1495
+ approval=${approval}
1496
+ entryId=${entry?.id}
1497
+ streaming=${entry === undefined}
1498
+ canRun=${canRun}
1499
+ busy=${busy}
1500
+ conversationId=${conversationId}
1501
+ />
1502
+ <//>`;
1503
+ })}
1504
+ </div>`
1505
+ }
1506
+ </div>`;
1507
+ }
1508
+
1509
+ /**
1510
+ * A page Pi showed in its reply (`artifact_inline`): its card once the call has published, between the words around
1511
+ * it. Until then, and if the call failed, was denied, or stopped, its step as the timeline draws it, so the reason shows.
1512
+ */
1513
+ function InlineRow({ item, results, slots, approvals, busy, conversationId }) {
1514
+ const { block, entry, canRun } = item.step;
1515
+ const result = results.get(block.id);
1516
+ const details = result?.details;
1517
+ const slot = slots.get(block.id);
1518
+ const approval =
1519
+ slot?.taskId === undefined
1520
+ ? undefined
1521
+ : approvals.find((each) => each.taskId === slot.taskId);
1522
+ const published =
1523
+ result !== undefined &&
1524
+ !result.isError &&
1525
+ details?.inline === true &&
1526
+ typeof details.id === "string" &&
1527
+ Number.isInteger(details.version);
1528
+
1529
+ return html`<div class="inline-row">
1530
+ ${item.anchors.map(
1531
+ (entryId) => html`<span class="entry-anchor" id=${`entry-${entryId}`}></span>`,
1532
+ )}
1533
+ ${
1534
+ published
1535
+ ? html`<${InlineArtifact}
1536
+ conversationId=${conversationId}
1537
+ id=${details.id}
1538
+ title=${details.title ?? details.id}
1539
+ version=${details.version}
1540
+ />`
1541
+ : html`<div class="activity open">
1542
+ <div class="timeline">
1543
+ <${ToolStep}
1544
+ call=${block}
1545
+ result=${result}
1546
+ slot=${slot}
1547
+ approval=${approval}
1548
+ entryId=${entry?.id}
1549
+ streaming=${entry === undefined}
1550
+ canRun=${canRun}
1551
+ busy=${busy}
1552
+ conversationId=${conversationId}
1553
+ />
1554
+ </div>
1555
+ </div>`
1556
+ }
1557
+ </div>`;
1558
+ }
1559
+
1560
+ /** Whether a step is still at work: a thought being written, or a call that has not finished. */
1561
+ function stepLive(step, results, slots, approvals, busy) {
1562
+ if (step.block.type === "thinking") {
1563
+ return step.streaming;
1564
+ }
1565
+
1566
+ const slot = slots.get(step.block.id);
1567
+ const approval =
1568
+ slot?.taskId === undefined
1569
+ ? undefined
1570
+ : approvals.find((each) => each.taskId === slot.taskId);
1571
+ const status = callStatus({
1572
+ result: results.get(step.block.id),
1573
+ slot,
1574
+ approval,
1575
+ decision: store.state.view.decisions?.[step.block.id],
1576
+ streaming: step.entry === undefined,
1577
+ canRun: step.canRun,
1578
+ busy,
1579
+ });
1580
+
1581
+ return status === "running" || status === "approval";
1582
+ }
1583
+
1584
+ function AskChoices({ field, chosen, busy, onChange }) {
1585
+ const { picked, other } = chosen;
1586
+
1587
+ const pick = (label) => {
1588
+ const on = picked.includes(label);
1589
+ const next = field.multiple
1590
+ ? on
1591
+ ? picked.filter((each) => each !== label)
1592
+ : [...picked, label]
1593
+ : [label];
1594
+
1595
+ onChange({ picked: next, other: "" });
1596
+ };
1597
+
1598
+ return html`<fieldset class="ask-choices" disabled=${busy}>
1599
+ <legend class="ask-question">
1600
+ ${field.header && html`<span class="ask-header">${field.header}</span>`}
1601
+ <span>${field.label}</span>
1602
+ ${field.multiple && html`<span class="ask-hint">Choose one or more</span>`}
1603
+ </legend>
1604
+ <div class="ask-options">
1605
+ ${field.choices.map(
1606
+ (choice) => html`<button
1607
+ type="button"
1608
+ class="ask-option"
1609
+ key=${choice.label}
1610
+ aria-pressed=${picked.includes(choice.label)}
1611
+ onClick=${() => pick(choice.label)}
1612
+ >
1613
+ <span class="ask-option-label">${choice.label}</span>
1614
+ ${choice.description && html`<span class="ask-option-text">${choice.description}</span>`}
1615
+ </button>`,
1616
+ )}
1617
+ </div>
1618
+ ${
1619
+ field.other &&
1620
+ html`<input
1621
+ class="ask-other"
1622
+ type="text"
1623
+ aria-label=${`Your own answer to: ${field.label}`}
1624
+ placeholder=${field.placeholder ?? "Or type your own answer"}
1625
+ value=${other}
1626
+ onInput=${(event) => onChange({ picked: [], other: event.currentTarget.value })}
1627
+ />`
1628
+ }
1629
+ </fieldset>`;
1630
+ }
1631
+
1632
+ function choiceValue(field, { picked, other }) {
1633
+ if (other.trim() !== "") {
1634
+ return other;
1635
+ }
1636
+
1637
+ return field.choices
1638
+ .map((choice) => choice.label)
1639
+ .filter((label) => picked.includes(label))
1640
+ .join("\n");
1641
+ }
1642
+
1643
+ /** Sent only by that person, and declined by anyone who can steer. Only the `ask` tool passes what is typed on to Pi. */
1644
+ function AskCard({ approval }) {
1645
+ const { me, users } = store.state;
1646
+ const form = approval.form;
1647
+ const [values, setValues] = useState(() =>
1648
+ Object.fromEntries(
1649
+ form.fields.map((field) => [
1650
+ field.name,
1651
+ field.choices ? { picked: [], other: "" } : "",
1652
+ ]),
1653
+ ),
1654
+ );
1655
+ const [busy, setBusy] = useState(false);
1656
+ const owner = users?.find?.((each) => each.role === "owner")?.id;
1657
+ const mine = (approval.requestedBy ?? owner) === me?.id;
1658
+ const who =
1659
+ users?.find?.((each) => each.id === approval.requestedBy)?.name ??
1660
+ "the person Pi works for";
1661
+ const typed = Object.fromEntries(
1662
+ form.fields.map((field) => [
1663
+ field.name,
1664
+ field.choices ? choiceValue(field, values[field.name]) : values[field.name],
1665
+ ]),
1666
+ );
1667
+ const ready = form.fields.every((field) => typed[field.name] !== "");
1668
+ // Only a card of plain fields hides what is typed from Pi: the `ask` tool's answers are for Pi.
1669
+ const plain = form.fields.some((field) => !field.choices);
1670
+
1671
+ const answer = (allow) => {
1672
+ setBusy(true);
1673
+ attempt(() => actions.approve(approval.id, allow, allow ? typed : undefined)).finally(() =>
1674
+ setBusy(false),
1675
+ );
1676
+ };
1677
+
1678
+ return html`<form
1679
+ class="approval ask"
1680
+ data-part="approval"
1681
+ onSubmit=${(event) => {
1682
+ event.preventDefault();
1683
+
1684
+ if (mine && ready && !busy) {
1685
+ answer(true);
1686
+ }
1687
+ }}
1688
+ >
1689
+ <div class="approval-head">
1690
+ <span class="approval-shield" aria-hidden="true"><${Icon} name="shield" size=${16} /></span>
1691
+ <div class="approval-title">
1692
+ <span class="approval-ask">${form.title}</span>
1693
+ ${form.text && html`<span class="approval-reason">${form.text}</span>`}
1694
+ </div>
1695
+ </div>
1696
+ ${
1697
+ mine
1698
+ ? html`${form.fields.map((field) =>
1699
+ field.choices
1700
+ ? html`<${AskChoices}
1701
+ key=${field.name}
1702
+ field=${field}
1703
+ chosen=${values[field.name]}
1704
+ busy=${busy}
1705
+ onChange=${(chosen) => setValues({ ...values, [field.name]: chosen })}
1706
+ />`
1707
+ : html`<label class="ask-field" key=${field.name}>
1708
+ <span>${field.label}</span>
1709
+ <input
1710
+ type=${field.secret ? "password" : "text"}
1711
+ name=${field.name}
1712
+ value=${values[field.name]}
1713
+ placeholder=${field.placeholder ?? ""}
1714
+ autocomplete=${field.autocomplete ?? "off"}
1715
+ autocapitalize="off"
1716
+ spellcheck="false"
1717
+ disabled=${busy}
1718
+ onInput=${(event) =>
1719
+ setValues({
1720
+ ...values,
1721
+ [field.name]: event.currentTarget.value,
1722
+ })}
1723
+ />
1724
+ </label>`,
1725
+ )}
1726
+ ${
1727
+ plain &&
1728
+ html`<div class="muted small">
1729
+ What you type goes to the page or tool that asked. Pi does not see it.
1730
+ </div>`
1731
+ }`
1732
+ : html`<div class="muted small">Waiting for ${who} to answer.</div>`
1733
+ }
1734
+ ${
1735
+ canSteer() &&
1736
+ html`<div class="approval-actions">
1737
+ <button type="button" class="button deny" disabled=${busy} onClick=${() => answer(false)}>
1738
+ Decline
1739
+ </button>
1740
+ ${
1741
+ mine &&
1742
+ html`<button type="submit" class="button primary" disabled=${busy || !ready}>
1743
+ ${form.submit}
1744
+ </button>`
1745
+ }
1746
+ </div>`
1747
+ }
1748
+ </form>`;
1749
+ }
1750
+
1751
+ function AskAnswers({ answers }) {
1752
+ return html`<dl class="ask-answers">
1753
+ ${answers.map(
1754
+ (each, at) => html`<div class="ask-answer" key=${at}>
1755
+ <dt>
1756
+ <span class="ask-header">${each.header}</span>
1757
+ <span>${each.question}</span>
1758
+ </dt>
1759
+ <dd>${each.answer}</dd>
1760
+ </div>`,
1761
+ )}
1762
+ </dl>`;
1763
+ }
1764
+
1765
+ /** What someone did with a call that waited for them: an ask (`host.ask`) is answered or declined. */
1766
+ function decided(decision) {
1767
+ if (decision.ask) {
1768
+ return decision.allow ? "Answered" : "Declined";
1769
+ }
1770
+
1771
+ return decision.allow ? "Allowed" : "Denied";
1772
+ }
1773
+
1774
+ function ApprovalCard({ approval }) {
1775
+ if (approval.form) {
1776
+ return html`<${AskCard} approval=${approval} />`;
1777
+ }
1778
+
1779
+ const { me, server } = store.state;
1780
+ const [busy, setBusy] = useState(false);
1781
+
1782
+ const answer = (allow) => {
1783
+ setBusy(true);
1784
+ attempt(() => actions.approve(approval.id, allow)).finally(() => setBusy(false));
1785
+ };
1786
+
1787
+ // With approvals that need someone else, a guest cannot allow a call their own message led to (the server checks too).
1788
+ const ownCall =
1789
+ server?.approvalRule === "others" &&
1790
+ me?.role !== "owner" &&
1791
+ approval.requestedBy === me?.id;
1792
+
1793
+ return html`<div class="approval" data-part="approval">
1794
+ <div class="approval-head">
1795
+ <span class="approval-shield" aria-hidden="true"><${Icon} name="shield" size=${16} /></span>
1796
+ <div class="approval-title">
1797
+ <span class="approval-ask">Lancet Guard asks before this ${approval.tool} call</span>
1798
+ <span class="approval-reason">${approval.reason}</span>
1799
+ </div>
1800
+ </div>
1801
+ <pre class="cmd">${approval.tool === "bash" && html`<span class="cmd-sign">$ </span>`}${approval.subject}</pre>
1802
+ ${
1803
+ !canSteer()
1804
+ ? html`<div class="muted small">Waiting for someone who can steer to answer.</div>`
1805
+ : html`${
1806
+ ownCall &&
1807
+ html`<div class="muted small">
1808
+ Someone else has to allow this: it came from your message. You can deny it.
1809
+ </div>`
1810
+ }
1811
+ <div class="approval-actions">
1812
+ <button class="button deny" disabled=${busy} onClick=${() => answer(false)}>
1813
+ Deny
1814
+ </button>
1815
+ ${
1816
+ !ownCall &&
1817
+ html`<button
1818
+ class="button primary"
1819
+ disabled=${busy}
1820
+ onClick=${() => answer(true)}
1821
+ >
1822
+ Allow
1823
+ </button>`
1824
+ }
1825
+ </div>`
1826
+ }
1827
+ </div>`;
1828
+ }
1829
+
1830
+ // ─── Pi's words ───────────────────────────────────────────────────────────────────────
1831
+
1832
+ // The actions under Pi's answers, after its reactions: the app's own items of the `reply` slot (slots.js), which a
1833
+ // person can hide and reorder in Appearance, with extensions' before More, which stays last. Each gets the answer
1834
+ // (`entry`, its `text`), whether it is pinned, and the reaction picker's state.
1835
+ defineItem("reply", {
1836
+ id: "core:time",
1837
+ label: "Message time",
1838
+ order: 0,
1839
+ render: ({ entry }) => {
1840
+ if (!entry.at) {
1841
+ return null;
1842
+ }
1843
+
1844
+ const at = new Date(fromServer(entry.at));
1845
+
1846
+ return html`<time class="muted" dateTime=${at.toISOString()} title=${at.toLocaleString()}>
1847
+ ${clock(entry.at)}
1848
+ </time>`;
1849
+ },
1850
+ });
1851
+ defineItem("reply", {
1852
+ id: "core:react",
1853
+ label: "React",
1854
+ order: 100,
1855
+ render: ({ picking, setPicking, react }) =>
1856
+ html`<span class="reaction-host answer-tool">
1857
+ <button
1858
+ class="reaction add"
1859
+ aria-label="React"
1860
+ title="React"
1861
+ onClick=${() => setPicking(!picking)}
1862
+ >
1863
+ ☺+
1864
+ </button>
1865
+ ${
1866
+ picking &&
1867
+ html`<span class="reaction-picker">
1868
+ ${(store.state.server?.reactions ?? []).map(
1869
+ (emoji) => html`<button onClick=${() => react(emoji)}>${emoji}</button>`,
1870
+ )}
1871
+ </span>`
1872
+ }
1873
+ </span>`,
1874
+ });
1875
+ defineItem("reply", {
1876
+ id: "core:discuss",
1877
+ label: "Discuss",
1878
+ order: 200,
1879
+ render: ({ entry, text }) =>
1880
+ html`<button
1881
+ class="answer-tool"
1882
+ onClick=${() => discuss(entry.id, plainText(text).replace(/\s+/g, " ").slice(0, 280))}
1883
+ >
1884
+ Discuss
1885
+ </button>`,
1886
+ });
1887
+ defineItem("reply", {
1888
+ id: "core:pin",
1889
+ label: "Pin",
1890
+ order: 300,
1891
+ render: ({ entry, pinned }) =>
1892
+ html`<button
1893
+ class="answer-tool"
1894
+ onClick=${() => attempt(() => actions.pin({ entryId: entry.id }))}
1895
+ >
1896
+ ${pinned ? html`<${Icon} name="pin" size=${12} /> Pinned` : "Pin"}
1897
+ </button>`,
1898
+ });
1899
+ defineItem("reply", {
1900
+ id: "core:copy",
1901
+ label: "Copy",
1902
+ order: 400,
1903
+ render: ({ text }) =>
1904
+ html`<button
1905
+ class="answer-tool"
1906
+ onClick=${() =>
1907
+ copyText(text).then(
1908
+ () => notify("info", "Copied."),
1909
+ () => notify("error", "Could not copy."),
1910
+ )}
1911
+ >
1912
+ Copy
1913
+ </button>`,
1914
+ });
1915
+ defineItem("reply", {
1916
+ id: "core:more",
1917
+ label: "More",
1918
+ order: 1000,
1919
+ fixed: true,
1920
+ render: ({ entry }) =>
1921
+ html`<button
1922
+ class="answer-tool"
1923
+ title="Fork, retry"
1924
+ onClick=${() => openSheet({ type: "message", entryId: entry.id })}
1925
+ >
1926
+ More
1927
+ </button>`,
1928
+ });
1929
+
1930
+ /** Under Pi's answers: the message time, reactions, and ways to talk about it with the people here. */
1931
+ function AnswerActions({ entry }) {
1932
+ const { view, me, users, server } = store.state;
1933
+ const [picking, setPicking] = useState(false);
1934
+ const reactions = view.reactions?.[entry.id] ?? {};
1935
+ const pinned = (view.pins ?? []).some((pin) => pin.entryId === entry.id);
1936
+ const text = replyText(entry);
1937
+ const names = (ids) =>
1938
+ ids
1939
+ .map((id) =>
1940
+ id === me?.id ? "you" : (users.find((user) => user.id === id)?.name ?? "someone"),
1941
+ )
1942
+ .join(", ");
1943
+
1944
+ const react = (emoji) => {
1945
+ setPicking(false);
1946
+ attempt(() => actions.react(entry.id, emoji));
1947
+ };
1948
+
1949
+ return html`<div class=${`answer-actions ${picking ? "picking" : ""}`} data-part="reply-actions">
1950
+ ${Object.entries(reactions).map(
1951
+ ([emoji, ids]) =>
1952
+ html`<button
1953
+ class=${`reaction ${ids.includes(me?.id) ? "mine" : ""}`}
1954
+ title=${`${emoji} ${names(ids)}`}
1955
+ onClick=${() => react(emoji)}
1956
+ >
1957
+ ${emoji} <span>${ids.length}</span>
1958
+ </button>`,
1959
+ )}
1960
+ ${slotItems("reply", { entry, text, pinned, picking, setPicking, react }).map(({ node }) => node)}
1961
+ </div>`;
1962
+ }
1963
+
1964
+ /** How one of Pi's answers ended, under its last words: a failed request, a stop, or the answer's actions. */
1965
+ function AnswerEnd({ entry }) {
1966
+ // A final answer (not a step between tool calls) with something to say gets reactions and the discuss row.
1967
+ const answer = collab() && entry.stopReason !== "toolUse" && entry.blocks.some(says);
1968
+
1969
+ return html`${
1970
+ entry.stopReason === "error" &&
1971
+ html`<div class="error-box">${entry.error ?? "The model request failed."}</div>`
1972
+ }
1973
+ ${entry.stopReason === "aborted" && html`<div class="muted small">Stopped.</div>`}
1974
+ ${answer && html`<${AnswerActions} entry=${entry} />`}`;
1975
+ }
1976
+
1977
+ /** A run of Pi's words: plain prose, and how the answer ended when they are its last. */
1978
+ function Reply({ item, conversationId }) {
1979
+ const [fresh] = useState(() => settledFor !== null && settledFor === conversationId);
1980
+ const { entry, blocks } = item;
1981
+
1982
+ return html`<div
1983
+ class=${`reply ${fresh ? "enter" : ""}`}
1984
+ data-part="reply"
1985
+ id=${item.anchor ? `entry-${entry.id}` : undefined}
1986
+ >
1987
+ ${blocks.map(({ block, growing }, index) => {
1988
+ // The block still growing changes on every update: caching each version would only push out finished ones.
1989
+ return html`<${Markdown}
1990
+ key=${index}
1991
+ text=${block.text}
1992
+ class=${growing ? "streaming" : ""}
1993
+ cache=${!growing}
1994
+ />`;
1995
+ })}
1996
+ ${item.end && html`<${AnswerEnd} entry=${entry} />`}
1997
+ </div>`;
1998
+ }
1999
+
2000
+ function Divider({ entry }) {
2001
+ const [open, setOpen] = useState(false);
2002
+
2003
+ // A session continued from Pi in the terminal: what is above is Pi's, from that session's file.
2004
+ if (entry.kind === "fromPi") {
2005
+ return html`<div class="divider">
2006
+ <span>Continued from Pi in the terminal</span>
2007
+ <div class="muted small mono">${entry.file}</div>
2008
+ </div>`;
2009
+ }
2010
+
2011
+ if (entry.kind === "reset") {
2012
+ return html`<div class="divider">
2013
+ <span>New context</span>
2014
+ ${entry.text && html`<div class="divider-body"><${Markdown} text=${entry.text} /></div>`}
2015
+ </div>`;
2016
+ }
2017
+
2018
+ return html`<div class="divider">
2019
+ <button class="link" onClick=${() => setOpen(!open)}>
2020
+ Context compacted ${open ? "▾" : "▸"}
2021
+ </button>
2022
+ ${open && html`<div class="divider-body"><${Markdown} text=${entry.summary} /></div>`}
2023
+ </div>`;
2024
+ }
2025
+
2026
+ function History({ firstId, results, keepPlace, conversationId }) {
2027
+ const history = store.state.history;
2028
+ const { entries } = store.state.view;
2029
+ const talk = collab();
2030
+ // Built again only when the history or the entries change, not on each draw of the transcript.
2031
+ const items = useMemo(
2032
+ () =>
2033
+ history === null
2034
+ ? []
2035
+ : itemsOf(
2036
+ history.filter((entry) => isRow(entry) && !entries.has(entry.id)),
2037
+ null,
2038
+ { prefix: "h", talk },
2039
+ ),
2040
+ [history, entries, talk],
2041
+ );
2042
+
2043
+ if (history === null) {
2044
+ return html`<div class="divider">
2045
+ <button
2046
+ class="link"
2047
+ onClick=${() =>
2048
+ attempt(async () => {
2049
+ const earlier = await actions.history(firstId);
2050
+ const current = store.state;
2051
+
2052
+ // A response belongs to the conversation and context that asked for it, not the one now open.
2053
+ if (
2054
+ current.conversationId !== conversationId ||
2055
+ current.view.order[0] !== firstId
2056
+ ) {
2057
+ return;
2058
+ }
2059
+
2060
+ keepPlace();
2061
+ store.set({ history: earlier });
2062
+ })}
2063
+ >
2064
+ Show earlier messages
2065
+ </button>
2066
+ </div>`;
2067
+ }
2068
+
2069
+ return html`<div class="history">
2070
+ <${Items}
2071
+ items=${items}
2072
+ results=${results}
2073
+ slots=${NO_SLOTS}
2074
+ approvals=${NO_APPROVALS}
2075
+ busy=${false}
2076
+ past=${true}
2077
+ conversationId=${conversationId}
2078
+ />
2079
+ </div>`;
2080
+ }
2081
+
2082
+ /** How a command someone ran ended, when that is worth saying. */
2083
+ const SHELL_ENDS = {
2084
+ timeout: "timed out",
2085
+ failed: "could not run",
2086
+ interrupted: "cut off by a restart",
2087
+ stopped: "stopped",
2088
+ };
2089
+
2090
+ /** How many of a command's last lines show before "Show all". */
2091
+ const SHELL_LINES = 12;
2092
+
2093
+ /**
2094
+ * What a command printed, as a card: a `!` command's, or an extension command's card. A long output shows its last lines
2095
+ * until opened, and one the server cut short loads in full on a tap.
2096
+ */
2097
+ function OutputCard({ entry, command, meta, failed = false, longCommand = false, copy = false }) {
2098
+ const [open, setOpen] = useState(false);
2099
+ const [full, setFull] = useState(null);
2100
+ const output = (full ?? entry.output).replace(/\s+$/, "");
2101
+ const lines = output.split("\n");
2102
+ const long = lines.length > SHELL_LINES;
2103
+ const whole = async () =>
2104
+ full ?? (entry.truncated ? (await actions.fullEntry(entry.id)).output : entry.output);
2105
+
2106
+ return html`<div class="shell-row" id=${`entry-${entry.id}`}>
2107
+ <div class=${`shell-card ${failed ? "failed" : ""} ${open ? "open" : ""}`}>
2108
+ <div class="shell-head">
2109
+ <span class="mono shell-command">${command}</span>
2110
+ <span class="muted small">${meta}</span>
2111
+ </div>
2112
+ ${
2113
+ output !== "" &&
2114
+ html`<pre class="output">
2115
+ ${long && !open ? `…\n${lines.slice(-SHELL_LINES).join("\n")}` : output}
2116
+ </pre>`
2117
+ }
2118
+ ${
2119
+ (long || longCommand) &&
2120
+ html`<button class="link small" onClick=${() => setOpen(!open)}>
2121
+ ${open ? "Show less" : long ? `Show all ${lines.length} lines` : "Show all"}
2122
+ </button>`
2123
+ }
2124
+ ${
2125
+ entry.truncated &&
2126
+ full === null &&
2127
+ html`<button
2128
+ class="link small"
2129
+ onClick=${() =>
2130
+ attempt(async () => {
2131
+ setFull(await whole());
2132
+ setOpen(true);
2133
+ })}
2134
+ >
2135
+ Load everything
2136
+ </button>`
2137
+ }
2138
+ ${
2139
+ copy &&
2140
+ html`<button
2141
+ class="link small"
2142
+ onClick=${() =>
2143
+ attempt(async () => {
2144
+ const text = await whole();
2145
+
2146
+ setFull(text);
2147
+ await copyText(text);
2148
+ notify("info", "Copied the output.");
2149
+ })}
2150
+ >
2151
+ Copy output
2152
+ </button>`
2153
+ }
2154
+ </div>
2155
+ </div>`;
2156
+ }
2157
+
2158
+ /** An extension command's card: text for the people here, never in Pi's context. */
2159
+ function CommandEntry({ entry }) {
2160
+ return html`<${OutputCard}
2161
+ entry=${entry}
2162
+ command=${`/${entry.command}`}
2163
+ meta=${`${entry.name} · not shown to Pi${entry.clipped ? " · only the end is kept" : ""}`}
2164
+ copy=${true}
2165
+ />`;
2166
+ }
2167
+
2168
+ /** A command someone ran with `!` (or `!!`, which Pi does not see): what it printed, and how it ended. */
2169
+ function ShellEntry({ entry }) {
2170
+ // A long command (a paste in it, say) shows its first lines until opened.
2171
+ const longCommand = entry.command.split("\n").length > 3 || entry.command.length > 240;
2172
+ const end =
2173
+ entry.status === "done"
2174
+ ? entry.code === 0
2175
+ ? ""
2176
+ : `exit ${entry.code}`
2177
+ : SHELL_ENDS[entry.status];
2178
+
2179
+ return html`<${OutputCard}
2180
+ entry=${entry}
2181
+ command=${`$ ${entry.command}`}
2182
+ meta=${`${entry.name}${end ? ` · ${end}` : ""}${entry.context ? "" : " · not shown to Pi"}`}
2183
+ failed=${Boolean(end)}
2184
+ longCommand=${longCommand}
2185
+ />`;
2186
+ }
2187
+
2188
+ /** Something a person did that Pi was told about, such as undoing a file. */
2189
+ function NoteEntry({ entry }) {
2190
+ return html`<div class="note-line" id=${`entry-${entry.id}`}>
2191
+ ${entry.name} ${entry.text}. <span class="muted">Pi was told.</span>
2192
+ </div>`;
2193
+ }
2194
+
2195
+ /** How long a `!` command's row may wait for its entry: past the server's limit for a command, something went wrong. */
2196
+ const PENDING_MS = 11 * 60_000;
2197
+
2198
+ /** How long a scroller may glide on after a finger lets go of it, at most. */
2199
+ const GLIDE_MS = 3000;
2200
+
2201
+ /** How long after a tap or a key in the conversation what grows there is what the reader opened, not new rows. */
2202
+ const OPENED_MS = 1000;
2203
+
2204
+ /** A touch screen, where scrollers glide on after a flick and their scroll bars take no room. */
2205
+ const COARSE = matchMedia("(pointer: coarse)");
2206
+
2207
+ /**
2208
+ * Straight to the bottom of `element`. On an iPhone a scroller can still be gliding from a flick when the way to the
2209
+ * bottom is tapped, and WebKit keeps the glide's place over one set from script: the screen goes blank, and the next
2210
+ * touch carries on from where it was. Not scrolling for two frames ends the glide, so the jump holds. (Elsewhere a
2211
+ * scroll bar that went for a moment would shift the page.)
2212
+ */
2213
+ function toBottom(element) {
2214
+ element.scrollTop = element.scrollHeight;
2215
+
2216
+ if (!COARSE.matches) {
2217
+ return;
2218
+ }
2219
+
2220
+ element.style.overflowY = "hidden";
2221
+ requestAnimationFrame(() =>
2222
+ requestAnimationFrame(() => {
2223
+ element.style.overflowY = "";
2224
+ element.scrollTop = element.scrollHeight;
2225
+ }),
2226
+ );
2227
+ }
2228
+
2229
+ /** `!` commands this tab started that have no entry yet: still running, or waiting for Pi to finish its turn. */
2230
+ function PendingShells({ conversationId, rows }) {
2231
+ const pending = store.state.pendingShells.filter(
2232
+ (each) =>
2233
+ each.conversationId === conversationId &&
2234
+ Date.now() - each.at < PENDING_MS &&
2235
+ !rows.some((row) => row.kind === "shell" && row.taskId === each.taskId),
2236
+ );
2237
+
2238
+ return pending.map(
2239
+ (each) => html`<div class="shell-row pending" key=${each.taskId}>
2240
+ <div class="shell-card">
2241
+ <div class="shell-head">
2242
+ <span class="mono shell-command">$ ${each.command}</span>
2243
+ <span class="muted small"><${Spinner} /> running</span>
2244
+ <button
2245
+ class="link small"
2246
+ onClick=${() => attempt(() => actions.stopShell(each.taskId))}
2247
+ >
2248
+ Stop
2249
+ </button>
2250
+ </div>
2251
+ </div>
2252
+ </div>`,
2253
+ );
2254
+ }
2255
+
2256
+ function EntryView({ entry }) {
2257
+ const { view, users } = store.state;
2258
+
2259
+ if (entry.kind === "shell") {
2260
+ return html`<${ShellEntry} entry=${entry} />`;
2261
+ }
2262
+
2263
+ if (entry.kind === "command") {
2264
+ return html`<${CommandEntry} entry=${entry} />`;
2265
+ }
2266
+
2267
+ if (entry.kind === "note") {
2268
+ return html`<${NoteEntry} entry=${entry} />`;
2269
+ }
2270
+
2271
+ if (entry.kind === "user") {
2272
+ return html`<${UserEntry} entry=${entry} view=${view} users=${users} />`;
2273
+ }
2274
+
2275
+ if (entry.kind === "compaction" || entry.kind === "reset" || entry.kind === "fromPi") {
2276
+ return html`<${Divider} entry=${entry} />`;
2277
+ }
2278
+
2279
+ return null;
2280
+ }
2281
+
2282
+ const NO_SLOTS = new Map();
2283
+
2284
+ const NO_APPROVALS = [];
2285
+
2286
+ /** `agentsKey` for each list of subagents the server sent: every report and subagent call asks, on every draw. */
2287
+ const agentsKeys = new WeakMap();
2288
+ const NO_AGENTS = [];
2289
+
2290
+ /** The subagents as their rows and lines show them: who, where, and how they stand. */
2291
+ function agentsKey(view) {
2292
+ const agents = view.subagents ?? NO_AGENTS;
2293
+
2294
+ if (!agentsKeys.has(agents)) {
2295
+ agentsKeys.set(
2296
+ agents,
2297
+ agents
2298
+ .map(
2299
+ (agent) =>
2300
+ `${agent.name}:${agent.conversationId}:${stateOf(agent)}:${agent.askedAt ?? ""}:${agent.answeredAt ?? ""}`,
2301
+ )
2302
+ .join(),
2303
+ );
2304
+ }
2305
+
2306
+ return agentsKeys.get(agents);
2307
+ }
2308
+
2309
+ /**
2310
+ * Everything a row shows besides its entry, as values that compare with Object.is. Each update from the server builds
2311
+ * new objects for the whole view, so this picks out the parts one row uses: a row whose parts did not change skips
2312
+ * rendering, and a long thread stays cheap while Pi streams into its newest message.
2313
+ */
2314
+ function rowDeps(entry) {
2315
+ const { view, users, me, server } = store.state;
2316
+ const deps = [entry, users, me, server, view.conversation?.id, view.conversation?.kind];
2317
+
2318
+ if (entry.kind === "user") {
2319
+ deps.push(view.authors?.[entry.id]);
2320
+
2321
+ if (entry.text.startsWith("[subagent ")) {
2322
+ deps.push(agentsKey(view));
2323
+ }
2324
+ }
2325
+
2326
+ return deps;
2327
+ }
2328
+
2329
+ /** What a tool call's line shows besides the call: its result, its live slot, who decided on it, what it made. */
2330
+ function callDeps(block, results, slots, approvals) {
2331
+ const { view } = store.state;
2332
+ const slot = slots.get(block.id);
2333
+ const decision = decisionOf(view.decisions, block, results.get(block.id));
2334
+ const deps = [
2335
+ results.get(block.id),
2336
+ slot === undefined ? "" : JSON.stringify(slot),
2337
+ decision === undefined ? "" : JSON.stringify(decision),
2338
+ // How extensions describe their tools' calls (`pocket.toolCall`).
2339
+ describersRevision(),
2340
+ ];
2341
+
2342
+ if (slot?.taskId !== undefined) {
2343
+ deps.push(approvals.find((each) => each.taskId === slot.taskId)?.id);
2344
+ }
2345
+
2346
+ if (isArtifactCall(block.name)) {
2347
+ deps.push((view.artifacts ?? []).map((each) => `${each.id}:${each.type}`).join());
2348
+ }
2349
+
2350
+ if (block.name === "subagent") {
2351
+ deps.push(agentsKey(view));
2352
+ }
2353
+
2354
+ // Whether the browser is there to watch (`browserAvailable`), and open already.
2355
+ if (block.name === "browser") {
2356
+ deps.push(store.state.browserOpen, store.state.server);
2357
+ }
2358
+
2359
+ return deps;
2360
+ }
2361
+
2362
+ /**
2363
+ * What a run of Pi's words shows besides its text: the answer's reactions and pin, who is looking, and the actions under
2364
+ * it (`actions`, the reply slot's `slotKey`, the same for every answer of a draw).
2365
+ */
2366
+ function replyDeps(item, actions) {
2367
+ const { view, users, me, server } = store.state;
2368
+ const { entry } = item;
2369
+ const reactions = view.reactions?.[entry.id];
2370
+
2371
+ return [
2372
+ entry,
2373
+ item.blocks.length,
2374
+ item.end,
2375
+ item.anchor,
2376
+ users,
2377
+ me,
2378
+ server,
2379
+ view.conversation?.id,
2380
+ reactions === undefined ? "" : JSON.stringify(reactions),
2381
+ (view.pins ?? []).some((pin) => pin.entryId === entry.id),
2382
+ // The actions under it: the app's and extensions', as arranged (`slots.js`).
2383
+ actions,
2384
+ ];
2385
+ }
2386
+
2387
+ /** One item of the transcript, rendered again only when its `deps` change. Its cards keep their open state. */
2388
+ class Memo extends Component {
2389
+ shouldComponentUpdate(next) {
2390
+ const before = this.props.deps;
2391
+ const after = next.deps;
2392
+
2393
+ return (
2394
+ before.length !== after.length ||
2395
+ before.some((value, index) => !Object.is(value, after[index]))
2396
+ );
2397
+ }
2398
+
2399
+ render({ children }) {
2400
+ return children;
2401
+ }
2402
+ }
2403
+
2404
+ /** The answer being written, as an entry: the same object for as long as its blocks stay the same. */
2405
+ const partials = new WeakMap();
2406
+
2407
+ function partialEntry(blocks) {
2408
+ if (blocks.length === 0) {
2409
+ return null;
2410
+ }
2411
+
2412
+ if (!partials.has(blocks)) {
2413
+ partials.set(blocks, { kind: "assistant", blocks });
2414
+ }
2415
+
2416
+ return partials.get(blocks);
2417
+ }
2418
+
2419
+ /**
2420
+ * The transcript's items, each rendered again only when what it shows changed. A group is live while one of its calls
2421
+ * works or waits, or while it is the last thing in a run; groups after the last person's message are the newest turn.
2422
+ */
2423
+ function Items({ items, results, slots, approvals, busy, past = false, conversationId }) {
2424
+ let lastMessage = -1;
2425
+ const actions = slotKey("reply");
2426
+
2427
+ items.forEach((item, index) => {
2428
+ if (item.type === "row" && item.entry.kind === "user") {
2429
+ lastMessage = index;
2430
+ }
2431
+ });
2432
+
2433
+ return items.flatMap((item, index) => {
2434
+ if (item.type === "row") {
2435
+ return html`<${Memo} key=${item.key} deps=${rowDeps(item.entry)}>
2436
+ <${EntryView} entry=${item.entry} />
2437
+ <//>`;
2438
+ }
2439
+
2440
+ if (item.type === "inline") {
2441
+ const { block, entry, streaming, canRun } = item.step;
2442
+
2443
+ return html`<${Memo}
2444
+ key=${item.key}
2445
+ deps=${[
2446
+ block,
2447
+ entry,
2448
+ streaming,
2449
+ canRun,
2450
+ busy,
2451
+ conversationId,
2452
+ item.anchors.join(),
2453
+ ...callDeps(block, results, slots, approvals),
2454
+ ]}
2455
+ >
2456
+ <${InlineRow}
2457
+ item=${item}
2458
+ results=${results}
2459
+ slots=${slots}
2460
+ approvals=${approvals}
2461
+ busy=${busy}
2462
+ conversationId=${conversationId}
2463
+ />
2464
+ <//>`;
2465
+ }
2466
+
2467
+ // An answer with nothing to show (blank words): only where a jump to it lands.
2468
+ if (item.type === "mark") {
2469
+ return html`<span
2470
+ key=${item.key}
2471
+ class="entry-anchor entry-mark"
2472
+ id=${`entry-${item.entry.id}`}
2473
+ ></span>`;
2474
+ }
2475
+
2476
+ if (item.type === "reply") {
2477
+ return html`<${Memo} key=${item.key} deps=${replyDeps(item, actions)}>
2478
+ <${Reply} item=${item} conversationId=${conversationId} />
2479
+ <//>`;
2480
+ }
2481
+
2482
+ const live =
2483
+ !past &&
2484
+ (item.steps.some((step) => stepLive(step, results, slots, approvals, busy)) ||
2485
+ (busy && index === items.length - 1));
2486
+ const newest = !past && index > lastMessage;
2487
+ // When what came before it was stored: a group still streaming began about then, also before a reload.
2488
+ const before = items[index - 1];
2489
+ const after = before?.entry?.at ?? before?.step?.entry?.at;
2490
+ const deps = [
2491
+ item.steps.length,
2492
+ item.before.length,
2493
+ item.before[0]?.block,
2494
+ item.before.at(-1)?.block,
2495
+ item.anchors.join(),
2496
+ live,
2497
+ newest,
2498
+ busy,
2499
+ conversationId,
2500
+ after,
2501
+ prefs().showThinking === true,
2502
+ // Keep activity folded: a group drawn before the switch moved opens or folds with it.
2503
+ prefs().foldActivity === true,
2504
+ ];
2505
+
2506
+ // The calls before the rows shown count in the group's line: their results, for its time.
2507
+ for (const step of item.before) {
2508
+ if (step.block.type === "toolCall") {
2509
+ deps.push(results.get(step.block.id));
2510
+ }
2511
+ }
2512
+
2513
+ for (const step of item.steps) {
2514
+ deps.push(step.block, step.entry, step.streaming, step.canRun);
2515
+
2516
+ if (step.block.type === "toolCall") {
2517
+ deps.push(...callDeps(step.block, results, slots, approvals));
2518
+ }
2519
+ }
2520
+
2521
+ const group = html`<${Memo} key=${item.key} deps=${deps}>
2522
+ <${ActivityGroup}
2523
+ item=${item}
2524
+ live=${live}
2525
+ newest=${newest}
2526
+ after=${after}
2527
+ busy=${busy}
2528
+ results=${results}
2529
+ slots=${slots}
2530
+ approvals=${approvals}
2531
+ conversationId=${conversationId}
2532
+ />
2533
+ <//>`;
2534
+ const lastCall = item.steps.findLast((step) => step.block.type === "toolCall");
2535
+
2536
+ // Stopped in a call, no answer follows to say so: the run ends here, as a stopped answer does.
2537
+ if (
2538
+ past ||
2539
+ busy ||
2540
+ index !== items.length - 1 ||
2541
+ !STOPPED.test(results.get(lastCall?.block.id)?.text ?? "")
2542
+ ) {
2543
+ return group;
2544
+ }
2545
+
2546
+ return [
2547
+ group,
2548
+ html`<div key=${`${item.key}:stopped`} class="reply" data-part="reply">
2549
+ <div class="muted small">Stopped.</div>
2550
+ </div>`,
2551
+ ];
2552
+ });
2553
+ }
2554
+
2555
+ /** When the runs this tab saw started, per conversation: the newest message then, and the time. */
2556
+ const runsSeen = new Map();
2557
+
2558
+ /**
2559
+ * When the running work started: when the newest message was sent, if this run is its answer; otherwise when this tab
2560
+ * first saw it run (a retry, or a run this tab opened in the middle of). A steer joins the run on the clock; a message
2561
+ * queued for after a run starts the clock again, once that run gave its answer.
2562
+ */
2563
+ function runStart(conversationId, entries, busy) {
2564
+ if (!busy) {
2565
+ runsSeen.delete(conversationId);
2566
+
2567
+ return undefined;
2568
+ }
2569
+
2570
+ const at = entries.findLastIndex((entry) => entry.kind === "user");
2571
+ const newest = entries[at];
2572
+ const finished = (from, to) =>
2573
+ entries
2574
+ .slice(from, to)
2575
+ .some((entry) => entry.kind === "assistant" && entry.stopReason !== "toolUse");
2576
+
2577
+ const begin = () => {
2578
+ // Pi answered it already (stopped, failed, or done): this run is not that message's.
2579
+ const sent =
2580
+ newest?.at === undefined || finished(at + 1)
2581
+ ? undefined
2582
+ : Math.min(Date.now(), fromServer(newest.at));
2583
+
2584
+ return { message: newest?.id, at: sent ?? Date.now() };
2585
+ };
2586
+
2587
+ let seen = runsSeen.get(conversationId);
2588
+
2589
+ if (seen === undefined) {
2590
+ seen = begin();
2591
+ } else if (seen.message !== newest?.id) {
2592
+ const before = entries.findIndex((entry) => entry.id === seen.message);
2593
+
2594
+ seen = finished(before + 1, at) ? begin() : { ...seen, message: newest?.id };
2595
+ }
2596
+
2597
+ keep(runsSeen, conversationId, seen);
2598
+
2599
+ return seen.at;
2600
+ }
2601
+
2602
+ /** Whether this person can answer one of the approvals waiting: one who can steer, and for a call not of their own asking where that needs someone else. */
2603
+ function canAnswer(approvals) {
2604
+ const { me, server, users } = store.state;
2605
+ const owner = users?.find?.((each) => each.role === "owner")?.id;
2606
+
2607
+ return (
2608
+ canSteer() &&
2609
+ approvals.some((approval) =>
2610
+ // An ask (`host.ask`) is for the person Pi works for, or the owner when nobody is known.
2611
+ approval.form
2612
+ ? (approval.requestedBy ?? owner) === me?.id
2613
+ : !(
2614
+ server?.approvalRule === "others" &&
2615
+ me?.role !== "owner" &&
2616
+ approval.requestedBy === me?.id
2617
+ ),
2618
+ )
2619
+ );
2620
+ }
2621
+
2622
+ /**
2623
+ * The sessions whose live thinking is folded, in this tab. With Show thinking on, the thought shows until folded, and a
2624
+ * fold holds from one run to the next, and over a reload.
2625
+ */
2626
+ const THINKING_FOLDED_KEY = "pocket.thinkingFolded";
2627
+ const thinkingFolded = new Set(
2628
+ (() => {
2629
+ try {
2630
+ return JSON.parse(sessionStorage.getItem(THINKING_FOLDED_KEY) ?? "[]");
2631
+ } catch {
2632
+ return [];
2633
+ }
2634
+ })(),
2635
+ );
2636
+
2637
+ /**
2638
+ * The active response is authoritative, even before it shares any thinking: never pass an old thought off as the new
2639
+ * one. While tools work, use only the latest response in this turn, even if it shared no thinking. Keys follow
2640
+ * responses and block positions, not text prefixes: two thoughts may start with the same words.
2641
+ */
2642
+ function newestThought(generation, entries) {
2643
+ if (generation !== undefined) {
2644
+ const blocks = generation.message?.blocks ?? [];
2645
+ const index = blocks.findLastIndex((block) => block.type === "thinking");
2646
+
2647
+ if (index === -1) {
2648
+ return null;
2649
+ }
2650
+
2651
+ const previous = entries.findLast((entry) => entry.kind === "assistant")?.id;
2652
+
2653
+ return {
2654
+ block: blocks[index],
2655
+ key: `live:${previous}:${generation.attempt}:${index}`,
2656
+ handoff: `${previous}:${index}`,
2657
+ writing: true,
2658
+ streaming: index === blocks.length - 1,
2659
+ };
2660
+ }
2661
+
2662
+ for (let index = entries.length - 1; index >= 0; index--) {
2663
+ const entry = entries[index];
2664
+
2665
+ if (entry.kind === "user") {
2666
+ break;
2667
+ }
2668
+
2669
+ if (entry.kind !== "assistant") {
2670
+ continue;
2671
+ }
2672
+
2673
+ const at = entry.blocks.findLastIndex((block) => block.type === "thinking");
2674
+ const previous = entries.findLast(
2675
+ (each, before) => before < index && each.kind === "assistant",
2676
+ )?.id;
2677
+
2678
+ return at === -1
2679
+ ? null
2680
+ : {
2681
+ block: entry.blocks[at],
2682
+ key: `entry:${entry.id}:${at}`,
2683
+ handoff: `${previous}:${at}`,
2684
+ writing: false,
2685
+ streaming: false,
2686
+ };
2687
+ }
2688
+
2689
+ return null;
2690
+ }
2691
+
2692
+ /**
2693
+ * A quiet reading area above the live line, separate from the timeline's Thought steps. Follow new words until the
2694
+ * reader scrolls up; follow again for a new thought, but not when the current response is saved for the tool phase.
2695
+ * Mounted only when open, and memoized away from the live timer: no selection, text diff, or layout work while hidden.
2696
+ */
2697
+ function LiveThought({ generation, entries, id }) {
2698
+ const box = useRef(null);
2699
+ const follow = useRef(true);
2700
+ const previous = useRef(null);
2701
+ const thought = newestThought(generation, entries);
2702
+ const text = thought?.block.text ?? "";
2703
+ // A coalesced update can skip whole responses. Only the same response/block keeps the reader's scroll.
2704
+ const handedOff =
2705
+ previous.current?.writing === true &&
2706
+ thought?.writing === false &&
2707
+ previous.current.handoff === thought.handoff;
2708
+
2709
+ if (thought?.key !== previous.current?.key && !handedOff) {
2710
+ follow.current = true;
2711
+ }
2712
+
2713
+ previous.current = thought;
2714
+
2715
+ useLayoutEffect(() => {
2716
+ if (follow.current) {
2717
+ box.current.scrollTop = box.current.scrollHeight;
2718
+ }
2719
+ }, [text, thought?.key]);
2720
+
2721
+ const onScroll = () => {
2722
+ const element = box.current;
2723
+
2724
+ follow.current = element.scrollHeight - element.scrollTop - element.clientHeight < 8;
2725
+ };
2726
+
2727
+ const empty = text === "";
2728
+ const streaming = thought?.streaming && !empty;
2729
+ const label = empty ? "Thinking" : thought.writing ? "Current thought" : "Latest thought";
2730
+ const shown = !empty
2731
+ ? text
2732
+ : thought?.block.redacted
2733
+ ? "The model has hidden its thinking."
2734
+ : generation !== undefined && generation.message === undefined
2735
+ ? "Waiting for thinking text…"
2736
+ : "No thinking text shared for this response.";
2737
+
2738
+ return html`<section class="live-thought" id=${id} aria-label="Pi's thinking">
2739
+ <div class="live-thought-label">${label}</div>
2740
+ <div
2741
+ class=${`live-thought-text ${empty ? "is-empty" : ""} ${streaming ? "streaming" : ""}`}
2742
+ ref=${box}
2743
+ onScroll=${onScroll}
2744
+ tabindex="0"
2745
+ role="region"
2746
+ aria-label=${label}
2747
+ >${shown}</div>
2748
+ </section>`;
2749
+ }
2750
+
2751
+ /**
2752
+ * The end of the live turn, in one line: what Pi does (thinks, works, waits for an approval, retries a failed request,
2753
+ * compacts its context), for how long, and how to stop it. Screen readers hear what it does when that changes, not the
2754
+ * seconds counting. With Appearance → Show thinking on, Pi's newest thought shows above the line, and a chevron before
2755
+ * the line's dot folds it; folded, nothing of the thought is drawn or looked for.
2756
+ */
2757
+ function LiveLine({ live, approvals, start, entries, conversationId }) {
2758
+ const [, tick] = useState(0);
2759
+ const line = useRef(null);
2760
+ /** Where the line was on screen as the thought above it opened or closed, to keep it there. */
2761
+ const was = useRef(null);
2762
+
2763
+ useEffect(() => {
2764
+ const timer = setInterval(() => tick((n) => n + 1), 1000);
2765
+
2766
+ return () => clearInterval(timer);
2767
+ }, []);
2768
+
2769
+ const thinking = prefs().showThinking === true;
2770
+ const open = thinking && !thinkingFolded.has(conversationId);
2771
+
2772
+ // Only as the thought opens or closes: not on the line's every tick.
2773
+ useLayoutEffect(() => {
2774
+ if (was.current === null) {
2775
+ return;
2776
+ }
2777
+
2778
+ line.current.closest(".scroller").scrollTop +=
2779
+ line.current.getBoundingClientRect().top - was.current;
2780
+ was.current = null;
2781
+ }, [open]);
2782
+
2783
+ const fold = () => {
2784
+ was.current = line.current.getBoundingClientRect().top;
2785
+
2786
+ if (open) {
2787
+ thinkingFolded.add(conversationId);
2788
+ } else {
2789
+ thinkingFolded.delete(conversationId);
2790
+ }
2791
+
2792
+ try {
2793
+ sessionStorage.setItem(THINKING_FOLDED_KEY, JSON.stringify([...thinkingFolded]));
2794
+ } catch {
2795
+ // Storage is full or off: the fold still holds in this page.
2796
+ }
2797
+
2798
+ tick((n) => n + 1);
2799
+ };
2800
+
2801
+ const partial = live.generation?.message?.blocks ?? [];
2802
+ const retry = live.generation?.retry;
2803
+ // Pi's request waits for the provider's limit to clear (`limit-gate.ts`): until a time, or a try.
2804
+ const limit = live.limit;
2805
+ const working = (live.tools ?? []).some((slot) => slot.status !== "done");
2806
+ const waiting = approvals.length > 0;
2807
+ const seconds = retry ? Math.ceil((fromServer(retry.at) - Date.now()) / 1000) : 0;
2808
+ let state;
2809
+
2810
+ if (waiting) {
2811
+ // Questions from extensions (`host.ask`) wait for an answer, not an approval.
2812
+ const asks = approvals.every((approval) => approval.form);
2813
+
2814
+ state = canAnswer(approvals)
2815
+ ? asks
2816
+ ? "Waiting for your answer"
2817
+ : "Waiting for your approval"
2818
+ : asks
2819
+ ? "Waiting for an answer"
2820
+ : "Waiting for approval";
2821
+ } else if (limit) {
2822
+ state = `Waiting for ${limitWho(limit)}`;
2823
+ } else if (retry) {
2824
+ state = "Retrying";
2825
+ } else if ((live.compactions ?? []).length > 0) {
2826
+ state = "Compacting context";
2827
+ } else if (working || (partial.length > 0 && partial.at(-1).type !== "thinking")) {
2828
+ state = "Working";
2829
+ } else {
2830
+ state = "Thinking";
2831
+ }
2832
+
2833
+ const thoughtId = `live-thought-${conversationId}`;
2834
+
2835
+ return html`${
2836
+ open &&
2837
+ html`<${Memo} deps=${[live.generation, entries, conversationId]}>
2838
+ <${LiveThought} generation=${live.generation} entries=${entries} id=${thoughtId} />
2839
+ <//>`
2840
+ }
2841
+ <div class=${`live-line ${waiting ? "waiting" : ""}`} data-part="live-line" ref=${line}>
2842
+ ${
2843
+ thinking &&
2844
+ html`<button
2845
+ class=${`live-fold ${open ? "open" : ""}`}
2846
+ aria-expanded=${open}
2847
+ aria-controls=${thoughtId}
2848
+ aria-label=${open ? "Hide Pi's thinking" : "Show Pi's thinking"}
2849
+ title=${open ? "Hide Pi's thinking" : "Show Pi's thinking"}
2850
+ onClick=${fold}
2851
+ ><${Icon} name="chevron" size=${14} class="chev" /></button>`
2852
+ }
2853
+ <span class="live-dot" data-part="live-mark" aria-hidden="true"></span>
2854
+ <span class="live-state" role="status">
2855
+ ${state}${retry && !waiting && seconds > 0 && html`<span aria-hidden="true"> in ${seconds}s</span>`}
2856
+ </span>
2857
+ ${
2858
+ limit &&
2859
+ !waiting &&
2860
+ html`<span class="live-limit" data-part="live-limit">
2861
+ ${limit.probing ? "trying now" : `next try in ${span(fromServer(limit.until) - Date.now())}`}${
2862
+ limit.resetAt !== undefined && ` · resets ${clock(limit.resetAt)}`
2863
+ }
2864
+ </span>`
2865
+ }
2866
+ ${start !== undefined && html`<span class="live-time" aria-hidden="true">${span(Date.now() - start)}</span>`}
2867
+ ${
2868
+ limit &&
2869
+ !waiting &&
2870
+ html`<span class="live-why" title=${limit.reason}>
2871
+ ${limit.reason}
2872
+ </span>`
2873
+ }
2874
+ ${
2875
+ limit &&
2876
+ !waiting &&
2877
+ canSteer() &&
2878
+ !limit.probing &&
2879
+ html`<button
2880
+ class="live-retry"
2881
+ data-part="live-retry"
2882
+ title="Send the request now. Stop ends the wait."
2883
+ onClick=${() =>
2884
+ attempt(async () => {
2885
+ // The gate takes one try each 30 seconds: a press it ignores says so.
2886
+ if ((await actions.retryLimit(conversationId))?.ok === false) {
2887
+ notify("info", "Already trying: wait a moment.");
2888
+ }
2889
+ })}
2890
+ >Retry now</button>`
2891
+ }
2892
+ ${
2893
+ retry &&
2894
+ !waiting &&
2895
+ html`<span
2896
+ class="live-why"
2897
+ title=${`Attempt ${live.generation.attempt + 1}, after: ${retry.error}`}
2898
+ >
2899
+ ${retry.error}
2900
+ </span>`
2901
+ }
2902
+ ${
2903
+ live.busy &&
2904
+ canSteer() &&
2905
+ !COARSE.matches &&
2906
+ html`<span class="live-esc" title="Press Esc twice">Esc stops</span>`
2907
+ }
2908
+ </div>`;
2909
+ }
2910
+
2911
+ /**
2912
+ * Which rows to render: from the row `store.state.transcriptFrom` names down. Older rows wait behind "Show earlier
2913
+ * messages", so opening a long session and streaming into it stay fast on a phone. Without that row (none chosen yet,
2914
+ * or gone after a compaction), the newest `TRANSCRIPT_ROWS`.
2915
+ */
2916
+ function windowStart(rows, from) {
2917
+ const index = from === null ? -1 : rows.findIndex((row) => row.id === from);
2918
+
2919
+ return index === -1 ? Math.max(0, rows.length - TRANSCRIPT_ROWS) : index;
2920
+ }
2921
+
2922
+ export function Transcript() {
2923
+ const { view, missing, history, transcriptFrom } = store.state;
2924
+ const scroller = useRef(null);
2925
+ /** The column of rows in the scroller, whose growth keeps it at the bottom (below). */
2926
+ const column = useRef(null);
2927
+ const stick = useRef(true);
2928
+ /** A jump to a message is under way: the reader has not scrolled or tapped since. */
2929
+ const jumping = useRef(false);
2930
+ /** Distance from the bottom to restore after rows are added above, so the reader stays in place. */
2931
+ const keep = useRef(null);
2932
+ const counted = useRef(0);
2933
+ const [showJump, setShowJump] = useState(false);
2934
+ // Opened by a route on a phone, the scroller slides in with it: the column does not rise in as well, unless it comes
2935
+ // after the slide (a slow connection). Chosen as the column first draws, so a draw after does not start it.
2936
+ const entrance = useRef(null);
2937
+
2938
+ if (entrance.current === null && view.conversation && !missing) {
2939
+ entrance.current = routeSliding() ? "" : "rising";
2940
+ }
2941
+
2942
+ // Keep the snapshot's rows stable across unrelated UI updates, including scrolling the thought reading area. Only
2943
+ // the entries this tab has, which are the newest: earlier ones load as "Show earlier messages" shows them.
2944
+ const unloaded = useMemo(() => loadedFrom(view), [view.order, view.entries]);
2945
+ const entries = useMemo(
2946
+ () => view.order.slice(unloaded).map((id) => view.entries.get(id)),
2947
+ [view.order, view.entries, unloaded],
2948
+ );
2949
+ // Built again only when the entries change: the app draws for every change to the store, and a streamed answer's
2950
+ // updates bring no new entries (`mergeView`).
2951
+ const rows = useMemo(() => entries.filter(isRow), [entries]);
2952
+ const start = useMemo(() => windowStart(rows, transcriptFrom), [rows, transcriptFrom]);
2953
+ const talk = collab();
2954
+ const from = useMemo(() => rowsBefore(rows, start, talk), [rows, start, talk]);
2955
+ const partial = partialEntry(view.live.generation?.message?.blocks ?? []);
2956
+ const items = useMemo(
2957
+ () => itemsOf(rows.slice(start), partial, { from, talk }),
2958
+ [rows, start, partial, from, talk],
2959
+ );
2960
+ const results = useMemo(() => {
2961
+ const map = new Map();
2962
+
2963
+ for (const entry of [...(history ?? []), ...entries]) {
2964
+ if (entry.kind === "toolResult") {
2965
+ map.set(entry.callId, entry);
2966
+ }
2967
+ }
2968
+
2969
+ return map;
2970
+ }, [history, entries]);
2971
+ const slots = useMemo(
2972
+ () => new Map((view.live.tools ?? []).map((slot) => [slot.callId, slot])),
2973
+ [view.live.tools],
2974
+ );
2975
+ const [fetching, setFetching] = useState(false);
2976
+
2977
+ // After a draw that can change the rows, not after every change to the store (each draws the app): to the bottom, or
2978
+ // back to where the reader was, before the draw shows. What grows between such draws is the ResizeObserver's below.
2979
+ useLayoutEffect(() => {
2980
+ const element = scroller.current;
2981
+
2982
+ if (!element) {
2983
+ return;
2984
+ }
2985
+
2986
+ const grew = rows.length > counted.current;
2987
+ const newest = rows.at(-1);
2988
+
2989
+ // A message of your own just sent: the reader goes down to it, from wherever they had scrolled to.
2990
+ if (
2991
+ grew &&
2992
+ counted.current > 0 &&
2993
+ newest?.kind === "user" &&
2994
+ view.authors?.[newest.id] === store.state.me?.id &&
2995
+ !stick.current
2996
+ ) {
2997
+ stick.current = true;
2998
+ keep.current = null;
2999
+ setShowJump(false);
3000
+ }
3001
+
3002
+ if (keep.current !== null) {
3003
+ element.scrollTop = element.scrollHeight - keep.current;
3004
+ keep.current = null;
3005
+ } else if (stick.current) {
3006
+ element.scrollTop = element.scrollHeight;
3007
+ }
3008
+
3009
+ counted.current = rows.length;
3010
+
3011
+ if (rows.length === 0) {
3012
+ return;
3013
+ }
3014
+
3015
+ // Fix the first row shown, so rows arriving below never push the ones being read off the top: noted without a
3016
+ // draw, as this draw began there already (`revealEntry` and "Show earlier messages" read it). While the reader
3017
+ // follows along at the bottom, drop the oldest once there are twice as many as a fresh open shows.
3018
+ if (rows[start].id !== transcriptFrom) {
3019
+ store.note({ transcriptFrom: rows[start].id });
3020
+ } else if (grew && stick.current && rows.length - start > 2 * TRANSCRIPT_ROWS) {
3021
+ store.set({ transcriptFrom: rows[rows.length - TRANSCRIPT_ROWS].id });
3022
+ }
3023
+ }, [view, history, start]);
3024
+
3025
+ // Once the rows a session opens with are on screen, rows that mount later are new and animate in.
3026
+ const conversationId = view.conversation?.id;
3027
+ const hasRows = rows.length > 0;
3028
+
3029
+ useEffect(() => {
3030
+ settledFor = null;
3031
+
3032
+ if (conversationId === undefined) {
3033
+ return;
3034
+ }
3035
+
3036
+ const timer = setTimeout(() => (settledFor = conversationId), hasRows ? 350 : 0);
3037
+
3038
+ return () => clearTimeout(timer);
3039
+ }, [conversationId, hasRows]);
3040
+
3041
+ // The conversation gets shorter or taller as what is above the message box changes (the queue, the subagents' list,
3042
+ // a message box that grows), and its rows grow between draws (a card that draws late, a font that loads, a line
3043
+ // under a reply): stay at the bottom if we were there, before the change shows.
3044
+ useEffect(() => {
3045
+ const element = scroller.current;
3046
+
3047
+ if (!element) {
3048
+ return;
3049
+ }
3050
+
3051
+ // Not while a finger moves it, or it glides after one: where it goes is the person's, and on an iPhone a scroll
3052
+ // set from script mid-glide does not hold (see `toBottom`).
3053
+ let touching = false;
3054
+ // Never yet: the page's clock starts at 0 when it loads, so 0 would read as a finger let go just now.
3055
+ let released = -Infinity;
3056
+ let moved = -Infinity;
3057
+
3058
+ const down = () => {
3059
+ touching = true;
3060
+ };
3061
+
3062
+ const up = () => {
3063
+ touching = false;
3064
+ released = performance.now();
3065
+ };
3066
+
3067
+ const scrolled = () => {
3068
+ moved = performance.now();
3069
+ };
3070
+
3071
+ const gliding = () =>
3072
+ touching ||
3073
+ (performance.now() - released < GLIDE_MS && performance.now() - moved < 100);
3074
+ // Nor what a tap or a key there opened (a group, a step): it unrolls in place, where the reader looks. The next
3075
+ // draw of new rows takes them down again.
3076
+ let pressed = -Infinity;
3077
+
3078
+ const press = () => {
3079
+ pressed = performance.now();
3080
+ };
3081
+
3082
+ const opened = (changes) =>
3083
+ performance.now() - pressed < OPENED_MS &&
3084
+ changes.every((change) => change.target !== element);
3085
+ const observer = new ResizeObserver((changes) => {
3086
+ if (stick.current && !gliding() && !opened(changes)) {
3087
+ element.scrollTop = element.scrollHeight;
3088
+ }
3089
+ });
3090
+
3091
+ element.addEventListener("touchstart", down, { passive: true });
3092
+ element.addEventListener("touchend", up, { passive: true });
3093
+ element.addEventListener("touchcancel", up, { passive: true });
3094
+ element.addEventListener("scroll", scrolled, { passive: true });
3095
+ element.addEventListener("pointerdown", press, { capture: true, passive: true });
3096
+ element.addEventListener("keydown", press, { capture: true, passive: true });
3097
+ observer.observe(element);
3098
+
3099
+ if (column.current) {
3100
+ observer.observe(column.current);
3101
+ }
3102
+
3103
+ return () => {
3104
+ observer.disconnect();
3105
+ element.removeEventListener("touchstart", down);
3106
+ element.removeEventListener("touchend", up);
3107
+ element.removeEventListener("touchcancel", up);
3108
+ element.removeEventListener("scroll", scrolled);
3109
+ element.removeEventListener("pointerdown", press, { capture: true });
3110
+ element.removeEventListener("keydown", press, { capture: true });
3111
+ };
3112
+ }, [view.conversation?.id]);
3113
+
3114
+ // A jump to a message (`jumpToEntry`) leaves the bottom, until the reader scrolls or taps: its smooth scroll starts
3115
+ // near the bottom, where `onScroll` would follow along again, and the next update (earlier rows that load for it)
3116
+ // would take them back down.
3117
+ useEffect(() => {
3118
+ const away = () => {
3119
+ jumping.current = true;
3120
+ stick.current = false;
3121
+ keep.current = null;
3122
+ };
3123
+
3124
+ const back = () => (jumping.current = false);
3125
+ const takes = ["wheel", "touchstart", "pointerdown", "keydown"];
3126
+
3127
+ document.addEventListener("pocket:reveal", away);
3128
+
3129
+ for (const name of takes) {
3130
+ document.addEventListener(name, back, { capture: true, passive: true });
3131
+ }
3132
+
3133
+ return () => {
3134
+ document.removeEventListener("pocket:reveal", away);
3135
+
3136
+ for (const name of takes) {
3137
+ document.removeEventListener(name, back, { capture: true });
3138
+ }
3139
+ };
3140
+ }, []);
3141
+
3142
+ // Images finish loading after the transcript renders and make it taller: stay at the bottom if we were there.
3143
+ useEffect(() => {
3144
+ const element = scroller.current;
3145
+
3146
+ if (!element) {
3147
+ return;
3148
+ }
3149
+
3150
+ const onLoad = (event) => {
3151
+ if (event.target?.tagName === "IMG" && stick.current) {
3152
+ element.scrollTop = element.scrollHeight;
3153
+ }
3154
+ };
3155
+
3156
+ element.addEventListener("load", onLoad, true);
3157
+
3158
+ return () => element.removeEventListener("load", onLoad, true);
3159
+ }, [view.conversation?.id]);
3160
+
3161
+ // A `!` command whose entry arrived is no longer pending.
3162
+ const shells = useMemo(() => rows.filter((row) => row.kind === "shell").length, [rows]);
3163
+
3164
+ useEffect(() => {
3165
+ const pending = store.state.pendingShells;
3166
+ const left = pending.filter(
3167
+ (each) =>
3168
+ Date.now() - each.at < PENDING_MS &&
3169
+ !rows.some((row) => row.kind === "shell" && row.taskId === each.taskId),
3170
+ );
3171
+
3172
+ if (left.length !== pending.length) {
3173
+ store.set({ pendingShells: left });
3174
+ }
3175
+ }, [shells]);
3176
+
3177
+ const onScroll = () => {
3178
+ const element = scroller.current;
3179
+ const atBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 60;
3180
+
3181
+ stick.current = atBottom && !jumping.current;
3182
+
3183
+ if (showJump === atBottom) {
3184
+ setShowJump(!atBottom);
3185
+ }
3186
+ };
3187
+
3188
+ if (missing) {
3189
+ return html`<main class="scroller">
3190
+ <div class="empty">
3191
+ <p>${missing}</p>
3192
+ <button class="button" onClick=${() => navigate(null)}>All sessions</button>
3193
+ </div>
3194
+ </main>`;
3195
+ }
3196
+
3197
+ if (!view.conversation) {
3198
+ const { connection, sessions, conversationId } = store.state;
3199
+ const title = sessions.find((session) => session.id === conversationId)?.title;
3200
+ const caption =
3201
+ connection === "connecting"
3202
+ ? "connecting"
3203
+ : connection === "open"
3204
+ ? "loading session"
3205
+ : "reconnecting";
3206
+
3207
+ return html`<main class="scroller">
3208
+ <${Boot} inline caption=${caption} detail=${title ?? ""} />
3209
+ </main>`;
3210
+ }
3211
+
3212
+ const keepPlace = () => {
3213
+ // Earlier rows arriving above are not new: they appear without sliding in.
3214
+ const id = view.conversation?.id;
3215
+
3216
+ settledFor = null;
3217
+ setTimeout(() => (settledFor = id), 400);
3218
+ const element = scroller.current;
3219
+
3220
+ if (!element) {
3221
+ return;
3222
+ }
3223
+
3224
+ keep.current = element.scrollHeight - element.scrollTop;
3225
+ stick.current = false;
3226
+ };
3227
+
3228
+ const showEarlier = () => {
3229
+ if (start >= TRANSCRIPT_ROWS || unloaded === 0) {
3230
+ keepPlace();
3231
+ store.set({ transcriptFrom: rows[Math.max(0, start - TRANSCRIPT_ROWS)].id });
3232
+
3233
+ return;
3234
+ }
3235
+
3236
+ // Not here yet: load the rows to show (and as many more), then show them.
3237
+ const id = conversation.id;
3238
+
3239
+ setFetching(true);
3240
+ attempt(async () => {
3241
+ await loadEarlier({ rows: 2 * TRANSCRIPT_ROWS });
3242
+ const { view: now } = store.state;
3243
+
3244
+ if (now.conversation?.id !== id) {
3245
+ return;
3246
+ }
3247
+
3248
+ const loaded = now.order
3249
+ .slice(loadedFrom(now))
3250
+ .map((entryId) => now.entries.get(entryId))
3251
+ .filter(isRow);
3252
+ const at = loaded.findIndex((row) => row.id === rows[start]?.id);
3253
+
3254
+ keepPlace();
3255
+ store.set({ transcriptFrom: loaded[Math.max(0, at - TRANSCRIPT_ROWS)]?.id ?? null });
3256
+ }).finally(() => setFetching(false));
3257
+ };
3258
+
3259
+ const approvals = view.approvals ?? [];
3260
+ const first = entries[0];
3261
+ const busy = view.live.busy;
3262
+ const compacting = (view.live.compactions ?? []).length > 0;
3263
+ const conversation = view.conversation;
3264
+ const started = runStart(conversation.id, entries, busy);
3265
+ // Only a session this person can see is in their list.
3266
+ const { sessions, sessionsLoaded } = store.state;
3267
+ const source =
3268
+ conversation.forkedFrom && sessions.find((each) => each.id === conversation.forkedFrom.id);
3269
+
3270
+ return html`<main class="scroller" ref=${scroller} onScroll=${onScroll}>
3271
+ <div class=${`transcript ${entrance.current}`} data-part="transcript" ref=${column}>
3272
+ <${LinkedSessions} />
3273
+ ${
3274
+ conversation.forkedFrom &&
3275
+ sessionsLoaded &&
3276
+ (source
3277
+ ? html`<button class="breadcrumb" onClick=${() => navigate(source.id)}>
3278
+ <${Icon} name="fork" size=${14} /> Forked from ${source.title ?? "New session"}
3279
+ </button>`
3280
+ : html`<p class="muted small">
3281
+ <${Icon} name="fork" size=${14} /> Forked from another session
3282
+ </p>`)
3283
+ }
3284
+ ${
3285
+ start > 0 || unloaded > 0
3286
+ ? html`<div class="divider">
3287
+ <button class="link" disabled=${fetching} onClick=${showEarlier}>
3288
+ ${fetching ? html`<${Spinner} /> Loading earlier messages` : "Show earlier messages"}
3289
+ </button>
3290
+ </div>`
3291
+ : first &&
3292
+ (first.kind === "compaction" || first.kind === "reset") &&
3293
+ html`<${History}
3294
+ firstId=${first.id}
3295
+ results=${results}
3296
+ keepPlace=${keepPlace}
3297
+ conversationId=${conversation.id}
3298
+ />`
3299
+ }
3300
+ ${
3301
+ entries.length === 0 &&
3302
+ !view.live.busy &&
3303
+ html`<div class="empty hint">
3304
+ <div class="pi">π</div>
3305
+ <p>
3306
+ ${conversation.kind === "subagent" ? "This subagent has no messages yet." : "Ask anything. Pi works in this session's folder, and keeps working if the server restarts."}
3307
+ </p>
3308
+ </div>`
3309
+ }
3310
+ <${Items}
3311
+ items=${items}
3312
+ results=${results}
3313
+ slots=${slots}
3314
+ approvals=${approvals}
3315
+ busy=${busy}
3316
+ conversationId=${conversation.id}
3317
+ />
3318
+ <${PendingShells} conversationId=${conversation.id} rows=${rows} />
3319
+ ${approvals.map(
3320
+ (approval) => html`<${ApprovalCard} key=${approval.id} approval=${approval} />`,
3321
+ )}
3322
+ ${
3323
+ (busy || compacting) &&
3324
+ html`<${LiveLine}
3325
+ live=${view.live}
3326
+ approvals=${approvals}
3327
+ start=${started}
3328
+ entries=${entries}
3329
+ conversationId=${conversation.id}
3330
+ />`
3331
+ }
3332
+ </div>
3333
+ ${
3334
+ showJump &&
3335
+ html`<button
3336
+ class="jump"
3337
+ aria-label="To the bottom"
3338
+ onClick=${() => {
3339
+ stick.current = true;
3340
+ toBottom(scroller.current);
3341
+ setShowJump(false);
3342
+ }}
3343
+ >
3344
+ <${Icon} name="down" />
3345
+ </button>`
3346
+ }
3347
+ </main>`;
3348
+ }