@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,920 @@
1
+ import type { ComponentContext, ComponentRenderer } from "./registry";
2
+ import type {
3
+ AgentWidgetArtifactsFeature,
4
+ PersonaArtifactFileMeta,
5
+ PersonaArtifactRecord
6
+ } from "../types";
7
+ import { createElement } from "../utils/dom";
8
+ import { basenameOf, fileKindOf, fileTypeLabel } from "../utils/artifact-file";
9
+ import {
10
+ applyArtifactStatus,
11
+ clearArtifactStatusTracking,
12
+ resolveArtifactStatusLabel
13
+ } from "../utils/artifact-status-label";
14
+ import { artifactRecordActionContext, buildArtifactActionButton } from "../utils/artifact-custom-actions";
15
+ import { createIconButton, createToggleGroup } from "../utils/buttons";
16
+ import { renderLucideIcon } from "../utils/icons";
17
+ import {
18
+ renderArtifactPreviewBody,
19
+ runArtifactBodyTransition,
20
+ type ArtifactBodyLayout
21
+ } from "./artifact-preview";
22
+ import { PersonaArtifactCard } from "./artifact-card";
23
+
24
+ /**
25
+ * Built-in inline artifact block component (display: "inline").
26
+ *
27
+ * Renders the artifact preview body directly in the chat thread via the
28
+ * shared `renderArtifactPreviewBody` — the same surface the artifact pane
29
+ * uses — inside a file-preview chrome (title bar + toolbar) so an inline block
30
+ * reads like a document, not a naked body in a bordered box.
31
+ *
32
+ * Data flow:
33
+ * - The initial render builds a record from the message props (the same
34
+ * rawContent JSON-component shape the card uses). While streaming that is
35
+ * an empty/partial record; after `artifact_complete` the client embeds the
36
+ * accumulated markdown + file meta in the props, so a refreshed session
37
+ * re-renders the full preview from props alone (hydration path).
38
+ * - Live streaming updates come from the session artifact registry: the block
39
+ * registers an updater keyed on its root element, and the UI layer pushes
40
+ * every `onArtifactsState` emission through `updateInlineArtifactBlocks`,
41
+ * which routes the matching record into the preview handle's `update()` and
42
+ * the chrome's in-place `updateChrome()`.
43
+ *
44
+ * The chrome updates in place on the same stable root (pane-style), never a
45
+ * card-style remount — re-attaching the body would reload a live file-preview
46
+ * iframe. While streaming the chrome shows a streaming status and hides copy +
47
+ * custom actions; on complete it swaps the status for the type label and
48
+ * reveals the complete-gated actions. The chrome also carries an optional
49
+ * per-block rendered/source view toggle (availability-gated to file artifacts
50
+ * that have a rendered alternative to their source), which cross-fades the body
51
+ * between the preview and the raw highlighted source.
52
+ *
53
+ * Chrome + built-in actions are configurable via
54
+ * `config.features.artifacts.inlineChrome`
55
+ * (boolean or { showCopy, showExpand, showViewToggle })
56
+ * and `config.features.artifacts.inlineActions`. A full structural override is
57
+ * available via `config.features.artifacts.renderInline` (mirroring the card's
58
+ * `renderCard`), which short-circuits before the default renderer.
59
+ *
60
+ * Body layout is configured via `config.features.artifacts.inlineBody`,
61
+ * resolved here into a flat {@link ArtifactBodyLayout} that is threaded into the
62
+ * shared preview renderer (inline path only — the pane never passes it). This
63
+ * component owns the height model that surrounds that renderer: it sets the
64
+ * `--persona-artifact-inline-body-height` CSS var on the root per state, toggles
65
+ * the complete-state non-iframe height cap on the body wrapper, and wraps the
66
+ * streaming→complete swap in a View Transition when enabled. The default
67
+ * (fixed 320px streaming window, tail-follow, top fade, auto transition) changes
68
+ * the pre-existing grow-with-content streaming behavior; `inlineBody.height:
69
+ * "auto"` restores it.
70
+ */
71
+
72
+ export type InlineArtifactUpdateOptions = {
73
+ /**
74
+ * Skip the streaming→complete View Transition for this update. The View
75
+ * Transition captures the whole document, so running it while the transcript
76
+ * is still streaming cross-fades a stale snapshot of the moving message text
77
+ * over the live text — which reads as ghosting/motion blur on the chat
78
+ * messages. The body swap still happens, just instantly.
79
+ */
80
+ suppressTransition?: boolean;
81
+ };
82
+
83
+ const inlineBlockUpdaters = new WeakMap<
84
+ HTMLElement,
85
+ (record: PersonaArtifactRecord, opts?: InlineArtifactUpdateOptions) => void
86
+ >();
87
+
88
+ /**
89
+ * Push the current session artifact registry state into every inline artifact
90
+ * block under `root`. Blocks whose artifact has no registry record (e.g. after
91
+ * a page refresh) are left on their props render — that render already carries
92
+ * the complete content once the client embedded it on `artifact_complete`.
93
+ *
94
+ * v1 tradeoff: an empty registry (e.g. `clearArtifacts()` mid-stream) is a
95
+ * no-op, so a block already showing pushed content stays frozen on it rather
96
+ * than reverting to its props render. Resetting to props on every clear would
97
+ * churn the common case and reload live file-preview iframes; accepted as-is.
98
+ */
99
+ export function updateInlineArtifactBlocks(
100
+ root: HTMLElement,
101
+ artifacts: PersonaArtifactRecord[],
102
+ opts?: InlineArtifactUpdateOptions
103
+ ): void {
104
+ if (artifacts.length === 0) return;
105
+ const byId = new Map(artifacts.map((a) => [a.id, a]));
106
+ root.querySelectorAll<HTMLElement>("[data-artifact-inline]").forEach((el) => {
107
+ const update = inlineBlockUpdaters.get(el);
108
+ if (!update) return;
109
+ const record = byId.get(el.getAttribute("data-artifact-inline") ?? "");
110
+ if (record) update(record, opts);
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Whether `el` is an inline artifact block root with a live registry updater.
116
+ * Lets the message renderer (ui.ts) reuse a mounted block across bubble
117
+ * rebuilds instead of replacing it.
118
+ */
119
+ export function hasLiveInlineArtifactBlock(el: HTMLElement): boolean {
120
+ return inlineBlockUpdaters.has(el);
121
+ }
122
+
123
+ /** Rebuild a preview record from the persisted block props (hydration path). */
124
+ function recordFromProps(props: Record<string, unknown>): PersonaArtifactRecord {
125
+ const id = typeof props.artifactId === "string" ? props.artifactId : "";
126
+ const title =
127
+ typeof props.title === "string" && props.title ? props.title : undefined;
128
+ const status = props.status === "streaming" ? "streaming" : "complete";
129
+ if (props.artifactType === "component") {
130
+ const embedded = props.componentProps;
131
+ const componentProps =
132
+ embedded && typeof embedded === "object" && !Array.isArray(embedded)
133
+ ? (embedded as Record<string, unknown>)
134
+ : {};
135
+ return {
136
+ id,
137
+ artifactType: "component",
138
+ title,
139
+ status,
140
+ component: typeof props.component === "string" ? props.component : "",
141
+ props: componentProps
142
+ };
143
+ }
144
+ const file =
145
+ props.file && typeof props.file === "object" && !Array.isArray(props.file)
146
+ ? (props.file as PersonaArtifactFileMeta)
147
+ : undefined;
148
+ return {
149
+ id,
150
+ artifactType: "markdown",
151
+ title,
152
+ status,
153
+ markdown: typeof props.markdown === "string" ? props.markdown : "",
154
+ ...(file ? { file } : {})
155
+ };
156
+ }
157
+
158
+ /** Title (basename for file artifacts) + type label, derived like the card. */
159
+ function chromeLabelsFor(record: PersonaArtifactRecord): {
160
+ title: string;
161
+ typeLabel: string;
162
+ } {
163
+ const file = record.artifactType === "markdown" ? record.file : undefined;
164
+ const title = file
165
+ ? basenameOf(file.path)
166
+ : record.title && record.title.trim()
167
+ ? record.title
168
+ : "Untitled artifact";
169
+ const typeLabel = file
170
+ ? fileTypeLabel(file)
171
+ : record.artifactType === "component"
172
+ ? "Component"
173
+ : "Document";
174
+ return { title, typeLabel };
175
+ }
176
+
177
+ /**
178
+ * Build the card component's prop shape from a live preview record, for the
179
+ * `completeDisplay: "card"` collapse. The card renderer reads title / status /
180
+ * type / id and (for markdown records) markdown + file meta; component records
181
+ * only need id + title + type. Mirrors the prop keys `recordFromProps` reads.
182
+ */
183
+ function cardPropsFromRecord(
184
+ record: PersonaArtifactRecord
185
+ ): Record<string, unknown> {
186
+ const props: Record<string, unknown> = {
187
+ artifactId: record.id,
188
+ title: record.title ?? "",
189
+ status: record.status,
190
+ artifactType: record.artifactType
191
+ };
192
+ if (record.artifactType === "markdown") {
193
+ if (typeof record.markdown === "string") props.markdown = record.markdown;
194
+ if (record.file) props.file = record.file;
195
+ }
196
+ return props;
197
+ }
198
+
199
+ /**
200
+ * Tuning knobs for the `completeDisplay: "card"` collapse handoff (see
201
+ * animateInlineCollapse below). All values are milliseconds unless noted.
202
+ */
203
+ /** Phase 1: how long the streamed chrome + body fade out in place. */
204
+ const COLLAPSE_FADE_OUT_MS = 180;
205
+ /** Phase 2 height collapse: base duration before the distance term. */
206
+ const COLLAPSE_HEIGHT_BASE_MS = 240;
207
+ /** Phase 2 height collapse: extra ms per pixel of collapse distance. */
208
+ const COLLAPSE_HEIGHT_MS_PER_PX = 0.8;
209
+ /** Phase 2 height collapse: duration clamp (min/max). */
210
+ const COLLAPSE_HEIGHT_MIN_MS = 300;
211
+ const COLLAPSE_HEIGHT_MAX_MS = 500;
212
+ /** Phase 2 height collapse: easing (M3 standard curve). */
213
+ const COLLAPSE_HEIGHT_EASING = "cubic-bezier(0.2, 0, 0, 1)";
214
+ /** Card fade-in duration. */
215
+ const COLLAPSE_CARD_FADE_IN_MS = 240;
216
+ /**
217
+ * Card fade-in start, as a fraction of the height duration (0 = fade in with
218
+ * the collapse, 1 = only after the height has fully settled).
219
+ */
220
+ const COLLAPSE_CARD_FADE_DELAY_FRACTION = 0.35;
221
+
222
+ /**
223
+ * Collapse animation for `completeDisplay: "card"`: fade the streamed chrome +
224
+ * body out under a locked height, then `swap()` in the card and collapse the
225
+ * height down to it while the card fades in. `onSettled` fires exactly once at
226
+ * the end so the caller can hand updater ownership back to the card. Timings
227
+ * live in the COLLAPSE_* constants above; the height duration scales with the
228
+ * collapse distance.
229
+ *
230
+ * Runs on the Web Animations API, not CSS transitions: the completion window
231
+ * re-renders the transcript several times (props embed, turn/execution
232
+ * completion, lazy-chunk rebuilds) and may reparent this root, and a DOM move
233
+ * cancels CSS transitions but not `element.animate()`.
234
+ *
235
+ * Degrades to an instant swap under `prefers-reduced-motion`, without layout
236
+ * (detached / zero-height measure in tests), or without `element.animate`.
237
+ * `swap()` and `onSettled()` always run.
238
+ */
239
+ function animateInlineCollapse(
240
+ root: HTMLElement,
241
+ startHeight: number,
242
+ opts: { swap: () => void; onSettled: () => void }
243
+ ): void {
244
+ const { swap, onSettled } = opts;
245
+ let reduceMotion = false;
246
+ try {
247
+ reduceMotion =
248
+ typeof window !== "undefined" &&
249
+ typeof window.matchMedia === "function" &&
250
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
251
+ } catch {
252
+ reduceMotion = false;
253
+ }
254
+ if (
255
+ !startHeight ||
256
+ !root.isConnected ||
257
+ reduceMotion ||
258
+ typeof root.animate !== "function"
259
+ ) {
260
+ swap();
261
+ onSettled();
262
+ return;
263
+ }
264
+
265
+ // Phase 1 — fade the streamed content out under a locked height. The lock
266
+ // is inline style (survives DOM moves); the fades are WAAPI (survive too).
267
+ root.style.height = `${startHeight}px`;
268
+ root.style.overflow = "hidden";
269
+ const fadeOuts: Animation[] = [];
270
+ for (const child of Array.from(root.children)) {
271
+ if (child instanceof HTMLElement) {
272
+ fadeOuts.push(
273
+ child.animate([{ opacity: 1 }, { opacity: 0 }], {
274
+ duration: COLLAPSE_FADE_OUT_MS,
275
+ easing: "ease-out",
276
+ fill: "forwards"
277
+ })
278
+ );
279
+ }
280
+ }
281
+
282
+ window.setTimeout(() => {
283
+ // The faded-out children are about to be replaced; drop their fills so
284
+ // the animations don't linger.
285
+ for (const anim of fadeOuts) anim.cancel();
286
+ swap();
287
+ const clearRoot = () => {
288
+ root.style.removeProperty("height");
289
+ root.style.removeProperty("overflow");
290
+ };
291
+ if (!root.isConnected) {
292
+ clearRoot();
293
+ onSettled();
294
+ return;
295
+ }
296
+
297
+ // Measure the card height without painting a frame: auto → read → relock.
298
+ root.style.height = "auto";
299
+ const endHeight = root.getBoundingClientRect().height;
300
+ root.style.height = `${startHeight}px`;
301
+ if (!endHeight || Math.abs(endHeight - startHeight) < 1) {
302
+ clearRoot();
303
+ onSettled();
304
+ return;
305
+ }
306
+
307
+ // Phase 2 — collapse the height while the card fades in.
308
+ const distance = Math.abs(startHeight - endHeight);
309
+ const duration = Math.round(
310
+ Math.min(
311
+ COLLAPSE_HEIGHT_MAX_MS,
312
+ Math.max(
313
+ COLLAPSE_HEIGHT_MIN_MS,
314
+ COLLAPSE_HEIGHT_BASE_MS + distance * COLLAPSE_HEIGHT_MS_PER_PX
315
+ )
316
+ )
317
+ );
318
+ const cardFadeDelay = Math.round(
319
+ duration * COLLAPSE_CARD_FADE_DELAY_FRACTION
320
+ );
321
+ const card =
322
+ root.firstElementChild instanceof HTMLElement
323
+ ? root.firstElementChild
324
+ : null;
325
+ const cardFade = card
326
+ ? card.animate([{ opacity: 0 }, { opacity: 1 }], {
327
+ duration: COLLAPSE_CARD_FADE_IN_MS,
328
+ delay: cardFadeDelay,
329
+ easing: "ease-out",
330
+ fill: "backwards"
331
+ })
332
+ : null;
333
+ const heightAnim = root.animate(
334
+ [{ height: `${startHeight}px` }, { height: `${endHeight}px` }],
335
+ { duration, easing: COLLAPSE_HEIGHT_EASING }
336
+ );
337
+ // The animation overrides the inline lock while it plays; park the inline
338
+ // height on the end value so there is no jump when it finishes.
339
+ root.style.height = `${endHeight}px`;
340
+
341
+ let done = false;
342
+ const clear = () => {
343
+ if (done) return;
344
+ done = true;
345
+ clearRoot();
346
+ onSettled();
347
+ };
348
+ // Settle when BOTH animations are done (the card fade can outlast the
349
+ // height collapse); a cancelled animation rejects `finished`, which
350
+ // allSettled treats as done.
351
+ Promise.allSettled(
352
+ [heightAnim.finished, cardFade?.finished].filter(Boolean)
353
+ ).then(clear);
354
+ // Fallback in case `finished` never resolves (interrupted layout, etc.).
355
+ // Covers whichever ends later: the height collapse or the card fade-in.
356
+ window.setTimeout(
357
+ clear,
358
+ Math.max(duration, cardFadeDelay + COLLAPSE_CARD_FADE_IN_MS) + 120
359
+ );
360
+ }, COLLAPSE_FADE_OUT_MS);
361
+ }
362
+
363
+ /** Resolve the inline chrome config knobs (chrome on by default). */
364
+ function resolveInlineChrome(cfg: AgentWidgetArtifactsFeature | undefined): {
365
+ chromeEnabled: boolean;
366
+ showCopy: boolean;
367
+ showExpand: boolean;
368
+ showViewToggle: boolean;
369
+ } {
370
+ const inlineChrome = cfg?.inlineChrome;
371
+ if (inlineChrome === false) {
372
+ return {
373
+ chromeEnabled: false,
374
+ showCopy: false,
375
+ showExpand: false,
376
+ showViewToggle: false
377
+ };
378
+ }
379
+ const opts = typeof inlineChrome === "object" ? inlineChrome : undefined;
380
+ return {
381
+ chromeEnabled: true,
382
+ showCopy: opts?.showCopy !== false,
383
+ showExpand: opts?.showExpand !== false,
384
+ showViewToggle: opts?.showViewToggle !== false
385
+ };
386
+ }
387
+
388
+ /** CSS var carrying the current-state numeric body height (px); unset = "auto". */
389
+ const BODY_HEIGHT_VAR = "--persona-artifact-inline-body-height";
390
+
391
+ /**
392
+ * Resolve `features.artifacts.inlineBody` into the flat {@link ArtifactBodyLayout}
393
+ * the shared preview renderer consumes. Defaults: 320px both states, source
394
+ * streaming view, rendered complete view, tail-follow on, top+bottom edge fade
395
+ * (each edge clip-gated at render time), auto transition.
396
+ */
397
+ export function resolveInlineBody(
398
+ cfg: AgentWidgetArtifactsFeature | undefined
399
+ ): ArtifactBodyLayout {
400
+ const b = cfg?.inlineBody;
401
+ const streamingView = b?.streamingView === "status" ? "status" : "source";
402
+ const viewMode = b?.viewMode === "source" ? "source" : "rendered";
403
+
404
+ let streamingHeight: number | "auto" = 320;
405
+ let completeHeight: number | "auto" = 320;
406
+ const h = b?.height;
407
+ if (typeof h === "number" || h === "auto") {
408
+ streamingHeight = h;
409
+ completeHeight = h;
410
+ } else if (h && typeof h === "object") {
411
+ streamingHeight = h.streaming ?? 320;
412
+ completeHeight = h.complete ?? 320;
413
+ }
414
+
415
+ // Clip mode shows the top of the document in a static window: no tail-follow
416
+ // (followOutput is ignored — the window never scrolls), and its unset-fadeMask
417
+ // default flips to bottom-only (the top is always visible, so a top fade would
418
+ // never be correct). An explicit fadeMask still wins below.
419
+ const overflow = b?.overflow === "clip" ? "clip" : "scroll";
420
+ const followOutput = overflow === "clip" ? false : b?.followOutput !== false;
421
+
422
+ // fadeMask: when unset, the default depends on the overflow mode — scroll gets
423
+ // both edges (top + bottom), clip gets bottom-only. An explicit value (boolean
424
+ // or object) always wins over that mode default. Each edge is clip-gated at
425
+ // runtime in artifact-preview.ts `updateFadeClasses` (only shown when that edge
426
+ // is actually clipped), so with scroll+tail-follow pinned at the bottom the
427
+ // bottom fade stays visually inert until the reader scrolls up mid-stream.
428
+ let fadeTop: boolean;
429
+ let fadeBottom: boolean;
430
+ const fm = b?.fadeMask;
431
+ if (fm === true) {
432
+ fadeTop = true;
433
+ fadeBottom = true;
434
+ } else if (fm === false) {
435
+ fadeTop = false;
436
+ fadeBottom = false;
437
+ } else if (fm && typeof fm === "object") {
438
+ fadeTop = fm.top === true;
439
+ fadeBottom = fm.bottom === true;
440
+ } else if (overflow === "clip") {
441
+ fadeTop = false;
442
+ fadeBottom = true;
443
+ } else {
444
+ fadeTop = true;
445
+ fadeBottom = true;
446
+ }
447
+
448
+ const transition = b?.transition === "none" ? "none" : "auto";
449
+ const completeDisplay = b?.completeDisplay === "card" ? "card" : "inline";
450
+
451
+ return {
452
+ streamingView,
453
+ viewMode,
454
+ streamingHeight,
455
+ completeHeight,
456
+ followOutput,
457
+ overflow,
458
+ fadeTop,
459
+ fadeBottom,
460
+ transition,
461
+ completeDisplay
462
+ };
463
+ }
464
+
465
+ function renderDefaultArtifactInline(
466
+ props: Record<string, unknown>,
467
+ context: ComponentContext
468
+ ): HTMLElement {
469
+ const record = recordFromProps(props);
470
+ const artifactsCfg = context.config?.features?.artifacts;
471
+ const { chromeEnabled, showCopy, showExpand, showViewToggle } =
472
+ resolveInlineChrome(artifactsCfg);
473
+ const inlineActions = artifactsCfg?.inlineActions ?? [];
474
+ const bodyLayout = resolveInlineBody(artifactsCfg);
475
+ const collapseToCard = bodyLayout.completeDisplay === "card";
476
+
477
+ // Per-block rendered/source view toggle state. `null` = follow the configured
478
+ // default (`bodyLayout.viewMode`); a set value is the user's explicit choice
479
+ // for this block. Reset to `null` when content restarts streaming or a
480
+ // different artifact id arrives (see the block updater below) so hydrated/new
481
+ // content starts on the configured default. `currentRecord` tracks the latest
482
+ // record so the toggle's direct click handler re-renders against fresh content.
483
+ let userViewMode: "rendered" | "source" | null = null;
484
+ let currentRecord = record;
485
+
486
+ const root = createElement(
487
+ "div",
488
+ "persona-artifact-inline persona-w-full persona-max-w-full"
489
+ );
490
+ root.setAttribute("data-persona-theme-zone", "artifact-inline");
491
+ if (record.id) {
492
+ root.setAttribute("data-artifact-inline", record.id);
493
+ }
494
+
495
+ // completeDisplay: "card" — collapse the block to the compact reference card
496
+ // once the artifact completes. `PersonaArtifactCard` is the public card entry,
497
+ // so a host `features.artifacts.renderCard` override still applies. The card
498
+ // is mounted on the SAME root (data-artifact-inline keying + theme zone
499
+ // survive); the frame chrome styling is dropped via a modifier class because
500
+ // the card carries its own border/background. The registered updater becomes a
501
+ // card-sync updater so later records (title/status changes) re-render the card.
502
+ const renderCardChild = (rec: PersonaArtifactRecord): HTMLElement =>
503
+ PersonaArtifactCard(cardPropsFromRecord(rec), context);
504
+ const mountCard = (rec: PersonaArtifactRecord): void => {
505
+ root.classList.add("persona-artifact-inline--card");
506
+ root.style.removeProperty(BODY_HEIGHT_VAR);
507
+ root.replaceChildren(renderCardChild(rec));
508
+ inlineBlockUpdaters.set(root, (r) => {
509
+ root.replaceChildren(renderCardChild(r));
510
+ });
511
+ };
512
+
513
+ // Hydration: a block whose props arrive already-complete renders the card
514
+ // directly — no flash of the inline body, no collapse animation.
515
+ if (collapseToCard && record.status === "complete") {
516
+ mountCard(record);
517
+ return root;
518
+ }
519
+
520
+ const handle = renderArtifactPreviewBody(record, {
521
+ config: context.config,
522
+ bodyLayout,
523
+ // The user's per-block toggle wins over the configured default; `null`
524
+ // falls back to `bodyLayout.viewMode`, so with no toggle interaction this
525
+ // is behavior-identical to passing no `resolveViewMode` at all.
526
+ resolveViewMode: () => userViewMode ?? bodyLayout.viewMode
527
+ });
528
+
529
+ // Body wrapper is always present so the frame's padding lives here (chrome
530
+ // sits flush to the frame edge); required even when chrome is disabled.
531
+ const body = createElement("div", "persona-artifact-inline-body");
532
+ body.appendChild(handle.el);
533
+
534
+ // Height model (inlineBody): set the current-state numeric height as a CSS
535
+ // var on the root, then classify the rendered body (post-render — the shared
536
+ // renderer owns the pre/iframe/markdown branching, mirroring the pane's flush
537
+ // toggle):
538
+ // - code body → `persona-artifact-content-flush`, full-bleed like the pane's
539
+ // source view (gutter flush to the frame edge)
540
+ // - fill-capable body (fixed source window / iframe / status placeholder) →
541
+ // the wrapper owns the reserved height (border-box), so flush and padded
542
+ // states occupy the same outer box and every swap is layout neutral
543
+ // - flow body (rendered markdown, component) → complete-state max-height cap
544
+ const applyBodyHeight = (rec: PersonaArtifactRecord) => {
545
+ const streaming = rec.status !== "complete";
546
+ const h = streaming ? bodyLayout.streamingHeight : bodyLayout.completeHeight;
547
+ const numeric = typeof h === "number";
548
+ if (numeric) {
549
+ root.style.setProperty(BODY_HEIGHT_VAR, `${h}px`);
550
+ } else {
551
+ root.style.removeProperty(BODY_HEIGHT_VAR);
552
+ }
553
+ const hasCodePre = Boolean(handle.el.querySelector(".persona-code-pre"));
554
+ const fillCapable = Boolean(
555
+ handle.el.querySelector(
556
+ "iframe, .persona-artifact-source-window--fixed, .persona-artifact-status-view"
557
+ )
558
+ );
559
+ body.classList.toggle("persona-artifact-content-flush", hasCodePre);
560
+ body.classList.toggle(
561
+ "persona-artifact-inline-body--sized",
562
+ numeric && fillCapable
563
+ );
564
+ body.classList.toggle(
565
+ "persona-artifact-inline-body--cap",
566
+ !streaming && !fillCapable && typeof bodyLayout.completeHeight === "number"
567
+ );
568
+ };
569
+
570
+ // Body updater: set the height var, run the (optionally animated) swap on the
571
+ // streaming→complete boundary, then re-apply the complete-state cap.
572
+ let lastBodyStatus: string = record.status;
573
+ const runBodyUpdate = (
574
+ rec: PersonaArtifactRecord,
575
+ opts?: InlineArtifactUpdateOptions
576
+ ) => {
577
+ const boundary = lastBodyStatus !== "complete" && rec.status === "complete";
578
+ if (boundary) {
579
+ // Pre-set the target (complete) height so the reserved streaming window
580
+ // and the completed body share a height and the swap is layout-neutral.
581
+ if (typeof bodyLayout.completeHeight === "number") {
582
+ root.style.setProperty(BODY_HEIGHT_VAR, `${bodyLayout.completeHeight}px`);
583
+ } else {
584
+ root.style.removeProperty(BODY_HEIGHT_VAR);
585
+ }
586
+ // Re-apply inside the swap too: with a live View Transition the swap
587
+ // callback runs after the snapshot, so the outer applyBodyHeight below
588
+ // would read the pre-swap DOM for iframe/window detection.
589
+ runArtifactBodyTransition(
590
+ body,
591
+ opts?.suppressTransition ? "none" : bodyLayout.transition,
592
+ rec.id,
593
+ () => {
594
+ handle.update(rec);
595
+ applyBodyHeight(rec);
596
+ }
597
+ );
598
+ } else {
599
+ handle.update(rec);
600
+ }
601
+ applyBodyHeight(rec);
602
+ lastBodyStatus = rec.status;
603
+ };
604
+
605
+ applyBodyHeight(record);
606
+
607
+ // Clip-mode expand hitbox: in `inlineBody.overflow: "clip"` the body shows a
608
+ // static top-of-document window with no internal scroll, so — when the chrome's
609
+ // Expand control is enabled and the record has an id — the whole body doubles
610
+ // as the expand affordance (industry inline-card pattern). Reuse the existing
611
+ // `data-expand-artifact-inline` delegation in ui.ts (matched via closest(), so
612
+ // a body-level attribute works; Enter/Space are handled by the same keydown
613
+ // delegation). The aria-label is refreshed in updateChrome as the title
614
+ // streams in. When Expand is disabled the clip stays purely visual.
615
+ const clipExpandHitbox =
616
+ bodyLayout.overflow === "clip" && showExpand && !!record.id;
617
+ if (clipExpandHitbox) {
618
+ body.setAttribute("data-expand-artifact-inline", record.id);
619
+ body.setAttribute("role", "button");
620
+ body.setAttribute("tabindex", "0");
621
+ body.classList.add("persona-cursor-pointer");
622
+ body.setAttribute(
623
+ "aria-label",
624
+ `Open ${chromeLabelsFor(record).title} in panel`
625
+ );
626
+ }
627
+
628
+ if (!chromeEnabled) {
629
+ root.appendChild(body);
630
+ inlineBlockUpdaters.set(root, runBodyUpdate);
631
+ return root;
632
+ }
633
+
634
+ // Chrome bar: [icon][title][type/status] … [inlineActions][copy][expand].
635
+ const chrome = createElement("div", "persona-artifact-inline-chrome");
636
+ chrome.setAttribute("data-persona-theme-zone", "artifact-inline-chrome");
637
+
638
+ const lead = createElement("div", "persona-artifact-inline-chrome-lead");
639
+ const iconWrap = createElement(
640
+ "span",
641
+ "persona-flex persona-items-center persona-flex-shrink-0"
642
+ );
643
+ const icon = renderLucideIcon("file-text", 16, "currentColor", 2);
644
+ if (icon) iconWrap.appendChild(icon);
645
+ const titleEl = createElement(
646
+ "span",
647
+ "persona-artifact-inline-title persona-truncate persona-min-w-0"
648
+ );
649
+ // Meta span toggles between the type label (complete) and the animated
650
+ // streaming status; rebuilt in place on every update.
651
+ const metaEl = createElement("span", "persona-artifact-inline-type");
652
+ lead.append(iconWrap, titleEl, metaEl);
653
+
654
+ const actions = createElement("div", "persona-artifact-inline-chrome-actions");
655
+ // Custom inline actions are rebuilt per update so per-artifact visible() gates
656
+ // re-evaluate as content materializes; they carry no direct listeners (clicks
657
+ // are delegated in ui.ts via data-artifact-custom-action, keyed to the nearest
658
+ // [data-artifact-inline] container).
659
+ const customActionsWrap = createElement(
660
+ "span",
661
+ "persona-flex persona-items-center persona-gap-1"
662
+ );
663
+
664
+ const effectiveViewMode = (): "rendered" | "source" =>
665
+ userViewMode ?? bodyLayout.viewMode;
666
+
667
+ // Rendered/source view toggle: the same segmented toggle group the artifact
668
+ // pane uses (createToggleGroup), so the inline chrome matches the pane's UX.
669
+ // Both segments (eye "Rendered view", code-xml "Source") are always visible and
670
+ // the active one is highlighted. Created only when enabled; per-record
671
+ // availability is gated in updateChrome via persona-hidden on the whole group,
672
+ // like copy. updateChrome also syncs the selected segment on every update.
673
+ const viewToggle = showViewToggle
674
+ ? createToggleGroup({
675
+ items: [
676
+ {
677
+ id: "rendered",
678
+ icon: "eye",
679
+ label: "Rendered view",
680
+ className: "persona-artifact-doc-icon-btn persona-artifact-view-btn"
681
+ },
682
+ {
683
+ id: "source",
684
+ icon: "code-xml",
685
+ label: "Source",
686
+ className: "persona-artifact-doc-icon-btn persona-artifact-code-btn"
687
+ }
688
+ ],
689
+ selectedId: effectiveViewMode(),
690
+ className: "persona-artifact-toggle-group persona-flex-shrink-0",
691
+ // Direct handlers via the group's own click listeners (deliberate
692
+ // deviation from the delegated copy/expand pattern): the toggle is purely
693
+ // block-local state + a re-render of this block's own preview handle. It
694
+ // needs no session access, and the chrome element is stable for the
695
+ // block's lifetime (never remounted), so direct handlers are simpler and
696
+ // correct here — no delegation round-trip needed.
697
+ onSelect: (id) => {
698
+ const next = id === "source" ? "source" : "rendered";
699
+ // Selecting the already-active segment is a no-op (createToggleGroup
700
+ // still fires onSelect on a re-click of the active segment).
701
+ if (next === effectiveViewMode()) return;
702
+ userViewMode = next;
703
+ // Known tradeoff: toggling away from a live file-preview iframe drops it,
704
+ // and toggling back rebuilds it (srcdoc reload → iframe-internal state
705
+ // lost). Same behavior as the pane's toggle; we deliberately do not keep
706
+ // a detached iframe alive.
707
+ //
708
+ // Swap instantly — no runArtifactBodyTransition here. The View
709
+ // Transition exists for the streaming→complete swap; on a toggle
710
+ // click its document-wide snapshot crossfade would paint over the
711
+ // group's sliding-thumb animation (which plays inside the snapshot
712
+ // window and reads as a plain dissolve). The pane's toggle also
713
+ // swaps its body instantly, so this keeps the two in parity: the
714
+ // thumb slide IS the toggle's animation.
715
+ handle.update(currentRecord);
716
+ applyBodyHeight(currentRecord);
717
+ }
718
+ })
719
+ : null;
720
+
721
+ const copyBtn = showCopy
722
+ ? createIconButton({
723
+ icon: "copy",
724
+ label: "Copy",
725
+ className: "persona-artifact-doc-icon-btn persona-flex-shrink-0"
726
+ })
727
+ : null;
728
+ if (copyBtn && record.id) {
729
+ // No direct listener: delegated in ui.ts via data-copy-artifact so the
730
+ // handler survives re-renders and page-refresh hydration.
731
+ copyBtn.setAttribute("data-copy-artifact", record.id);
732
+ }
733
+
734
+ const expandBtn = showExpand
735
+ ? createIconButton({
736
+ icon: "maximize",
737
+ label: "Open in panel",
738
+ className: "persona-artifact-doc-icon-btn persona-flex-shrink-0"
739
+ })
740
+ : null;
741
+ if (expandBtn && record.id) {
742
+ // Delegated in ui.ts via data-expand-artifact-inline (opens the pane).
743
+ expandBtn.setAttribute("data-expand-artifact-inline", record.id);
744
+ }
745
+
746
+ actions.appendChild(customActionsWrap);
747
+ if (viewToggle) actions.appendChild(viewToggle.element);
748
+ if (copyBtn) actions.appendChild(copyBtn);
749
+ if (expandBtn) actions.appendChild(expandBtn);
750
+
751
+ chrome.append(lead, actions);
752
+ root.append(chrome, body);
753
+
754
+ const updateChrome = (rec: PersonaArtifactRecord) => {
755
+ const { title, typeLabel } = chromeLabelsFor(rec);
756
+ titleEl.textContent = title;
757
+ titleEl.title = title;
758
+ // Keep the clip-mode body hitbox's label in sync with the streaming title.
759
+ if (clipExpandHitbox) {
760
+ body.setAttribute("aria-label", `Open ${title} in panel`);
761
+ }
762
+
763
+ const streaming = rec.status !== "complete";
764
+ if (streaming) {
765
+ // Animation-stable: only switch the meta span into status mode (clearing
766
+ // its children) on the type→status transition. On subsequent deltas the
767
+ // span is left in place so applyArtifactStatus can reuse the animated
768
+ // label span (re-applied only when the label text changes) and update the
769
+ // plain detail span freely.
770
+ if (!metaEl.classList.contains("persona-artifact-inline-status")) {
771
+ metaEl.className = "persona-artifact-inline-status";
772
+ metaEl.removeAttribute("data-preserve-animation");
773
+ metaEl.replaceChildren();
774
+ }
775
+ const resolved = resolveArtifactStatusLabel(rec, artifactsCfg, "inline-chrome");
776
+ applyArtifactStatus(metaEl, resolved, artifactsCfg);
777
+ } else {
778
+ // Reset the meta span (className replace clears stale animation classes).
779
+ clearArtifactStatusTracking(rec.id);
780
+ metaEl.className = "persona-artifact-inline-type";
781
+ metaEl.removeAttribute("data-preserve-animation");
782
+ metaEl.replaceChildren();
783
+ metaEl.textContent = typeLabel;
784
+ }
785
+
786
+ // Copy + custom actions only act on complete content; expand stays visible
787
+ // through streaming so it is always available.
788
+ if (copyBtn) copyBtn.classList.toggle("persona-hidden", streaming);
789
+
790
+ // View toggle availability: complete file artifacts that actually have a
791
+ // rendered alternative to their source. Plain markdown (no file meta) and
792
+ // component artifacts are hidden here (v1 decision: the pane toggle covers
793
+ // them). Also hidden when the host forced source-only via
794
+ // `inlineBody.viewMode: "source"` — there is no preview to switch to.
795
+ if (viewToggle) {
796
+ const file = rec.artifactType === "markdown" ? rec.file : undefined;
797
+ let canToggle = false;
798
+ if (!streaming && file && bodyLayout.viewMode !== "source") {
799
+ const kind = fileKindOf(file);
800
+ if (kind === "markdown") {
801
+ canToggle = true;
802
+ } else if (kind === "html" || kind === "svg") {
803
+ canToggle = artifactsCfg?.filePreview?.enabled !== false;
804
+ }
805
+ // kind "other" → source-only, no rendered alternative → stays hidden.
806
+ }
807
+ viewToggle.element.classList.toggle("persona-hidden", !canToggle);
808
+ // Sync the group's selected segment on every update: inlineBlockUpdaters
809
+ // resets userViewMode to null on re-stream / new id, so the group must snap
810
+ // back to the configured default here.
811
+ viewToggle.setSelected(effectiveViewMode());
812
+ }
813
+
814
+ customActionsWrap.replaceChildren();
815
+ if (!streaming && inlineActions.length > 0) {
816
+ const ctx = artifactRecordActionContext(rec);
817
+ if (ctx) {
818
+ for (const action of inlineActions) {
819
+ try {
820
+ if (action.visible === undefined || action.visible(ctx)) {
821
+ const btn = buildArtifactActionButton(action, { documentChrome: true });
822
+ btn.setAttribute("data-artifact-custom-action", action.id);
823
+ btn.classList.add("persona-flex-shrink-0");
824
+ customActionsWrap.appendChild(btn);
825
+ }
826
+ } catch {
827
+ // A single bad action must not take down the whole chrome.
828
+ }
829
+ }
830
+ }
831
+ }
832
+ };
833
+
834
+ updateChrome(record);
835
+
836
+ inlineBlockUpdaters.set(root, (rec, opts) => {
837
+ // completeDisplay: "card" — on the streaming→complete boundary, collapse the
838
+ // whole block (chrome + body) to the reference card on the same root instead
839
+ // of swapping the body. Measure the reserved streaming height first, then
840
+ // run the staged collapse (fade out → card mount → height collapse). The
841
+ // WAAPI collapse replaces the body's View Transition here, so
842
+ // `runBodyUpdate` is deliberately bypassed — it must not double-fire
843
+ // runArtifactBodyTransition for this boundary.
844
+ if (
845
+ collapseToCard &&
846
+ lastBodyStatus !== "complete" &&
847
+ rec.status === "complete"
848
+ ) {
849
+ const startHeight = root.isConnected
850
+ ? root.getBoundingClientRect().height
851
+ : 0;
852
+ lastBodyStatus = "complete";
853
+ // Buffer records for the whole animation — a card-sync replaceChildren
854
+ // mid-collapse would cut the card fade. onSettled hands ownership back
855
+ // to the card-sync updater.
856
+ let latest = rec;
857
+ let dirtySinceSwap = false;
858
+ const buffer = (r: PersonaArtifactRecord) => {
859
+ latest = r;
860
+ dirtySinceSwap = true;
861
+ };
862
+ inlineBlockUpdaters.set(root, buffer);
863
+ animateInlineCollapse(root, startHeight, {
864
+ swap: () => {
865
+ mountCard(latest);
866
+ dirtySinceSwap = false;
867
+ inlineBlockUpdaters.set(root, buffer);
868
+ },
869
+ onSettled: () => {
870
+ // mountCard re-registers the card-sync updater; skip the re-render
871
+ // when nothing changed while the animation ran.
872
+ if (dirtySinceSwap) {
873
+ mountCard(latest);
874
+ } else {
875
+ inlineBlockUpdaters.set(root, (r) => {
876
+ root.replaceChildren(renderCardChild(r));
877
+ });
878
+ }
879
+ }
880
+ });
881
+ return;
882
+ }
883
+ // Reset the per-block view toggle when content restarts streaming or a
884
+ // different artifact id arrives, so hydrated/new content starts on the
885
+ // configured default. Must run before runBodyUpdate → handle.update reads
886
+ // userViewMode via resolveViewMode.
887
+ if (rec.status !== "complete" || rec.id !== currentRecord.id) {
888
+ userViewMode = null;
889
+ }
890
+ currentRecord = rec;
891
+ runBodyUpdate(rec, opts);
892
+ updateChrome(rec);
893
+ });
894
+
895
+ return root;
896
+ }
897
+
898
+ export const PersonaArtifactInline: ComponentRenderer = (props, context) => {
899
+ const customRenderer = context?.config?.features?.artifacts?.renderInline;
900
+ if (customRenderer) {
901
+ const title =
902
+ typeof props.title === "string" && props.title
903
+ ? props.title
904
+ : "Untitled artifact";
905
+ const artifactId =
906
+ typeof props.artifactId === "string" ? props.artifactId : "";
907
+ const status = props.status === "streaming" ? "streaming" : "complete";
908
+ const artifactType =
909
+ typeof props.artifactType === "string" ? props.artifactType : "markdown";
910
+
911
+ const result = customRenderer({
912
+ artifact: { artifactId, title, artifactType, status },
913
+ config: context.config,
914
+ defaultRenderer: () => renderDefaultArtifactInline(props, context)
915
+ });
916
+ if (result) return result;
917
+ }
918
+
919
+ return renderDefaultArtifactInline(props, context);
920
+ };