@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
@@ -1,11 +1,23 @@
1
1
  import { createElement } from "../utils/dom";
2
- import type { AgentWidgetConfig, AgentWidgetMessage, PersonaArtifactRecord } from "../types";
3
- import { escapeHtml, createMarkdownProcessorFromConfig } from "../postprocessors";
4
- import { resolveSanitizer } from "../utils/sanitize";
5
- import { componentRegistry, type ComponentContext } from "./registry";
2
+ import type {
3
+ AgentWidgetConfig,
4
+ PersonaArtifactRecord,
5
+ PersonaArtifactCustomAction,
6
+ } from "../types";
7
+ import { fileTypeLabel, basenameOf } from "../utils/artifact-file";
8
+ import {
9
+ buildArtifactActionButton,
10
+ artifactRecordActionContext,
11
+ } from "../utils/artifact-custom-actions";
12
+ import {
13
+ renderArtifactPreviewBody,
14
+ artifactCopyText,
15
+ type ArtifactPreviewBodyHandle,
16
+ } from "./artifact-preview";
6
17
  import { renderLucideIcon } from "../utils/icons";
7
18
  import { createDropdownMenu, type DropdownMenuHandle } from "../utils/dropdown";
8
- import { createIconButton, createLabelButton } from "../utils/buttons";
19
+ import { createIconButton, createLabelButton, createToggleGroup } from "../utils/buttons";
20
+ import { createRovingTablist } from "../utils/roving-tablist";
9
21
 
10
22
  export type ArtifactPaneApi = {
11
23
  element: HTMLElement;
@@ -13,22 +25,37 @@ export type ArtifactPaneApi = {
13
25
  backdrop: HTMLElement | null;
14
26
  update: (state: { artifacts: PersonaArtifactRecord[]; selectedId: string | null }) => void;
15
27
  setMobileOpen: (open: boolean) => void;
28
+ /** Reflect the expanded state on the toolbar toggle (icon + label). */
29
+ setExpanded: (expanded: boolean) => void;
30
+ /** Show/hide the expand toggle; driven from the parent's config on every sync so live config updates apply. */
31
+ setExpandToggleVisible: (visible: boolean) => void;
32
+ /** Show/hide the default-preset copy button; no-op in the document preset (copy is always shown there). */
33
+ setCopyButtonVisible: (visible: boolean) => void;
34
+ /** Replace the toolbar custom-action list and re-render it; driven from the parent's config on every sync so live config updates apply. */
35
+ setCustomActions: (actions: PersonaArtifactCustomAction[]) => void;
36
+ /** Update the tab-strip edge fade live; driven from the parent's config on every sync. */
37
+ setTabFade: (cfg: boolean | { start?: boolean; end?: boolean } | undefined) => void;
38
+ /** Swap the tab bar live between the built-in strip (undefined) and a host renderTabBar; driven from the parent's config on every sync. */
39
+ setRenderTabBar: (
40
+ fn:
41
+ | ((ctx: {
42
+ records: PersonaArtifactRecord[];
43
+ selectedId: string | null;
44
+ onSelect: (id: string) => void;
45
+ }) => HTMLElement)
46
+ | undefined
47
+ ) => void;
48
+ /**
49
+ * Explicit "the pane surface is shown to the user" signal from the host
50
+ * (ui.ts), which owns the display-mode / expand / mobile-drawer routing.
51
+ * While hidden the pane records state on update() but does NOT build/refresh
52
+ * the preview DOM, so an inline/card artifact never spawns a second
53
+ * sandboxed iframe (which would execute artifact scripts a second time). On
54
+ * reveal the pane renders the current recorded state.
55
+ */
56
+ setVisible: (visible: boolean) => void;
16
57
  };
17
58
 
18
- function fallbackComponentCard(sel: PersonaArtifactRecord): HTMLElement {
19
- const card = createElement(
20
- "div",
21
- "persona-rounded-lg persona-border persona-border-persona-border persona-p-3 persona-text-persona-primary"
22
- );
23
- const title = createElement("div", "persona-font-semibold persona-text-sm persona-mb-2");
24
- title.textContent = sel.component ? `Component: ${sel.component}` : "Component";
25
- const pre = createElement("pre", "persona-font-mono persona-text-xs persona-whitespace-pre-wrap persona-overflow-x-auto");
26
- pre.textContent = JSON.stringify(sel.props ?? {}, null, 2);
27
- card.appendChild(title);
28
- card.appendChild(pre);
29
- return card;
30
- }
31
-
32
59
  /**
33
60
  * Right-hand artifact sidebar / mobile drawer content.
34
61
  */
@@ -38,19 +65,28 @@ export function createArtifactPane(
38
65
  onSelect: (id: string) => void;
39
66
  /** User closed the pane (mobile drawer or split sidebar): parent should persist “hidden until reopened”. */
40
67
  onDismiss?: () => void;
68
+ /** User clicked the expand/collapse toggle: parent owns the state and calls back via setExpanded. */
69
+ onToggleExpand?: () => void;
41
70
  }
42
71
  ): ArtifactPaneApi {
43
72
  const layout = config.features?.artifacts?.layout;
44
73
  const toolbarPreset = layout?.toolbarPreset ?? "default";
45
74
  const documentChrome = toolbarPreset === "document";
75
+ const toolbarTitle = layout?.toolbarTitle ?? "Artifacts";
76
+ const closeButtonLabel = layout?.closeButtonLabel ?? "Close";
46
77
  const panePadding = layout?.panePadding?.trim();
47
78
 
48
- const md = config.markdown ? createMarkdownProcessorFromConfig(config.markdown) : null;
49
- const sanitize = resolveSanitizer(config.sanitize);
50
- const toHtml = (text: string) => {
51
- const raw = md ? md(text) : escapeHtml(text);
52
- return sanitize ? sanitize(raw) : raw;
53
- };
79
+ const tabFadeSize = layout?.tabFadeSize?.trim();
80
+ // Per-edge fade enablement is mutable so setTabFade can switch it live (the
81
+ // host re-reads config on every sync). false disables both edges; the object
82
+ // form gates each edge; true/undefined keeps both dynamic.
83
+ const resolveFade = (
84
+ cfg: boolean | { start?: boolean; end?: boolean } | undefined
85
+ ) => ({
86
+ start: cfg === false ? false : typeof cfg === "object" && cfg ? cfg.start !== false : true,
87
+ end: cfg === false ? false : typeof cfg === "object" && cfg ? cfg.end !== false : true,
88
+ });
89
+ let { start: fadeStartEnabled, end: fadeEndEnabled } = resolveFade(layout?.tabFade);
54
90
 
55
91
  const backdrop =
56
92
  typeof document !== "undefined"
@@ -91,15 +127,9 @@ export function createArtifactPane(
91
127
  toolbar.classList.add("persona-artifact-toolbar-document");
92
128
  }
93
129
  const titleEl = createElement("span", "persona-text-xs persona-font-medium persona-truncate");
94
- titleEl.textContent = "Artifacts";
130
+ titleEl.textContent = toolbarTitle;
95
131
 
96
- const closeBtn = createElement(
97
- "button",
98
- "persona-rounded-md persona-border persona-border-persona-border persona-px-2 persona-py-1 persona-text-xs persona-bg-persona-surface"
99
- );
100
- closeBtn.type = "button";
101
- closeBtn.textContent = "Close";
102
- closeBtn.setAttribute("aria-label", "Close artifacts panel");
132
+ const closeBtn = createIconButton({ icon: "x", label: closeButtonLabel });
103
133
  closeBtn.addEventListener("click", () => {
104
134
  dismissLocalUi();
105
135
  options.onDismiss?.();
@@ -107,13 +137,28 @@ export function createArtifactPane(
107
137
 
108
138
  /** Document preset: view vs raw source */
109
139
  let viewMode: "rendered" | "source" = "rendered";
110
- const leftTools = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0 persona-artifact-toggle-group");
111
- const viewBtn = documentChrome
112
- ? createIconButton({ icon: "eye", label: "Rendered view", className: "persona-artifact-doc-icon-btn persona-artifact-view-btn" })
113
- : createIconButton({ icon: "eye", label: "Rendered view" });
114
- const codeBtn = documentChrome
115
- ? createIconButton({ icon: "code-2", label: "Source", className: "persona-artifact-doc-icon-btn persona-artifact-code-btn" })
116
- : createIconButton({ icon: "code-2", label: "Source" });
140
+ const viewToggle = createToggleGroup({
141
+ items: [
142
+ {
143
+ id: "rendered",
144
+ icon: "eye",
145
+ label: "Rendered view",
146
+ className: documentChrome ? "persona-artifact-doc-icon-btn persona-artifact-view-btn" : undefined,
147
+ },
148
+ {
149
+ id: "source",
150
+ icon: "code-xml",
151
+ label: "Source",
152
+ className: documentChrome ? "persona-artifact-doc-icon-btn persona-artifact-code-btn" : undefined,
153
+ },
154
+ ],
155
+ selectedId: "rendered",
156
+ className: "persona-artifact-toggle-group persona-shrink-0",
157
+ onSelect: (id) => {
158
+ viewMode = id === "source" ? "source" : "rendered";
159
+ render();
160
+ },
161
+ });
117
162
  const actionsRight = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
118
163
  const showCopyLabel = layout?.documentToolbarShowCopyLabel === true;
119
164
  const showCopyChevron = layout?.documentToolbarShowCopyChevron === true;
@@ -152,15 +197,42 @@ export function createArtifactPane(
152
197
  } else if (documentChrome) {
153
198
  copyBtn = createIconButton({ icon: "copy", label: "Copy", className: "persona-artifact-doc-icon-btn" });
154
199
  } else {
200
+ // Always built (like the expand toggle) so a live config update can
201
+ // reveal it via setCopyButtonVisible; hidden unless layout opts in.
155
202
  copyBtn = createIconButton({ icon: "copy", label: "Copy" });
203
+ if (layout?.showCopyButton !== true) {
204
+ copyBtn.classList.add("persona-hidden");
205
+ }
156
206
  }
157
207
 
158
208
  const refreshBtn = documentChrome
159
209
  ? createIconButton({ icon: "refresh-cw", label: "Refresh", className: "persona-artifact-doc-icon-btn" })
160
210
  : createIconButton({ icon: "refresh-cw", label: "Refresh" });
161
211
  const closeIconBtn = documentChrome
162
- ? createIconButton({ icon: "x", label: "Close", className: "persona-artifact-doc-icon-btn" })
163
- : createIconButton({ icon: "x", label: "Close" });
212
+ ? createIconButton({ icon: "x", label: closeButtonLabel, className: "persona-artifact-doc-icon-btn" })
213
+ : createIconButton({ icon: "x", label: closeButtonLabel });
214
+
215
+ // Always built so a live config update can reveal it (the pane is created
216
+ // once; syncArtifactPane re-reads the config and drives setExpandToggleVisible).
217
+ const expandBtn = createIconButton({
218
+ icon: "maximize",
219
+ label: "Expand artifacts panel",
220
+ className: "persona-artifact-expand-btn" + (documentChrome ? " persona-artifact-doc-icon-btn" : ""),
221
+ onClick: () => options.onToggleExpand?.(),
222
+ });
223
+ if (layout?.showExpandToggle !== true) {
224
+ expandBtn.classList.add("persona-hidden");
225
+ }
226
+
227
+ // Host container for integrator-supplied toolbar buttons. Built once and
228
+ // slotted into whichever preset renders; setCustomActions re-renders its
229
+ // children on every parent sync so live config updates apply.
230
+ const customActions = createElement(
231
+ "div",
232
+ "persona-flex persona-items-center persona-gap-1 persona-shrink-0 persona-artifact-toolbar-custom-actions"
233
+ );
234
+ let customActionList: PersonaArtifactCustomAction[] =
235
+ config.features?.artifacts?.toolbarActions ?? [];
164
236
 
165
237
  const getSelectedArtifactText = (): { markdown: string; jsonPayload: string; id: string | null } => {
166
238
  const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
@@ -173,16 +245,9 @@ export function createArtifactPane(
173
245
  };
174
246
 
175
247
  const defaultCopy = async () => {
176
- const { markdown, jsonPayload } = getSelectedArtifactText();
177
248
  const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
178
- const text =
179
- sel?.artifactType === "markdown"
180
- ? markdown
181
- : sel
182
- ? jsonPayload
183
- : "";
184
249
  try {
185
- await navigator.clipboard.writeText(text);
250
+ await navigator.clipboard.writeText(artifactCopyText(sel));
186
251
  } catch {
187
252
  /* ignore */
188
253
  }
@@ -258,22 +323,6 @@ export function createArtifactPane(
258
323
  options.onDismiss?.();
259
324
  });
260
325
 
261
- const syncViewToggleState = () => {
262
- if (!documentChrome) return;
263
- viewBtn.setAttribute("aria-pressed", viewMode === "rendered" ? "true" : "false");
264
- codeBtn.setAttribute("aria-pressed", viewMode === "source" ? "true" : "false");
265
- };
266
- viewBtn.addEventListener("click", () => {
267
- viewMode = "rendered";
268
- syncViewToggleState();
269
- render();
270
- });
271
- codeBtn.addEventListener("click", () => {
272
- viewMode = "source";
273
- syncViewToggleState();
274
- render();
275
- });
276
-
277
326
  const centerTitle = createElement(
278
327
  "span",
279
328
  "persona-min-w-0 persona-flex-1 persona-text-xs persona-font-medium persona-text-persona-primary persona-truncate persona-text-center md:persona-text-left"
@@ -281,17 +330,26 @@ export function createArtifactPane(
281
330
 
282
331
  if (documentChrome) {
283
332
  toolbar.replaceChildren();
284
- leftTools.append(viewBtn, codeBtn);
285
333
  if (copyWrap) {
286
334
  actionsRight.append(copyWrap, refreshBtn, closeIconBtn);
287
335
  } else {
288
336
  actionsRight.append(copyBtn, refreshBtn, closeIconBtn);
289
337
  }
290
- toolbar.append(leftTools, centerTitle, actionsRight);
291
- syncViewToggleState();
338
+ // Order: copy, refresh, custom, expand, close.
339
+ actionsRight.insertBefore(customActions, closeIconBtn);
340
+ actionsRight.insertBefore(expandBtn, closeIconBtn);
341
+ toolbar.append(viewToggle.element, centerTitle, actionsRight);
292
342
  } else {
343
+ // Group expand + Close so the toolbar's justify-between spaces
344
+ // [toggle] [title] [actions] instead of distributing each button.
345
+ const defaultActions = createElement(
346
+ "div",
347
+ "persona-flex persona-items-center persona-gap-1 persona-shrink-0"
348
+ );
349
+ // Order: title, copy, custom, expand, Close.
350
+ defaultActions.append(copyBtn, customActions, expandBtn, closeBtn);
293
351
  toolbar.appendChild(titleEl);
294
- toolbar.appendChild(closeBtn);
352
+ toolbar.appendChild(defaultActions);
295
353
  }
296
354
 
297
355
  if (panePadding) {
@@ -303,98 +361,288 @@ export function createArtifactPane(
303
361
  "div",
304
362
  "persona-artifact-list persona-shrink-0 persona-flex persona-gap-1 persona-overflow-x-auto persona-p-2 persona-border-b persona-border-persona-border"
305
363
  );
364
+ if (tabFadeSize) {
365
+ list.style.setProperty("--persona-artifact-tab-fade-size", tabFadeSize);
366
+ }
367
+
368
+ // Seam A: a full tab-bar replacement. Both the built-in strip and a custom
369
+ // mount are always built so renderTabBar can switch live (the host re-reads
370
+ // config on every sync); render() shows the active one and hides the other.
371
+ let currentRenderTabBar = config.features?.artifacts?.renderTabBar;
372
+ // Stable host for the replacement bar, mounted where the strip goes; hidden
373
+ // until a custom bar is active.
374
+ const customBarMount = createElement(
375
+ "div",
376
+ "persona-artifact-tab-custom persona-shrink-0 persona-border-b persona-border-persona-border persona-hidden"
377
+ );
378
+
379
+ // Accessible tablist behavior for the built-in strip (roles, roving tabindex,
380
+ // Arrow/Home/End, focus survival). The host owns a11y for a custom bar.
381
+ const tablist = createRovingTablist(list, {
382
+ onSelect: (index) => options.onSelect(records[index].id),
383
+ });
384
+
385
+ // Toggle the tab-strip edge fades from scroll position. RTL reports a negative
386
+ // scrollLeft, so normalize against the max offset rather than trusting sign.
387
+ // Per-edge fade classes are gated by config.
388
+ const updateTabFades = (el: HTMLElement) => {
389
+ const max = el.scrollWidth - el.clientWidth;
390
+ const overflow = max > 1;
391
+ const offset = Math.abs(el.scrollLeft);
392
+ el.classList.toggle(
393
+ "persona-artifact-tab-fade-start",
394
+ fadeStartEnabled && overflow && offset > 1
395
+ );
396
+ el.classList.toggle(
397
+ "persona-artifact-tab-fade-end",
398
+ fadeEndEnabled && overflow && offset < max - 1
399
+ );
400
+ };
401
+ // rAF-throttle the scroll-driven fade recompute (falls back to setTimeout).
402
+ let tabFadeRaf = 0;
403
+ const scheduleTabFades = () => {
404
+ if (tabFadeRaf) return;
405
+ const run = () => {
406
+ tabFadeRaf = 0;
407
+ updateTabFades(list);
408
+ };
409
+ tabFadeRaf =
410
+ typeof requestAnimationFrame === "function"
411
+ ? requestAnimationFrame(run)
412
+ : (setTimeout(run, 0) as unknown as number);
413
+ };
414
+ list.addEventListener("scroll", () => scheduleTabFades(), { passive: true });
415
+ // Recompute on mid-stream tab additions and pane resizes.
416
+ if (typeof ResizeObserver !== "undefined") {
417
+ new ResizeObserver(() => updateTabFades(list)).observe(list);
418
+ }
419
+
306
420
  const content = createElement(
307
421
  "div",
308
422
  "persona-artifact-content persona-flex-1 persona-min-h-0 persona-overflow-y-auto persona-p-3"
309
423
  );
310
424
  if (panePadding) {
311
- list.style.paddingLeft = panePadding;
312
- list.style.paddingRight = panePadding;
425
+ for (const el of [list, customBarMount]) {
426
+ el.style.paddingLeft = panePadding;
427
+ el.style.paddingRight = panePadding;
428
+ }
313
429
  content.style.padding = panePadding;
314
430
  }
315
431
 
316
432
  shell.appendChild(toolbar);
317
433
  shell.appendChild(list);
434
+ shell.appendChild(customBarMount);
318
435
  shell.appendChild(content);
319
436
 
320
437
  let records: PersonaArtifactRecord[] = [];
321
438
  let selectedId: string | null = null;
322
439
  let mobileOpen = false;
440
+ // Lazy-render gate. `visible` reflects the host's explicit setVisible() signal
441
+ // (never inferred from layout/offsetParent, so class-hiding + jsdom behave the
442
+ // same). It defaults to true so the pane renders eagerly when constructed
443
+ // directly (its historical behavior, and the path pane tests exercise); ui.ts
444
+ // always calls setVisible() before update() so the real widget drives it. When
445
+ // hidden, update() records state and sets `dirty` instead of rendering; the
446
+ // next reveal flushes the current state.
447
+ let visible = true;
448
+ let dirty = false;
449
+ // Track the last tab we auto-scrolled to so we only nudge the strip when the
450
+ // selection actually changes (not on every streaming re-render), which would
451
+ // otherwise fight a user who has scrolled the strip manually.
452
+ let lastScrolledTabId: string | null = null;
453
+ // Signature of the last renderTabBar invocation (record ids + selection) so a
454
+ // streaming re-render with an unchanged bar does not tear down the host bar.
455
+ let customBarSig = "";
456
+
457
+ // Shared preview body renderer (artifact-preview.ts). One handle serves the
458
+ // whole content area; it is updated with whichever record is selected and
459
+ // internally reuses the file-preview iframe across idle re-renders.
460
+ let preview: ArtifactPreviewBodyHandle | null = null;
461
+ let fileToggleMounted = false;
462
+ const resolveViewMode = (rec: PersonaArtifactRecord): "rendered" | "source" => {
463
+ const isFile = rec.artifactType === "markdown" && Boolean(rec.file);
464
+ // File artifacts honor the toggle in every toolbar preset; plain markdown
465
+ // only has a source view in the document toolbar preset.
466
+ if (isFile) return viewMode;
467
+ return documentChrome ? viewMode : "rendered";
468
+ };
469
+ // For the default (non-document) toolbar, mount the rendered/source toggle only
470
+ // while a previewable file artifact is selected. The document toolbar already
471
+ // carries the toggle permanently, so it is mounted once at build time. Here we
472
+ // add/remove the group from the toolbar as the selection gains or loses a
473
+ // previewable file (the toggle group owns its own buttons and aria-pressed state).
474
+ const updateFileToggleVisibility = (previewable: boolean) => {
475
+ if (documentChrome) return;
476
+ if (previewable && !fileToggleMounted) {
477
+ toolbar.insertBefore(viewToggle.element, titleEl);
478
+ fileToggleMounted = true;
479
+ } else if (!previewable && fileToggleMounted) {
480
+ viewToggle.element.remove();
481
+ fileToggleMounted = false;
482
+ }
483
+ };
484
+
485
+ // Selection fallback shared with render(): explicit selection, else last record.
486
+ const currentSelectedRecord = (): PersonaArtifactRecord | undefined =>
487
+ (selectedId && records.find((x) => x.id === selectedId)) || records[records.length - 1];
488
+
489
+ // Rebuild the custom-action buttons. Called from render() (so per-artifact
490
+ // visible() gates re-evaluate when the selection changes) and from
491
+ // setCustomActions (so live config updates apply). The onClick context is
492
+ // resolved at click time, not here, so it reflects the current content.
493
+ const renderCustomActions = () => {
494
+ const ctx = artifactRecordActionContext(currentSelectedRecord());
495
+ if (!ctx) {
496
+ // No records: no meaningful context, so render nothing.
497
+ customActions.replaceChildren();
498
+ return;
499
+ }
500
+ const buttons = customActionList
501
+ .filter((action) => action.visible === undefined || action.visible(ctx))
502
+ .map((action) =>
503
+ buildArtifactActionButton(action, {
504
+ documentChrome,
505
+ onClick: () => {
506
+ const c = artifactRecordActionContext(currentSelectedRecord());
507
+ if (!c) return;
508
+ try {
509
+ void Promise.resolve(action.onClick(c)).catch(() => {});
510
+ } catch {
511
+ /* ignore */
512
+ }
513
+ },
514
+ })
515
+ );
516
+ customActions.replaceChildren(...buttons);
517
+ };
323
518
 
324
519
  const render = () => {
520
+ renderCustomActions();
325
521
  const hideTabs = documentChrome && records.length <= 1;
326
- list.classList.toggle("persona-hidden", hideTabs);
327
-
328
- list.replaceChildren();
329
- for (const r of records) {
330
- const tab = createElement(
331
- "button",
332
- "persona-artifact-tab persona-shrink-0 persona-rounded-lg persona-px-2 persona-py-1 persona-text-xs persona-border persona-border-transparent persona-text-persona-primary"
333
- );
334
- tab.type = "button";
335
- tab.textContent = r.title || r.id.slice(0, 8);
336
- if (r.id === selectedId) {
337
- tab.classList.add("persona-bg-persona-container", "persona-border-persona-border");
522
+ const useCustom = Boolean(currentRenderTabBar);
523
+ // Show the active bar and hide the other; both hide in the ≤1 document case.
524
+ list.classList.toggle("persona-hidden", hideTabs || useCustom);
525
+ customBarMount.classList.toggle("persona-hidden", hideTabs || !useCustom);
526
+
527
+ if (useCustom && currentRenderTabBar) {
528
+ // Signature-gate so unrelated streaming re-renders don't tear down the
529
+ // host bar and lose its internal state or focus.
530
+ const sig = records.map((r) => r.id).join("|") + " " + (selectedId ?? "");
531
+ if (sig !== customBarSig) {
532
+ customBarSig = sig;
533
+ const bar = currentRenderTabBar({
534
+ records,
535
+ selectedId,
536
+ onSelect: options.onSelect,
537
+ });
538
+ // A renderer may return the SAME element across invocations to keep its
539
+ // internal state (e.g. a roving tablist's keyboard focus on the selected
540
+ // tab). Only remount when the node actually changed; replaceChildren
541
+ // detaches the subtree, which would blur a focused tab and break arrow
542
+ // nav on every selection change.
543
+ if (customBarMount.firstElementChild !== bar) {
544
+ customBarMount.replaceChildren(bar);
545
+ }
546
+ }
547
+ } else {
548
+ // Capture focus before replacing tab DOM so the controller restores the
549
+ // roving stop after the rebuild (keyboard nav dies otherwise).
550
+ tablist.beforeRender();
551
+ list.replaceChildren();
552
+ const tabEls: HTMLButtonElement[] = [];
553
+ let selectedIndex = -1;
554
+ for (const [index, r] of records.entries()) {
555
+ const tab = createElement(
556
+ "button",
557
+ "persona-artifact-tab persona-shrink-0 persona-rounded-lg persona-px-2 persona-py-1 persona-text-xs persona-border persona-border-transparent persona-text-persona-primary"
558
+ );
559
+ tab.type = "button";
560
+ // Prefer the file basename over the full path so tabs stay readable
561
+ // (matches the toolbar title); keep the full path/title in a tooltip.
562
+ const fileMeta = r.artifactType === "markdown" ? r.file : undefined;
563
+ const label = fileMeta ? basenameOf(fileMeta.path) : r.title || r.id.slice(0, 8);
564
+ const tooltip = fileMeta?.path || r.title || label;
565
+ tab.textContent = label;
566
+ tab.title = tooltip;
567
+ tab.setAttribute("aria-label", tooltip);
568
+ if (r.id === selectedId) {
569
+ tab.classList.add("persona-bg-persona-container", "persona-border-persona-border");
570
+ selectedIndex = index;
571
+ }
572
+ tab.addEventListener("click", () => options.onSelect(r.id));
573
+ list.appendChild(tab);
574
+ tabEls.push(tab);
338
575
  }
339
- tab.addEventListener("click", () => options.onSelect(r.id));
340
- list.appendChild(tab);
576
+ // The controller owns role/aria-selected/roving tabindex, keyboard nav,
577
+ // and focus restoration across this rebuild.
578
+ tablist.render(tabEls, selectedIndex);
579
+
580
+ // Keep the selected tab visible when the selection changes (e.g. a new
581
+ // artifact streams in and auto-selects). `inline: "nearest"` is a no-op
582
+ // when the tab is already visible, so this never yanks the strip needlessly.
583
+ if (selectedIndex >= 0 && selectedId !== lastScrolledTabId) {
584
+ lastScrolledTabId = selectedId;
585
+ const activeTab = tabEls[selectedIndex];
586
+ if (typeof activeTab.scrollIntoView === "function") {
587
+ activeTab.scrollIntoView({ block: "nearest", inline: "nearest" });
588
+ }
589
+ }
590
+
591
+ // Recompute the edge fades after tabs and the selection scroll settle.
592
+ updateTabFades(list);
341
593
  }
342
594
 
343
- content.replaceChildren();
344
595
  const sel =
345
596
  (selectedId && records.find((x) => x.id === selectedId)) ||
346
597
  records[records.length - 1];
347
- if (!sel) return;
598
+ if (!sel) {
599
+ content.replaceChildren();
600
+ preview = null;
601
+ updateFileToggleVisibility(false);
602
+ return;
603
+ }
604
+
605
+ const selFile = sel.artifactType === "markdown" ? sel.file : undefined;
606
+
607
+ // Expose the rendered/source toggle for previewable file artifacts even
608
+ // outside the document toolbar preset.
609
+ updateFileToggleVisibility(Boolean(selFile));
348
610
 
349
611
  if (documentChrome) {
350
- const kind = sel.artifactType === "markdown" ? "MD" : sel.component ?? "Component";
612
+ const kind = selFile
613
+ ? fileTypeLabel(selFile)
614
+ : sel.artifactType === "markdown"
615
+ ? "MD"
616
+ : sel.component ?? "Component";
351
617
  const rawTitle = (sel.title || "Document").trim();
352
- const baseTitle = rawTitle.replace(/\s*·\s*MD\s*$/i, "").trim() || "Document";
618
+ const baseTitle = selFile
619
+ ? basenameOf(selFile.path)
620
+ : rawTitle.replace(/\s*·\s*MD\s*$/i, "").trim() || "Document";
353
621
  centerTitle.textContent = `${baseTitle} · ${kind}`;
354
622
  } else {
355
- titleEl.textContent = "Artifacts";
623
+ titleEl.textContent = selFile ? basenameOf(selFile.path) : toolbarTitle;
356
624
  }
357
625
 
358
- if (sel.artifactType === "markdown") {
359
- if (documentChrome && viewMode === "source") {
360
- const pre = createElement(
361
- "pre",
362
- "persona-font-mono persona-text-xs persona-whitespace-pre-wrap persona-break-words persona-text-persona-primary"
363
- );
364
- pre.textContent = sel.markdown ?? "";
365
- content.appendChild(pre);
366
- return;
367
- }
368
- const wrap = createElement("div", "persona-text-sm persona-leading-relaxed persona-markdown-bubble");
369
- wrap.innerHTML = toHtml(sel.markdown ?? "");
370
- content.appendChild(wrap);
371
- return;
372
- }
373
-
374
- const renderer = sel.component ? componentRegistry.get(sel.component) : undefined;
375
- if (renderer) {
376
- const stubMessage: AgentWidgetMessage = {
377
- id: sel.id,
378
- role: "assistant",
379
- content: "",
380
- createdAt: new Date().toISOString()
381
- };
382
- const ctx: ComponentContext = {
383
- message: stubMessage,
384
- config,
385
- updateProps: () => {}
386
- };
387
- try {
388
- const el = renderer(sel.props ?? {}, ctx);
389
- if (el) {
390
- content.appendChild(el);
391
- return;
392
- }
393
- } catch {
394
- /* fall through */
626
+ // Delegate the body to the shared preview renderer. Keep the handle's
627
+ // element attached across updates: re-attaching would reload a live
628
+ // file-preview iframe.
629
+ if (!preview) {
630
+ preview = renderArtifactPreviewBody(sel, { config, resolveViewMode });
631
+ content.replaceChildren(preview.el);
632
+ } else {
633
+ if (preview.el.parentElement !== content) {
634
+ content.replaceChildren(preview.el);
395
635
  }
636
+ preview.update(sel);
396
637
  }
397
- content.appendChild(fallbackComponentCard(sel));
638
+ // Source view fills the pane edge-to-edge (gutter flush left, no inset),
639
+ // so drop the content padding whenever the body rendered a code pre.
640
+ // Checked after the render rather than re-deriving the pre/markdown/iframe
641
+ // decision here — renderArtifactPreviewBody owns that branching.
642
+ content.classList.toggle(
643
+ "persona-artifact-content-flush",
644
+ Boolean(content.querySelector(".persona-code-pre"))
645
+ );
398
646
  };
399
647
 
400
648
  const applyLayoutVisibility = () => {
@@ -421,6 +669,15 @@ export function createArtifactPane(
421
669
  }
422
670
  };
423
671
 
672
+ // Build/refresh the visible DOM (tab strip, title, preview body) plus the
673
+ // layout chrome. Gated behind `visible` so we never create a preview iframe
674
+ // for a pane the user isn't looking at.
675
+ const flush = () => {
676
+ dirty = false;
677
+ render();
678
+ applyLayoutVisibility();
679
+ };
680
+
424
681
  return {
425
682
  element: shell,
426
683
  backdrop,
@@ -433,8 +690,12 @@ export function createArtifactPane(
433
690
  if (records.length > 0) {
434
691
  mobileOpen = true;
435
692
  }
436
- render();
437
- applyLayoutVisibility();
693
+ // Record the latest state cheaply; only build the preview DOM when shown.
694
+ // While hidden (inline/card display modes), rendering here would spawn a
695
+ // second sandboxed srcdoc iframe alongside the inline transcript preview,
696
+ // executing artifact scripts twice. Defer the whole render() to reveal.
697
+ dirty = true;
698
+ if (visible) flush();
438
699
  },
439
700
  setMobileOpen(open: boolean) {
440
701
  mobileOpen = open;
@@ -444,6 +705,54 @@ export function createArtifactPane(
444
705
  } else {
445
706
  applyLayoutVisibility();
446
707
  }
708
+ },
709
+ setExpanded(expanded: boolean) {
710
+ // Swap the icon (the state signal) and update the accessible label. We
711
+ // deliberately avoid aria-pressed here: the icon-btn [aria-pressed] CSS
712
+ // would add unwanted active styling.
713
+ const svg = renderLucideIcon(expanded ? "minimize" : "maximize", 16, "currentColor", 2);
714
+ if (svg) expandBtn.replaceChildren(svg);
715
+ const label = expanded ? "Collapse artifacts panel" : "Expand artifacts panel";
716
+ expandBtn.setAttribute("aria-label", label);
717
+ expandBtn.title = label;
718
+ },
719
+ setExpandToggleVisible(visible: boolean) {
720
+ expandBtn.classList.toggle("persona-hidden", !visible);
721
+ },
722
+ setCopyButtonVisible(visible: boolean) {
723
+ if (documentChrome) return;
724
+ copyBtn.classList.toggle("persona-hidden", !visible);
725
+ },
726
+ setCustomActions(actions: PersonaArtifactCustomAction[]) {
727
+ customActionList = actions;
728
+ renderCustomActions();
729
+ },
730
+ setTabFade(cfg: boolean | { start?: boolean; end?: boolean } | undefined) {
731
+ const resolved = resolveFade(cfg);
732
+ if (resolved.start === fadeStartEnabled && resolved.end === fadeEndEnabled) {
733
+ return;
734
+ }
735
+ fadeStartEnabled = resolved.start;
736
+ fadeEndEnabled = resolved.end;
737
+ updateTabFades(list);
738
+ },
739
+ setRenderTabBar(fn) {
740
+ if (fn === currentRenderTabBar) return;
741
+ currentRenderTabBar = fn;
742
+ // Force a custom-bar rebuild on the next render and re-render now so the
743
+ // switch (built-in <-> custom) applies live.
744
+ customBarSig = "";
745
+ render();
746
+ },
747
+ setVisible(next: boolean) {
748
+ if (next === visible) return;
749
+ visible = next;
750
+ // Reveal after lazy-skipped updates: render the CURRENT recorded state.
751
+ // Hiding never tears down an already-rendered preview — re-appending the
752
+ // iframe on re-open would reload it (a hard invariant; see the reuse
753
+ // comments in artifact-preview.ts). Laziness only blocks building NEW
754
+ // previews while hidden; an already-mounted one stays put.
755
+ if (next && dirty) flush();
447
756
  }
448
757
  };
449
758
  }