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.
- package/README.md +33 -2
- package/apps/README.md +7 -0
- package/apps/web/README.md +21 -0
- package/apps/web/pocket-adapter.mjs +323 -0
- package/apps/web/public/LICENSE.pocket +21 -0
- package/apps/web/public/activity-fold.js +28 -0
- package/apps/web/public/agent-now.js +37 -0
- package/apps/web/public/agent-tile.js +79 -0
- package/apps/web/public/app.js +975 -0
- package/apps/web/public/apple-touch-icon.png +0 -0
- package/apps/web/public/artifact-bridge.js +687 -0
- package/apps/web/public/avatar.js +87 -0
- package/apps/web/public/back.js +554 -0
- package/apps/web/public/box-keys.js +216 -0
- package/apps/web/public/browser-panel.js +1097 -0
- package/apps/web/public/browser.js +68 -0
- package/apps/web/public/calls.js +139 -0
- package/apps/web/public/chat.js +919 -0
- package/apps/web/public/clock.js +34 -0
- package/apps/web/public/commands.js +578 -0
- package/apps/web/public/composer.js +2068 -0
- package/apps/web/public/diff-parse.js +672 -0
- package/apps/web/public/diff.js +1646 -0
- package/apps/web/public/dock.js +299 -0
- package/apps/web/public/extension-ui.js +2734 -0
- package/apps/web/public/files-panel.js +775 -0
- package/apps/web/public/files.js +368 -0
- package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
- package/apps/web/public/fonts/Geist.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
- package/apps/web/public/fonts/InterVariable.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
- package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
- package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
- package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
- package/apps/web/public/fonts/SOURCES.md +69 -0
- package/apps/web/public/gestures.js +610 -0
- package/apps/web/public/highlight.js +480 -0
- package/apps/web/public/history.js +70 -0
- package/apps/web/public/home.js +169 -0
- package/apps/web/public/icon-192.png +0 -0
- package/apps/web/public/icon-512.png +0 -0
- package/apps/web/public/icon-maskable-512.png +0 -0
- package/apps/web/public/icon.svg +16 -0
- package/apps/web/public/index.html +71 -0
- package/apps/web/public/launcher.js +1293 -0
- package/apps/web/public/linked-sessions.js +55 -0
- package/apps/web/public/live.js +39 -0
- package/apps/web/public/manifest.webmanifest +32 -0
- package/apps/web/public/model-refresh.js +73 -0
- package/apps/web/public/notify.js +251 -0
- package/apps/web/public/peeks.js +920 -0
- package/apps/web/public/pip-attachments.js +182 -0
- package/apps/web/public/pip-window.js +141 -0
- package/apps/web/public/pip.js +1217 -0
- package/apps/web/public/quote-reply.js +109 -0
- package/apps/web/public/quote-text.js +31 -0
- package/apps/web/public/rich.js +403 -0
- package/apps/web/public/run-frame.js +106 -0
- package/apps/web/public/safe-mode.js +71 -0
- package/apps/web/public/seen.js +169 -0
- package/apps/web/public/session-tree.js +117 -0
- package/apps/web/public/sessions.js +2695 -0
- package/apps/web/public/share.js +126 -0
- package/apps/web/public/sheets/appearance.js +820 -0
- package/apps/web/public/sheets/branch.js +353 -0
- package/apps/web/public/sheets/extensions.js +818 -0
- package/apps/web/public/sheets/file.js +318 -0
- package/apps/web/public/sheets/find.js +250 -0
- package/apps/web/public/sheets/folder.js +680 -0
- package/apps/web/public/sheets/gallery.js +2340 -0
- package/apps/web/public/sheets/hooks.js +350 -0
- package/apps/web/public/sheets/memory.js +531 -0
- package/apps/web/public/sheets/menu.js +580 -0
- package/apps/web/public/sheets/message.js +220 -0
- package/apps/web/public/sheets/model.js +412 -0
- package/apps/web/public/sheets/people.js +414 -0
- package/apps/web/public/sheets/pi-sessions.js +264 -0
- package/apps/web/public/sheets/providers.js +525 -0
- package/apps/web/public/sheets/running.js +130 -0
- package/apps/web/public/sheets/schedules.js +71 -0
- package/apps/web/public/sheets/session.js +249 -0
- package/apps/web/public/sheets/shortcuts.js +101 -0
- package/apps/web/public/sheets/spend.js +150 -0
- package/apps/web/public/sheets/text.js +53 -0
- package/apps/web/public/sheets/viewers.js +363 -0
- package/apps/web/public/sheets/worktree.js +50 -0
- package/apps/web/public/sheets.js +410 -0
- package/apps/web/public/signin.js +57 -0
- package/apps/web/public/slots.js +208 -0
- package/apps/web/public/store.js +1760 -0
- package/apps/web/public/style.css +18625 -0
- package/apps/web/public/subagents-board.js +879 -0
- package/apps/web/public/subagents.js +273 -0
- package/apps/web/public/sw.js +169 -0
- package/apps/web/public/taps.js +26 -0
- package/apps/web/public/theme-surface.d.ts +4 -0
- package/apps/web/public/theme-surface.js +266 -0
- package/apps/web/public/theme.js +845 -0
- package/apps/web/public/themes/lantern/colors.toml +25 -0
- package/apps/web/public/themes/lantern/direction.css +60 -0
- package/apps/web/public/themes/lantern/frame.css +254 -0
- package/apps/web/public/themes/lantern/theme.css +10 -0
- package/apps/web/public/themes/lantern/theme.json +7 -0
- package/apps/web/public/themes/pocket/theme.css +189 -0
- package/apps/web/public/themes/pocket/theme.json +6 -0
- package/apps/web/public/themes.js +556 -0
- package/apps/web/public/transcript.js +3348 -0
- package/apps/web/public/trust.js +295 -0
- package/apps/web/public/turns.js +507 -0
- package/apps/web/public/ui.js +1910 -0
- package/apps/web/public/voice.js +345 -0
- package/apps/web/server.mjs +774 -0
- package/package.json +43 -4
- package/packages/cli/src/bom-pi.mjs +452 -0
- package/packages/cli/templates/blank.factory.json +7 -0
- package/packages/cli/templates/dev.factory.json +11 -0
- package/packages/cli/templates/docs.factory.json +12 -0
- package/packages/cli/templates/ops.factory.json +14 -0
- package/packages/cli/templates/triage.factory.json +10 -0
- package/packages/core/src/daemon.mjs +2047 -0
- package/packages/core/src/limit-gate.mjs +192 -0
- package/packages/core/src/rpc-client.mjs +66 -0
- 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
|
+
}
|