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,414 @@
1
+ // People with access to this server, and invites for more.
2
+ import { useEffect, useRef, useState } from "preact/hooks";
3
+ import { Avatar } from "../avatar.js";
4
+ import {
5
+ actions,
6
+ api,
7
+ attempt,
8
+ canSteer,
9
+ closeSheet,
10
+ collab,
11
+ notify,
12
+ openSheet,
13
+ scoped,
14
+ store,
15
+ } from "../store.js";
16
+ import { copyText, html, Icon, Loader, Sheet, timeAgo } from "../ui.js";
17
+
18
+ const ROLE_TEXT = {
19
+ guest: "Can steer Pi, which can run commands on this machine",
20
+ viewer: "Can read along, react, and chat, but not steer Pi",
21
+ };
22
+
23
+ /** How long an invite may last: minutes, its button, and how the help text says it. */
24
+ const LASTS = [
25
+ [15, "15 min", "in 15 minutes"],
26
+ [60, "1 hour", "in an hour"],
27
+ [24 * 60, "1 day", "in a day"],
28
+ [7 * 24 * 60, "1 week", "in a week"],
29
+ ];
30
+
31
+ /** End an invite nobody will see: one the sheet stopped showing, or one that answered too late to show. */
32
+ const cancelInvite = (invite) => {
33
+ // A server that says how long its invites last also ends them on request; an older one has no such route.
34
+ if (invite?.minutes !== undefined) {
35
+ // Sent even when the page goes (a reload, a web file edited), so it is never left live and unseen.
36
+ fetch("/api/invite/cancel", {
37
+ method: "POST",
38
+ keepalive: true,
39
+ headers: { "X-Pocket": "1", "content-type": "application/json" },
40
+ body: JSON.stringify({ code: invite.code }),
41
+ }).catch(() => {});
42
+ }
43
+ };
44
+
45
+ export function InviteSheet({ session = null }) {
46
+ const { view, me, sessions } = store.state;
47
+ const here =
48
+ session !== null
49
+ ? (sessions.find((each) => each.id === session) ?? null)
50
+ : view.conversation?.kind === "session"
51
+ ? view.conversation
52
+ : null;
53
+ const [role, setRole] = useState("guest");
54
+ const [only, setOnly] = useState(session !== null);
55
+ // The owner's own other device: it signs in as the owner, not as someone new.
56
+ const [asOwner, setAsOwner] = useState(false);
57
+ const [minutes, setMinutes] = useState(15);
58
+ const [made, setMade] = useState(null);
59
+ // The newest invite could not be made: the sheet offers to try again rather than wait for ever.
60
+ const [failed, setFailed] = useState(false);
61
+ // What the sheet says now: an invite shows only while it was made for exactly this.
62
+ const asked = JSON.stringify([asOwner, role, only, minutes]);
63
+ // Only the newest invite shows: one asked for before a change, answering late, must not stand for what the sheet says.
64
+ const latest = useRef(0);
65
+ // The invite the sheet last showed: a change to what it grants, or how long it lasts, ends it.
66
+ const shown = useRef(null);
67
+ // Closed (gone, or still sliding away): nothing more is made, and what was being made ends when it answers.
68
+ const mounted = useRef(true);
69
+ const open = () => mounted.current && store.state.sheet?.type === "invite";
70
+ const create = ({ replacing = false } = {}) =>
71
+ attempt(async () => {
72
+ if (!open()) {
73
+ return;
74
+ }
75
+
76
+ const ticket = ++latest.current;
77
+
78
+ if (replacing) {
79
+ cancelInvite(shown.current);
80
+ shown.current = null;
81
+ }
82
+
83
+ setMade(null);
84
+ setFailed(false);
85
+ let answer;
86
+
87
+ try {
88
+ answer = await api(
89
+ "invite",
90
+ asOwner
91
+ ? { role: "owner" }
92
+ : collab()
93
+ ? { role, ...(only && here ? { session: here.id } : {}), minutes }
94
+ : { minutes },
95
+ );
96
+ } catch (error) {
97
+ if (ticket === latest.current) {
98
+ setFailed(true);
99
+ }
100
+
101
+ throw error;
102
+ }
103
+
104
+ if (ticket !== latest.current || !open()) {
105
+ cancelInvite(answer);
106
+
107
+ return;
108
+ }
109
+
110
+ // A server from before owner invites makes a guest's: never shown as the owner's.
111
+ if (asOwner && answer.grant?.role !== "owner") {
112
+ setFailed(true);
113
+
114
+ throw new Error("Restart Pi Pocket to sign in another device as the owner.");
115
+ }
116
+
117
+ shown.current = answer;
118
+ setMade({ ...answer, asked });
119
+ });
120
+
121
+ useEffect(() => {
122
+ create({ replacing: true });
123
+ }, [role, only, asOwner, minutes]);
124
+ useEffect(
125
+ () => () => {
126
+ mounted.current = false;
127
+ },
128
+ [],
129
+ );
130
+ // Shown only while it is what the sheet says, not for a frame after a change: above all, never an owner's invite
131
+ // under a cleared box (which the server's answer says too).
132
+ const invite =
133
+ made && made.asked === asked && (made.grant?.role === "owner") === asOwner ? made : null;
134
+ const where = only && here ? `only “${here.title}”` : "every session";
135
+ // As long as the invite shown lasts (a server that does not say: 15 minutes), or, while one is made, as chosen.
136
+ const lasting = LASTS.find(
137
+ ([each]) => each === (invite ? (invite.minutes ?? 15) : minutes),
138
+ )?.[2];
139
+
140
+ return html`<${Sheet} title="Invite someone" onClose=${closeSheet}>
141
+ ${
142
+ me?.role === "owner" &&
143
+ html`<label class="check">
144
+ <input
145
+ type="checkbox"
146
+ checked=${asOwner}
147
+ onChange=${(event) => setAsOwner(event.currentTarget.checked)}
148
+ />
149
+ Sign in as me (owner)
150
+ </label>`
151
+ }
152
+ ${
153
+ collab() &&
154
+ !asOwner &&
155
+ html`<div class="field">
156
+ <div class="label">They can</div>
157
+ <div class="segmented">
158
+ <button
159
+ class=${role === "guest" ? "on" : ""}
160
+ aria-pressed=${role === "guest"}
161
+ onClick=${() => setRole("guest")}
162
+ >
163
+ Steer
164
+ </button>
165
+ <button
166
+ class=${role === "viewer" ? "on" : ""}
167
+ aria-pressed=${role === "viewer"}
168
+ onClick=${() => setRole("viewer")}
169
+ >
170
+ View only
171
+ </button>
172
+ </div>
173
+ <div class="muted small">
174
+ ${ROLE_TEXT[role]}.${role === "guest" && only ? " Seeing one session in the app does not limit what Pi can reach on the machine." : ""}
175
+ </div>
176
+ </div>
177
+ ${
178
+ here &&
179
+ html`<div class="field">
180
+ <div class="label">In</div>
181
+ <div class="segmented">
182
+ <button
183
+ class=${!only ? "on" : ""}
184
+ aria-pressed=${!only}
185
+ onClick=${() => setOnly(false)}
186
+ >
187
+ Every session
188
+ </button>
189
+ <button
190
+ class=${only ? "on" : ""}
191
+ aria-pressed=${only}
192
+ onClick=${() => setOnly(true)}
193
+ >
194
+ Only this session
195
+ </button>
196
+ </div>
197
+ </div>`
198
+ }`
199
+ }
200
+ ${
201
+ !asOwner &&
202
+ html`<div class="field">
203
+ <div class="label">Expires after</div>
204
+ <div class="segmented">
205
+ ${LASTS.map(
206
+ ([each, label]) => html`<button
207
+ class=${minutes === each ? "on" : ""}
208
+ aria-pressed=${minutes === each}
209
+ onClick=${() => setMinutes(each)}
210
+ >
211
+ ${label}
212
+ </button>`,
213
+ )}
214
+ </div>
215
+ </div>`
216
+ }
217
+ <p class="muted small">
218
+ ${
219
+ asOwner
220
+ ? "Scan this on your other device, or send it the link. That device signs in as you, the owner. It works once and expires in 15 minutes."
221
+ : `Scan this on the other device, or send it the link. It works once and expires ${lasting}. Whoever joins sees ${where}.`
222
+ }
223
+ </p>
224
+ ${
225
+ invite?.access?.url &&
226
+ html`<p class="muted small">Other devices connect through ${invite.access.label}.</p>`
227
+ }
228
+ ${
229
+ invite
230
+ ? html`<div class="invite">
231
+ ${
232
+ invite.local &&
233
+ html`<div class="error-box">
234
+ This link only works on this device. To let other devices in, press <span class="mono">a</span> in the Pi Pocket terminal (or start it with <span class="mono">--access</span>) and pick Local network, Cloudflare Tunnel, or Tailscale. Then make a new invite.
235
+ </div>`
236
+ }
237
+ <div class="invite-details">
238
+ <div class="qr" dangerouslySetInnerHTML=${{ __html: invite.svg }}></div>
239
+ <div class="invite-code">
240
+ <div class="invite-code-head">
241
+ <span>Invite code</span>
242
+ <button
243
+ class="link small"
244
+ onClick=${() =>
245
+ copyText(invite.code).then(
246
+ () => notify("info", "Code copied."),
247
+ () => notify("error", "Could not copy."),
248
+ )}
249
+ >
250
+ Copy
251
+ </button>
252
+ </div>
253
+ <div
254
+ class="invite-code-value"
255
+ aria-label=${`Invite code ${invite.code.split("").join(" ")}`}
256
+ >
257
+ <span>${invite.code.slice(0, 5)}</span>
258
+ <span>${invite.code.slice(5)}</span>
259
+ </div>
260
+ <div class="muted small">
261
+ Or enter it on the other device's sign-in screen.
262
+ </div>
263
+ </div>
264
+ </div>
265
+ <div class="row">
266
+ <input
267
+ class="mono"
268
+ readonly
269
+ value=${invite.url}
270
+ onFocus=${(event) => event.currentTarget.select()}
271
+ />
272
+ <button
273
+ class="button"
274
+ onClick=${() =>
275
+ copyText(invite.url).then(
276
+ () => notify("info", "Link copied."),
277
+ () => notify("error", "Could not copy."),
278
+ )}
279
+ >
280
+ Copy
281
+ </button>
282
+ </div>
283
+ ${
284
+ invite.alternatives?.length > 0 &&
285
+ html`<p class="muted small">
286
+ Also reachable at: ${invite.alternatives.map(
287
+ (url) => html`<span class="mono">${url} </span>`,
288
+ )}
289
+ </p>`
290
+ }
291
+ </div>`
292
+ : failed
293
+ ? html`<p class="muted small">No invite yet. Tap New invite to try again.</p>`
294
+ : html`<${Loader} label="Making an invite" />`
295
+ }
296
+ <button class="button wide" onClick=${create}>New invite</button>
297
+ ${!collab() && html`<${PeopleList} />`}
298
+ <//>`;
299
+ }
300
+
301
+ function lastSeen(person) {
302
+ if (person.online) {
303
+ return "here now";
304
+ }
305
+
306
+ if (!person.lastSeen) {
307
+ return "not seen yet";
308
+ }
309
+
310
+ return `seen ${timeAgo(person.lastSeen)}${timeAgo(person.lastSeen) === "now" ? "" : " ago"}`;
311
+ }
312
+
313
+ /** Everyone with access: who is online, when the others were last here, and (for the owner) what each may do. */
314
+ function PeopleList() {
315
+ const { me, users, sessions } = store.state;
316
+ const owner = me?.role === "owner";
317
+ const sessionTitle = (id) => sessions.find((each) => each.id === id)?.title ?? `session ${id}`;
318
+ const ordered = [...users].sort(
319
+ (a, b) =>
320
+ Number(Boolean(b.online)) - Number(Boolean(a.online)) ||
321
+ (b.lastSeen ?? 0) - (a.lastSeen ?? 0),
322
+ );
323
+ const change = (person, patch) =>
324
+ attempt(async () => store.set({ users: await actions.setAccess(person.id, patch) }));
325
+
326
+ return html`<div class="group">
327
+ <div class="group-title">People</div>
328
+ <div class="sheet-list">
329
+ ${ordered.map(
330
+ (person) => html`<div class="person-row" key=${person.id}>
331
+ <span class="people-here">
332
+ <${Avatar} person=${person} size=${32} />
333
+ <span
334
+ class=${`online-mark ${person.online ? "" : "away"}`}
335
+ aria-hidden="true"
336
+ ></span>
337
+ </span>
338
+ <div class="person-main">
339
+ <div>
340
+ ${person.name}
341
+ ${person.id === me?.id ? html` <span class="muted small">(you)</span>` : ""}
342
+ </div>
343
+ <div class="muted small">
344
+ ${person.role === "owner" ? "owner" : person.role === "viewer" ? "view only" : "can steer"}
345
+ ${person.sessions ? ` · only ${person.sessions.map(sessionTitle).join(", ")}` : ""} · ${lastSeen(person)}
346
+ </div>
347
+ </div>
348
+ ${
349
+ owner &&
350
+ person.role !== "owner" &&
351
+ html`<div class="person-actions">
352
+ ${
353
+ collab() &&
354
+ html`<button
355
+ class="button small"
356
+ title="Change what they can do"
357
+ onClick=${() => change(person, { role: person.role === "viewer" ? "guest" : "viewer" })}
358
+ >
359
+ ${person.role === "viewer" ? "Let steer" : "View only"}
360
+ </button>`
361
+ }
362
+ ${
363
+ collab() &&
364
+ person.sessions &&
365
+ html`<button
366
+ class="button small"
367
+ title="Let them see every session"
368
+ onClick=${() => change(person, { sessions: null })}
369
+ >
370
+ All sessions
371
+ </button>`
372
+ }
373
+ <button
374
+ class="button small ghost"
375
+ onClick=${() =>
376
+ confirm(`Remove ${person.name}? Their devices are signed out.`) &&
377
+ attempt(async () => {
378
+ await api(`users/${person.id}/remove`, {});
379
+ store.set({
380
+ users: store.state.users.filter(
381
+ (each) => each.id !== person.id,
382
+ ),
383
+ });
384
+ })}
385
+ >
386
+ Remove
387
+ </button>
388
+ </div>`
389
+ }
390
+ </div>`,
391
+ )}
392
+ </div>
393
+ </div>`;
394
+ }
395
+
396
+ export function PeopleSheet() {
397
+ const canInvite = canSteer() && !scoped();
398
+
399
+ return html`<${Sheet} title="People" onClose=${closeSheet}>
400
+ ${
401
+ canInvite &&
402
+ html`<button class="button primary wide" onClick=${() => openSheet({ type: "invite" })}>
403
+ <${Icon} name="plus" size=${16} /> Invite someone
404
+ </button>`
405
+ }
406
+ <${PeopleList} />
407
+ <button
408
+ class="button ghost wide sheet-footer"
409
+ onClick=${() => openSheet({ type: "notifications" })}
410
+ >
411
+ Notifications on this device…
412
+ </button>
413
+ <//>`;
414
+ }
@@ -0,0 +1,264 @@
1
+ // Pi's sessions from the terminal, which the owner can continue here: the list, by folder, and one of them before it
2
+ // continues, as a short conversation.
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
4
+ import { actions, attempt, closeSheet, navigate, notify, openSheet, store } from "../store.js";
5
+ import { html, Icon, Loader, Sheet, shortPath, timeAgo } from "../ui.js";
6
+
7
+ /** Only the owner: Pi's sessions are the owner's files. */
8
+ export const piSessionsAvailable = () => store.state.me?.role === "owner";
9
+
10
+ const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
11
+
12
+ /** A folder's last part, and the folder it is in: `pi-pocket`, `~/code`. */
13
+ function folderParts(cwd, home) {
14
+ const path = shortPath(cwd, home);
15
+ const cut = path.replace(/\/+$/, "").lastIndexOf("/");
16
+
17
+ return cut <= 0
18
+ ? { name: path, parent: "" }
19
+ : { name: path.slice(cut + 1), parent: path.slice(0, cut) };
20
+ }
21
+
22
+ /** The sessions by folder, the folder used last first; each folder's newest first, as they come. */
23
+ function byFolder(sessions) {
24
+ const folders = new Map();
25
+
26
+ for (const session of sessions) {
27
+ folders.set(session.cwd, [...(folders.get(session.cwd) ?? []), session]);
28
+ }
29
+
30
+ return [...folders];
31
+ }
32
+
33
+ function SessionRow({ session }) {
34
+ const pocket = session.pocket;
35
+
36
+ return html`<button
37
+ class="pi-session"
38
+ onClick=${() => openSheet({ type: "pi-session", id: session.path })}
39
+ >
40
+ <span class="pi-session-text">
41
+ <span class="pi-session-title">${session.title}</span>
42
+ <span class="pi-session-meta">
43
+ ${plural(session.messages, "message")} · ${timeAgo(session.modified)}
44
+ </span>
45
+ </span>
46
+ ${
47
+ pocket &&
48
+ html`<span class=${`pi-tag ${pocket.behind ? "behind" : ""}`}>
49
+ ${pocket.behind ? "Pi went on" : "in Pocket"}
50
+ </span>`
51
+ }
52
+ <${Icon} name="chevron" size=${14} />
53
+ </button>`;
54
+ }
55
+
56
+ /** Pi's sessions on this computer, by folder, with a search the server runs through all of them, words included. */
57
+ export function PiSessionsSheet() {
58
+ const home = store.state.server?.home;
59
+ const [sessions, setSessions] = useState(null);
60
+ // Whether Pi has any at all, from the first list: a search that finds none is not "no sessions".
61
+ const [any, setAny] = useState(null);
62
+ const [problem, setProblem] = useState(null);
63
+ const [query, setQuery] = useState("");
64
+ const needle = query.trim();
65
+ const box = useRef(null);
66
+ const results = useRef(null);
67
+ // The list's height as it first showed: fewer results while searching keep the sheet that tall, so the box stays put.
68
+ const [floor, setFloor] = useState(0);
69
+
70
+ useEffect(() => {
71
+ let current = true;
72
+ // A pause in typing, then the search: the first list at once.
73
+ const timer = setTimeout(
74
+ () =>
75
+ actions.piSessions(needle).then(
76
+ (result) => {
77
+ if (current) {
78
+ setSessions(result.sessions);
79
+ setAny((was) => was ?? result.sessions.length > 0);
80
+ }
81
+ },
82
+ (error) => current && setProblem(error.message),
83
+ ),
84
+ needle === "" ? 0 : 250,
85
+ );
86
+
87
+ return () => {
88
+ current = false;
89
+ clearTimeout(timer);
90
+ };
91
+ }, [needle]);
92
+ const folders = byFolder(sessions ?? []);
93
+
94
+ useLayoutEffect(() => {
95
+ if (floor === 0 && needle === "" && sessions?.length > 0 && results.current) {
96
+ setFloor(results.current.offsetHeight);
97
+ }
98
+ }, [sessions]);
99
+ // Straight to the search where there are keys to type it with.
100
+ useEffect(() => {
101
+ if (any && !matchMedia("(pointer: coarse)").matches) {
102
+ box.current?.focus({ preventScroll: true });
103
+ }
104
+ }, [any]);
105
+
106
+ return html`<${Sheet} title="Continue a Pi session" onClose=${closeSheet}>
107
+ <p class="muted small">
108
+ Sessions of Pi in the terminal on this computer. One continues here as a new session; its file stays as it is.
109
+ </p>
110
+ ${problem && html`<div class="error-box small">${problem}</div>`}
111
+ ${sessions === null && problem === null && html`<${Loader} label="Finding Pi's sessions" />`}
112
+ ${any === false && html`<p class="muted">Pi has no sessions on this computer yet.</p>`}
113
+ ${
114
+ any === true &&
115
+ html`<input
116
+ class="find-input"
117
+ type="search"
118
+ ref=${box}
119
+ placeholder="Search sessions"
120
+ aria-label="Search Pi's sessions: titles, folders, and messages"
121
+ value=${query}
122
+ onInput=${(event) => setQuery(event.currentTarget.value)}
123
+ />`
124
+ }
125
+ <div
126
+ class="pi-results"
127
+ ref=${results}
128
+ style=${floor > 0 ? `min-height:${floor}px` : ""}
129
+ >
130
+ ${folders.map(([cwd, list]) => {
131
+ const { name, parent } = folderParts(cwd, home);
132
+
133
+ return html`<section class="pi-folder" key=${cwd}>
134
+ <div class="pi-folder-head" title=${cwd}>
135
+ <${Icon} name="folder" size=${14} />
136
+ <span class="pi-folder-name">${name}</span>
137
+ ${parent && html`<span class="pi-folder-parent">${parent}</span>`}
138
+ </div>
139
+ <div class="pi-folder-list">
140
+ ${list.map((session) => html`<${SessionRow} key=${session.path} session=${session} />`)}
141
+ </div>
142
+ </section>`;
143
+ })}
144
+ ${
145
+ any === true &&
146
+ needle !== "" &&
147
+ sessions?.length === 0 &&
148
+ html`<p class="muted">No session matches “${needle}”.</p>`
149
+ }
150
+ </div>
151
+ <//>`;
152
+ }
153
+
154
+ /** One of Pi's sessions: where it worked and with what, its last messages, and continuing it here. */
155
+ export function PiSessionSheet({ path }) {
156
+ const home = store.state.server?.home;
157
+ const [info, setInfo] = useState(null);
158
+ const [problem, setProblem] = useState(null);
159
+ const [busy, setBusy] = useState(false);
160
+ // Set at once, not at the next render: a second tap in the same moment must not make a second copy.
161
+ const going = useRef(false);
162
+
163
+ useEffect(() => {
164
+ actions.piSession(path).then(setInfo, (error) => setProblem(error.message));
165
+ }, [path]);
166
+
167
+ const open = (id) => {
168
+ closeSheet();
169
+ navigate(id);
170
+ };
171
+
172
+ const go = () => {
173
+ if (going.current) {
174
+ return;
175
+ }
176
+
177
+ going.current = true;
178
+ attempt(async () => {
179
+ setBusy(true);
180
+
181
+ try {
182
+ const { id } = await actions.continuePiSession(path);
183
+
184
+ open(id);
185
+ notify(
186
+ "info",
187
+ "Continued from Pi. Pi has the conversation as Pi left it; nothing it did runs again.",
188
+ );
189
+ } finally {
190
+ going.current = false;
191
+ setBusy(false);
192
+ }
193
+ });
194
+ };
195
+
196
+ if (info === null) {
197
+ return html`<${Sheet} title="Pi session" onClose=${closeSheet}>
198
+ ${
199
+ problem === null
200
+ ? html`<${Loader} label="Reading the session" />`
201
+ : html`<div class="error-box small">${problem}</div>`
202
+ }
203
+ <//>`;
204
+ }
205
+
206
+ return html`<${Sheet} title="Pi session" onClose=${closeSheet}>
207
+ <h3 class="pi-title">${info.title}</h3>
208
+ <dl class="pi-facts">
209
+ <div>
210
+ <dt>Folder</dt>
211
+ <dd class="mono">${shortPath(info.cwd, home)}</dd>
212
+ </div>
213
+ <div>
214
+ <dt>Model</dt>
215
+ <dd class="mono">
216
+ ${info.model ?? "—"}${info.model && !info.modelHere ? html`<span class="muted"> · not signed in here</span>` : ""}
217
+ </dd>
218
+ </div>
219
+ <div>
220
+ <dt>Messages</dt>
221
+ <dd>${info.messages}</dd>
222
+ </div>
223
+ </dl>
224
+ ${
225
+ !info.cwdExists &&
226
+ html`<div class="error-box small">
227
+ The folder it worked in is not there anymore, so it cannot continue here.
228
+ </div>`
229
+ }
230
+ ${
231
+ info.model &&
232
+ !info.modelHere &&
233
+ html`<p class="muted small">It continues with Pi Pocket's usual model.</p>`
234
+ }
235
+ ${
236
+ info.pocket &&
237
+ html`<div class="pi-note">
238
+ <span>
239
+ ${info.pocket.behind ? "Continued here before Pi went on in the terminal." : "Already continued here."}
240
+ </span>
241
+ <button class="link" onClick=${() => open(info.pocket.id)}>Open it</button>
242
+ </div>`
243
+ }
244
+ ${
245
+ info.last.length > 0 &&
246
+ html`<div class="pi-convo" aria-label="Its last messages">
247
+ <div class="label">Its last messages</div>
248
+ ${info.last.map((line) =>
249
+ line.role === "user"
250
+ ? html`<div class="pi-ask"><div>${line.text}</div></div>`
251
+ : html`<div class="pi-reply">${line.text}</div>`,
252
+ )}
253
+ </div>`
254
+ }
255
+ <div class="pi-continue">
256
+ <button class="button primary wide" disabled=${busy || !info.cwdExists} onClick=${go}>
257
+ ${info.pocket ? "Continue it here again" : "Continue in Pocket"}
258
+ </button>
259
+ <p class="muted small">
260
+ Pi gets the conversation as Pi left it, with Pi Pocket's instructions and tools. Nothing Pi did runs again, and the session's file stays as it is.
261
+ </p>
262
+ </div>
263
+ <//>`;
264
+ }