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,1097 @@
1
+ // The Browser panel: the session's page in the server's browser, the same one Pi uses with its browser tool. Frames
2
+ // come as JPEGs by long polling (which passes any tunnel); taps, drags, scrolls, and keys go back as input events. Wide
3
+ // screens show it as a window beside the conversation; phones show it full screen.
4
+
5
+ import { useEffect, useRef, useState } from "preact/hooks";
6
+ import { useBack } from "./back.js";
7
+ import {
8
+ browserApi,
9
+ browserAvailable,
10
+ displayUrl,
11
+ openInBrowser,
12
+ reportNavigation,
13
+ setBrowserOpen,
14
+ toggleBrowser,
15
+ } from "./browser.js";
16
+ import { api, attempt, canSteer, notify, panelsBeside, store } from "./store.js";
17
+ import { APPLE, html, Icon, Spinner } from "./ui.js";
18
+
19
+ const WIDTH_KEY = "pocket.browserWidth";
20
+
21
+ /** Sizes the size button steps through. Fit makes the page the panel's size, as a browser window is. */
22
+ const PRESETS = [
23
+ { id: "fit", icon: "fit", label: "Fit the panel" },
24
+ { id: "desktop", icon: "monitor", label: "Desktop 1280×800" },
25
+ { id: "mobile", icon: "phone", label: "Phone 390×844" },
26
+ { id: "tablet", icon: "tablet", label: "Tablet 820×1180" },
27
+ ];
28
+
29
+ /** The panel's own size as a viewport, pixel for pixel. */
30
+ function fitViewport(stage) {
31
+ const width = Math.round(stage.clientWidth);
32
+ const height = Math.round(stage.clientHeight);
33
+
34
+ if (width < 240 || height < 240) {
35
+ return undefined;
36
+ }
37
+
38
+ const touch = matchMedia("(pointer: coarse)").matches;
39
+
40
+ return {
41
+ width,
42
+ height,
43
+ scale: Math.min(2, Math.max(1, Math.round(devicePixelRatio * 4) / 4)),
44
+ mobile: touch && width < 820,
45
+ };
46
+ }
47
+
48
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
49
+
50
+ /** Whether this tab is in front: hidden tabs fetch no frames. */
51
+ function useVisible() {
52
+ const [visible, setVisible] = useState(document.visibilityState === "visible");
53
+
54
+ useEffect(() => {
55
+ const update = () => setVisible(document.visibilityState === "visible");
56
+
57
+ document.addEventListener("visibilitychange", update);
58
+
59
+ return () => document.removeEventListener("visibilitychange", update);
60
+ }, []);
61
+
62
+ return visible;
63
+ }
64
+
65
+ /** The page's newest frame, `{ src, width, height }` (CSS pixels), while `active`. Each answer asks for the next. */
66
+ function useFrames(conversationId, active) {
67
+ const [frame, setFrame] = useState(null);
68
+
69
+ useEffect(() => {
70
+ if (!active) {
71
+ return undefined;
72
+ }
73
+
74
+ let stopped = false;
75
+ let request = null;
76
+ let shown = null;
77
+ let seq = 0;
78
+
79
+ (async () => {
80
+ let failures = 0;
81
+
82
+ while (!stopped) {
83
+ request = new AbortController();
84
+
85
+ try {
86
+ const response = await fetch(
87
+ `/api/c/${conversationId}/browser/frame?after=${seq}`,
88
+ { signal: request.signal, cache: "no-store" },
89
+ );
90
+
91
+ if (stopped) {
92
+ return;
93
+ }
94
+
95
+ if (response.status === 204) {
96
+ // Closed pages have no frames: wait for one to open.
97
+ if (response.headers.get("x-closed")) {
98
+ await sleep(1000);
99
+ }
100
+
101
+ continue;
102
+ }
103
+
104
+ if (!response.ok) {
105
+ throw new Error(`HTTP ${response.status}`);
106
+ }
107
+
108
+ const blob = await response.blob();
109
+
110
+ seq = Number(response.headers.get("x-seq")) || seq;
111
+ const src = URL.createObjectURL(blob);
112
+ const image = new Image();
113
+
114
+ image.src = src;
115
+ // Decoded before it shows, so frames swap without a flash.
116
+ await image.decode().catch(() => {});
117
+
118
+ if (stopped) {
119
+ URL.revokeObjectURL(src);
120
+
121
+ return;
122
+ }
123
+
124
+ const previous = shown;
125
+
126
+ shown = src;
127
+ setFrame({
128
+ src,
129
+ width: Number(response.headers.get("x-width")) || image.naturalWidth,
130
+ height: Number(response.headers.get("x-height")) || image.naturalHeight,
131
+ });
132
+
133
+ if (previous) {
134
+ setTimeout(() => URL.revokeObjectURL(previous), 1000);
135
+ }
136
+
137
+ failures = 0;
138
+ } catch {
139
+ if (stopped) {
140
+ return;
141
+ }
142
+
143
+ failures++;
144
+ await sleep(Math.min(5000, 400 * failures));
145
+ }
146
+ }
147
+ })();
148
+
149
+ return () => {
150
+ stopped = true;
151
+ request?.abort();
152
+ const last = shown;
153
+
154
+ if (last) {
155
+ setTimeout(() => URL.revokeObjectURL(last), 1000);
156
+ }
157
+ };
158
+ }, [conversationId, active]);
159
+
160
+ return frame;
161
+ }
162
+
163
+ /**
164
+ * Input events go out in order, one request at a time; what piles up meanwhile goes in the next one, with moves and
165
+ * scrolls merged. An error shows once, not for every move.
166
+ */
167
+ function useSender(conversationId) {
168
+ const ref = useRef(null);
169
+
170
+ if (ref.current?.conversationId !== conversationId) {
171
+ let queue = [];
172
+ let busy = false;
173
+ let lastError = { message: "", at: 0 };
174
+
175
+ const flush = async () => {
176
+ if (busy || queue.length === 0) {
177
+ return;
178
+ }
179
+
180
+ busy = true;
181
+ const events = queue;
182
+
183
+ queue = [];
184
+
185
+ try {
186
+ await api(`c/${conversationId}/browser/input`, { events });
187
+ } catch (error) {
188
+ queue = [];
189
+
190
+ if (error.message !== lastError.message || Date.now() - lastError.at > 5000) {
191
+ notify("error", error.message);
192
+ }
193
+
194
+ lastError = { message: error.message, at: Date.now() };
195
+ }
196
+
197
+ busy = false;
198
+ flush();
199
+ };
200
+
201
+ const send = (event) => {
202
+ const last = queue.at(-1);
203
+
204
+ if (
205
+ last &&
206
+ event.type === "mouse" &&
207
+ event.action === "move" &&
208
+ last.type === "mouse" &&
209
+ last.action === "move" &&
210
+ Boolean(last.pressed) === Boolean(event.pressed)
211
+ ) {
212
+ queue[queue.length - 1] = event;
213
+ } else if (last && event.type === "wheel" && last.type === "wheel") {
214
+ last.dx += event.dx;
215
+ last.dy += event.dy;
216
+ } else {
217
+ queue.push(event);
218
+ }
219
+
220
+ flush();
221
+ };
222
+
223
+ ref.current = { conversationId, send };
224
+ }
225
+
226
+ return ref.current.send;
227
+ }
228
+
229
+ /** Keys the app keeps even while the page has the keyboard: the launcher, the sidebar, and Alt shortcuts. */
230
+ function appKey(event) {
231
+ const mod = event.ctrlKey || event.metaKey;
232
+
233
+ if (mod && !event.altKey && ["k", "b"].includes(event.key.toLowerCase())) {
234
+ return true;
235
+ }
236
+
237
+ return (
238
+ event.altKey &&
239
+ !event.ctrlKey &&
240
+ !event.metaKey &&
241
+ /^(Digit[1-9]|ArrowUp|ArrowDown|KeyN|KeyB|KeyA|KeyV)$/.test(event.code)
242
+ );
243
+ }
244
+
245
+ /** The page itself: a frame to look at, which takes taps, drags, the wheel, and (once focused) the keyboard. */
246
+ function Screen({ frame, interactive, send }) {
247
+ const ref = useRef(null);
248
+ const drag = useRef(null);
249
+ const lastDown = useRef({ at: 0, x: 0, y: 0, count: 0 });
250
+ const size = useRef(frame);
251
+
252
+ size.current = frame;
253
+
254
+ const toPage = (event) => {
255
+ const box = ref.current.getBoundingClientRect();
256
+ const { width, height } = size.current;
257
+
258
+ return {
259
+ x: Math.round(
260
+ Math.min(width, Math.max(0, ((event.clientX - box.left) / box.width) * width)),
261
+ ),
262
+ y: Math.round(
263
+ Math.min(height, Math.max(0, ((event.clientY - box.top) / box.height) * height)),
264
+ ),
265
+ };
266
+ };
267
+
268
+ const scale = () => size.current.width / ref.current.getBoundingClientRect().width;
269
+
270
+ // The wheel scrolls the page, not the app: a listener that may prevent the default.
271
+ useEffect(() => {
272
+ const element = ref.current;
273
+
274
+ if (!element || !interactive) {
275
+ return undefined;
276
+ }
277
+
278
+ const wheel = (event) => {
279
+ event.preventDefault();
280
+ const unit =
281
+ event.deltaMode === 1 ? 40 : event.deltaMode === 2 ? size.current.height : 1;
282
+
283
+ send({
284
+ type: "wheel",
285
+ ...toPage(event),
286
+ dx: event.deltaX * unit,
287
+ dy: event.deltaY * unit,
288
+ });
289
+ };
290
+
291
+ element.addEventListener("wheel", wheel, { passive: false });
292
+
293
+ return () => element.removeEventListener("wheel", wheel);
294
+ }, [interactive, send]);
295
+
296
+ if (!interactive) {
297
+ return html`<img
298
+ class="browser-screen"
299
+ src=${frame.src}
300
+ alt="The page"
301
+ draggable="false"
302
+ />`;
303
+ }
304
+
305
+ const down = (event) => {
306
+ ref.current.focus({ preventScroll: true });
307
+ const at = toPage(event);
308
+
309
+ ref.current.setPointerCapture?.(event.pointerId);
310
+
311
+ if (event.pointerType === "mouse") {
312
+ if (event.button !== 0) {
313
+ return;
314
+ }
315
+
316
+ event.preventDefault();
317
+ const previous = lastDown.current;
318
+ const count =
319
+ Date.now() - previous.at < 400 &&
320
+ Math.hypot(at.x - previous.x, at.y - previous.y) < 6
321
+ ? Math.min(previous.count + 1, 3)
322
+ : 1;
323
+
324
+ lastDown.current = { at: Date.now(), ...at, count };
325
+ drag.current = { mouse: true, count };
326
+ send({ type: "mouse", action: "down", ...at, count });
327
+
328
+ return;
329
+ }
330
+
331
+ drag.current = {
332
+ startX: event.clientX,
333
+ startY: event.clientY,
334
+ lastX: event.clientX,
335
+ lastY: event.clientY,
336
+ at,
337
+ scrolling: false,
338
+ };
339
+ };
340
+
341
+ const move = (event) => {
342
+ if (event.pointerType === "mouse") {
343
+ send({
344
+ type: "mouse",
345
+ action: "move",
346
+ ...toPage(event),
347
+ ...(drag.current?.mouse ? { pressed: true } : {}),
348
+ });
349
+
350
+ return;
351
+ }
352
+
353
+ const touch = drag.current;
354
+
355
+ if (!touch || touch.mouse) {
356
+ return;
357
+ }
358
+
359
+ if (
360
+ !touch.scrolling &&
361
+ Math.hypot(event.clientX - touch.startX, event.clientY - touch.startY) > 8
362
+ ) {
363
+ touch.scrolling = true;
364
+ }
365
+
366
+ if (!touch.scrolling) {
367
+ return;
368
+ }
369
+
370
+ const by = scale();
371
+
372
+ send({
373
+ type: "wheel",
374
+ ...touch.at,
375
+ dx: (touch.lastX - event.clientX) * by,
376
+ dy: (touch.lastY - event.clientY) * by,
377
+ });
378
+ touch.lastX = event.clientX;
379
+ touch.lastY = event.clientY;
380
+ };
381
+
382
+ const up = (event) => {
383
+ const held = drag.current;
384
+
385
+ drag.current = null;
386
+
387
+ if (!held) {
388
+ return;
389
+ }
390
+
391
+ if (held.mouse) {
392
+ send({ type: "mouse", action: "up", ...toPage(event), count: held.count });
393
+ } else if (!held.scrolling) {
394
+ send({ type: "click", ...held.at });
395
+ }
396
+ };
397
+
398
+ const key = (event) => {
399
+ if (event.isComposing || appKey(event)) {
400
+ return;
401
+ }
402
+
403
+ // Pasting is this device's clipboard: let the paste event come, and send its text.
404
+ if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === "v") {
405
+ return;
406
+ }
407
+
408
+ event.preventDefault();
409
+ event.stopPropagation();
410
+ // AltGr (Ctrl+Alt on Windows layouts) and Option on a Mac type characters such as @ and €: text, not shortcuts.
411
+ const option = APPLE && event.altKey && !event.ctrlKey && !event.metaKey;
412
+
413
+ if (
414
+ event.key.length === 1 &&
415
+ (option || event.getModifierState?.("AltGraph") || (event.ctrlKey && event.altKey))
416
+ ) {
417
+ send({ type: "text", text: event.key });
418
+
419
+ return;
420
+ }
421
+
422
+ const modifiers =
423
+ (event.altKey ? 1 : 0) |
424
+ (event.ctrlKey ? 2 : 0) |
425
+ (event.metaKey ? 4 : 0) |
426
+ (event.shiftKey ? 8 : 0);
427
+
428
+ send({ type: "key", key: event.key, modifiers });
429
+ };
430
+
431
+ const paste = (event) => {
432
+ const text = event.clipboardData?.getData("text") ?? "";
433
+
434
+ if (text === "") {
435
+ return;
436
+ }
437
+
438
+ event.preventDefault();
439
+ send({ type: "text", text });
440
+ };
441
+
442
+ return html`<img
443
+ ref=${ref}
444
+ class="browser-screen live"
445
+ src=${frame.src}
446
+ alt="The page. Click to use it; once focused, keys go to the page."
447
+ tabindex="0"
448
+ draggable="false"
449
+ onPointerDown=${down}
450
+ onPointerMove=${move}
451
+ onPointerUp=${up}
452
+ onPointerCancel=${() => (drag.current = null)}
453
+ onContextMenu=${(event) => event.preventDefault()}
454
+ onKeyDown=${key}
455
+ onPaste=${paste}
456
+ />`;
457
+ }
458
+
459
+ /**
460
+ * Text for the page on touch screens, whose keyboards only open for a text field of the app's own. It stays in the page
461
+ * while hidden: iOS opens the keyboard only for a field focused during the tap that shows it.
462
+ */
463
+ function TypeBar({ send, onClose, input, shown }) {
464
+ const submit = (event) => {
465
+ event.preventDefault();
466
+ const text = input.current.value;
467
+
468
+ if (text !== "") {
469
+ send({ type: "text", text });
470
+ }
471
+
472
+ input.current.value = "";
473
+ };
474
+
475
+ return html`<form
476
+ class=${`browser-type ${shown ? "" : "hidden"}`}
477
+ aria-hidden=${shown ? "false" : "true"}
478
+ onSubmit=${submit}
479
+ >
480
+ <input
481
+ ref=${input}
482
+ tabindex=${shown ? "0" : "-1"}
483
+ placeholder="Type into the page…"
484
+ autocapitalize="off"
485
+ autocomplete="off"
486
+ autocorrect="off"
487
+ spellcheck="false"
488
+ enterkeyhint="send"
489
+ />
490
+ <button type="submit" class="button small">Send</button>
491
+ <button
492
+ type="button"
493
+ class="key"
494
+ title="Enter"
495
+ onClick=${() => send({ type: "key", key: "Enter" })}
496
+ >
497
+ ⏎
498
+ </button>
499
+ <button
500
+ type="button"
501
+ class="key"
502
+ title="Backspace"
503
+ onClick=${() => send({ type: "key", key: "Backspace" })}
504
+ >
505
+ ⌫
506
+ </button>
507
+ <button
508
+ type="button"
509
+ class="key"
510
+ title="Tab"
511
+ onClick=${() => send({ type: "key", key: "Tab" })}
512
+ >
513
+ ⇥
514
+ </button>
515
+ <button
516
+ type="button"
517
+ class="icon-button small"
518
+ aria-label="Close the keyboard bar"
519
+ onClick=${onClose}
520
+ >
521
+ <${Icon} name="close" size=${14} />
522
+ </button>
523
+ </form>`;
524
+ }
525
+
526
+ /** The page's console: logs, errors, failed loads, and dialogs, with a line where each page began. */
527
+ function ConsolePane({ conversationId, logs, steer, onClose }) {
528
+ const [entries, setEntries] = useState(null);
529
+ const list = useRef(null);
530
+ const pending = useRef(null);
531
+ const mounted = useRef(true);
532
+
533
+ useEffect(
534
+ () => () => {
535
+ mounted.current = false;
536
+ clearTimeout(pending.current);
537
+ },
538
+ [],
539
+ );
540
+ // A page that logs all the time changes this often: fetch at most every 400 ms, never waiting for it to stop.
541
+ useEffect(() => {
542
+ if (pending.current) {
543
+ return;
544
+ }
545
+
546
+ pending.current = setTimeout(
547
+ () => {
548
+ pending.current = null;
549
+ api(`c/${conversationId}/browser/console`).then(
550
+ (data) => mounted.current && setEntries(data.entries),
551
+ () => mounted.current && setEntries([]),
552
+ );
553
+ },
554
+ entries === null ? 0 : 400,
555
+ );
556
+ }, [conversationId, logs]);
557
+ useEffect(() => {
558
+ const element = list.current;
559
+
560
+ if (element) {
561
+ element.scrollTop = element.scrollHeight;
562
+ }
563
+ }, [entries]);
564
+ const source = (text) => text?.replace(/^https?:\/\/[^/]+/, "");
565
+
566
+ return html`<section class="browser-console" aria-label="Console">
567
+ <header>
568
+ <strong>Console</strong>
569
+ <span class="muted small">
570
+ ${entries ? `${entries.filter((entry) => entry.level !== "nav").length} lines` : ""}
571
+ </span>
572
+ <span class="spacer"></span>
573
+ ${
574
+ steer &&
575
+ html`<button class="link" onClick=${() => attempt(() => browserApi("clear", {}))}>
576
+ Clear
577
+ </button>`
578
+ }
579
+ <button class="icon-button small" aria-label="Close the console" onClick=${onClose}>
580
+ <${Icon} name="close" size=${14} />
581
+ </button>
582
+ </header>
583
+ <div class="browser-console-lines mono" ref=${list}>
584
+ ${
585
+ entries === null
586
+ ? html`<div class="muted small">Loading…</div>`
587
+ : entries.length === 0
588
+ ? html`<div class="muted small">
589
+ Nothing yet. Logs, errors, and failed loads show here.
590
+ </div>`
591
+ : entries.map((entry) =>
592
+ entry.level === "nav"
593
+ ? html`<div key=${entry.seq} class="console-nav">
594
+ ${displayUrl(entry.text) || entry.text}
595
+ </div>`
596
+ : html`<div key=${entry.seq} class=${`console-line ${entry.level}`}>
597
+ <span class="console-text">${entry.text}</span>
598
+ ${
599
+ entry.source &&
600
+ html`<span class="console-source">
601
+ ${source(entry.source)}
602
+ </span>`
603
+ }
604
+ </div>`,
605
+ )
606
+ }
607
+ </div>
608
+ </section>`;
609
+ }
610
+
611
+ /** What shows on a blank page: where to go, with the web servers running on this machine. */
612
+ function StartScreen({ steer }) {
613
+ const [servers, setServers] = useState(null);
614
+ const owner = store.state.me?.role === "owner";
615
+
616
+ useEffect(() => {
617
+ if (!owner) {
618
+ return;
619
+ }
620
+
621
+ browserApi("servers").then(
622
+ (data) => setServers(data.servers),
623
+ () => setServers([]),
624
+ );
625
+ }, []);
626
+
627
+ return html`<div class="browser-start">
628
+ <h2>Browser</h2>
629
+ <p>
630
+ ${steer ? "Type an address above, such as localhost:5173, or ask Pi to open one." : "Nothing is open yet. Pi, or someone who can steer, opens pages here."}
631
+ </p>
632
+ ${owner && servers === null && html`<${Spinner} label="Looking for servers" />`}
633
+ ${
634
+ servers?.length > 0 &&
635
+ html`<div class="browser-servers">
636
+ <div class="browser-servers-label">Running on this machine</div>
637
+ <div class="browser-server-list">
638
+ ${servers.slice(0, 8).map(
639
+ (server) => html`<button
640
+ key=${server.port}
641
+ class="browser-server"
642
+ onClick=${() => openInBrowser(server.url)}
643
+ >
644
+ <span class="browser-live" aria-hidden="true"></span>
645
+ <span class="mono">localhost:${server.port}</span>
646
+ ${server.title && html`<span class="browser-server-title">${server.title}</span>`}
647
+ </button>`,
648
+ )}
649
+ </div>
650
+ </div>`
651
+ }
652
+ </div>`;
653
+ }
654
+
655
+ /** Drag the panel's left edge to resize it on wide screens; double-click goes back to half. */
656
+ function ResizeEdge() {
657
+ const start = (event) => {
658
+ if (event.button !== 0) {
659
+ return;
660
+ }
661
+
662
+ event.preventDefault();
663
+ const root = document.documentElement;
664
+ const right = event.currentTarget.parentElement.getBoundingClientRect().right;
665
+ let width = 0;
666
+
667
+ root.classList.add("resizing");
668
+
669
+ const move = (each) => {
670
+ width = Math.round(Math.min(innerWidth - 420, Math.max(360, right - each.clientX)));
671
+ root.style.setProperty("--browser-w", `${width}px`);
672
+ };
673
+
674
+ const stop = () => {
675
+ root.classList.remove("resizing");
676
+ removeEventListener("pointermove", move);
677
+ removeEventListener("pointerup", stop);
678
+ removeEventListener("pointercancel", stop);
679
+
680
+ if (width > 0) {
681
+ localStorage.setItem(WIDTH_KEY, String(width));
682
+ }
683
+ };
684
+
685
+ addEventListener("pointermove", move);
686
+ addEventListener("pointerup", stop);
687
+ addEventListener("pointercancel", stop);
688
+ };
689
+
690
+ const reset = () => {
691
+ localStorage.removeItem(WIDTH_KEY);
692
+ document.documentElement.style.removeProperty("--browser-w");
693
+ };
694
+
695
+ return html`<div
696
+ class="resize-handle browser-resize"
697
+ role="separator"
698
+ aria-orientation="vertical"
699
+ title="Drag to resize"
700
+ onPointerDown=${start}
701
+ onDblClick=${reset}
702
+ ></div>`;
703
+ }
704
+
705
+ const savedWidth = Number(localStorage.getItem(WIDTH_KEY));
706
+
707
+ if (savedWidth > 0) {
708
+ document.documentElement.style.setProperty("--browser-w", `${savedWidth}px`);
709
+ }
710
+
711
+ /** The page may close again after it opens (a crash, a long while unused): open it by itself at most this often. */
712
+ const REOPEN_MS = 2 * 60_000;
713
+
714
+ export function BrowserPanel({ leaving = false }) {
715
+ const { conversationId, browser: state, me, view } = store.state;
716
+ // Using the page takes what steering Pi takes: while take turns is on, the wheel.
717
+ const turns = view.turns;
718
+ const steer = canSteer() && (!turns?.on || turns.driver === me?.id);
719
+ const visible = useVisible();
720
+ const open = state?.open === true;
721
+ const frame = useFrames(conversationId, open && visible);
722
+ const send = useSender(conversationId);
723
+ const [address, setAddress] = useState(null);
724
+ const [showConsole, setShowConsole] = useState(false);
725
+ const [typing, setTyping] = useState(false);
726
+ const [problem, setProblem] = useState(null);
727
+ const [tries, setTries] = useState(0);
728
+ const opened = useRef(0);
729
+ const wasOpen = useRef(false);
730
+ const stageRef = useRef(null);
731
+ const typeInput = useRef(null);
732
+ /** The size this tab last fitted the page to: the page follows the panel only while it still has it. */
733
+ const fittedHere = useRef(null);
734
+ const beside = panelsBeside();
735
+
736
+ // Over the conversation, back closes the panel.
737
+ useBack(!beside && !leaving, () => setBrowserOpen(false));
738
+
739
+ if (open) {
740
+ wasOpen.current = true;
741
+ }
742
+
743
+ // Open the page when the panel shows in a tab in front, and again after it closed (a restart), but not over and over:
744
+ // a page that closes again soon after waits for a tap.
745
+ const waiting = !open && wasOpen.current && Date.now() - opened.current < REOPEN_MS;
746
+
747
+ useEffect(() => {
748
+ if (!state || open || !steer || !state.available || !visible) {
749
+ return;
750
+ }
751
+
752
+ if (Date.now() - opened.current < REOPEN_MS) {
753
+ return;
754
+ }
755
+
756
+ opened.current = Date.now();
757
+ setProblem(null);
758
+ // The size of a page that has none yet: a phone's, or the panel's.
759
+ const phone = matchMedia("(max-width: 700px)").matches;
760
+ const viewport = phone ? "mobile" : stageRef.current && fitViewport(stageRef.current);
761
+
762
+ fittedHere.current = phone ? null : viewport;
763
+
764
+ browserApi("open", viewport ? { viewport } : {}).catch((error) =>
765
+ setProblem(error.message),
766
+ );
767
+ }, [conversationId, open, state?.available, steer, visible, tries]);
768
+
769
+ const retry = () => {
770
+ opened.current = 0;
771
+ wasOpen.current = false;
772
+ setProblem(null);
773
+ store.set({ browser: { ...state, problem: undefined } });
774
+ setTries(tries + 1);
775
+ };
776
+
777
+ const go = (event) => {
778
+ event.preventDefault();
779
+ const url = (address ?? "").trim();
780
+
781
+ if (url === "") {
782
+ return;
783
+ }
784
+
785
+ setAddress(null);
786
+ event.currentTarget.querySelector("input")?.blur();
787
+ attempt(() => browserApi("navigate", { url }).then(reportNavigation));
788
+ };
789
+
790
+ const run = (action, body = {}) => attempt(() => browserApi(action, body));
791
+ // A size that is no preset is a fitted (or Pi's own) one.
792
+ const preset = PRESETS.find((each) => each.id === (state?.preset ?? "fit"));
793
+ const nextPreset = PRESETS[(PRESETS.indexOf(preset) + 1) % PRESETS.length];
794
+
795
+ const resize = () => {
796
+ const viewport =
797
+ nextPreset.id === "fit"
798
+ ? stageRef.current && fitViewport(stageRef.current)
799
+ : nextPreset.id;
800
+
801
+ fittedHere.current = nextPreset.id === "fit" ? viewport : null;
802
+
803
+ if (viewport) {
804
+ run("viewport", { viewport });
805
+ }
806
+ };
807
+
808
+ // Fitted here, the page follows the panel's size (a dragged edge, a folded sidebar, a resized window) once it
809
+ // settles; not a size Pi or another tab chose since, which reads as "fit" too.
810
+ const fitted = open && steer && preset.id === "fit";
811
+ const shown = useRef(state?.viewport);
812
+
813
+ shown.current = state?.viewport;
814
+
815
+ useEffect(() => {
816
+ const element = stageRef.current;
817
+
818
+ if (!fitted || !element) {
819
+ return;
820
+ }
821
+
822
+ let timer = 0;
823
+ let size = `${element.clientWidth}×${element.clientHeight}`;
824
+ const observer = new ResizeObserver(() => {
825
+ const now = `${element.clientWidth}×${element.clientHeight}`;
826
+
827
+ if (now === size) {
828
+ return;
829
+ }
830
+
831
+ size = now;
832
+ clearTimeout(timer);
833
+ timer = setTimeout(() => {
834
+ const viewport = fitViewport(element);
835
+ const { width, height } = shown.current ?? {};
836
+ const mine = fittedHere.current;
837
+
838
+ if (
839
+ viewport &&
840
+ mine?.width === width &&
841
+ mine?.height === height &&
842
+ (viewport.width !== width || viewport.height !== height)
843
+ ) {
844
+ fittedHere.current = viewport;
845
+ run("viewport", { viewport });
846
+ }
847
+ }, 300);
848
+ });
849
+
850
+ observer.observe(element);
851
+
852
+ return () => {
853
+ clearTimeout(timer);
854
+ observer.disconnect();
855
+ };
856
+ }, [fitted, conversationId]);
857
+
858
+ const blank = open && (state.url === "" || state.url === "about:blank");
859
+ const touch = matchMedia("(pointer: coarse)").matches;
860
+ const why = problem ?? state?.problem;
861
+
862
+ let stage;
863
+
864
+ if (!state) {
865
+ stage = html`<div class="browser-note"><${Spinner} /></div>`;
866
+ } else if (!state.available) {
867
+ stage = html`<div class="browser-note">
868
+ <${Icon} name="globe" size=${28} />
869
+ <p>
870
+ No browser was found on the machine Pi Pocket runs on. Install Chromium or Google Chrome there (or set <code>PI_POCKET_BROWSER</code> to a browser's path), then restart Pi Pocket.
871
+ </p>
872
+ </div>`;
873
+ } else if (!open && why) {
874
+ stage = html`<div class="browser-note">
875
+ <p>${why}</p>
876
+ ${steer && html`<button class="button small" onClick=${retry}>Try again</button>`}
877
+ </div>`;
878
+ } else if (!open && !steer) {
879
+ stage = html`<div class="browser-note"><${StartScreen} steer=${false} /></div>`;
880
+ } else if (waiting) {
881
+ stage = html`<div class="browser-note">
882
+ <p>The page closed.</p>
883
+ <button class="button small" onClick=${retry}>Open it again</button>
884
+ </div>`;
885
+ } else if (!open || !frame) {
886
+ stage = html`<div class="browser-note">
887
+ <${Spinner} label="Starting the browser" />
888
+ <span class="muted small">Starting the browser…</span>
889
+ </div>`;
890
+ } else {
891
+ stage = html`<div class="browser-frame">
892
+ <${Screen} frame=${frame} interactive=${steer} send=${send} />
893
+ ${blank && html`<${StartScreen} steer=${steer} />`}
894
+ ${
895
+ state.problem &&
896
+ html`<div class="browser-crashed">
897
+ <span>${state.problem}</span>
898
+ ${
899
+ steer &&
900
+ html`<button class="button small" onClick=${() => run("reload")}>
901
+ Reload
902
+ </button>`
903
+ }
904
+ </div>`
905
+ }
906
+ </div>`;
907
+ }
908
+
909
+ return html`<section
910
+ class=${`browser window ${leaving ? "leaving" : ""}`}
911
+ data-part="browser-panel"
912
+ data-state=${leaving ? "closing" : undefined}
913
+ role=${beside ? undefined : "dialog"}
914
+ aria-modal=${beside ? undefined : "true"}
915
+ aria-label="Browser"
916
+ tabindex="-1"
917
+ inert=${leaving}
918
+ >
919
+ <${ResizeEdge} />
920
+ <header class="browser-bar">
921
+ <button
922
+ class="icon-button"
923
+ aria-label="Back"
924
+ title="Back"
925
+ disabled=${!steer || !state?.canGoBack}
926
+ onClick=${() => run("back")}
927
+ >
928
+ <${Icon} name="back" size=${18} />
929
+ </button>
930
+ <button
931
+ class="icon-button browser-forward"
932
+ aria-label="Forward"
933
+ title="Forward"
934
+ disabled=${!steer || !state?.canGoForward}
935
+ onClick=${() => run("forward")}
936
+ >
937
+ <${Icon} name="chevron" size=${18} />
938
+ </button>
939
+ ${
940
+ state?.loading && open
941
+ ? html`<button
942
+ class="icon-button"
943
+ aria-label="Stop loading"
944
+ title="Stop"
945
+ disabled=${!steer}
946
+ onClick=${() => run("stop")}
947
+ >
948
+ <${Icon} name="close" size=${18} />
949
+ </button>`
950
+ : html`<button
951
+ class="icon-button"
952
+ aria-label="Reload"
953
+ title="Reload"
954
+ disabled=${!steer || !open}
955
+ onClick=${() => run("reload")}
956
+ >
957
+ <${Icon} name="reload" size=${18} />
958
+ </button>`
959
+ }
960
+ <form class="browser-address" onSubmit=${go}>
961
+ ${open && !blank && html`<span class="browser-live" aria-hidden="true"></span>`}
962
+ <input
963
+ value=${address ?? displayUrl(state?.url)}
964
+ placeholder=${steer ? "Address, such as localhost:5173" : ""}
965
+ title=${state?.title || state?.url || ""}
966
+ readonly=${!steer}
967
+ autocapitalize="off"
968
+ autocomplete="off"
969
+ autocorrect="off"
970
+ spellcheck="false"
971
+ enterkeyhint="go"
972
+ inputmode="url"
973
+ onFocus=${(event) => {
974
+ setAddress(event.currentTarget.value);
975
+ event.currentTarget.select();
976
+ }}
977
+ onInput=${(event) => setAddress(event.currentTarget.value)}
978
+ onBlur=${() => setTimeout(() => setAddress(null), 150)}
979
+ onKeyDown=${(event) => {
980
+ if (event.key === "Escape") {
981
+ event.preventDefault();
982
+ event.currentTarget.blur();
983
+ }
984
+ }}
985
+ />
986
+ </form>
987
+ <button
988
+ class="icon-button"
989
+ aria-label=${`Size: ${preset.id === "fit" ? `${state?.viewport?.width ?? ""}×${state?.viewport?.height ?? ""}` : preset.label}. Switch to ${nextPreset.label}`}
990
+ title=${`${preset.id === "fit" ? `${state?.viewport?.width}×${state?.viewport?.height}` : preset.label} · next: ${nextPreset.label}`}
991
+ disabled=${!steer || !open}
992
+ onClick=${resize}
993
+ >
994
+ <${Icon} name=${preset.icon} size=${18} />
995
+ </button>
996
+ <button
997
+ class=${`icon-button badge-host ${showConsole ? "on" : ""}`}
998
+ aria-label="Console"
999
+ title="Console"
1000
+ onClick=${() => setShowConsole(!showConsole)}
1001
+ >
1002
+ <${Icon} name="terminal" size=${18} />
1003
+ ${
1004
+ state?.errors > 0 &&
1005
+ html`<span class="badge err">${state.errors > 99 ? "99+" : state.errors}</span>`
1006
+ }
1007
+ </button>
1008
+ ${
1009
+ steer &&
1010
+ touch &&
1011
+ open &&
1012
+ html`<button
1013
+ class=${`icon-button ${typing ? "on" : ""}`}
1014
+ aria-label="Type into the page"
1015
+ title="Type into the page"
1016
+ onClick=${() => {
1017
+ setTyping(!typing);
1018
+
1019
+ // Focused in the tap itself, so the phone's keyboard opens; let go of, so it closes.
1020
+ if (typing) {
1021
+ typeInput.current?.blur();
1022
+ } else {
1023
+ typeInput.current?.focus();
1024
+ }
1025
+ }}
1026
+ >
1027
+ <${Icon} name="keyboard" size=${18} />
1028
+ </button>`
1029
+ }
1030
+ <button
1031
+ class="icon-button browser-close"
1032
+ aria-label="Close the browser"
1033
+ title="Close the browser (Alt+B)"
1034
+ onClick=${() => setBrowserOpen(false)}
1035
+ >
1036
+ <${Icon} name="close" size=${18} />
1037
+ </button>
1038
+ </header>
1039
+ <div class=${`browser-progress ${state?.loading ? "on" : ""}`}></div>
1040
+ <div class="browser-stage" ref=${stageRef}>${stage}</div>
1041
+ ${
1042
+ steer &&
1043
+ touch &&
1044
+ open &&
1045
+ html`<${TypeBar}
1046
+ send=${send}
1047
+ input=${typeInput}
1048
+ shown=${typing}
1049
+ onClose=${() => {
1050
+ typeInput.current?.blur();
1051
+ setTyping(false);
1052
+ }}
1053
+ />`
1054
+ }
1055
+ ${
1056
+ showConsole &&
1057
+ html`<${ConsolePane}
1058
+ conversationId=${conversationId}
1059
+ logs=${state?.logs ?? 0}
1060
+ steer=${steer}
1061
+ onClose=${() => setShowConsole(false)}
1062
+ />`
1063
+ }
1064
+ ${
1065
+ me &&
1066
+ !steer &&
1067
+ open &&
1068
+ html`<div class="browser-foot muted small">
1069
+ ${canSteer() ? "Take turns is on: the driver uses the page." : "You can watch; people who can steer use the page."}
1070
+ </div>`
1071
+ }
1072
+ </section>`;
1073
+ }
1074
+
1075
+ /** The top bar's button: shows and hides the panel, marked while a page is open. */
1076
+ export function BrowserButton() {
1077
+ const { browser: state, browserOpen } = store.state;
1078
+
1079
+ if (!browserAvailable()) {
1080
+ return null;
1081
+ }
1082
+
1083
+ const live = state?.open && state.url !== "" && state.url !== "about:blank";
1084
+
1085
+ // Quiet with no page open: the menu has the browser. On a phone a page open is a dot on the places button (by the
1086
+ // message box) instead, so the top bar keeps room.
1087
+ return html`<button
1088
+ class=${`icon-button badge-host ${browserOpen ? "on" : live ? "quiet-phone" : "quiet"}`}
1089
+ aria-label="Browser"
1090
+ aria-pressed=${browserOpen}
1091
+ title="Browser (Alt+B)"
1092
+ onClick=${toggleBrowser}
1093
+ >
1094
+ <${Icon} name="globe" />
1095
+ ${live && html`<span class="browser-dot" aria-hidden="true"></span>`}
1096
+ </button>`;
1097
+ }