@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.
Files changed (120) hide show
  1. package/dist/animations/glyph-cycle.cjs +2 -2
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/glyph-cycle.js +2 -2
  5. package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
  6. package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
  7. package/dist/animations/wipe.cjs +2 -2
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/chunk-5EIIHQLQ.js +1 -0
  11. package/dist/codegen.cjs +12 -12
  12. package/dist/codegen.js +14 -14
  13. package/dist/index.cjs +91 -68
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +918 -174
  16. package/dist/index.d.ts +918 -174
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +89 -66
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.global.js +1 -1
  22. package/dist/install.global.js.map +1 -1
  23. package/dist/launcher.global.js +3 -2
  24. package/dist/launcher.global.js.map +1 -1
  25. package/dist/markdown-parsers.js +24 -24
  26. package/dist/plugin-kit.cjs +1 -1
  27. package/dist/plugin-kit.js +1 -1
  28. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  29. package/dist/runtype-tts.js +1 -1
  30. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  31. package/dist/smart-dom-reader.cjs +17 -17
  32. package/dist/smart-dom-reader.d.cts +753 -15
  33. package/dist/smart-dom-reader.d.ts +753 -15
  34. package/dist/smart-dom-reader.js +17 -17
  35. package/dist/testing.cjs +3 -3
  36. package/dist/testing.js +3 -3
  37. package/dist/theme-editor-preview.cjs +81 -58
  38. package/dist/theme-editor-preview.d.cts +761 -15
  39. package/dist/theme-editor-preview.d.ts +761 -15
  40. package/dist/theme-editor-preview.js +81 -58
  41. package/dist/theme-editor.cjs +6 -6
  42. package/dist/theme-editor.d.cts +753 -15
  43. package/dist/theme-editor.d.ts +753 -15
  44. package/dist/theme-editor.js +10 -10
  45. package/dist/theme-reference.cjs +1 -1
  46. package/dist/theme-reference.d.cts +74 -0
  47. package/dist/theme-reference.d.ts +74 -0
  48. package/dist/theme-reference.js +1 -1
  49. package/dist/voice-worklet-player.cjs +2 -2
  50. package/dist/voice-worklet-player.js +2 -2
  51. package/dist/webmcp-polyfill.js +2 -2
  52. package/dist/widget.css +1 -1
  53. package/package.json +2 -3
  54. package/src/artifacts-session.test.ts +178 -0
  55. package/src/client.test.ts +500 -1
  56. package/src/client.ts +285 -93
  57. package/src/components/artifact-card.test.ts +333 -0
  58. package/src/components/artifact-card.ts +75 -28
  59. package/src/components/artifact-inline.test.ts +1328 -0
  60. package/src/components/artifact-inline.ts +920 -0
  61. package/src/components/artifact-pane.test.ts +1042 -0
  62. package/src/components/artifact-pane.ts +440 -131
  63. package/src/components/artifact-preview.test.ts +1155 -0
  64. package/src/components/artifact-preview.ts +994 -0
  65. package/src/components/pill-composer-builder.test.ts +6 -2
  66. package/src/components/pill-composer-builder.ts +6 -6
  67. package/src/components/reasoning-bubble.ts +1 -13
  68. package/src/components/registry.ts +38 -3
  69. package/src/components/tool-bubble.ts +1 -13
  70. package/src/defaults.ts +1 -0
  71. package/src/generated/runtype-openapi-contract.ts +55 -3
  72. package/src/index-core.ts +20 -1
  73. package/src/index.ts +8 -0
  74. package/src/markdown-parsers-loader.test.ts +158 -0
  75. package/src/markdown-parsers-loader.ts +74 -9
  76. package/src/runtime/host-layout.test.ts +163 -0
  77. package/src/runtime/host-layout.ts +110 -7
  78. package/src/runtime/init.ts +18 -61
  79. package/src/runtime/persist-state.test.ts +118 -0
  80. package/src/session.ts +76 -22
  81. package/src/styles/widget.css +773 -26
  82. package/src/theme-editor/preview.ts +2 -0
  83. package/src/theme-editor/sections.test.ts +26 -1
  84. package/src/theme-editor/sections.ts +10 -2
  85. package/src/theme-reference.ts +2 -2
  86. package/src/tool-call-display-defaults.test.ts +1 -0
  87. package/src/types/theme.ts +77 -0
  88. package/src/types.ts +516 -17
  89. package/src/ui.artifact-pane-gating.test.ts +636 -0
  90. package/src/ui.component-directive.test.ts +104 -0
  91. package/src/ui.composer-bar.test.ts +60 -2
  92. package/src/ui.detached-panel.test.ts +1049 -0
  93. package/src/ui.scroll-additive.test.ts +1 -1
  94. package/src/ui.streaming-coalescing.test.ts +312 -0
  95. package/src/ui.tool-display.test.ts +51 -0
  96. package/src/ui.ts +933 -214
  97. package/src/utils/artifact-custom-actions.ts +128 -0
  98. package/src/utils/artifact-display.test.ts +42 -0
  99. package/src/utils/artifact-display.ts +84 -0
  100. package/src/utils/artifact-file.test.ts +116 -0
  101. package/src/utils/artifact-file.ts +117 -0
  102. package/src/utils/artifact-gate.test.ts +112 -5
  103. package/src/utils/artifact-gate.ts +39 -14
  104. package/src/utils/artifact-loading-status.ts +55 -0
  105. package/src/utils/artifact-status-label.ts +190 -0
  106. package/src/utils/buttons.ts +7 -1
  107. package/src/utils/code-highlight.test.ts +186 -0
  108. package/src/utils/code-highlight.ts +400 -0
  109. package/src/utils/icons.ts +2 -0
  110. package/src/utils/roving-tablist.test.ts +152 -0
  111. package/src/utils/roving-tablist.ts +111 -0
  112. package/src/utils/spinner.ts +45 -0
  113. package/src/utils/theme.test.ts +48 -0
  114. package/src/utils/theme.ts +7 -0
  115. package/src/utils/tokens.ts +91 -0
  116. package/src/utils/tool-loading-animation.test.ts +32 -0
  117. package/src/utils/tool-loading-animation.ts +24 -0
  118. package/dist/chunk-DFBSCFYN.js +0 -1
  119. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  120. 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
+ }