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,687 @@
1
+ // What an artifact's page may ask of the app, through `parent.postMessage`: put a follow-up in the person's message
2
+ // box, for them to review and send (#88); and load or save a small JSON value of its own, for that person (#89). The
3
+ // page keeps its sandbox: it never gets the app, its cookies, or a way to make Pi act. Only the frame's first page is
4
+ // heard (over a channel it opens as it starts), only while its session shows, and the app's rights decide, not the page.
5
+ //
6
+ // Design mode (`docs/design/design-mode.md`) adds a line the other way, for the workspace: `send` puts the app's
7
+ // messages to a frame (inspect, text edit, tweaks, pins, goto), and `onMessage` hears what the frame's inspector says
8
+ // (a pick, a text edit, the tweaks manifest, a deck's slides). The page's own script cannot send those four: they come
9
+ // from the inspector (`src/server/http/artifact-inspect.ts`) down the line, and the app checks their shape and length
10
+ // (`checkPageMessage`) before it hands them on as data.
11
+ import { useLayoutEffect, useRef } from "preact/hooks";
12
+ import { api, canSteer, insertIntoComposer, notify, store } from "./store.js";
13
+
14
+ /** The most a follow-up's text and its selection (as JSON) may take, and how often a frame may send one. */
15
+ const TEXT_MAX = 2000;
16
+ const SELECTION_MAX = 2000;
17
+ const FOLLOW_UP_MS = 2000;
18
+ const REQUEST_ID = /^[\w.:-]{1,64}$/;
19
+
20
+ /**
21
+ * A follow-up as it goes into the box: what it is from first, so the box never takes it for a command (`!`, `/`), then
22
+ * the page's words, then its selection as data.
23
+ */
24
+ export function followUpText(text, selection, source) {
25
+ const from = `From ${source}:`;
26
+
27
+ if (selection === undefined) {
28
+ return `${from}\n${text}`;
29
+ }
30
+
31
+ // A fence longer than any run of backticks in the selection: nothing in it can end the fence early.
32
+ const json = JSON.stringify(selection, null, 2);
33
+ const fence = "`".repeat(
34
+ Math.max(3, ...(json.match(/`+/g) ?? []).map((run) => run.length + 1)),
35
+ );
36
+
37
+ return `${from}\n${text}\n\n${fence}json\n${json}\n${fence}`;
38
+ }
39
+
40
+ /** Why a follow-up cannot go in, or null when it can: its text and selection, checked. */
41
+ export function followUpProblem(data) {
42
+ if (typeof data.text !== "string" || data.text.trim() === "") {
43
+ return "A follow-up needs text";
44
+ }
45
+
46
+ if (data.text.length > TEXT_MAX) {
47
+ return `A follow-up's text is up to ${TEXT_MAX} characters`;
48
+ }
49
+
50
+ if (data.selection !== undefined) {
51
+ let json;
52
+
53
+ try {
54
+ json = JSON.stringify(data.selection);
55
+ } catch {
56
+ return "A selection is JSON";
57
+ }
58
+
59
+ if (json === undefined || json.length > SELECTION_MAX) {
60
+ return `A selection is JSON, up to ${SELECTION_MAX} characters`;
61
+ }
62
+ }
63
+
64
+ return null;
65
+ }
66
+
67
+ /** How many pages each frame has loaded: only a frame that has not loaded one yet may still say hello. */
68
+ const loads = new WeakMap();
69
+
70
+ // Loads do not bubble, but they pass through the document on their way down to each frame.
71
+ addEventListener(
72
+ "load",
73
+ (event) => {
74
+ if (event.target instanceof HTMLIFrameElement) {
75
+ loads.set(event.target, (loads.get(event.target) ?? 0) + 1);
76
+ }
77
+ },
78
+ true,
79
+ );
80
+
81
+ /** The pages using the bridge now: `{ ref, handle(frame, data), ready(frame) }`. */
82
+ const hooks = new Set();
83
+ /** The line to each frame's first page: a `MessagePort` that page gave us when it started. */
84
+ const ports = new WeakMap();
85
+ /** What the app said to a frame before its page opened the line: the newest of each kind, sent once it does. */
86
+ const waiting = new WeakMap();
87
+
88
+ const hookOf = (frame) => [...hooks].find((hook) => hook.ref.current === frame);
89
+
90
+ // ─── Design mode: the messages, and their checks ─────────────────────────────────────────────────────────────────────
91
+
92
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
93
+ const isText = (value, max) => typeof value === "string" && value.length <= max;
94
+ const isCount = (value, max) => Number.isInteger(value) && value >= 0 && value <= max;
95
+ const isNumber = (value) =>
96
+ typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= 1e7;
97
+ const clip = (value, max) => (typeof value === "string" ? value.slice(0, max) : undefined);
98
+
99
+ /** The most a tweak, a pin, and the like may take, as the page side keeps to them (`artifact-inspect.ts`). */
100
+ export const LIMITS = {
101
+ selector: 500,
102
+ pid: 200,
103
+ text: 200,
104
+ html: 1000,
105
+ style: 200,
106
+ edit: 2000,
107
+ knobs: 40,
108
+ options: 50,
109
+ tweakValues: 100,
110
+ pins: 100,
111
+ slides: 1000,
112
+ };
113
+ const STYLE_KEYS = [
114
+ "color",
115
+ "background-color",
116
+ "font-family",
117
+ "font-size",
118
+ "font-weight",
119
+ "line-height",
120
+ "padding",
121
+ "margin",
122
+ "border-radius",
123
+ "gap",
124
+ ];
125
+ const KNOB_TYPES = ["range", "color", "select", "toggle", "text"];
126
+ const TAG = /^[a-z][a-z0-9-]{0,39}$/i;
127
+ const CSS_VARIABLE = /^--[A-Za-z0-9_-]{1,78}$/;
128
+
129
+ /**
130
+ * A page's `Target`, rebuilt from the fields it may have: `{ pid?, selector, tag, text, html, rect, styles }`. Text and
131
+ * html are cut to their limits; a selector that is too long or a field of the wrong kind makes it null.
132
+ */
133
+ export function checkTarget(raw) {
134
+ if (
135
+ !isRecord(raw) ||
136
+ typeof raw.selector !== "string" ||
137
+ raw.selector === "" ||
138
+ raw.selector.length > LIMITS.selector ||
139
+ typeof raw.tag !== "string" ||
140
+ !TAG.test(raw.tag) ||
141
+ typeof raw.text !== "string" ||
142
+ typeof raw.html !== "string" ||
143
+ !isRecord(raw.rect) ||
144
+ !["x", "y", "width", "height"].every((key) => isNumber(raw.rect[key]))
145
+ ) {
146
+ return null;
147
+ }
148
+
149
+ const styles = {};
150
+
151
+ for (const key of STYLE_KEYS) {
152
+ if (isRecord(raw.styles) && typeof raw.styles[key] === "string") {
153
+ styles[key] = clip(raw.styles[key], LIMITS.style);
154
+ }
155
+ }
156
+
157
+ const target = {
158
+ selector: raw.selector,
159
+ tag: raw.tag.toLowerCase(),
160
+ text: clip(raw.text, LIMITS.text),
161
+ html: clip(raw.html, LIMITS.html),
162
+ rect: { x: raw.rect.x, y: raw.rect.y, width: raw.rect.width, height: raw.rect.height },
163
+ styles,
164
+ };
165
+
166
+ if (typeof raw.pid === "string" && raw.pid !== "") {
167
+ target.pid = clip(raw.pid, LIMITS.pid);
168
+ }
169
+
170
+ return target;
171
+ }
172
+
173
+ /** A knob of the page's tweaks manifest, or null: `{ name, label?, type, value, min?, max?, step?, unit?, options? }`. */
174
+ function checkKnob(raw) {
175
+ if (
176
+ !isRecord(raw) ||
177
+ !isText(raw.name, 80) ||
178
+ raw.name === "" ||
179
+ !KNOB_TYPES.includes(raw.type) ||
180
+ !(isText(raw.value, LIMITS.style) || typeof raw.value === "boolean" || isNumber(raw.value))
181
+ ) {
182
+ return null;
183
+ }
184
+
185
+ const knob = { name: raw.name, type: raw.type, value: raw.value };
186
+
187
+ if (isText(raw.label, 80)) {
188
+ knob.label = raw.label;
189
+ }
190
+
191
+ for (const key of ["min", "max", "step"]) {
192
+ if (isNumber(raw[key])) {
193
+ knob[key] = raw[key];
194
+ }
195
+ }
196
+
197
+ if (isText(raw.unit, 10)) {
198
+ knob.unit = raw.unit;
199
+ }
200
+
201
+ if (Array.isArray(raw.options)) {
202
+ knob.options = raw.options.filter((option) => isText(option, 80)).slice(0, LIMITS.options);
203
+ }
204
+
205
+ return knob;
206
+ }
207
+
208
+ /**
209
+ * What the frame's inspector said, as clean data the app may use, or null when it is not one of the messages or
210
+ * has the wrong shape. A new object is built from the known fields only, whatever else the message had.
211
+ */
212
+ export function checkPageMessage(data) {
213
+ if (!isRecord(data)) {
214
+ return null;
215
+ }
216
+
217
+ switch (data.type) {
218
+ case "pocket-picked": {
219
+ const target = checkTarget(data.target);
220
+
221
+ return target && { type: data.type, target };
222
+ }
223
+
224
+ case "pocket-text-edited": {
225
+ const target = checkTarget(data.target);
226
+
227
+ return target && isText(data.before, LIMITS.edit) && isText(data.after, LIMITS.edit)
228
+ ? { type: data.type, target, before: data.before, after: data.after }
229
+ : null;
230
+ }
231
+
232
+ case "pocket-tweaks": {
233
+ if (!Array.isArray(data.knobs)) {
234
+ return null;
235
+ }
236
+
237
+ const seen = new Set();
238
+ const knobs = data.knobs
239
+ .slice(0, LIMITS.knobs)
240
+ .map(checkKnob)
241
+ .filter((knob) => {
242
+ if (knob === null || seen.has(knob.name)) {
243
+ return false;
244
+ }
245
+
246
+ seen.add(knob.name);
247
+
248
+ return true;
249
+ });
250
+
251
+ return { type: data.type, knobs };
252
+ }
253
+
254
+ case "pocket-pin": {
255
+ const rect = data.rect;
256
+ const finite = (value) =>
257
+ typeof value === "number" && Number.isFinite(value) && Math.abs(value) < 1e6;
258
+
259
+ if (!isText(data.label, 8)) {
260
+ return null;
261
+ }
262
+
263
+ if (isRecord(rect)) {
264
+ return finite(rect.x) && finite(rect.y) && finite(rect.width) && finite(rect.height)
265
+ ? {
266
+ type: data.type,
267
+ label: data.label,
268
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
269
+ }
270
+ : null;
271
+ }
272
+
273
+ return data.tap === true ? { type: data.type, label: data.label, tap: true } : null;
274
+ }
275
+
276
+ case "pocket-size":
277
+ // 0: the page was too big to measure.
278
+ return isCount(data.height, 100_000) ? { type: data.type, height: data.height } : null;
279
+
280
+ case "pocket-deck":
281
+ return isCount(data.slides, LIMITS.slides) &&
282
+ isCount(data.current, Math.max(data.slides - 1, 0))
283
+ ? { type: data.type, slides: data.slides, current: data.current }
284
+ : null;
285
+
286
+ default:
287
+ return null;
288
+ }
289
+ }
290
+
291
+ /** The one reason a message to a page is refused, or a clean copy of it: `{ message }` or `{ error }`. */
292
+ export function checkAppMessage(raw) {
293
+ if (!isRecord(raw)) {
294
+ return { error: "A message is an object" };
295
+ }
296
+
297
+ switch (raw.type) {
298
+ case "pocket-inspect":
299
+ case "pocket-edit-text":
300
+ return typeof raw.on === "boolean"
301
+ ? { message: { type: raw.type, on: raw.on } }
302
+ : { error: "`on` is true or false" };
303
+
304
+ case "pocket-tweak-set": {
305
+ if (!isRecord(raw.values)) {
306
+ return { error: "`values` is an object" };
307
+ }
308
+
309
+ const names = Object.keys(raw.values);
310
+
311
+ if (names.length > LIMITS.tweakValues) {
312
+ return { error: `At most ${LIMITS.tweakValues} values` };
313
+ }
314
+
315
+ const values = {};
316
+
317
+ for (const name of names) {
318
+ const value = raw.values[name];
319
+
320
+ if (
321
+ name === "" ||
322
+ name.length > 80 ||
323
+ (name.startsWith("--") && !CSS_VARIABLE.test(name)) ||
324
+ !(
325
+ value === null ||
326
+ isText(value, LIMITS.style) ||
327
+ typeof value === "boolean" ||
328
+ isNumber(value)
329
+ )
330
+ ) {
331
+ return { error: `Not a tweak value: ${name.slice(0, 40)}` };
332
+ }
333
+
334
+ values[name] = value;
335
+ }
336
+
337
+ return { message: { type: raw.type, values } };
338
+ }
339
+
340
+ case "pocket-highlight": {
341
+ if (!Array.isArray(raw.targets) || raw.targets.length > LIMITS.pins) {
342
+ return { error: `\`targets\` is a list of up to ${LIMITS.pins}` };
343
+ }
344
+
345
+ const targets = [];
346
+
347
+ for (const target of raw.targets) {
348
+ if (
349
+ !isRecord(target) ||
350
+ !isText(target.selector, LIMITS.selector) ||
351
+ (target.pid !== undefined && !isText(target.pid, LIMITS.pid)) ||
352
+ (target.reveal !== undefined && typeof target.reveal !== "boolean") ||
353
+ !(
354
+ target.label === undefined ||
355
+ isText(target.label, 8) ||
356
+ isCount(target.label, 99_999)
357
+ )
358
+ ) {
359
+ return { error: "A pin needs a selector (and a short label, if any)" };
360
+ }
361
+
362
+ const pin = { selector: target.selector };
363
+
364
+ if (target.pid !== undefined) {
365
+ pin.pid = target.pid;
366
+ }
367
+
368
+ if (target.label !== undefined) {
369
+ pin.label = target.label;
370
+ }
371
+
372
+ if (target.reveal === true) {
373
+ pin.reveal = true;
374
+ }
375
+
376
+ targets.push(pin);
377
+ }
378
+
379
+ return { message: { type: raw.type, targets } };
380
+ }
381
+
382
+ case "pocket-goto":
383
+ return isCount(raw.slide, LIMITS.slides)
384
+ ? { message: { type: raw.type, slide: raw.slide } }
385
+ : { error: "`slide` is a number from 0" };
386
+
387
+ default:
388
+ return { error: "Not a message for a page" };
389
+ }
390
+ }
391
+
392
+ /** A message to a page that waits for its line keeps only the newest of its kind; tweak values add up. */
393
+ function queue(frame, message) {
394
+ const held = waiting.get(frame) ?? new Map();
395
+ const before = held.get(message.type);
396
+
397
+ if (message.type === "pocket-tweak-set" && before) {
398
+ message = { ...message, values: { ...before.values, ...message.values } };
399
+ }
400
+
401
+ held.delete(message.type);
402
+ held.set(message.type, message);
403
+ waiting.set(frame, held);
404
+ }
405
+
406
+ /**
407
+ * Say `message` to the page in `frame` (a design message: `checkAppMessage`). True when it went down the line, or waits
408
+ * for the line to open (the frame has not loaded a page yet); false when it is refused or nobody can hear it.
409
+ */
410
+ export function sendToFrame(frame, message, { wait = true } = {}) {
411
+ const { message: clean } = checkAppMessage(message);
412
+ const hook = frame && hookOf(frame);
413
+
414
+ if (!clean || !hook) {
415
+ return false;
416
+ }
417
+
418
+ if (
419
+ (clean.type === "pocket-inspect" || clean.type === "pocket-edit-text") &&
420
+ clean.on &&
421
+ !canSteer()
422
+ ) {
423
+ return false;
424
+ }
425
+
426
+ const port = ports.get(frame);
427
+
428
+ if (port) {
429
+ port.postMessage(clean);
430
+
431
+ return true;
432
+ }
433
+
434
+ if (!wait || (loads.get(frame) ?? 0) > 0) {
435
+ return false;
436
+ }
437
+
438
+ queue(frame, clean);
439
+
440
+ return true;
441
+ }
442
+
443
+ // The first page in a frame says hello as it starts (`ARTIFACT_BRIDGE` in `src/server/http/artifacts.ts`) and hands over
444
+ // one end of a channel. That channel is the only way in: a page the frame goes on to gets no line, so it is not heard,
445
+ // and an answer goes down the line, so it reaches no page but the first. Only a frame that is still on its first load
446
+ // and has no line yet is heard, and only while a page of the app uses the bridge for it.
447
+ addEventListener("message", (event) => {
448
+ const hook = [...hooks].find((each) => each.ref.current?.contentWindow === event.source);
449
+ const frame = hook?.ref.current;
450
+
451
+ if (
452
+ !frame ||
453
+ event.data?.type !== "pocket-bridge" ||
454
+ event.ports.length !== 1 ||
455
+ ports.has(frame) ||
456
+ (loads.get(frame) ?? 0) > 0
457
+ ) {
458
+ return;
459
+ }
460
+
461
+ const [port] = event.ports;
462
+
463
+ ports.set(frame, port);
464
+
465
+ port.onmessage = (message) => {
466
+ const data = message.data;
467
+
468
+ if (typeof data === "object" && data) {
469
+ hookOf(frame)?.handle(frame, data);
470
+ }
471
+ };
472
+
473
+ // What was said before the line opened goes first; then the workspace may say the rest.
474
+ for (const held of waiting.get(frame)?.values() ?? []) {
475
+ port.postMessage(held);
476
+ }
477
+
478
+ waiting.delete(frame);
479
+ hook.ready(frame);
480
+ });
481
+
482
+ /** How many messages from one page the app hears in a second: the inspector sends when a person acts, so this is plenty. */
483
+ const RATE = 60;
484
+
485
+ /**
486
+ * Hear the first page of the frame `ref`: follow-ups always, saved state only for an artifact (`artifact`, its id).
487
+ * `source` names the page in the box ("the artifact “Plan”"). `enabled` is false for a page that has no bridge (SVG,
488
+ * Markdown). The page asks with `parent.postMessage`, as a page does, but the frame page it starts with (`ARTIFACT_BRIDGE`)
489
+ * takes the call, checks that a person tapped the page (a real tap, which the page cannot make), and sends it down the
490
+ * line. A follow-up so reaches us only after a tap; a frame inside PiP's panel adds nothing to the session's box.
491
+ *
492
+ * Design mode (stream B) adds, in `settings` (read each time, so they may change):
493
+ * - `onMessage(message, frame)`: a checked `pocket-picked`, `pocket-text-edited`, `pocket-tweaks`, or `pocket-deck`
494
+ * (`checkPageMessage`). Not for a viewer's frame in PiP's panel, nor once the session is another.
495
+ * - `onReady(frame)`: the frame's page opened its line. Its page has just started: say the modes again.
496
+ *
497
+ * Returns `{ send(message), ready(), detach() }`. `send` is `sendToFrame` for this frame (see there); `ready` is true
498
+ * once the page opened its line. Use it in a component with `useArtifactBridge`.
499
+ */
500
+ export function attachBridge(ref, settings = {}) {
501
+ const { artifact = null, source = "the page" } = settings;
502
+ const conversationId = store.state.conversationId;
503
+ let lastFollowUp = 0;
504
+ let windowStart = 0;
505
+ let heard = 0;
506
+
507
+ const reply = (frame, fields) => ports.get(frame)?.postMessage(fields);
508
+
509
+ const followUp = (frame, data) => {
510
+ const answer = (fields) => reply(frame, { type: "pocket-follow-up", ...fields });
511
+ const problem = followUpProblem(data);
512
+ const now = Date.now();
513
+
514
+ if (problem !== null) {
515
+ return answer({ ok: false, error: problem });
516
+ }
517
+
518
+ if (store.state.conversationId !== conversationId || frame.closest(".pip-panel")) {
519
+ return answer({ ok: false, error: "Not in this session" });
520
+ }
521
+
522
+ if (now - lastFollowUp < FOLLOW_UP_MS) {
523
+ return answer({ ok: false, error: "Too soon" });
524
+ }
525
+
526
+ lastFollowUp = now;
527
+
528
+ if (!canSteer()) {
529
+ notify("info", `${capital(source)} offered a message, but you can only view here.`);
530
+
531
+ return answer({ ok: false, error: "View only" });
532
+ }
533
+
534
+ insertIntoComposer(followUpText(data.text.trim(), data.selection, source));
535
+ notify("info", `${capital(source)} added a message to your box: read it, then send it.`);
536
+ answer({ ok: true });
537
+ };
538
+
539
+ const state = async (frame, data) => {
540
+ const requestId = data.requestId;
541
+
542
+ const answer = (fields) => {
543
+ if (ref.current === frame) {
544
+ reply(frame, { type: "pocket-state", requestId, ...fields });
545
+ }
546
+ };
547
+
548
+ if (!REQUEST_ID.test(String(requestId ?? ""))) {
549
+ return;
550
+ }
551
+
552
+ if (artifact === null) {
553
+ return answer({ ok: false, error: "Only a saved artifact keeps state" });
554
+ }
555
+
556
+ if (store.state.conversationId !== conversationId) {
557
+ return answer({ ok: false, error: "Not in this session" });
558
+ }
559
+
560
+ // The same for a get and a set: a schema is a string, or none.
561
+ if (data.schema !== undefined && data.schema !== null && typeof data.schema !== "string") {
562
+ return answer({ ok: false, error: "A schema is a string" });
563
+ }
564
+
565
+ const path = `c/${conversationId}/artifact-state/${encodeURIComponent(artifact)}`;
566
+
567
+ try {
568
+ if (data.type === "pocket-state-get") {
569
+ const query = data.schema ? `?schema=${encodeURIComponent(data.schema)}` : "";
570
+ const result = await api(`${path}${query}`);
571
+
572
+ answer({ ok: true, state: result.state, at: result.at });
573
+ } else {
574
+ const result = await api(path, {
575
+ state: data.state ?? null,
576
+ schema: data.schema ?? undefined,
577
+ });
578
+
579
+ answer({ ok: true, at: result.at });
580
+ }
581
+ } catch (error) {
582
+ answer({ ok: false, error: error.message ?? String(error) });
583
+ }
584
+ };
585
+
586
+ const design = (frame, data) => {
587
+ const now = Date.now();
588
+
589
+ if (now - windowStart > 1000) {
590
+ windowStart = now;
591
+ heard = 0;
592
+ }
593
+
594
+ heard += 1;
595
+
596
+ const message = heard <= RATE ? checkPageMessage(data) : null;
597
+
598
+ if (
599
+ message &&
600
+ store.state.conversationId === conversationId &&
601
+ !frame.closest(".pip-panel")
602
+ ) {
603
+ settings.onMessage?.(message, frame);
604
+ }
605
+ };
606
+
607
+ const hook = {
608
+ ref,
609
+ handle: (frame, data) => {
610
+ if (data.type === "pocket-follow-up") {
611
+ followUp(frame, data);
612
+ } else if (data.type === "pocket-state-get" || data.type === "pocket-state-set") {
613
+ void state(frame, data);
614
+ } else {
615
+ design(frame, data);
616
+ }
617
+ },
618
+ ready: (frame) => {
619
+ if (store.state.conversationId === conversationId) {
620
+ settings.onReady?.(frame);
621
+ }
622
+ },
623
+ };
624
+
625
+ hooks.add(hook);
626
+
627
+ return {
628
+ send: (message) => sendToFrame(ref.current, message),
629
+ ready: () => Boolean(ref.current && ports.has(ref.current)),
630
+ detach: () => hooks.delete(hook),
631
+ };
632
+ }
633
+
634
+ /**
635
+ * `attachBridge` for a component: `useArtifactBridge(ref, { artifact, source, enabled, onMessage, onReady })`. It
636
+ * returns one `{ send, ready }` that stays the same between renders; `send` says no (false) while `enabled` is false.
637
+ * The callbacks may change between renders; the bridge is made again only when `artifact`, `source`, or `enabled` do.
638
+ */
639
+ export function useArtifactBridge(ref, settings) {
640
+ const live = useRef({});
641
+ const control = useRef(null);
642
+
643
+ Object.assign(live.current, settings);
644
+
645
+ if (control.current === null) {
646
+ const attached = { current: null };
647
+
648
+ control.current = {
649
+ attached,
650
+ api: {
651
+ send: (message) => attached.current?.send(message) ?? false,
652
+ ready: () => attached.current?.ready() ?? false,
653
+ },
654
+ };
655
+ }
656
+
657
+ const { artifact = null, source, enabled = true } = settings;
658
+
659
+ useLayoutEffect(() => {
660
+ if (!enabled) {
661
+ return undefined;
662
+ }
663
+
664
+ // The settings are read live: a new callback needs no new line.
665
+ const bridge = attachBridge(ref, {
666
+ artifact,
667
+ source,
668
+ get onMessage() {
669
+ return live.current.onMessage;
670
+ },
671
+ get onReady() {
672
+ return live.current.onReady;
673
+ },
674
+ });
675
+
676
+ control.current.attached.current = bridge;
677
+
678
+ return () => {
679
+ control.current.attached.current = null;
680
+ bridge.detach();
681
+ };
682
+ }, [artifact, source, enabled]);
683
+
684
+ return control.current.api;
685
+ }
686
+
687
+ const capital = (text) => text.charAt(0).toUpperCase() + text.slice(1);