bom-pi 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +33 -2
  2. package/apps/README.md +7 -0
  3. package/apps/web/README.md +21 -0
  4. package/apps/web/pocket-adapter.mjs +323 -0
  5. package/apps/web/public/LICENSE.pocket +21 -0
  6. package/apps/web/public/activity-fold.js +28 -0
  7. package/apps/web/public/agent-now.js +37 -0
  8. package/apps/web/public/agent-tile.js +79 -0
  9. package/apps/web/public/app.js +975 -0
  10. package/apps/web/public/apple-touch-icon.png +0 -0
  11. package/apps/web/public/artifact-bridge.js +687 -0
  12. package/apps/web/public/avatar.js +87 -0
  13. package/apps/web/public/back.js +554 -0
  14. package/apps/web/public/box-keys.js +216 -0
  15. package/apps/web/public/browser-panel.js +1097 -0
  16. package/apps/web/public/browser.js +68 -0
  17. package/apps/web/public/calls.js +139 -0
  18. package/apps/web/public/chat.js +919 -0
  19. package/apps/web/public/clock.js +34 -0
  20. package/apps/web/public/commands.js +578 -0
  21. package/apps/web/public/composer.js +2068 -0
  22. package/apps/web/public/diff-parse.js +672 -0
  23. package/apps/web/public/diff.js +1646 -0
  24. package/apps/web/public/dock.js +299 -0
  25. package/apps/web/public/extension-ui.js +2734 -0
  26. package/apps/web/public/files-panel.js +775 -0
  27. package/apps/web/public/files.js +368 -0
  28. package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
  29. package/apps/web/public/fonts/Geist.woff2 +0 -0
  30. package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
  31. package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
  32. package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
  33. package/apps/web/public/fonts/InterVariable.woff2 +0 -0
  34. package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
  35. package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
  36. package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
  37. package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
  38. package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
  39. package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
  40. package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
  41. package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
  42. package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
  43. package/apps/web/public/fonts/SOURCES.md +69 -0
  44. package/apps/web/public/gestures.js +610 -0
  45. package/apps/web/public/highlight.js +480 -0
  46. package/apps/web/public/history.js +70 -0
  47. package/apps/web/public/home.js +169 -0
  48. package/apps/web/public/icon-192.png +0 -0
  49. package/apps/web/public/icon-512.png +0 -0
  50. package/apps/web/public/icon-maskable-512.png +0 -0
  51. package/apps/web/public/icon.svg +16 -0
  52. package/apps/web/public/index.html +71 -0
  53. package/apps/web/public/launcher.js +1293 -0
  54. package/apps/web/public/linked-sessions.js +55 -0
  55. package/apps/web/public/live.js +39 -0
  56. package/apps/web/public/manifest.webmanifest +32 -0
  57. package/apps/web/public/model-refresh.js +73 -0
  58. package/apps/web/public/notify.js +251 -0
  59. package/apps/web/public/peeks.js +920 -0
  60. package/apps/web/public/pip-attachments.js +182 -0
  61. package/apps/web/public/pip-window.js +141 -0
  62. package/apps/web/public/pip.js +1217 -0
  63. package/apps/web/public/quote-reply.js +109 -0
  64. package/apps/web/public/quote-text.js +31 -0
  65. package/apps/web/public/rich.js +403 -0
  66. package/apps/web/public/run-frame.js +106 -0
  67. package/apps/web/public/safe-mode.js +71 -0
  68. package/apps/web/public/seen.js +169 -0
  69. package/apps/web/public/session-tree.js +117 -0
  70. package/apps/web/public/sessions.js +2695 -0
  71. package/apps/web/public/share.js +126 -0
  72. package/apps/web/public/sheets/appearance.js +820 -0
  73. package/apps/web/public/sheets/branch.js +353 -0
  74. package/apps/web/public/sheets/extensions.js +818 -0
  75. package/apps/web/public/sheets/file.js +318 -0
  76. package/apps/web/public/sheets/find.js +250 -0
  77. package/apps/web/public/sheets/folder.js +680 -0
  78. package/apps/web/public/sheets/gallery.js +2340 -0
  79. package/apps/web/public/sheets/hooks.js +350 -0
  80. package/apps/web/public/sheets/memory.js +531 -0
  81. package/apps/web/public/sheets/menu.js +580 -0
  82. package/apps/web/public/sheets/message.js +220 -0
  83. package/apps/web/public/sheets/model.js +412 -0
  84. package/apps/web/public/sheets/people.js +414 -0
  85. package/apps/web/public/sheets/pi-sessions.js +264 -0
  86. package/apps/web/public/sheets/providers.js +525 -0
  87. package/apps/web/public/sheets/running.js +130 -0
  88. package/apps/web/public/sheets/schedules.js +71 -0
  89. package/apps/web/public/sheets/session.js +249 -0
  90. package/apps/web/public/sheets/shortcuts.js +101 -0
  91. package/apps/web/public/sheets/spend.js +150 -0
  92. package/apps/web/public/sheets/text.js +53 -0
  93. package/apps/web/public/sheets/viewers.js +363 -0
  94. package/apps/web/public/sheets/worktree.js +50 -0
  95. package/apps/web/public/sheets.js +410 -0
  96. package/apps/web/public/signin.js +57 -0
  97. package/apps/web/public/slots.js +208 -0
  98. package/apps/web/public/store.js +1760 -0
  99. package/apps/web/public/style.css +18625 -0
  100. package/apps/web/public/subagents-board.js +879 -0
  101. package/apps/web/public/subagents.js +273 -0
  102. package/apps/web/public/sw.js +169 -0
  103. package/apps/web/public/taps.js +26 -0
  104. package/apps/web/public/theme-surface.d.ts +4 -0
  105. package/apps/web/public/theme-surface.js +266 -0
  106. package/apps/web/public/theme.js +845 -0
  107. package/apps/web/public/themes/lantern/colors.toml +25 -0
  108. package/apps/web/public/themes/lantern/direction.css +60 -0
  109. package/apps/web/public/themes/lantern/frame.css +254 -0
  110. package/apps/web/public/themes/lantern/theme.css +10 -0
  111. package/apps/web/public/themes/lantern/theme.json +7 -0
  112. package/apps/web/public/themes/pocket/theme.css +189 -0
  113. package/apps/web/public/themes/pocket/theme.json +6 -0
  114. package/apps/web/public/themes.js +556 -0
  115. package/apps/web/public/transcript.js +3348 -0
  116. package/apps/web/public/trust.js +295 -0
  117. package/apps/web/public/turns.js +507 -0
  118. package/apps/web/public/ui.js +1910 -0
  119. package/apps/web/public/voice.js +345 -0
  120. package/apps/web/server.mjs +774 -0
  121. package/package.json +43 -4
  122. package/packages/cli/src/bom-pi.mjs +452 -0
  123. package/packages/cli/templates/blank.factory.json +7 -0
  124. package/packages/cli/templates/dev.factory.json +11 -0
  125. package/packages/cli/templates/docs.factory.json +12 -0
  126. package/packages/cli/templates/ops.factory.json +14 -0
  127. package/packages/cli/templates/triage.factory.json +10 -0
  128. package/packages/core/src/daemon.mjs +2047 -0
  129. package/packages/core/src/limit-gate.mjs +192 -0
  130. package/packages/core/src/rpc-client.mjs +66 -0
  131. package/systemd/README.md +11 -0
@@ -0,0 +1,318 @@
1
+ // The file viewer: a file's text, an image, or a folder's entries. `FileView` draws it in the Files tile and in the
2
+ // file sheet alike.
3
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
4
+ import { browserAvailable, openInBrowser } from "../browser.js";
5
+ import { mentionText } from "../files.js";
6
+ import { highlightLines, langOf } from "../highlight.js";
7
+ import { HtmlPreview } from "../rich.js";
8
+ import { RunFrame } from "../run-frame.js";
9
+ import { actions, canSteer, closeSheet, insertIntoComposer, notify, store } from "../store.js";
10
+ import {
11
+ APPLE,
12
+ copyText,
13
+ fileUrl,
14
+ formatBytes,
15
+ html,
16
+ Icon,
17
+ item,
18
+ Loader,
19
+ Markdown,
20
+ openFile,
21
+ revealFile,
22
+ Sheet,
23
+ } from "../ui.js";
24
+
25
+ /** How many lines of a file show at first: a long file draws the rest on request. */
26
+ const FILE_LINES = 3000;
27
+ /** Past this many bytes, a file shows without colors: coloring it would hold up the page. */
28
+ const COLOR_BYTES = 256 * 1024;
29
+
30
+ const isMarkdown = (path) => /\.(md|markdown|mdx)$/i.test(path);
31
+ const isPage = (path) => /\.(html?|xhtml)$/i.test(path);
32
+
33
+ /** A file's lines, colored for its language when it is not too big to. */
34
+ function useLines(file, path) {
35
+ return useMemo(() => {
36
+ if (file?.kind !== "text") {
37
+ return { lines: [], colored: null };
38
+ }
39
+
40
+ const lines = file.text.replace(/\n$/, "").split("\n");
41
+ const lang = langOf(path);
42
+ const colored =
43
+ lang && file.text.length <= COLOR_BYTES ? highlightLines(lines, lang) : null;
44
+
45
+ return { lines, colored };
46
+ }, [file, path]);
47
+ }
48
+
49
+ /**
50
+ * A file of the session, read-only: text with line numbers and colors (Markdown shows rendered too), an image, or a
51
+ * folder to browse, under a bar with its path and the viewer's buttons (copy, mention, preview). `line` scrolls to and
52
+ * marks that line. `onOpen` opens an entry of a folder. `refresh` changes when the same file is asked for again.
53
+ */
54
+ export function FileView({ path, line, refresh, onOpen = openFile, onLoad }) {
55
+ const [file, setFile] = useState(null);
56
+ const [error, setError] = useState(null);
57
+ const markdown = isMarkdown(path);
58
+ const page = isPage(path);
59
+ const [preview, setPreview] = useState(markdown && line === undefined);
60
+ const [running, setRunning] = useState(false);
61
+ const [all, setAll] = useState(line !== undefined && line > FILE_LINES);
62
+ const body = useRef(null);
63
+ const shownPath = useRef(path);
64
+ const { lines, colored } = useLines(file, file?.path ?? path);
65
+ // The same element while the file and the marked line stay the same: Preact then skips its rows on every update of
66
+ // the app, instead of comparing thousands of them.
67
+ const rows = useMemo(
68
+ () => html`<${FileLines} lines=${lines} colored=${colored} all=${all} line=${line} />`,
69
+ [lines, colored, all, line],
70
+ );
71
+
72
+ useEffect(() => {
73
+ let live = true;
74
+
75
+ // The same file asked for again keeps its text until the new text arrives; another file shows the loader.
76
+ if (shownPath.current !== path) {
77
+ shownPath.current = path;
78
+ setFile(null);
79
+ }
80
+
81
+ setError(null);
82
+ actions.view(path).then(
83
+ (each) => {
84
+ if (live) {
85
+ setFile(each);
86
+ onLoad?.(each);
87
+ }
88
+ },
89
+ (failure) => {
90
+ if (live) {
91
+ setFile(null);
92
+ setError(failure.message);
93
+ }
94
+ },
95
+ );
96
+
97
+ return () => {
98
+ live = false;
99
+ };
100
+ }, [path, refresh]);
101
+ useEffect(() => {
102
+ setPreview(markdown && line === undefined);
103
+ setRunning(false);
104
+ }, [path, line]);
105
+ useEffect(() => {
106
+ if (file?.kind === "text" && line !== undefined && !preview) {
107
+ body.current
108
+ ?.querySelector(`[data-line="${line}"]`)
109
+ ?.scrollIntoView({ block: "center" });
110
+ }
111
+ }, [file, preview, line]);
112
+ const shown = file?.display ?? path;
113
+ const name = shown.replace(/\/$/, "").split("/").pop() || shown;
114
+
115
+ return html`<div class="file-viewer">
116
+ <div class="file-tools">
117
+ <span class="muted small mono file-path" title=${file?.path ?? path}>
118
+ ${shown}
119
+ ${file?.size !== undefined ? ` · ${formatBytes(file.size)}` : ""}
120
+ ${file?.kind === "text" ? ` · ${lines.length} ${lines.length === 1 ? "line" : "lines"}` : ""}
121
+ </span>
122
+ <${FileActions}
123
+ file=${file}
124
+ shown=${shown}
125
+ previewable=${markdown || page}
126
+ preview=${preview}
127
+ onPreview=${setPreview}
128
+ running=${page && preview ? running : null}
129
+ onRun=${setRunning}
130
+ />
131
+ </div>
132
+ ${error && html`<p class="muted">${error}</p>`}
133
+ ${!file && !error && html`<${Loader} label="Opening" />`}
134
+ ${
135
+ file?.kind === "image" &&
136
+ html`<img class="file-image" src=${fileUrl(file.path)} alt=${name} />`
137
+ }
138
+ ${
139
+ file?.kind === "binary" &&
140
+ html`<p class="muted">A binary file: nothing to show as text.</p>`
141
+ }
142
+ ${
143
+ file?.kind === "other" &&
144
+ html`<p class="muted">Not a regular file (a pipe or a device, say): nothing to show.</p>`
145
+ }
146
+ ${
147
+ file?.kind === "folder" &&
148
+ html`<div class="group">
149
+ ${file.entries.length === 0 && html`<p class="muted">An empty folder.</p>`}
150
+ ${file.entries.map((entry) =>
151
+ item(
152
+ html`<span class="file-entry">
153
+ <${Icon} name=${entry.dir ? "folder" : "file"} size=${15} /> ${entry.name}
154
+ ${entry.dir ? "/" : ""}
155
+ </span>`,
156
+ () => onOpen(`${file.path}/${entry.name}`),
157
+ ),
158
+ )}
159
+ ${
160
+ file.truncated &&
161
+ html`<p class="muted small">
162
+ Only the first ${file.entries.length} are listed.
163
+ </p>`
164
+ }
165
+ </div>`
166
+ }
167
+ ${
168
+ file?.kind === "text" &&
169
+ preview &&
170
+ markdown &&
171
+ html`<${Markdown}
172
+ text=${file.text}
173
+ base=${file.path.replace(/[^/]*$/, "")}
174
+ breaks=${false}
175
+ />`
176
+ }
177
+ ${
178
+ file?.kind === "text" &&
179
+ preview &&
180
+ page &&
181
+ html`${
182
+ running
183
+ ? html`<${RunFrame}
184
+ source=${file.text}
185
+ title=${name}
186
+ from=${`the file “${name}”`}
187
+ />`
188
+ : html`<${HtmlPreview} source=${file.text} title=${name} />`
189
+ }
190
+ <p class="muted small file-note">
191
+ The files it links to (styles, scripts, images) do not load here.
192
+ ${
193
+ store.state.me?.role === "owner" &&
194
+ browserAvailable() &&
195
+ html` <button class="link small" onClick=${() => openInBrowser(file.path)}>
196
+ Open it in the browser
197
+ </button>`
198
+ }
199
+ </p>`
200
+ }
201
+ ${
202
+ file?.kind === "text" &&
203
+ !preview &&
204
+ html`<div class="file-view" ref=${body}>${rows}</div>
205
+ ${
206
+ !all &&
207
+ lines.length > FILE_LINES &&
208
+ html`<button class="link" onClick=${() => setAll(true)}>
209
+ Show all ${lines.length} lines
210
+ </button>`
211
+ }`
212
+ }
213
+ ${
214
+ file?.kind === "text" &&
215
+ file.truncated &&
216
+ html`<p class="muted small">
217
+ Only the first ${formatBytes(file.text.length)} are shown.
218
+ </p>`
219
+ }
220
+ </div>`;
221
+ }
222
+
223
+ /** A file's lines, numbered, colored when there are colors, the first `FILE_LINES` of them unless `all`. */
224
+ function FileLines({ lines, colored, all, line }) {
225
+ return (all ? lines : lines.slice(0, FILE_LINES)).map(
226
+ (text, index) =>
227
+ html`<div
228
+ class=${`file-line ${index + 1 === line ? "on" : ""}`}
229
+ data-line=${index + 1}
230
+ >
231
+ <span class="ln">${index + 1}</span>
232
+ ${
233
+ colored
234
+ ? html`<span
235
+ class="lt"
236
+ dangerouslySetInnerHTML=${{ __html: colored[index] || " " }}
237
+ ></span>`
238
+ : html`<span class="lt">${text || " "}</span>`
239
+ }
240
+ </div>`,
241
+ );
242
+ }
243
+
244
+ /**
245
+ * The viewer's buttons: Preview or Source for Markdown and pages, Run for a previewed page (`running` is null where it
246
+ * does not apply), Copy, and @ Mention.
247
+ */
248
+ function FileActions({ file, shown, previewable, preview, onPreview, running, onRun }) {
249
+ return html`${
250
+ file?.kind === "text" &&
251
+ running !== null &&
252
+ html`<button
253
+ class=${`button small ${running ? "on" : ""}`}
254
+ title=${running ? "Stop its scripts" : "Run its scripts in a sandbox, as an artifact runs: it can reach the internet, not the app"}
255
+ onClick=${() => onRun(!running)}
256
+ >
257
+ ${running ? "■ Stop" : "▶ Run"}
258
+ </button>`
259
+ }
260
+ ${
261
+ file?.kind === "text" &&
262
+ previewable &&
263
+ html`<button class="button small" onClick=${() => onPreview(!preview)}>
264
+ ${preview ? "Source" : "Preview"}
265
+ </button>`
266
+ }
267
+ ${
268
+ file?.kind === "text" &&
269
+ html`<button
270
+ class="icon-button"
271
+ title="Copy"
272
+ aria-label="Copy"
273
+ onClick=${() => copyText(file.text).then(() => notify("info", "Copied."))}
274
+ >
275
+ <${Icon} name="copy" size=${16} />
276
+ </button>`
277
+ }
278
+ ${
279
+ file &&
280
+ canSteer() &&
281
+ html`<button
282
+ class="button small"
283
+ title="Mention it in the message box"
284
+ onClick=${() => insertIntoComposer(`${mentionText(shown)} `, [], { inline: true })}
285
+ >
286
+ @ Mention
287
+ </button>`
288
+ }
289
+ ${
290
+ // For the owner at the server's machine, with a mouse: show it in that machine's file manager.
291
+ // Ctrl+click (⌘+click) on a path does the same.
292
+ file &&
293
+ store.state.me?.role === "owner" &&
294
+ matchMedia("(pointer: fine)").matches &&
295
+ html`<button
296
+ class="icon-button"
297
+ title=${`Show in the file manager (${APPLE ? "⌘" : "Ctrl"}+click a path)`}
298
+ aria-label="Show in the file manager"
299
+ onClick=${() => revealFile(file.path)}
300
+ >
301
+ <${Icon} name="folder" size=${16} />
302
+ </button>`
303
+ }`;
304
+ }
305
+
306
+ /** The viewer as a sheet: opened from a path in a reply, a tool card, a mention, or Changes. */
307
+ export function FileSheet({ path, line, refresh }) {
308
+ const [name, setName] = useState(path.replace(/\/$/, "").split("/").pop() || path);
309
+
310
+ return html`<${Sheet} title=${name} onClose=${closeSheet} wide=${true}>
311
+ <${FileView}
312
+ path=${path}
313
+ line=${line}
314
+ refresh=${refresh}
315
+ onLoad=${(file) => setName((file.display ?? path).replace(/\/$/, "").split("/").pop() || path)}
316
+ />
317
+ <//>`;
318
+ }
@@ -0,0 +1,250 @@
1
+ // Find in session: messages, commands, and files, through the whole history.
2
+ import { useEffect, useRef, useState } from "preact/hooks";
3
+ import { jumpToEntry } from "../chat.js";
4
+ import { actions, closeSheet, isRow, loadEarlier, store } from "../store.js";
5
+ import { html, Icon, Marked, plainText, Sheet, Spinner, writtenText } from "../ui.js";
6
+
7
+ /** How many entries a page of earlier history has (`app.history` on the server), and how many pages find reads. */
8
+ const HISTORY_PAGE = 400;
9
+
10
+ const FIND_PAGES = 25;
11
+
12
+ /** What an entry says, to find text in: messages, Pi's calls' paths and commands, commands people ran, notes. */
13
+ function searchText(entry) {
14
+ switch (entry.kind) {
15
+ case "user":
16
+ return writtenText(entry);
17
+ case "assistant":
18
+ return entry.blocks
19
+ .map((block) =>
20
+ block.type === "text"
21
+ ? plainText(block.text)
22
+ : block.type === "toolCall"
23
+ ? [block.args?.path, block.args?.command, block.args?.url]
24
+ .filter(Boolean)
25
+ .join(" ")
26
+ : "",
27
+ )
28
+ .filter(Boolean)
29
+ .join("\n");
30
+ case "shell":
31
+ case "command":
32
+ return `${entry.command}\n${entry.output}`;
33
+ case "note":
34
+ return `${entry.name} ${entry.text}`;
35
+ case "compaction":
36
+ return entry.summary;
37
+ case "reset":
38
+ return entry.text ?? "";
39
+ default:
40
+ return "";
41
+ }
42
+ }
43
+
44
+ /** Who an entry is from, for a find result. */
45
+ function speaker(entry) {
46
+ const { view, users, me } = store.state;
47
+
48
+ if (entry.kind === "assistant") {
49
+ return "Pi";
50
+ }
51
+
52
+ if (entry.kind === "shell" || entry.kind === "command" || entry.kind === "note") {
53
+ return entry.name;
54
+ }
55
+
56
+ if (entry.kind !== "user") {
57
+ return "Context";
58
+ }
59
+
60
+ const userId = view.authors?.[entry.id];
61
+
62
+ if (userId === me?.id) {
63
+ return "You";
64
+ }
65
+
66
+ return users.find((user) => user.id === userId)?.name ?? entry.from ?? "You";
67
+ }
68
+
69
+ /**
70
+ * Find in this session: every message it has, earlier history included, newest first. The browser's own find misses
71
+ * the messages the transcript has not drawn. Picking one scrolls the transcript to it.
72
+ */
73
+ export function FindSheet({ initial }) {
74
+ const { view } = store.state;
75
+ const [query, setQuery] = useState(initial);
76
+ const [pick, setPick] = useState(0);
77
+ // History before a compaction or a new context, page by page, oldest first; `done` once all of it (or as much as
78
+ // is searched) is here.
79
+ const [earlier, setEarlier] = useState({ entries: [], done: false, all: true });
80
+ const box = useRef(null);
81
+
82
+ useEffect(() => box.current?.focus(), []);
83
+ useEffect(() => {
84
+ let live = true;
85
+
86
+ (async () => {
87
+ // The rest of the active context first: a view brings only its newest entries.
88
+ await loadEarlier();
89
+ const { view: now } = store.state;
90
+ const first = now.order.length > 0 ? now.entries.get(now.order[0]) : undefined;
91
+
92
+ if (!live) {
93
+ return;
94
+ }
95
+
96
+ if (!first || (first.kind !== "compaction" && first.kind !== "reset")) {
97
+ return setEarlier({ entries: [], done: true, all: true });
98
+ }
99
+
100
+ let entries = [];
101
+ let before = first.id;
102
+
103
+ for (let page = 0; page < FIND_PAGES; page++) {
104
+ const batch = await actions.history(before);
105
+
106
+ if (!live) {
107
+ return;
108
+ }
109
+
110
+ entries = [...batch, ...entries];
111
+ const last = batch.length < HISTORY_PAGE;
112
+
113
+ setEarlier({ entries, done: last, all: last });
114
+
115
+ if (last) {
116
+ return;
117
+ }
118
+
119
+ before = batch[0].id;
120
+ }
121
+
122
+ setEarlier({ entries, done: true, all: false });
123
+ })().catch(() => live && setEarlier((current) => ({ ...current, done: true, all: false })));
124
+
125
+ return () => {
126
+ live = false;
127
+ };
128
+ }, []);
129
+
130
+ /**
131
+ * Jump to a result. One in earlier history first shows that history from a little before it, and the transcript
132
+ * from its first row on: earlier history shows above the rows of now only when they all show.
133
+ */
134
+ const jump = (entry) => {
135
+ const at = earlier.entries.findIndex((each) => each.id === entry.id);
136
+
137
+ if (at !== -1 && !document.getElementById(`entry-${entry.id}`)) {
138
+ const firstRow = view.order
139
+ .map((id) => view.entries.get(id))
140
+ .find((each) => each && isRow(each));
141
+
142
+ store.set({
143
+ history: earlier.entries.slice(Math.max(0, at - 10)),
144
+ ...(firstRow ? { transcriptFrom: firstRow.id } : {}),
145
+ });
146
+ }
147
+
148
+ requestAnimationFrame(() => jumpToEntry(entry.id));
149
+ };
150
+
151
+ const needle = query.trim().toLowerCase();
152
+ const results = [];
153
+
154
+ if (needle !== "") {
155
+ const entries = [
156
+ ...earlier.entries,
157
+ ...view.order.map((id) => view.entries.get(id)),
158
+ ].filter((entry) => entry && isRow(entry));
159
+
160
+ for (let index = entries.length - 1; index >= 0 && results.length < 100; index--) {
161
+ const text = searchText(entries[index]).replace(/\s+/g, " ");
162
+ const at = text.toLowerCase().indexOf(needle);
163
+
164
+ if (at === -1) {
165
+ continue;
166
+ }
167
+
168
+ const start = Math.max(0, at - 50);
169
+ const snippet = `${start > 0 ? "…" : ""}${text.slice(start, at + needle.length + 90)}`;
170
+ const from = at - start + (start > 0 ? 1 : 0);
171
+
172
+ results.push({
173
+ entry: entries[index],
174
+ snippet,
175
+ hits: Array.from({ length: needle.length }, (_, offset) => from + offset),
176
+ });
177
+ }
178
+ }
179
+
180
+ const chosen = results[Math.min(pick, results.length - 1)];
181
+
182
+ const onKey = (event) => {
183
+ const move = { ArrowDown: 1, ArrowUp: -1 }[event.key];
184
+
185
+ if (move !== undefined && results.length > 0) {
186
+ event.preventDefault();
187
+ setPick((Math.min(pick, results.length - 1) + move + results.length) % results.length);
188
+ } else if (event.key === "Enter" && chosen) {
189
+ event.preventDefault();
190
+ jump(chosen.entry);
191
+ }
192
+ };
193
+
194
+ return html`<${Sheet} title="Find in session" onClose=${closeSheet}>
195
+ <label class="search find-search">
196
+ <${Icon} name="search" size=${16} />
197
+ <input
198
+ class="find-input"
199
+ ref=${box}
200
+ type="search"
201
+ aria-label="Find in session"
202
+ placeholder="Words in a message, a command, a file…"
203
+ value=${query}
204
+ onInput=${(event) => {
205
+ setQuery(event.currentTarget.value);
206
+ setPick(0);
207
+ }}
208
+ onKeyDown=${onKey}
209
+ />
210
+ ${
211
+ needle &&
212
+ html`<span class="find-count mono" role="status">
213
+ ${results.length}
214
+ </span>`
215
+ }
216
+ </label>
217
+ ${
218
+ needle !== "" &&
219
+ results.length === 0 &&
220
+ earlier.done &&
221
+ html`<p class="muted">Nothing here says “${query.trim()}”.</p>`
222
+ }
223
+ ${
224
+ !earlier.done &&
225
+ html`<p class="muted small"><${Spinner} /> Searching earlier history too…</p>`
226
+ }
227
+ ${
228
+ earlier.done &&
229
+ !earlier.all &&
230
+ html`<p class="muted small">
231
+ The oldest history was not searched: only the newest ${earlier.entries.length} earlier entries.
232
+ </p>`
233
+ }
234
+ ${results.length === 100 && html`<p class="muted small">The newest 100 are listed.</p>`}
235
+ <div class="find-results">
236
+ ${results.map(
237
+ (result) => html`<button
238
+ key=${result.entry.id}
239
+ class=${`find-result ${result === chosen ? "on" : ""}`}
240
+ onClick=${() => jump(result.entry)}
241
+ >
242
+ <span class="find-who">${speaker(result.entry)}</span>
243
+ <span class="find-snippet">
244
+ <${Marked} text=${result.snippet} hits=${result.hits} />
245
+ </span>
246
+ </button>`,
247
+ )}
248
+ </div>
249
+ <//>`;
250
+ }