@botiverse/porthole 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/annotate/types.d.ts +120 -0
- package/dist/annotate/types.js +42 -0
- package/dist/annotate/validate.d.ts +11 -0
- package/dist/annotate/validate.js +156 -0
- package/dist/bridge/bridge.d.ts +4 -0
- package/dist/bridge/bridge.js +12 -1
- package/dist/compile/policy.d.ts +10 -3
- package/dist/compile/policy.js +16 -5
- package/dist/config/capabilities.d.ts +37 -4
- package/dist/config/capabilities.js +20 -0
- package/dist/config/schema.d.ts +11 -0
- package/dist/config/validate.js +12 -0
- package/dist/conformance/clipboard.d.ts +2 -0
- package/dist/conformance/clipboard.js +167 -0
- package/dist/conformance/kit.d.ts +1 -1
- package/dist/conformance/probes.js +2 -0
- package/dist/conformance/served.d.ts +8 -1
- package/dist/conformance/served.js +12 -8
- package/dist/guide/guide.js +11 -5
- package/dist/host/clipboard.d.ts +11 -0
- package/dist/host/clipboard.js +43 -0
- package/dist/host/create-host.js +165 -7
- package/dist/host/types.d.ts +68 -2
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -3
- package/dist/mcp/adapter.js +10 -1
- package/dist/presets/presets.d.ts +3 -1
- package/dist/presets/presets.js +1 -0
- package/dist/protocol/mcp-apps.d.ts +5 -0
- package/dist/protocol/mcp-apps.js +5 -0
- package/dist/protocol/shell-adapter.js +6 -2
- package/dist/react/dialog.js +2 -0
- package/dist/react/frame.d.ts +50 -2
- package/dist/react/frame.js +153 -11
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.js +2 -1
- package/dist/react/toast.d.ts +33 -0
- package/dist/react/toast.js +42 -0
- package/dist/server/naming.d.ts +6 -0
- package/dist/server/naming.js +38 -0
- package/dist/server/pages.js +2 -0
- package/dist/server/server.js +49 -17
- package/dist/server/types.d.ts +6 -3
- package/dist/shell/annotate.d.ts +20 -0
- package/dist/shell/annotate.js +700 -0
- package/dist/shell/document.d.ts +6 -0
- package/dist/shell/document.js +12 -3
- package/dist/shell/plan.d.ts +2 -0
- package/dist/shell/plan.js +2 -1
- package/dist/shell/runtime.d.ts +4 -1
- package/dist/shell/runtime.js +71 -3
- package/package.json +1 -1
- package/src/annotate/types.ts +126 -0
- package/src/annotate/validate.ts +186 -0
- package/src/bridge/bridge.ts +13 -0
- package/src/compile/policy.ts +17 -5
- package/src/config/capabilities.ts +33 -3
- package/src/config/schema.ts +12 -0
- package/src/config/validate.ts +15 -0
- package/src/conformance/clipboard.ts +184 -0
- package/src/conformance/kit.ts +1 -1
- package/src/conformance/probes.ts +2 -0
- package/src/conformance/served.ts +24 -9
- package/src/guide/guide.ts +22 -5
- package/src/host/clipboard.ts +41 -0
- package/src/host/create-host.ts +181 -7
- package/src/host/types.ts +64 -2
- package/src/index.ts +24 -0
- package/src/mcp/adapter.ts +10 -1
- package/src/presets/presets.ts +4 -0
- package/src/protocol/mcp-apps.ts +6 -0
- package/src/protocol/shell-adapter.ts +11 -2
- package/src/react/dialog.tsx +2 -0
- package/src/react/frame.tsx +231 -13
- package/src/react/index.ts +9 -1
- package/src/react/toast.tsx +96 -0
- package/src/server/naming.ts +41 -0
- package/src/server/pages.ts +2 -0
- package/src/server/server.ts +61 -20
- package/src/server/types.ts +6 -3
- package/src/shell/annotate.ts +700 -0
- package/src/shell/document.ts +21 -3
- package/src/shell/plan.ts +4 -1
- package/src/shell/runtime.ts +71 -3
package/src/react/frame.tsx
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
type ReactElement,
|
|
15
15
|
type ReactNode,
|
|
16
16
|
} from "react";
|
|
17
|
+
import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
|
|
17
18
|
import type {
|
|
18
19
|
CallContext,
|
|
20
|
+
CapabilityHandler,
|
|
19
21
|
CapabilityHandlers,
|
|
20
22
|
Confirm,
|
|
21
23
|
ConfirmAnswer,
|
|
@@ -25,11 +27,13 @@ import type {
|
|
|
25
27
|
} from "../bridge/types.ts";
|
|
26
28
|
import type { CapabilityName } from "../config/capabilities.ts";
|
|
27
29
|
import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
|
|
30
|
+
import { writeClipboard } from "../host/clipboard.ts";
|
|
28
31
|
import { createHost } from "../host/create-host.ts";
|
|
29
|
-
import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
|
|
32
|
+
import type { DisplayEvents, DisplayHandle, FrameOutcome, HostErrorKind } from "../host/types.ts";
|
|
30
33
|
import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
|
|
31
34
|
import { ConfirmDialog } from "./dialog.tsx";
|
|
32
35
|
import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
|
36
|
+
import { CopyToast, type CopyNotice } from "./toast.tsx";
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
39
|
* The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
|
|
@@ -49,7 +53,11 @@ interface PageState {
|
|
|
49
53
|
readonly heights: Map<string, number>;
|
|
50
54
|
readonly confirm?: Confirm;
|
|
51
55
|
readonly hostInfo?: { readonly name: string; readonly version: string };
|
|
52
|
-
readonly
|
|
56
|
+
readonly nonce?: string;
|
|
57
|
+
/** The provider's locator, read when a frame locates (so an inline one restarts nothing). */
|
|
58
|
+
readonly locate?: InstanceLocator["locate"];
|
|
59
|
+
/** The default `copyText`: copies on the host's page and shows the toast (P052). */
|
|
60
|
+
readonly copyText?: CapabilityHandler<"copyText">;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
|
|
@@ -69,6 +77,13 @@ export interface PortholeProviderProps {
|
|
|
69
77
|
readonly locator?: InstanceLocator;
|
|
70
78
|
/** A class for the default dialog, to restyle it. */
|
|
71
79
|
readonly dialogClassName?: string;
|
|
80
|
+
/** A class for the default copy toast, to restyle it. */
|
|
81
|
+
readonly toastClassName?: string;
|
|
82
|
+
/**
|
|
83
|
+
* The page's CSP nonce, where its `script-src` is nonce-based: opaque frames inherit the page's
|
|
84
|
+
* policy, so their shell carries it (P054).
|
|
85
|
+
*/
|
|
86
|
+
readonly nonce?: string;
|
|
72
87
|
readonly children?: ReactNode;
|
|
73
88
|
}
|
|
74
89
|
|
|
@@ -96,16 +111,42 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
96
111
|
),
|
|
97
112
|
[],
|
|
98
113
|
);
|
|
99
|
-
|
|
114
|
+
// The default copyText: the host's page writes the clipboard (in the frame's click, which
|
|
115
|
+
// activated this page too) and shows the person what went on it, or offers to copy it.
|
|
116
|
+
const [notice, setNotice] = useState<CopyNotice | null>(null);
|
|
117
|
+
const copyText = useCallback<CapabilityHandler<"copyText">>(async ({ text }, context) => {
|
|
118
|
+
const copied = await writeClipboard(text, globalThis.document);
|
|
119
|
+
setNotice({ text, copied, title: context.title });
|
|
120
|
+
return copied;
|
|
121
|
+
}, []);
|
|
122
|
+
const closeNotice = useCallback(() => setNotice(null), []);
|
|
123
|
+
// Compared by value, and read when used: an inline hostInfo or locator doesn't start every
|
|
124
|
+
// frame again on each render.
|
|
125
|
+
const hostInfo = useByValue(props.hostInfo);
|
|
126
|
+
const ownLocator = useRef(props.locator);
|
|
127
|
+
useLayoutEffect(() => {
|
|
128
|
+
ownLocator.current = props.locator;
|
|
129
|
+
});
|
|
130
|
+
const locate = useCallback<InstanceLocator["locate"]>(
|
|
131
|
+
(instance, signal) =>
|
|
132
|
+
ownLocator.current === undefined
|
|
133
|
+
? Promise.reject(new Error("No locator (PortholeProvider.locator)"))
|
|
134
|
+
: ownLocator.current.locate(instance, signal),
|
|
135
|
+
[],
|
|
136
|
+
);
|
|
137
|
+
const locates = props.locator !== undefined;
|
|
138
|
+
const { nonce } = props;
|
|
100
139
|
const value = useMemo<PageState>(
|
|
101
140
|
() => ({
|
|
102
141
|
live,
|
|
103
142
|
heights,
|
|
104
143
|
confirm,
|
|
144
|
+
copyText,
|
|
105
145
|
...(hostInfo === undefined ? {} : { hostInfo }),
|
|
106
|
-
...(
|
|
146
|
+
...(locates ? { locate } : {}),
|
|
147
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
107
148
|
}),
|
|
108
|
-
[live, heights, confirm, hostInfo,
|
|
149
|
+
[live, heights, confirm, copyText, hostInfo, locates, locate, nonce],
|
|
109
150
|
);
|
|
110
151
|
const current = queue[0] ?? null;
|
|
111
152
|
return (
|
|
@@ -125,6 +166,17 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
|
|
|
125
166
|
}}
|
|
126
167
|
/>
|
|
127
168
|
) : null}
|
|
169
|
+
<CopyToast
|
|
170
|
+
notice={notice}
|
|
171
|
+
{...(props.toastClassName === undefined ? {} : { className: props.toastClassName })}
|
|
172
|
+
onClose={closeNotice}
|
|
173
|
+
onCopy={(failed) => {
|
|
174
|
+
// A click on the host's own page: the write has the gesture it needs now.
|
|
175
|
+
void writeClipboard(failed.text, globalThis.document).then((copied) =>
|
|
176
|
+
setNotice({ ...failed, copied }),
|
|
177
|
+
);
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
128
180
|
</Page.Provider>
|
|
129
181
|
);
|
|
130
182
|
}
|
|
@@ -163,9 +215,28 @@ export interface PortholeFrameProps {
|
|
|
163
215
|
readonly generation?: number;
|
|
164
216
|
/** Run at once, without waiting to near the screen (it still counts toward the page's most). */
|
|
165
217
|
readonly eager?: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* Fill the box (give it, or its parent, a height) and let the content scroll inside, instead of
|
|
220
|
+
* growing with the content: an artifact viewer's page. A url-mode app always fills.
|
|
221
|
+
*/
|
|
222
|
+
readonly fill?: boolean;
|
|
166
223
|
readonly onInitialized?: () => void;
|
|
167
224
|
readonly onResize?: (size: { readonly height: number; readonly width?: number }) => void;
|
|
168
|
-
|
|
225
|
+
/**
|
|
226
|
+
* What the content did wrong (what it threw, what the sandbox blocked, that it kept navigating
|
|
227
|
+
* away): the agent's to hear and fix.
|
|
228
|
+
*/
|
|
229
|
+
readonly onContentError?: (message: string) => void;
|
|
230
|
+
/**
|
|
231
|
+
* What failed on the host's side: its locator (`locate`), a display that couldn't be made
|
|
232
|
+
* (`mount`), an annotation call refused (`annotations`). Never the agent's.
|
|
233
|
+
*/
|
|
234
|
+
readonly onHostError?: (error: {
|
|
235
|
+
readonly message: string;
|
|
236
|
+
readonly kind: HostErrorKind;
|
|
237
|
+
}) => void;
|
|
238
|
+
/** Both, with their source (as before 0.1.4). Prefer `onContentError` and `onHostError`. */
|
|
239
|
+
readonly onError?: (message: string, source: "content" | "host") => void;
|
|
169
240
|
/** The content navigated its frame away; it was started again (or stopped, after a few). */
|
|
170
241
|
readonly onNavigated?: () => void;
|
|
171
242
|
readonly onRefused?: (capability: CapabilityName, refusal: Refusal) => void;
|
|
@@ -186,6 +257,27 @@ export interface PortholeFrameProps {
|
|
|
186
257
|
* restyle): the running frame reads them again.
|
|
187
258
|
*/
|
|
188
259
|
readonly themeKey?: string | number;
|
|
260
|
+
/**
|
|
261
|
+
* Annotations, where the config declares `annotate` (P053): what the person may point at now
|
|
262
|
+
* (elements, text, or both), or null to stop. A picked element ends element picking: set it
|
|
263
|
+
* again to pick again.
|
|
264
|
+
*/
|
|
265
|
+
readonly annotating?: AnnotateMode | null;
|
|
266
|
+
/** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
|
|
267
|
+
readonly annotations?: readonly AnnotationMark[];
|
|
268
|
+
/**
|
|
269
|
+
* The mark to bring into view and make the active one; each new id focuses it. To focus the
|
|
270
|
+
* same mark again (a second click on its comment), give `{ id, key }` with a new key.
|
|
271
|
+
*/
|
|
272
|
+
readonly focusedAnnotation?:
|
|
273
|
+
| string
|
|
274
|
+
| { readonly id: string; readonly key: string | number }
|
|
275
|
+
| null;
|
|
276
|
+
/**
|
|
277
|
+
* What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
|
|
278
|
+
* pick, where marks are. The content's word: UI hints, never permissions.
|
|
279
|
+
*/
|
|
280
|
+
readonly onAnnotation?: (report: AnnotationReport) => void;
|
|
189
281
|
/** Draws the placeholder instead of the default one. */
|
|
190
282
|
readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
|
|
191
283
|
readonly className?: string;
|
|
@@ -299,6 +391,8 @@ export function PortholeFrame(props: PortholeFrameProps): ReactElement {
|
|
|
299
391
|
style={{
|
|
300
392
|
position: "relative",
|
|
301
393
|
width: "100%",
|
|
394
|
+
// A display that fills takes its parent's height (the host sizes the parent, or this box).
|
|
395
|
+
...(props.fill === true || props.config.mode === "url" ? { height: "100%" } : {}),
|
|
302
396
|
...(state === "running" ? {} : { minHeight: Math.max(height, PLACEHOLDER_HEIGHT_PX) }),
|
|
303
397
|
...props.style,
|
|
304
398
|
}}
|
|
@@ -336,8 +430,11 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
336
430
|
const [ready, setReady] = useState(false);
|
|
337
431
|
// The height this display started at: later reports don't make it start again.
|
|
338
432
|
const [startHeight] = useState(initialHeightPx);
|
|
339
|
-
const {
|
|
340
|
-
|
|
433
|
+
const { document: view, content, partial, scheme } = frame;
|
|
434
|
+
// By value: a config or instance made inline (each render) is the same display.
|
|
435
|
+
const config = useByValue(frame.config);
|
|
436
|
+
const instance = useByValue(frame.instance);
|
|
437
|
+
const fills = config.mode === "url" || frame.fill === true;
|
|
341
438
|
|
|
342
439
|
// The latest props, read when the display calls back (never during render).
|
|
343
440
|
const latest = useEffectEvent(() => frame);
|
|
@@ -354,9 +451,17 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
354
451
|
frame.onResize?.(size);
|
|
355
452
|
return;
|
|
356
453
|
}
|
|
357
|
-
case "
|
|
358
|
-
frame.
|
|
454
|
+
case "contentError":
|
|
455
|
+
frame.onContentError?.((detail as DisplayEvents["contentError"]).message);
|
|
359
456
|
return;
|
|
457
|
+
case "hostError":
|
|
458
|
+
frame.onHostError?.(detail as DisplayEvents["hostError"]);
|
|
459
|
+
return;
|
|
460
|
+
case "error": {
|
|
461
|
+
const { message, source } = detail as DisplayEvents["error"];
|
|
462
|
+
frame.onError?.(message, source);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
360
465
|
case "navigated":
|
|
361
466
|
frame.onNavigated?.();
|
|
362
467
|
return;
|
|
@@ -371,6 +476,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
371
476
|
case "outcome":
|
|
372
477
|
frame.onOutcome?.(detail as DisplayEvents["outcome"]);
|
|
373
478
|
return;
|
|
479
|
+
case "annotation":
|
|
480
|
+
frame.onAnnotation?.(detail as DisplayEvents["annotation"]);
|
|
481
|
+
return;
|
|
374
482
|
}
|
|
375
483
|
},
|
|
376
484
|
);
|
|
@@ -397,6 +505,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
397
505
|
named(context),
|
|
398
506
|
);
|
|
399
507
|
}
|
|
508
|
+
// Copy buttons without a handler of the host's: the provider's, which shows a toast.
|
|
509
|
+
if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
|
|
510
|
+
const fallback = page.copyText;
|
|
511
|
+
if (fallback !== undefined) {
|
|
512
|
+
handlers.copyText = (args, context) =>
|
|
513
|
+
fallback(args as { readonly text: string }, named(context));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
400
516
|
const asks = first.confirm !== undefined || page.confirm !== undefined;
|
|
401
517
|
let display: DisplayHandle;
|
|
402
518
|
try {
|
|
@@ -413,7 +529,8 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
413
529
|
: {}),
|
|
414
530
|
theme: (each) => latest().theme?.(each) ?? {},
|
|
415
531
|
...(page.hostInfo === undefined ? {} : { hostInfo: page.hostInfo }),
|
|
416
|
-
...(page.
|
|
532
|
+
...(page.locate === undefined ? {} : { locator: { locate: page.locate } }),
|
|
533
|
+
...(page.nonce === undefined ? {} : { nonce: page.nonce }),
|
|
417
534
|
}).mount(container, {
|
|
418
535
|
displayId: first.displayId,
|
|
419
536
|
title: first.title,
|
|
@@ -421,9 +538,10 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
421
538
|
initialHeightPx: startHeight,
|
|
422
539
|
...(view === undefined ? {} : { document: view }),
|
|
423
540
|
...(instance === undefined ? {} : { instance }),
|
|
541
|
+
...(fills ? { fill: true } : {}),
|
|
424
542
|
});
|
|
425
543
|
} catch (error) {
|
|
426
|
-
first
|
|
544
|
+
hostFailed(first, error, "mount");
|
|
427
545
|
return;
|
|
428
546
|
}
|
|
429
547
|
handle.current = display;
|
|
@@ -435,10 +553,16 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
435
553
|
"refused",
|
|
436
554
|
"muted",
|
|
437
555
|
"outcome",
|
|
556
|
+
"annotation",
|
|
557
|
+
"contentError",
|
|
558
|
+
"hostError",
|
|
438
559
|
];
|
|
439
560
|
for (const event of events) {
|
|
440
561
|
display.on(event, (detail) => notify(event, detail));
|
|
441
562
|
}
|
|
563
|
+
if (config.capabilities.annotate !== undefined) {
|
|
564
|
+
applyAnnotations(display, first);
|
|
565
|
+
}
|
|
442
566
|
if (first.content !== undefined) {
|
|
443
567
|
display.setContent(first.content, { partial: first.partial === true });
|
|
444
568
|
}
|
|
@@ -448,13 +572,56 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
448
572
|
};
|
|
449
573
|
// A new display only for a new config, document, instance or page (or generation, by key);
|
|
450
574
|
// content, theme, handlers, id and title are fed to the running one.
|
|
451
|
-
}, [config, view, instance, page, startHeight]);
|
|
575
|
+
}, [config, view, instance, page, startHeight, fills]);
|
|
452
576
|
|
|
453
577
|
useEffect(() => {
|
|
454
578
|
if (content !== undefined) {
|
|
455
579
|
handle.current?.setContent(content, { partial: partial === true });
|
|
456
580
|
}
|
|
457
581
|
}, [content, partial]);
|
|
582
|
+
// Annotations follow their props (a new display gets them all when it is made, above).
|
|
583
|
+
const annotates = config.capabilities.annotate !== undefined;
|
|
584
|
+
const { annotating } = frame;
|
|
585
|
+
const focusedAnnotation = focusOf(frame.focusedAnnotation);
|
|
586
|
+
const focusToken =
|
|
587
|
+
focusedAnnotation === null
|
|
588
|
+
? ""
|
|
589
|
+
: typeof frame.focusedAnnotation === "object" && frame.focusedAnnotation !== null
|
|
590
|
+
? JSON.stringify([frame.focusedAnnotation.id, frame.focusedAnnotation.key])
|
|
591
|
+
: JSON.stringify([focusedAnnotation]);
|
|
592
|
+
// By value: the same marks given again don't mark them again.
|
|
593
|
+
const annotations = useByValue(frame.annotations);
|
|
594
|
+
const mode =
|
|
595
|
+
annotating === undefined || annotating === null
|
|
596
|
+
? ""
|
|
597
|
+
: JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
|
|
598
|
+
useEffect(() => {
|
|
599
|
+
const display = handle.current;
|
|
600
|
+
if (!annotates || display === null) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const wanted = mode === "" ? null : (JSON.parse(mode) as [boolean, boolean]);
|
|
604
|
+
attempt(
|
|
605
|
+
() =>
|
|
606
|
+
wanted === null
|
|
607
|
+
? display.annotations.stop()
|
|
608
|
+
: display.annotations.start({ elements: wanted[0], text: wanted[1] }),
|
|
609
|
+
latest(),
|
|
610
|
+
);
|
|
611
|
+
}, [annotates, mode]);
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
const display = handle.current;
|
|
614
|
+
if (annotates && display !== null) {
|
|
615
|
+
attempt(() => display.annotations.show(annotations ?? []), latest());
|
|
616
|
+
}
|
|
617
|
+
}, [annotates, annotations]);
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
const display = handle.current;
|
|
620
|
+
const id = focusOf(latest().focusedAnnotation);
|
|
621
|
+
if (annotates && display !== null && focusToken !== "" && id !== null) {
|
|
622
|
+
attempt(() => display.annotations.focus(id), latest());
|
|
623
|
+
}
|
|
624
|
+
}, [annotates, focusToken]);
|
|
458
625
|
useEffect(() => {
|
|
459
626
|
handle.current?.setTheme(scheme);
|
|
460
627
|
}, [scheme]);
|
|
@@ -484,3 +651,54 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
|
|
|
484
651
|
</div>
|
|
485
652
|
);
|
|
486
653
|
}
|
|
654
|
+
|
|
655
|
+
/** Tells the frame's props what failed on the host's side. */
|
|
656
|
+
function hostFailed(frame: PortholeFrameProps, error: unknown, kind: HostErrorKind): void {
|
|
657
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
658
|
+
frame.onHostError?.({ message, kind });
|
|
659
|
+
frame.onError?.(message, "host");
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** Runs an annotation call, telling the frame's props what it threw (a bad anchor, say). */
|
|
663
|
+
function attempt(run: () => void, frame: PortholeFrameProps): void {
|
|
664
|
+
try {
|
|
665
|
+
run();
|
|
666
|
+
} catch (error) {
|
|
667
|
+
hostFailed(frame, error, "annotations");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** A new display's annotations, from the props as they are. */
|
|
672
|
+
function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): void {
|
|
673
|
+
attempt(() => {
|
|
674
|
+
if (frame.annotations !== undefined && frame.annotations.length > 0) {
|
|
675
|
+
display.annotations.show(frame.annotations);
|
|
676
|
+
}
|
|
677
|
+
const focused = focusOf(frame.focusedAnnotation);
|
|
678
|
+
if (focused !== null) {
|
|
679
|
+
display.annotations.focus(focused);
|
|
680
|
+
}
|
|
681
|
+
if (frame.annotating !== undefined && frame.annotating !== null) {
|
|
682
|
+
display.annotations.start(frame.annotating);
|
|
683
|
+
}
|
|
684
|
+
}, frame);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* `value` as long as it is equal by value (as JSON): plain data a host builds inline on each
|
|
689
|
+
* render (a config, an instance, marks, hostInfo) keeps one identity, so effects keyed on it run
|
|
690
|
+
* only when it really changes.
|
|
691
|
+
*/
|
|
692
|
+
function useByValue<T>(value: T): T {
|
|
693
|
+
const key = value === undefined ? "" : JSON.stringify(value);
|
|
694
|
+
return useMemo(() => (key === "" ? undefined : JSON.parse(key)) as T, [key]);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** The id `focusedAnnotation` names, if any. */
|
|
698
|
+
function focusOf(value: PortholeFrameProps["focusedAnnotation"]): string | null {
|
|
699
|
+
return typeof value === "string"
|
|
700
|
+
? value
|
|
701
|
+
: value === null || value === undefined
|
|
702
|
+
? null
|
|
703
|
+
: value.id;
|
|
704
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
|
|
3
3
|
* package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
|
|
4
4
|
*
|
|
5
|
-
* <PortholeProvider> // per page: live-frame cap, heights, the dialog
|
|
5
|
+
* <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
|
|
6
6
|
* <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
|
|
7
7
|
* content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
|
|
8
8
|
* theme={tokensFor} onError={report} />
|
|
@@ -17,4 +17,12 @@ export {
|
|
|
17
17
|
type PortholeProviderProps,
|
|
18
18
|
} from "./frame.tsx";
|
|
19
19
|
export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
|
|
20
|
+
export {
|
|
21
|
+
CopyToast,
|
|
22
|
+
TOAST_CHARS,
|
|
23
|
+
TOAST_MS,
|
|
24
|
+
clipped,
|
|
25
|
+
type CopyNotice,
|
|
26
|
+
type CopyToastProps,
|
|
27
|
+
} from "./toast.tsx";
|
|
20
28
|
export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useEffect, type ReactElement } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The default copy notice (P052): when content's `copyText` copied something, the host's page
|
|
5
|
+
* shows the person what went on their clipboard, clipped, in a small toast outside every frame.
|
|
6
|
+
* If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
|
|
7
|
+
* host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
|
|
8
|
+
* styles it through the `porthole-toast` class and data attributes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How long a toast for a copy that worked stays. */
|
|
12
|
+
export const TOAST_MS = 5_000;
|
|
13
|
+
/** The most of the copied text a toast shows. */
|
|
14
|
+
export const TOAST_CHARS = 280;
|
|
15
|
+
const TOAST_LINES = 4;
|
|
16
|
+
|
|
17
|
+
export interface CopyNotice {
|
|
18
|
+
/** The whole text copied (or to copy). */
|
|
19
|
+
readonly text: string;
|
|
20
|
+
/** Whether it is on the clipboard. */
|
|
21
|
+
readonly copied: boolean;
|
|
22
|
+
/** The display's title, as the host named it. */
|
|
23
|
+
readonly title: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CopyToastProps {
|
|
27
|
+
readonly notice: CopyNotice | null;
|
|
28
|
+
/** The person asked to copy it again (it failed), from this click. */
|
|
29
|
+
readonly onCopy: (notice: CopyNotice) => void;
|
|
30
|
+
readonly onClose: () => void;
|
|
31
|
+
readonly className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
|
|
35
|
+
export function clipped(text: string): { readonly shown: string; readonly clipped: boolean } {
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
let shown = lines.slice(0, TOAST_LINES).join("\n");
|
|
38
|
+
if (shown.length > TOAST_CHARS) {
|
|
39
|
+
shown = shown.slice(0, TOAST_CHARS);
|
|
40
|
+
}
|
|
41
|
+
return shown.length < text.length
|
|
42
|
+
? { shown: `${shown}…`, clipped: true }
|
|
43
|
+
: { shown, clipped: false };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const STYLE = `.porthole-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:12px 14px;width:min(420px,calc(100vw - 32px));box-shadow:0 8px 28px rgb(0 0 0 / .25);font:inherit;font-size:.9em}
|
|
47
|
+
.porthole-toast p{margin:0 0 6px}
|
|
48
|
+
.porthole-toast [data-porthole-source]{opacity:.72}
|
|
49
|
+
.porthole-toast pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:8em;overflow:hidden;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
50
|
+
.porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
|
|
51
|
+
.porthole-toast button{font:inherit;min-height:32px;padding:0 12px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}`;
|
|
52
|
+
|
|
53
|
+
export function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement {
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (notice === null || !notice.copied) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const timer = setTimeout(onClose, TOAST_MS);
|
|
59
|
+
return () => clearTimeout(timer);
|
|
60
|
+
}, [notice, onClose]);
|
|
61
|
+
const view = notice === null ? null : clipped(notice.text);
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
role="status"
|
|
65
|
+
aria-live="polite"
|
|
66
|
+
className={className === undefined ? "porthole-toast" : `porthole-toast ${className}`}
|
|
67
|
+
data-porthole-toast=""
|
|
68
|
+
data-porthole-copied={notice === null ? "" : String(notice.copied)}
|
|
69
|
+
hidden={notice === null}
|
|
70
|
+
>
|
|
71
|
+
<style>{STYLE}</style>
|
|
72
|
+
{notice === null || view === null ? null : (
|
|
73
|
+
<>
|
|
74
|
+
<p>
|
|
75
|
+
<strong>{notice.copied ? "Copied" : "Not copied yet"}</strong>{" "}
|
|
76
|
+
<span data-porthole-source="">{`from “${notice.title}”`}</span>
|
|
77
|
+
</p>
|
|
78
|
+
<pre data-porthole-copied-text="">{view.shown}</pre>
|
|
79
|
+
{view.clipped ? (
|
|
80
|
+
<p data-porthole-length="">{`${notice.text.length.toLocaleString("en-US")} characters in all`}</p>
|
|
81
|
+
) : null}
|
|
82
|
+
<menu>
|
|
83
|
+
{notice.copied ? null : (
|
|
84
|
+
<button type="button" data-porthole-copy="" onClick={() => onCopy(notice)}>
|
|
85
|
+
Copy
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
<button type="button" data-porthole-close="" onClick={onClose}>
|
|
89
|
+
{notice.copied ? "OK" : "Close"}
|
|
90
|
+
</button>
|
|
91
|
+
</menu>
|
|
92
|
+
</>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
package/src/server/naming.ts
CHANGED
|
@@ -40,3 +40,44 @@ export function prefixedName(prefix: string): (scope: string) => string {
|
|
|
40
40
|
return label;
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
const PREFIX_ENTRY = /^[a-z0-9]([a-z0-9-]{0,61})?\*$/u;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a label is reserved (`ContentDomain.reserved`): a whole label, a prefix ending in `*`
|
|
48
|
+
* (`p-*`), or the host's own function. Entries are checked once, here; a function that throws
|
|
49
|
+
* reserves the label (fail closed: the request goes to the host's routes, never served as ours).
|
|
50
|
+
*/
|
|
51
|
+
export function reservedLabels(
|
|
52
|
+
reserved: readonly string[] | ((label: string) => boolean) | undefined,
|
|
53
|
+
): (label: string) => boolean {
|
|
54
|
+
if (reserved === undefined) {
|
|
55
|
+
return () => false;
|
|
56
|
+
}
|
|
57
|
+
if (typeof reserved === "function") {
|
|
58
|
+
return (label) => {
|
|
59
|
+
try {
|
|
60
|
+
return reserved(label);
|
|
61
|
+
} catch {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(reserved)) {
|
|
67
|
+
throw new TypeError("content.reserved: a list of labels and prefixes, or a function");
|
|
68
|
+
}
|
|
69
|
+
const whole = new Set<string>();
|
|
70
|
+
const prefixes: string[] = [];
|
|
71
|
+
for (const entry of reserved) {
|
|
72
|
+
if (typeof entry === "string" && PREFIX_ENTRY.test(entry)) {
|
|
73
|
+
prefixes.push(entry.slice(0, -1));
|
|
74
|
+
} else if (isHostLabel(entry)) {
|
|
75
|
+
whole.add(entry);
|
|
76
|
+
} else {
|
|
77
|
+
throw new TypeError(
|
|
78
|
+
`content.reserved: ${JSON.stringify(entry)} is not a lowercase DNS label or a prefix ending in *`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return (label) => whole.has(label) || prefixes.some((prefix) => label.startsWith(prefix));
|
|
83
|
+
}
|
package/src/server/pages.ts
CHANGED
|
@@ -93,6 +93,8 @@ const pageHeaders = (frameAncestors: string, connect: boolean) => ({
|
|
|
93
93
|
"content-security-policy": policy(frameAncestors, connect),
|
|
94
94
|
"referrer-policy": "no-referrer",
|
|
95
95
|
"x-content-type-options": "nosniff",
|
|
96
|
+
// Content origins are behind tickets: nothing on them is for search engines.
|
|
97
|
+
"x-robots-tag": "noindex, nofollow",
|
|
96
98
|
});
|
|
97
99
|
|
|
98
100
|
export function statusPage(
|