@runtypelabs/persona 4.6.1 → 4.8.0
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/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/codegen.cjs +12 -12
- package/dist/codegen.js +14 -14
- package/dist/index.cjs +91 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +918 -174
- package/dist/index.d.ts +918 -174
- package/dist/index.global.js +81 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +89 -66
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +17 -17
- package/dist/smart-dom-reader.d.cts +753 -15
- package/dist/smart-dom-reader.d.ts +753 -15
- package/dist/smart-dom-reader.js +17 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +81 -58
- package/dist/theme-editor-preview.d.cts +761 -15
- package/dist/theme-editor-preview.d.ts +761 -15
- package/dist/theme-editor-preview.js +81 -58
- package/dist/theme-editor.cjs +6 -6
- package/dist/theme-editor.d.cts +753 -15
- package/dist/theme-editor.d.ts +753 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +74 -0
- package/dist/theme-reference.d.ts +74 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +2 -3
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +500 -1
- package/src/client.ts +285 -93
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +20 -1
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +74 -9
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/runtime/persist-state.test.ts +118 -0
- package/src/session.ts +76 -22
- package/src/styles/widget.css +773 -26
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +10 -2
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +77 -0
- package/src/types.ts +516 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.scroll-additive.test.ts +1 -1
- package/src/ui.streaming-coalescing.test.ts +312 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +933 -214
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +48 -0
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +91 -0
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { createElement } from "../utils/dom";
|
|
2
|
+
import { createSpinner } from "../utils/spinner";
|
|
3
|
+
import type {
|
|
4
|
+
AgentWidgetConfig,
|
|
5
|
+
AgentWidgetMessage,
|
|
6
|
+
PersonaArtifactRecord,
|
|
7
|
+
} from "../types";
|
|
8
|
+
import { extractFileSource, fileKindOf } from "../utils/artifact-file";
|
|
9
|
+
import {
|
|
10
|
+
applyArtifactStatus,
|
|
11
|
+
clearArtifactStatusTracking,
|
|
12
|
+
resolveArtifactStatusLabel
|
|
13
|
+
} from "../utils/artifact-status-label";
|
|
14
|
+
import { highlightCode } from "../utils/code-highlight";
|
|
15
|
+
import { escapeHtml, createMarkdownProcessorFromConfig } from "../postprocessors";
|
|
16
|
+
import { getMarkdownParsersSync, onMarkdownParsersReady } from "../markdown-parsers-loader";
|
|
17
|
+
import { resolveSanitizer } from "../utils/sanitize";
|
|
18
|
+
import {
|
|
19
|
+
componentRegistry,
|
|
20
|
+
type ComponentContext,
|
|
21
|
+
type ComponentRenderer,
|
|
22
|
+
} from "./registry";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shared artifact preview body renderer.
|
|
26
|
+
*
|
|
27
|
+
* Renders the *body* of an artifact — rendered markdown, sandboxed file
|
|
28
|
+
* preview iframe, or registry-resolved component — without any surrounding
|
|
29
|
+
* chrome. The artifact pane delegates its content area here, and the inline
|
|
30
|
+
* transcript block (display: "inline") consumes the same renderer, so both
|
|
31
|
+
* surfaces stay pixel- and behavior-identical.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type ArtifactPreviewViewMode = "rendered" | "source";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Clipboard payload for an artifact, shared by the pane copy control and the
|
|
38
|
+
* inline chrome copy button so both derive the same text:
|
|
39
|
+
* - plain markdown artifacts → the raw markdown
|
|
40
|
+
* - previewable file artifacts → the raw unfenced file source
|
|
41
|
+
* - component artifacts → pretty-printed `{ component, props }` JSON
|
|
42
|
+
*/
|
|
43
|
+
export function artifactCopyText(
|
|
44
|
+
record: PersonaArtifactRecord | undefined
|
|
45
|
+
): string {
|
|
46
|
+
if (!record) return "";
|
|
47
|
+
if (record.artifactType === "markdown") {
|
|
48
|
+
const raw = record.markdown ?? "";
|
|
49
|
+
return record.file ? extractFileSource(raw) : raw;
|
|
50
|
+
}
|
|
51
|
+
return JSON.stringify(
|
|
52
|
+
{ component: record.component, props: record.props },
|
|
53
|
+
null,
|
|
54
|
+
2
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolved body-layout options for the inline block. Threaded through
|
|
60
|
+
* {@link ArtifactPreviewContext.bodyLayout} by `artifact-inline.ts` (resolved
|
|
61
|
+
* from `features.artifacts.inlineBody`). When absent — the pane path — the body
|
|
62
|
+
* renders content-sized exactly as before; none of the fixed-window / follow /
|
|
63
|
+
* fade / status / transition behavior below engages.
|
|
64
|
+
*/
|
|
65
|
+
export type ArtifactBodyLayout = {
|
|
66
|
+
/** Streaming body: `"source"` shows the source window, `"status"` a placeholder. */
|
|
67
|
+
streamingView: "source" | "status";
|
|
68
|
+
/**
|
|
69
|
+
* Complete-state view: `"source"` always shows raw highlighted source (no
|
|
70
|
+
* iframe, no rendered markdown). `ctx.resolveViewMode` still wins when set.
|
|
71
|
+
*/
|
|
72
|
+
viewMode: "rendered" | "source";
|
|
73
|
+
/** Reserved streaming height (px) or `"auto"` (grow with content). */
|
|
74
|
+
streamingHeight: number | "auto";
|
|
75
|
+
/** Complete-state height cap (px) or `"auto"` (content-sized). */
|
|
76
|
+
completeHeight: number | "auto";
|
|
77
|
+
/** Tail-follow the newest lines while a numeric-height source window streams. */
|
|
78
|
+
followOutput: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Overflow handling for a fixed-height source window. `"scroll"` (default) is
|
|
81
|
+
* an internally scrollable, tail-following window; `"clip"` is a fixed-height
|
|
82
|
+
* window showing the TOP of the document with `overflow: hidden`, no internal
|
|
83
|
+
* scroll listeners, and no tail-follow (`resolveInlineBody` forces
|
|
84
|
+
* `followOutput` off and the bottom-only fade default in this mode).
|
|
85
|
+
*/
|
|
86
|
+
overflow: "scroll" | "clip";
|
|
87
|
+
/** Show a top edge fade on the fixed window when content is clipped above. */
|
|
88
|
+
fadeTop: boolean;
|
|
89
|
+
/** Show a bottom edge fade on the fixed window when content is clipped below. */
|
|
90
|
+
fadeBottom: boolean;
|
|
91
|
+
/** Wrap the streaming→complete swap in a View Transition when supported. */
|
|
92
|
+
transition: "auto" | "none";
|
|
93
|
+
/**
|
|
94
|
+
* What the inline block becomes once the artifact completes. `"inline"`
|
|
95
|
+
* (default) keeps the streamed body in place; `"card"` collapses the block to
|
|
96
|
+
* the compact reference card. Consumed only by the inline block component
|
|
97
|
+
* (`artifact-inline.ts`) — the shared preview renderer ignores it.
|
|
98
|
+
*/
|
|
99
|
+
completeDisplay: "inline" | "card";
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type ArtifactPreviewContext = {
|
|
103
|
+
config: AgentWidgetConfig;
|
|
104
|
+
/**
|
|
105
|
+
* Component registry used to resolve component artifacts. Defaults to the
|
|
106
|
+
* global `componentRegistry`.
|
|
107
|
+
*/
|
|
108
|
+
registry?: { get(name: string): ComponentRenderer | undefined };
|
|
109
|
+
/**
|
|
110
|
+
* Host hook for the pane's rendered/source toolbar toggle. Re-read on every
|
|
111
|
+
* `update()`; return `"source"` to show raw source instead of the preview
|
|
112
|
+
* for the given record. Only affects markdown and file records — component
|
|
113
|
+
* records always render through the registry. Defaults to `"rendered"`.
|
|
114
|
+
*/
|
|
115
|
+
resolveViewMode?: (record: PersonaArtifactRecord) => ArtifactPreviewViewMode;
|
|
116
|
+
/**
|
|
117
|
+
* Inline-block body layout (fixed streaming window, tail-follow, fade,
|
|
118
|
+
* status view). Set only on the inline path; when omitted the body is
|
|
119
|
+
* content-sized as before (pane path).
|
|
120
|
+
*/
|
|
121
|
+
bodyLayout?: ArtifactBodyLayout;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Module-level guard so only one inline body swap animates at a time — a burst
|
|
126
|
+
* of artifacts completing together must not stack overlapping View Transitions
|
|
127
|
+
* (each captures the whole document).
|
|
128
|
+
*/
|
|
129
|
+
let bodyTransitionInFlight = false;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* `view-transition-name` must be a valid CSS custom-ident; artifact ids may
|
|
133
|
+
* carry characters that aren't, so map anything outside `[A-Za-z0-9_-]` to `-`
|
|
134
|
+
* and prefix a stable namespace (idents also can't start with a digit).
|
|
135
|
+
*/
|
|
136
|
+
function sanitizeViewTransitionName(id: string): string {
|
|
137
|
+
const base = (id || "artifact").replace(/[^A-Za-z0-9_-]/g, "-").replace(/^-+/, "");
|
|
138
|
+
return "persona-artifact-vt-" + (base || "artifact");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run the streaming→complete body swap, animated via the View Transitions API
|
|
143
|
+
* when `transition` is `"auto"`, the API exists, `prefers-reduced-motion` is
|
|
144
|
+
* off, and no other body swap is mid-flight; otherwise swap instantly. The swap
|
|
145
|
+
* callback performs the actual DOM mutation either way, so callers always see
|
|
146
|
+
* the new DOM (synchronously on the fallback path).
|
|
147
|
+
*/
|
|
148
|
+
export function runArtifactBodyTransition(
|
|
149
|
+
target: HTMLElement | null,
|
|
150
|
+
transition: "auto" | "none",
|
|
151
|
+
id: string,
|
|
152
|
+
swap: () => void
|
|
153
|
+
): void {
|
|
154
|
+
const start =
|
|
155
|
+
typeof document !== "undefined"
|
|
156
|
+
? (
|
|
157
|
+
document as unknown as {
|
|
158
|
+
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
|
159
|
+
}
|
|
160
|
+
).startViewTransition
|
|
161
|
+
: undefined;
|
|
162
|
+
let reduceMotion = false;
|
|
163
|
+
try {
|
|
164
|
+
reduceMotion =
|
|
165
|
+
typeof window !== "undefined" &&
|
|
166
|
+
typeof window.matchMedia === "function" &&
|
|
167
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
168
|
+
} catch {
|
|
169
|
+
reduceMotion = false;
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
transition !== "auto" ||
|
|
173
|
+
!target ||
|
|
174
|
+
typeof start !== "function" ||
|
|
175
|
+
bodyTransitionInFlight ||
|
|
176
|
+
reduceMotion
|
|
177
|
+
) {
|
|
178
|
+
swap();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
bodyTransitionInFlight = true;
|
|
182
|
+
const name = sanitizeViewTransitionName(id);
|
|
183
|
+
target.style.setProperty("view-transition-name", name);
|
|
184
|
+
const done = () => {
|
|
185
|
+
bodyTransitionInFlight = false;
|
|
186
|
+
target.style.removeProperty("view-transition-name");
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
const vt = start.call(document, () => {
|
|
190
|
+
swap();
|
|
191
|
+
});
|
|
192
|
+
vt.finished.then(done, done);
|
|
193
|
+
} catch {
|
|
194
|
+
done();
|
|
195
|
+
swap();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export type ArtifactPreviewBodyHandle = {
|
|
200
|
+
/**
|
|
201
|
+
* Stable wrapper element (styled `display: contents`, so children lay out
|
|
202
|
+
* as if they were direct children of the host container). Keep it attached
|
|
203
|
+
* across `update()` calls: re-attaching would reload a live file-preview
|
|
204
|
+
* iframe.
|
|
205
|
+
*/
|
|
206
|
+
el: HTMLElement;
|
|
207
|
+
/** Apply streaming deltas / status changes (may be a different record). */
|
|
208
|
+
update(record: PersonaArtifactRecord): void;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const PRE_CLASS =
|
|
212
|
+
"persona-font-mono persona-text-xs persona-whitespace-pre-wrap persona-break-words persona-text-persona-primary";
|
|
213
|
+
const MARKDOWN_WRAP_CLASS =
|
|
214
|
+
"persona-text-sm persona-leading-relaxed persona-markdown-bubble";
|
|
215
|
+
|
|
216
|
+
/** Inspector card shown when a component artifact has no registered renderer. */
|
|
217
|
+
function fallbackComponentCard(record: PersonaArtifactRecord): HTMLElement {
|
|
218
|
+
const card = createElement(
|
|
219
|
+
"div",
|
|
220
|
+
"persona-rounded-lg persona-border persona-border-persona-border persona-p-3 persona-text-persona-primary"
|
|
221
|
+
);
|
|
222
|
+
const title = createElement("div", "persona-font-semibold persona-text-sm persona-mb-2");
|
|
223
|
+
title.textContent = record.component ? `Component: ${record.component}` : "Component";
|
|
224
|
+
const pre = createElement(
|
|
225
|
+
"pre",
|
|
226
|
+
"persona-font-mono persona-text-xs persona-whitespace-pre-wrap persona-overflow-x-auto"
|
|
227
|
+
);
|
|
228
|
+
pre.textContent = JSON.stringify(record.props ?? {}, null, 2);
|
|
229
|
+
card.appendChild(title);
|
|
230
|
+
card.appendChild(pre);
|
|
231
|
+
return card;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Render context handed to a custom `filePreview.loading.renderIndicator`. */
|
|
235
|
+
type PreviewIndicatorContext = {
|
|
236
|
+
artifactId: string;
|
|
237
|
+
config: AgentWidgetConfig;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
type PreviewLoadingObject = {
|
|
241
|
+
delayMs?: number;
|
|
242
|
+
minVisibleMs?: number;
|
|
243
|
+
timeoutMs?: number;
|
|
244
|
+
injectReadySignal?: boolean;
|
|
245
|
+
label?: string | false;
|
|
246
|
+
labelDelayMs?: number;
|
|
247
|
+
renderIndicator?: (ctx: PreviewIndicatorContext) => HTMLElement | null;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Resolved preview-loading options (see `filePreview.loading`). */
|
|
251
|
+
type ResolvedPreviewLoading = {
|
|
252
|
+
enabled: boolean;
|
|
253
|
+
delayMs: number;
|
|
254
|
+
minVisibleMs: number;
|
|
255
|
+
timeoutMs: number;
|
|
256
|
+
injectReadySignal: boolean;
|
|
257
|
+
/** Escalation label text, or `false` for an icon-only indicator forever. */
|
|
258
|
+
label: string | false;
|
|
259
|
+
/** Delay before the escalation label fades in, from when the overlay shows. */
|
|
260
|
+
labelDelayMs: number;
|
|
261
|
+
/** Host override for the whole indicator; `undefined` uses the spinner+label. */
|
|
262
|
+
renderIndicator?: (ctx: PreviewIndicatorContext) => HTMLElement | null;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve `filePreview.loading` to concrete values. `false` disables the overlay
|
|
267
|
+
* and injected reporter entirely; `true` / `undefined` / an object with holes
|
|
268
|
+
* fall back to the documented defaults.
|
|
269
|
+
*/
|
|
270
|
+
function resolvePreviewLoading(
|
|
271
|
+
loading: boolean | PreviewLoadingObject | undefined
|
|
272
|
+
): ResolvedPreviewLoading {
|
|
273
|
+
if (loading === false) {
|
|
274
|
+
return {
|
|
275
|
+
enabled: false,
|
|
276
|
+
delayMs: 0,
|
|
277
|
+
minVisibleMs: 0,
|
|
278
|
+
timeoutMs: 0,
|
|
279
|
+
injectReadySignal: false,
|
|
280
|
+
label: "Starting preview...",
|
|
281
|
+
labelDelayMs: 2000,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const o = loading && typeof loading === "object" ? loading : undefined;
|
|
285
|
+
return {
|
|
286
|
+
enabled: true,
|
|
287
|
+
delayMs: o?.delayMs ?? 200,
|
|
288
|
+
minVisibleMs: o?.minVisibleMs ?? 300,
|
|
289
|
+
timeoutMs: o?.timeoutMs ?? 8000,
|
|
290
|
+
injectReadySignal: o?.injectReadySignal !== false,
|
|
291
|
+
// `label: false` disables the escalation text; a string overrides the default.
|
|
292
|
+
label: o?.label === false ? false : (o?.label ?? "Starting preview..."),
|
|
293
|
+
labelDelayMs: o?.labelDelayMs ?? 2000,
|
|
294
|
+
renderIndicator: o?.renderIndicator,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** How long the overlay fade-out runs before removal; matches the CSS transition. */
|
|
299
|
+
const PREVIEW_FADE_MS = 220;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Random token tying a ready `postMessage` to the exact iframe build that
|
|
303
|
+
* injected it. Collision is harmless — worst case an early overlay dismiss, not
|
|
304
|
+
* a security issue — so `Math.random` is an acceptable fallback for `crypto`.
|
|
305
|
+
*/
|
|
306
|
+
function makePreviewToken(): string {
|
|
307
|
+
try {
|
|
308
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
309
|
+
const a = new Uint32Array(2);
|
|
310
|
+
crypto.getRandomValues(a);
|
|
311
|
+
return a[0].toString(36) + a[1].toString(36);
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
/* fall through */
|
|
315
|
+
}
|
|
316
|
+
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Inline reporter APPENDED (never prepended — a script before `<!doctype html>`
|
|
321
|
+
* would trip quirks mode) to the srcdoc. On `window` load it waits a double
|
|
322
|
+
* `requestAnimationFrame` (a cheap "content has painted" heuristic) then posts
|
|
323
|
+
* the ready signal to the parent. The `token` is JSON-quoted so it can't break
|
|
324
|
+
* out of the string literal.
|
|
325
|
+
*/
|
|
326
|
+
function buildReadyReporter(token: string): string {
|
|
327
|
+
return (
|
|
328
|
+
"\n<script>(function(){var d=false;var t=" +
|
|
329
|
+
JSON.stringify(token) +
|
|
330
|
+
";function r(){if(d)return;d=true;try{parent.postMessage({persona:'artifact-preview-ready',token:t},'*');}catch(e){}}" +
|
|
331
|
+
// Race a double-rAF (paint heuristic) against a short timeout: the parent's
|
|
332
|
+
// opaque loading overlay can make the browser treat this (process-isolated)
|
|
333
|
+
// iframe as occluded and throttle its rendering, in which case rAF callbacks
|
|
334
|
+
// stall indefinitely — the overlay would delay its own dismissal signal. The
|
|
335
|
+
// timeout keeps the signal bounded; the rAF path stays paint-accurate when
|
|
336
|
+
// frames are being produced.
|
|
337
|
+
"function s(){if(typeof requestAnimationFrame==='function'){requestAnimationFrame(function(){requestAnimationFrame(r);});}setTimeout(r,150);}" +
|
|
338
|
+
// Split the closing tag so this JS string can't prematurely close a host
|
|
339
|
+
// <script> if the bundle is ever inlined; the srcdoc still receives </script>.
|
|
340
|
+
"if(document.readyState==='complete'){s();}else{window.addEventListener('load',s);}})();</scr" +
|
|
341
|
+
"ipt>"
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Overlay handle: the element plus a callback that fades the escalation label in. */
|
|
346
|
+
type PreviewOverlayHandle = {
|
|
347
|
+
el: HTMLElement;
|
|
348
|
+
/**
|
|
349
|
+
* Reveal the escalation label. No-op when the label is disabled
|
|
350
|
+
* (`label: false`) or a custom `renderIndicator` owns the content.
|
|
351
|
+
*/
|
|
352
|
+
revealLabel: () => void;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Build the preview-loading overlay content.
|
|
357
|
+
*
|
|
358
|
+
* Default indicator: an icon spinner with NO text (icon-first per HIG/Geist/
|
|
359
|
+
* Sandpack — text-only "loading" is used by no reputable preview surface). The
|
|
360
|
+
* optional escalation label is rendered hidden and only faded in later, from
|
|
361
|
+
* `setupPreviewLoading`, once the wait crosses `labelDelayMs`.
|
|
362
|
+
*
|
|
363
|
+
* `renderIndicator` (host override): called once with `{ artifactId, config }`.
|
|
364
|
+
* A returned element replaces the spinner + label entirely (the escalation
|
|
365
|
+
* logic is skipped — the host owns the content); `null`/`undefined` or a thrown
|
|
366
|
+
* error falls back to the default, mirroring the inlineActions null-falls-back
|
|
367
|
+
* pattern.
|
|
368
|
+
*/
|
|
369
|
+
function buildPreviewOverlay(
|
|
370
|
+
artifactId: string,
|
|
371
|
+
config: AgentWidgetConfig,
|
|
372
|
+
opts: ResolvedPreviewLoading
|
|
373
|
+
): PreviewOverlayHandle {
|
|
374
|
+
const overlay = createElement("div", "persona-artifact-frame-loading");
|
|
375
|
+
|
|
376
|
+
if (opts.renderIndicator) {
|
|
377
|
+
try {
|
|
378
|
+
const custom = opts.renderIndicator({ artifactId, config });
|
|
379
|
+
if (custom) {
|
|
380
|
+
overlay.appendChild(custom);
|
|
381
|
+
return { el: overlay, revealLabel: () => {} };
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
/* fall through to the default spinner + label */
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const indicator = createElement("div", "persona-artifact-frame-loading-indicator");
|
|
389
|
+
indicator.appendChild(createSpinner());
|
|
390
|
+
|
|
391
|
+
let labelEl: HTMLElement | null = null;
|
|
392
|
+
if (opts.label !== false) {
|
|
393
|
+
// Plain, calm text — no shimmer (deliberate escalation, per the research).
|
|
394
|
+
// Hidden until revealLabel() adds the --visible modifier (opacity transition).
|
|
395
|
+
labelEl = createElement("div", "persona-artifact-frame-loading-text");
|
|
396
|
+
labelEl.textContent = opts.label;
|
|
397
|
+
indicator.appendChild(labelEl);
|
|
398
|
+
}
|
|
399
|
+
overlay.appendChild(indicator);
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
el: overlay,
|
|
403
|
+
revealLabel: () => {
|
|
404
|
+
if (labelEl) {
|
|
405
|
+
labelEl.classList.add("persona-artifact-frame-loading-text--visible");
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Drive the preview-loading overlay for one built iframe. Plain DOM + timers, no
|
|
413
|
+
* deps. Returns a teardown that removes the window/message + iframe/load
|
|
414
|
+
* listeners, clears every timer, and drops the overlay immediately — called when
|
|
415
|
+
* the iframe is replaced/rebuilt so the reuse path never stacks listeners.
|
|
416
|
+
*
|
|
417
|
+
* Dismiss rules:
|
|
418
|
+
* - injection on (`token` set): dismiss on the matched `postMessage`, or on the
|
|
419
|
+
* hard timeout. The iframe `load` event is intentionally ignored — the message
|
|
420
|
+
* is strictly later and more accurate (post-DOMContentLoaded rendering).
|
|
421
|
+
* - injection off (`token` null): dismiss on `load` + a double rAF, or the timeout.
|
|
422
|
+
* The iframe itself is never hidden; the opaque themed overlay covers pre-paint.
|
|
423
|
+
*/
|
|
424
|
+
function setupPreviewLoading(
|
|
425
|
+
frame: HTMLElement,
|
|
426
|
+
iframe: HTMLIFrameElement,
|
|
427
|
+
token: string | null,
|
|
428
|
+
opts: ResolvedPreviewLoading,
|
|
429
|
+
artifactId: string,
|
|
430
|
+
config: AgentWidgetConfig
|
|
431
|
+
): () => void {
|
|
432
|
+
let overlay: HTMLElement | null = null;
|
|
433
|
+
let shownAt = 0;
|
|
434
|
+
let settled = false;
|
|
435
|
+
const timers = new Set<number>();
|
|
436
|
+
const clearTimers = () => {
|
|
437
|
+
timers.forEach((id) => clearTimeout(id));
|
|
438
|
+
timers.clear();
|
|
439
|
+
};
|
|
440
|
+
const addTimer = (fn: () => void, ms: number): void => {
|
|
441
|
+
const id = setTimeout(() => {
|
|
442
|
+
timers.delete(id);
|
|
443
|
+
fn();
|
|
444
|
+
}, ms) as unknown as number;
|
|
445
|
+
timers.add(id);
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const removeOverlay = () => {
|
|
449
|
+
if (overlay && overlay.parentElement) overlay.remove();
|
|
450
|
+
overlay = null;
|
|
451
|
+
};
|
|
452
|
+
const showOverlay = () => {
|
|
453
|
+
if (overlay || settled) return;
|
|
454
|
+
const built = buildPreviewOverlay(artifactId, config, opts);
|
|
455
|
+
overlay = built.el;
|
|
456
|
+
frame.appendChild(overlay);
|
|
457
|
+
shownAt = Date.now();
|
|
458
|
+
// Escalation label timer starts NOW (overlay-visible), not from iframe
|
|
459
|
+
// creation, and lives in the same timer Set so teardown/settle clear it —
|
|
460
|
+
// no post-teardown DOM mutation. No-op when label is disabled / a custom
|
|
461
|
+
// indicator owns the content.
|
|
462
|
+
if (opts.label !== false) addTimer(built.revealLabel, opts.labelDelayMs);
|
|
463
|
+
};
|
|
464
|
+
const fadeOut = () => {
|
|
465
|
+
if (!overlay) return;
|
|
466
|
+
overlay.classList.add("persona-artifact-frame-loading--out");
|
|
467
|
+
addTimer(removeOverlay, PREVIEW_FADE_MS);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const detachListeners = () => {
|
|
471
|
+
if (token !== null) {
|
|
472
|
+
if (typeof window !== "undefined") {
|
|
473
|
+
window.removeEventListener("message", onMessage);
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
iframe.removeEventListener("load", onLoad);
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// Ready: stop listening + the delay/timeout timers, then either drop a
|
|
481
|
+
// never-shown overlay's timers or enforce minVisibleMs before fading.
|
|
482
|
+
const settle = () => {
|
|
483
|
+
if (settled) return;
|
|
484
|
+
settled = true;
|
|
485
|
+
detachListeners();
|
|
486
|
+
clearTimers();
|
|
487
|
+
if (!overlay) return;
|
|
488
|
+
const remaining = Math.max(0, opts.minVisibleMs - (Date.now() - shownAt));
|
|
489
|
+
if (remaining > 0) addTimer(fadeOut, remaining);
|
|
490
|
+
else fadeOut();
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
function onMessage(e: MessageEvent) {
|
|
494
|
+
if (token === null) return;
|
|
495
|
+
const d = e.data as { persona?: unknown; token?: unknown } | null;
|
|
496
|
+
if (!d || d.persona !== "artifact-preview-ready" || d.token !== token) return;
|
|
497
|
+
// An opaque-origin srcdoc reports its origin as the string "null", so origin
|
|
498
|
+
// alone is worthless — match the source window identity instead.
|
|
499
|
+
if (e.source !== iframe.contentWindow) return;
|
|
500
|
+
settle();
|
|
501
|
+
}
|
|
502
|
+
function onLoad() {
|
|
503
|
+
const raf = (cb: () => void) => {
|
|
504
|
+
if (typeof requestAnimationFrame === "function") {
|
|
505
|
+
requestAnimationFrame(() => cb());
|
|
506
|
+
} else {
|
|
507
|
+
setTimeout(cb, 0);
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
raf(() => raf(() => settle()));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
addTimer(showOverlay, opts.delayMs);
|
|
514
|
+
addTimer(settle, opts.timeoutMs);
|
|
515
|
+
if (token !== null) {
|
|
516
|
+
if (typeof window !== "undefined") {
|
|
517
|
+
window.addEventListener("message", onMessage);
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
iframe.addEventListener("load", onLoad);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return () => {
|
|
524
|
+
settled = true;
|
|
525
|
+
clearTimers();
|
|
526
|
+
detachListeners();
|
|
527
|
+
removeOverlay();
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Render an artifact's preview body into a stable wrapper element.
|
|
533
|
+
*
|
|
534
|
+
* Covers every body kind the artifact pane renders:
|
|
535
|
+
* - markdown artifacts → sanitized rendered markdown (or raw source when the
|
|
536
|
+
* host resolves `"source"` view mode)
|
|
537
|
+
* - previewable file artifacts (markdown records carrying `file` meta) →
|
|
538
|
+
* sandboxed `iframe srcdoc` once complete, raw source while streaming or
|
|
539
|
+
* when `features.artifacts.filePreview` disables the preview
|
|
540
|
+
* - component artifacts → registry-resolved renderer, falling back to the
|
|
541
|
+
* inspector card for unknown components or renderer failures
|
|
542
|
+
*
|
|
543
|
+
* `update()` re-renders in place. Two nodes are reused across updates rather
|
|
544
|
+
* than rebuilt: the file-preview iframe (reused while the artifact id + source
|
|
545
|
+
* are unchanged, so idle re-renders don't reload it) and the source window's
|
|
546
|
+
* `<pre>`/`<code>` (only the highlighted content is replaced per streaming
|
|
547
|
+
* delta, so a fixed window's scroll position and reserved height survive).
|
|
548
|
+
*
|
|
549
|
+
* When `ctx.bodyLayout` is set (inline path) the streaming source renders into
|
|
550
|
+
* a fixed-height scroll window with optional tail-follow and edge fades, or —
|
|
551
|
+
* with `streamingView: "status"` — a centered placeholder; the pane path
|
|
552
|
+
* (no `bodyLayout`) renders content-sized exactly as before.
|
|
553
|
+
*/
|
|
554
|
+
export function renderArtifactPreviewBody(
|
|
555
|
+
record: PersonaArtifactRecord,
|
|
556
|
+
ctx: ArtifactPreviewContext
|
|
557
|
+
): ArtifactPreviewBodyHandle {
|
|
558
|
+
const { config } = ctx;
|
|
559
|
+
const registry = ctx.registry ?? componentRegistry;
|
|
560
|
+
const layout = ctx.bodyLayout;
|
|
561
|
+
|
|
562
|
+
const md = config.markdown ? createMarkdownProcessorFromConfig(config.markdown) : null;
|
|
563
|
+
const sanitize = resolveSanitizer(config.sanitize);
|
|
564
|
+
// Degraded path (IIFE/CDN build before the `markdown-parsers.js` chunk
|
|
565
|
+
// resolves): the markdown processor falls back to escapeHtml, and the default
|
|
566
|
+
// sanitizer's own fallback is escapeHtml too, so sanitizing that output would
|
|
567
|
+
// escape a SECOND time and display literal entities (mirrors
|
|
568
|
+
// `buildPostprocessor` in ui.ts). Chat messages self-heal via the
|
|
569
|
+
// parser-ready re-render at the end of createAgentExperience, but this body
|
|
570
|
+
// only re-renders on update() — so an artifact upserted right after init
|
|
571
|
+
// would stay escaped until the next update. When a render takes the fallback,
|
|
572
|
+
// self-heal through the shared `onMarkdownParsersReady` registry — the single
|
|
573
|
+
// self-heal path every markdown surface shares — instead of an ad-hoc
|
|
574
|
+
// `loadMarkdownParsers().then(...)`. The `parserRerenderScheduled` flag keeps
|
|
575
|
+
// repeated renders from stacking subscriptions. The returned unsubscribe is
|
|
576
|
+
// intentionally ignored: the subscription is fire-once and the preview handle
|
|
577
|
+
// exposes no teardown hook to call it from.
|
|
578
|
+
let current = record;
|
|
579
|
+
let parserRerenderScheduled = false;
|
|
580
|
+
const toHtml = (text: string) => {
|
|
581
|
+
const parsersReady = getMarkdownParsersSync() !== null;
|
|
582
|
+
if (md && !parsersReady && !parserRerenderScheduled) {
|
|
583
|
+
parserRerenderScheduled = true;
|
|
584
|
+
onMarkdownParsersReady(() => render(current));
|
|
585
|
+
}
|
|
586
|
+
const raw = md ? md(text) : escapeHtml(text);
|
|
587
|
+
// escapeHtml output is already inert — only real markdown HTML is sanitized.
|
|
588
|
+
return md && parsersReady && sanitize ? sanitize(raw) : raw;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const el = createElement("div", "persona-artifact-preview-body");
|
|
592
|
+
|
|
593
|
+
// File-preview reuse: re-appending a detached iframe reloads its srcdoc, so
|
|
594
|
+
// keep the positioned frame (wrapper + iframe + overlay) and skip rebuilding
|
|
595
|
+
// when the artifact + source are unchanged. `filePreviewLoadingCleanup` tears
|
|
596
|
+
// down the current frame's overlay state machine (listeners + timers) before a
|
|
597
|
+
// rebuild so the reuse path never stacks listeners.
|
|
598
|
+
let filePreviewFrame: HTMLElement | null = null;
|
|
599
|
+
let filePreviewKey: string | null = null;
|
|
600
|
+
let filePreviewLoadingCleanup: (() => void) | null = null;
|
|
601
|
+
const resetFilePreview = () => {
|
|
602
|
+
if (filePreviewLoadingCleanup) {
|
|
603
|
+
filePreviewLoadingCleanup();
|
|
604
|
+
filePreviewLoadingCleanup = null;
|
|
605
|
+
}
|
|
606
|
+
filePreviewFrame = null;
|
|
607
|
+
filePreviewKey = null;
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
// Source-window reuse: the top node appended to `el` for the source path —
|
|
611
|
+
// the scroll window on the inline path (bodyLayout set) or the bare <pre> on
|
|
612
|
+
// the pane path. Kept stable across deltas so `sourceCode`'s content can be
|
|
613
|
+
// swapped in place without resetting scroll position or reserved height.
|
|
614
|
+
let sourceRoot: HTMLElement | null = null;
|
|
615
|
+
let sourceCode: HTMLElement | null = null;
|
|
616
|
+
let sourceKey: string | null = null;
|
|
617
|
+
// Per-window "the reader deliberately scrolled away from the tail" latch.
|
|
618
|
+
// Set by an upward wheel / a touch drag (real intent), NEVER by a `scroll`
|
|
619
|
+
// event — growth-induced scroll is the industry-wide freeze bug we avoid.
|
|
620
|
+
// Cleared when the reader lands back near the bottom or the stream completes.
|
|
621
|
+
// Lives in this closure so it resets with the window (resetSource).
|
|
622
|
+
let escaped = false;
|
|
623
|
+
const resetSource = () => {
|
|
624
|
+
sourceRoot = null;
|
|
625
|
+
sourceCode = null;
|
|
626
|
+
sourceKey = null;
|
|
627
|
+
escaped = false;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// Status-view reuse: kept stable across streaming deltas so the "Generating …"
|
|
631
|
+
// animation doesn't restart on every delta.
|
|
632
|
+
let statusView: HTMLElement | null = null;
|
|
633
|
+
let statusKey: string | null = null;
|
|
634
|
+
const resetStatus = () => {
|
|
635
|
+
statusView = null;
|
|
636
|
+
statusKey = null;
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const NEAR_BOTTOM_PX = 40;
|
|
640
|
+
let followRaf = 0;
|
|
641
|
+
const scheduleFrame = (cb: () => void): number => {
|
|
642
|
+
if (typeof requestAnimationFrame === "function") {
|
|
643
|
+
return requestAnimationFrame(() => cb());
|
|
644
|
+
}
|
|
645
|
+
return setTimeout(cb, 0) as unknown as number;
|
|
646
|
+
};
|
|
647
|
+
const isNearBottom = (sc: HTMLElement): boolean =>
|
|
648
|
+
sc.scrollHeight - sc.clientHeight - sc.scrollTop <= NEAR_BOTTOM_PX;
|
|
649
|
+
const updateFadeClasses = (sc: HTMLElement) => {
|
|
650
|
+
if (!layout) return;
|
|
651
|
+
const overflow = sc.scrollHeight - sc.clientHeight > 1;
|
|
652
|
+
const distToBottom = sc.scrollHeight - sc.clientHeight - sc.scrollTop;
|
|
653
|
+
sc.classList.toggle(
|
|
654
|
+
"persona-artifact-fade-top",
|
|
655
|
+
layout.fadeTop && overflow && sc.scrollTop > 1
|
|
656
|
+
);
|
|
657
|
+
sc.classList.toggle(
|
|
658
|
+
"persona-artifact-fade-bottom",
|
|
659
|
+
layout.fadeBottom && overflow && distToBottom > 1
|
|
660
|
+
);
|
|
661
|
+
};
|
|
662
|
+
const stickToBottom = (sc: HTMLElement) => {
|
|
663
|
+
if (followRaf) return;
|
|
664
|
+
followRaf = scheduleFrame(() => {
|
|
665
|
+
followRaf = 0;
|
|
666
|
+
sc.scrollTop = sc.scrollHeight;
|
|
667
|
+
updateFadeClasses(sc);
|
|
668
|
+
});
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
// Render the syntax-highlighted source. On the inline path the <pre> lives in
|
|
672
|
+
// a scroll window (`fixed` → reserved height + internal scroll + follow/fade);
|
|
673
|
+
// on the pane path it is a bare child of `el`, byte-identical to before.
|
|
674
|
+
const renderSource = (
|
|
675
|
+
text: string,
|
|
676
|
+
opts: { language?: string; path?: string } | undefined,
|
|
677
|
+
rec: PersonaArtifactRecord,
|
|
678
|
+
fixed: boolean
|
|
679
|
+
) => {
|
|
680
|
+
const useWindow = !!layout;
|
|
681
|
+
const key = rec.id + "|" + (useWindow ? "w" : "p");
|
|
682
|
+
if (!sourceRoot || sourceKey !== key || sourceRoot.parentElement !== el) {
|
|
683
|
+
resetFilePreview();
|
|
684
|
+
resetStatus();
|
|
685
|
+
el.replaceChildren();
|
|
686
|
+
const pre = createElement("pre", PRE_CLASS + " persona-code-pre");
|
|
687
|
+
const code = createElement("code", "persona-code");
|
|
688
|
+
pre.appendChild(code);
|
|
689
|
+
if (useWindow) {
|
|
690
|
+
const scroll = createElement("div", "persona-artifact-source-window");
|
|
691
|
+
scroll.appendChild(pre);
|
|
692
|
+
el.appendChild(scroll);
|
|
693
|
+
// Clip mode (overflow: "clip") is a static top-of-document window:
|
|
694
|
+
// `overflow: hidden`, no internal scroll, no tail-follow — so it wires
|
|
695
|
+
// none of the scroll/wheel/touch intent listeners below (there is
|
|
696
|
+
// nothing to scroll and nothing to unstick).
|
|
697
|
+
const clip = layout?.overflow === "clip";
|
|
698
|
+
if (!clip && typeof scroll.addEventListener === "function") {
|
|
699
|
+
// Does the window actually clip content? Gesture intent only counts
|
|
700
|
+
// while there is somewhere to scroll to.
|
|
701
|
+
const overflows = () => scroll.scrollHeight - scroll.clientHeight > 1;
|
|
702
|
+
// `scroll` fires for BOTH user scrolling and growth-induced reflow, so
|
|
703
|
+
// it never SETS the escaped latch (that misread is the freeze bug).
|
|
704
|
+
// It only clears the latch when the reader has returned to the bottom.
|
|
705
|
+
scroll.addEventListener(
|
|
706
|
+
"scroll",
|
|
707
|
+
() => {
|
|
708
|
+
if (isNearBottom(scroll)) escaped = false;
|
|
709
|
+
updateFadeClasses(scroll);
|
|
710
|
+
},
|
|
711
|
+
{ passive: true }
|
|
712
|
+
);
|
|
713
|
+
// An upward wheel over an overflowing window is deliberate: the reader
|
|
714
|
+
// wants to look back, so stop pinning the tail (matches
|
|
715
|
+
// use-stick-to-bottom's `deltaY < 0` unstick).
|
|
716
|
+
scroll.addEventListener(
|
|
717
|
+
"wheel",
|
|
718
|
+
(e) => {
|
|
719
|
+
if (overflows() && e.deltaY < 0) escaped = true;
|
|
720
|
+
},
|
|
721
|
+
{ passive: true }
|
|
722
|
+
);
|
|
723
|
+
// Touch drags carry no reliable direction here, so any touch move over
|
|
724
|
+
// an overflowing window is treated as taking manual control; the scroll
|
|
725
|
+
// listener re-engages follow once they flick back to the bottom.
|
|
726
|
+
scroll.addEventListener(
|
|
727
|
+
"touchmove",
|
|
728
|
+
() => {
|
|
729
|
+
if (overflows()) escaped = true;
|
|
730
|
+
},
|
|
731
|
+
{ passive: true }
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
sourceRoot = scroll;
|
|
735
|
+
} else {
|
|
736
|
+
el.appendChild(pre);
|
|
737
|
+
sourceRoot = pre;
|
|
738
|
+
}
|
|
739
|
+
sourceCode = code;
|
|
740
|
+
sourceKey = key;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const scroll = useWindow ? sourceRoot : null;
|
|
744
|
+
if (scroll) {
|
|
745
|
+
scroll.classList.toggle("persona-artifact-source-window--fixed", fixed);
|
|
746
|
+
// Clip windows carry both classes: --fixed supplies the reserved height +
|
|
747
|
+
// fill-capable detection (artifact-inline.ts), and --clip overrides its
|
|
748
|
+
// overflow-y: auto with overflow: hidden (declared later in widget.css).
|
|
749
|
+
scroll.classList.toggle(
|
|
750
|
+
"persona-artifact-source-window--clip",
|
|
751
|
+
fixed && layout?.overflow === "clip"
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
// Measure before the swap: tail-follow only when the reader was already at
|
|
755
|
+
// the bottom (don't fight a reader who scrolled up).
|
|
756
|
+
const wasNearBottom = scroll ? isNearBottom(scroll) : true;
|
|
757
|
+
// Syntax highlighting + line-number gutter. The built fragment's
|
|
758
|
+
// textContent equals `text` verbatim; the DOM is span/#text only (no
|
|
759
|
+
// innerHTML) so it stays safe regardless of `sanitize` config.
|
|
760
|
+
sourceCode!.replaceChildren(highlightCode(text, opts?.language, opts?.path));
|
|
761
|
+
if (scroll) {
|
|
762
|
+
// Follow only while streaming; the complete render keeps whatever scroll
|
|
763
|
+
// position the window ended on. Two gates stop the pin: the `escaped`
|
|
764
|
+
// latch (an explicit wheel-up / touch gesture — sharp intent detection)
|
|
765
|
+
// and the positional `wasNearBottom` fallback (covers programmatic and
|
|
766
|
+
// keyboard scrolling, which fire neither wheel nor touch). Completion
|
|
767
|
+
// clears the latch so a reused window follows the next stream from scratch.
|
|
768
|
+
const streaming = rec.status !== "complete";
|
|
769
|
+
if (fixed && streaming && layout?.followOutput && !escaped && wasNearBottom) {
|
|
770
|
+
stickToBottom(scroll);
|
|
771
|
+
} else if (!streaming) {
|
|
772
|
+
escaped = false;
|
|
773
|
+
}
|
|
774
|
+
updateFadeClasses(scroll);
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const renderMarkdown = (text: string) => {
|
|
779
|
+
resetFilePreview();
|
|
780
|
+
resetSource();
|
|
781
|
+
resetStatus();
|
|
782
|
+
el.replaceChildren();
|
|
783
|
+
const wrap = createElement("div", MARKDOWN_WRAP_CLASS);
|
|
784
|
+
wrap.innerHTML = toHtml(text);
|
|
785
|
+
el.appendChild(wrap);
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
// A quiet, reserved-height placeholder shown while streaming when
|
|
789
|
+
// `streamingView: "status"`. Reuses the same animated status the chrome uses.
|
|
790
|
+
//
|
|
791
|
+
// The status VIEW element is reused across streaming deltas (rebuilding it
|
|
792
|
+
// would restart the loading animation), but the label/detail are re-resolved
|
|
793
|
+
// and re-applied on every update — that's what makes a dynamic `statusLabel`
|
|
794
|
+
// (live counters via `{ label, detail }`) tick. `applyArtifactStatus` keeps
|
|
795
|
+
// the animated label span stable, only re-applying it when its text changes.
|
|
796
|
+
const renderStatusView = (rec: PersonaArtifactRecord) => {
|
|
797
|
+
const key = rec.id;
|
|
798
|
+
const artifactsCfg = config.features?.artifacts;
|
|
799
|
+
const resolved = resolveArtifactStatusLabel(rec, artifactsCfg, "status-body");
|
|
800
|
+
if (statusView && statusKey === key && statusView.parentElement === el) {
|
|
801
|
+
const textEl = statusView.querySelector<HTMLElement>(
|
|
802
|
+
".persona-artifact-status-view-text"
|
|
803
|
+
);
|
|
804
|
+
if (textEl) applyArtifactStatus(textEl, resolved, artifactsCfg);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
resetFilePreview();
|
|
808
|
+
resetSource();
|
|
809
|
+
el.replaceChildren();
|
|
810
|
+
const wrap = createElement("div", "persona-artifact-status-view");
|
|
811
|
+
const textEl = createElement("div", "persona-artifact-status-view-text");
|
|
812
|
+
applyArtifactStatus(textEl, resolved, artifactsCfg);
|
|
813
|
+
wrap.appendChild(textEl);
|
|
814
|
+
el.appendChild(wrap);
|
|
815
|
+
statusView = wrap;
|
|
816
|
+
statusKey = key;
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const render = (rec: PersonaArtifactRecord) => {
|
|
820
|
+
current = rec;
|
|
821
|
+
const viewMode = ctx.resolveViewMode?.(rec) ?? layout?.viewMode ?? "rendered";
|
|
822
|
+
const fileMeta = rec.artifactType === "markdown" ? rec.file : undefined;
|
|
823
|
+
const isStreaming = rec.status !== "complete";
|
|
824
|
+
// Drop the status-label elapsed tracking once the artifact completes so the
|
|
825
|
+
// module-level map can't grow without bound (no-op for untracked ids / the
|
|
826
|
+
// pane path).
|
|
827
|
+
if (!isStreaming) clearArtifactStatusTracking(rec.id);
|
|
828
|
+
// Numeric height for the current state → fixed window (source view only).
|
|
829
|
+
// The complete state sizes the same inner window the streaming state does
|
|
830
|
+
// (never the padded body wrapper), so a source→source completion is
|
|
831
|
+
// geometry-identical and shifts nothing.
|
|
832
|
+
const stateHeight = isStreaming
|
|
833
|
+
? layout?.streamingHeight
|
|
834
|
+
: layout?.completeHeight;
|
|
835
|
+
const fixed = !!layout && typeof stateHeight === "number";
|
|
836
|
+
|
|
837
|
+
// Streaming status placeholder (inline only; gated by bodyLayout).
|
|
838
|
+
if (layout?.streamingView === "status" && isStreaming) {
|
|
839
|
+
renderStatusView(rec);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Previewable file artifact branch (markdown artifact carrying `file` meta).
|
|
844
|
+
if (fileMeta) {
|
|
845
|
+
const source = extractFileSource(rec.markdown ?? "");
|
|
846
|
+
const kind = fileKindOf(fileMeta);
|
|
847
|
+
const previewEnabled = config.features?.artifacts?.filePreview?.enabled !== false;
|
|
848
|
+
const wantIframe =
|
|
849
|
+
!isStreaming &&
|
|
850
|
+
viewMode === "rendered" &&
|
|
851
|
+
previewEnabled &&
|
|
852
|
+
(kind === "html" || kind === "svg");
|
|
853
|
+
|
|
854
|
+
if (wantIframe) {
|
|
855
|
+
// NUL separator: cannot appear in an id, so ids and sources never
|
|
856
|
+
// collide across the boundary.
|
|
857
|
+
const key = rec.id + "\u0000" + source;
|
|
858
|
+
// Reuse the existing frame when nothing changed so idle re-renders don't
|
|
859
|
+
// reload the iframe (re-appending a detached iframe reloads its srcdoc)
|
|
860
|
+
// or restart its loading overlay / stack a second message listener.
|
|
861
|
+
if (
|
|
862
|
+
filePreviewFrame &&
|
|
863
|
+
filePreviewKey === key &&
|
|
864
|
+
filePreviewFrame.parentElement === el
|
|
865
|
+
) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
resetSource();
|
|
869
|
+
resetStatus();
|
|
870
|
+
// Tear down any prior frame's overlay state machine before rebuilding.
|
|
871
|
+
resetFilePreview();
|
|
872
|
+
el.replaceChildren();
|
|
873
|
+
|
|
874
|
+
const fp = config.features?.artifacts?.filePreview;
|
|
875
|
+
// allow-same-origin would give agent-written srcdoc content the host
|
|
876
|
+
// page's origin; require the explicit dangerouslyAllowSameOrigin flag.
|
|
877
|
+
let sandbox = fp?.iframeSandbox ?? "allow-scripts";
|
|
878
|
+
if (!fp?.dangerouslyAllowSameOrigin && sandbox.includes("allow-same-origin")) {
|
|
879
|
+
console.warn(
|
|
880
|
+
"[AgentWidget] Stripped allow-same-origin from filePreview.iframeSandbox: it lets artifact content run with the page origin. Set filePreview.dangerouslyAllowSameOrigin to keep it."
|
|
881
|
+
);
|
|
882
|
+
sandbox = sandbox
|
|
883
|
+
.split(/\s+/)
|
|
884
|
+
.filter((token) => token && token !== "allow-same-origin")
|
|
885
|
+
.join(" ");
|
|
886
|
+
}
|
|
887
|
+
const loadingOpts = resolvePreviewLoading(fp?.loading);
|
|
888
|
+
|
|
889
|
+
// Positioned wrapper hosts the iframe + the (absolute) loading overlay;
|
|
890
|
+
// `el` is display: contents, so the iframe needs its own positioning
|
|
891
|
+
// context. Geometry (frame owns the height, iframe is 100% of it) lives
|
|
892
|
+
// in widget.css.
|
|
893
|
+
const frame = createElement("div", "persona-artifact-frame");
|
|
894
|
+
const iframe = createElement("iframe", "persona-artifact-iframe");
|
|
895
|
+
iframe.setAttribute("sandbox", sandbox);
|
|
896
|
+
iframe.setAttribute("data-artifact-id", rec.id);
|
|
897
|
+
|
|
898
|
+
// Assign srcdoc as a property (never innerHTML / marked / DOMPurify):
|
|
899
|
+
// the sandbox (no allow-same-origin → opaque origin) is the isolation
|
|
900
|
+
// boundary. The ready reporter is appended (never prepended) so the
|
|
901
|
+
// document's doctype stays first.
|
|
902
|
+
let token: string | null = null;
|
|
903
|
+
if (loadingOpts.enabled && loadingOpts.injectReadySignal) {
|
|
904
|
+
token = makePreviewToken();
|
|
905
|
+
iframe.srcdoc = source + buildReadyReporter(token);
|
|
906
|
+
} else {
|
|
907
|
+
iframe.srcdoc = source;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
frame.appendChild(iframe);
|
|
911
|
+
el.appendChild(frame);
|
|
912
|
+
|
|
913
|
+
if (loadingOpts.enabled) {
|
|
914
|
+
filePreviewLoadingCleanup = setupPreviewLoading(
|
|
915
|
+
frame,
|
|
916
|
+
iframe,
|
|
917
|
+
token,
|
|
918
|
+
loadingOpts,
|
|
919
|
+
rec.id,
|
|
920
|
+
config
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
filePreviewFrame = frame;
|
|
925
|
+
filePreviewKey = key;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Not rendering an iframe: drop the cached one.
|
|
930
|
+
resetFilePreview();
|
|
931
|
+
|
|
932
|
+
// Complete markdown file → existing markdown pipeline (sanitized).
|
|
933
|
+
if (!isStreaming && kind === "markdown" && viewMode === "rendered") {
|
|
934
|
+
renderMarkdown(source);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Streaming, source view, or non-previewable kind → raw source in a <pre>,
|
|
939
|
+
// syntax-highlighted by the file's language / path.
|
|
940
|
+
renderSource(source, { language: fileMeta.language, path: fileMeta.path }, rec, fixed);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Non-file markdown.
|
|
945
|
+
if (rec.artifactType === "markdown") {
|
|
946
|
+
if (viewMode === "source") {
|
|
947
|
+
resetFilePreview();
|
|
948
|
+
renderSource(rec.markdown ?? "", undefined, rec, fixed);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
renderMarkdown(rec.markdown ?? "");
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Component artifact.
|
|
956
|
+
resetFilePreview();
|
|
957
|
+
resetSource();
|
|
958
|
+
resetStatus();
|
|
959
|
+
el.replaceChildren();
|
|
960
|
+
const renderer = rec.component ? registry.get(rec.component) : undefined;
|
|
961
|
+
if (renderer) {
|
|
962
|
+
const stubMessage: AgentWidgetMessage = {
|
|
963
|
+
id: rec.id,
|
|
964
|
+
role: "assistant",
|
|
965
|
+
content: "",
|
|
966
|
+
createdAt: new Date().toISOString(),
|
|
967
|
+
};
|
|
968
|
+
const componentCtx: ComponentContext = {
|
|
969
|
+
message: stubMessage,
|
|
970
|
+
config,
|
|
971
|
+
updateProps: () => {},
|
|
972
|
+
};
|
|
973
|
+
try {
|
|
974
|
+
const node = renderer(rec.props ?? {}, componentCtx);
|
|
975
|
+
if (node) {
|
|
976
|
+
el.appendChild(node);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
} catch {
|
|
980
|
+
/* fall through to the inspector card */
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
el.appendChild(fallbackComponentCard(rec));
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
render(record);
|
|
987
|
+
|
|
988
|
+
return {
|
|
989
|
+
el,
|
|
990
|
+
update(rec: PersonaArtifactRecord) {
|
|
991
|
+
render(rec);
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
}
|