@runtypelabs/persona 1.48.0 → 2.0.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 (69) hide show
  1. package/README.md +140 -8
  2. package/dist/index.cjs +90 -39
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1055 -24
  5. package/dist/index.d.ts +1055 -24
  6. package/dist/index.global.js +111 -60
  7. package/dist/index.global.js.map +1 -1
  8. package/dist/index.js +90 -39
  9. package/dist/index.js.map +1 -1
  10. package/dist/install.global.js +1 -1
  11. package/dist/install.global.js.map +1 -1
  12. package/dist/widget.css +836 -513
  13. package/package.json +1 -1
  14. package/src/artifacts-session.test.ts +80 -0
  15. package/src/client.test.ts +20 -21
  16. package/src/client.ts +153 -4
  17. package/src/components/approval-bubble.ts +45 -42
  18. package/src/components/artifact-card.ts +91 -0
  19. package/src/components/artifact-pane.ts +501 -0
  20. package/src/components/composer-builder.ts +32 -27
  21. package/src/components/event-stream-view.ts +40 -40
  22. package/src/components/feedback.ts +36 -36
  23. package/src/components/forms.ts +11 -11
  24. package/src/components/header-builder.test.ts +32 -0
  25. package/src/components/header-builder.ts +55 -36
  26. package/src/components/header-layouts.ts +58 -125
  27. package/src/components/launcher.ts +36 -21
  28. package/src/components/message-bubble.ts +92 -65
  29. package/src/components/messages.ts +2 -2
  30. package/src/components/panel.ts +42 -11
  31. package/src/components/reasoning-bubble.ts +23 -23
  32. package/src/components/registry.ts +4 -0
  33. package/src/components/suggestions.ts +1 -1
  34. package/src/components/tool-bubble.ts +32 -32
  35. package/src/defaults.ts +30 -4
  36. package/src/index.ts +80 -2
  37. package/src/install.ts +22 -0
  38. package/src/plugins/types.ts +23 -0
  39. package/src/postprocessors.ts +2 -2
  40. package/src/runtime/host-layout.ts +174 -0
  41. package/src/runtime/init.test.ts +236 -0
  42. package/src/runtime/init.ts +114 -55
  43. package/src/session.ts +135 -2
  44. package/src/styles/tailwind.css +1 -1
  45. package/src/styles/widget.css +836 -513
  46. package/src/types/theme.ts +354 -0
  47. package/src/types.ts +314 -15
  48. package/src/ui.docked.test.ts +104 -0
  49. package/src/ui.ts +940 -227
  50. package/src/utils/artifact-gate.test.ts +255 -0
  51. package/src/utils/artifact-gate.ts +142 -0
  52. package/src/utils/artifact-resize.test.ts +64 -0
  53. package/src/utils/artifact-resize.ts +67 -0
  54. package/src/utils/attachment-manager.ts +10 -10
  55. package/src/utils/code-generators.test.ts +52 -0
  56. package/src/utils/code-generators.ts +40 -36
  57. package/src/utils/dock.ts +17 -0
  58. package/src/utils/dom-context.test.ts +504 -0
  59. package/src/utils/dom-context.ts +896 -0
  60. package/src/utils/dom.ts +12 -1
  61. package/src/utils/message-fingerprint.test.ts +187 -0
  62. package/src/utils/message-fingerprint.ts +105 -0
  63. package/src/utils/migration.ts +179 -0
  64. package/src/utils/morph.ts +1 -1
  65. package/src/utils/plugins.ts +175 -0
  66. package/src/utils/positioning.ts +4 -4
  67. package/src/utils/theme.test.ts +125 -0
  68. package/src/utils/theme.ts +216 -60
  69. package/src/utils/tokens.ts +682 -0
@@ -0,0 +1,91 @@
1
+ import type { ComponentRenderer } from "./registry";
2
+
3
+ /**
4
+ * Built-in artifact reference card component.
5
+ * Renders a compact clickable card in the chat thread that links to an artifact.
6
+ * Uses `data-open-artifact` attribute for click delegation (handled in ui.ts).
7
+ */
8
+ export const PersonaArtifactCard: ComponentRenderer = (props) => {
9
+ const title =
10
+ typeof props.title === "string" && props.title
11
+ ? props.title
12
+ : "Untitled artifact";
13
+ const artifactId =
14
+ typeof props.artifactId === "string" ? props.artifactId : "";
15
+ const status = props.status === "streaming" ? "streaming" : "complete";
16
+ const artifactType =
17
+ typeof props.artifactType === "string" ? props.artifactType : "markdown";
18
+ const subtitle =
19
+ artifactType === "component" ? "Component" : "Document";
20
+
21
+ const root = document.createElement("div");
22
+ root.className =
23
+ "persona-flex persona-w-full persona-max-w-full persona-items-center persona-gap-3 persona-rounded-xl persona-px-4 persona-py-3";
24
+ root.style.border = "1px solid var(--persona-border, #e5e7eb)";
25
+ root.style.backgroundColor = "var(--persona-bg, #ffffff)";
26
+ root.style.cursor = "pointer";
27
+ root.tabIndex = 0;
28
+ root.setAttribute("role", "button");
29
+ root.setAttribute("aria-label", `Open ${title} in artifact panel`);
30
+ if (artifactId) {
31
+ root.setAttribute("data-open-artifact", artifactId);
32
+ }
33
+
34
+ // Document icon
35
+ const iconBox = document.createElement("div");
36
+ iconBox.className =
37
+ "persona-flex persona-h-10 persona-w-10 persona-flex-shrink-0 persona-items-center persona-justify-center persona-rounded-lg";
38
+ iconBox.style.border = "1px solid var(--persona-border, #e5e7eb)";
39
+ iconBox.style.color = "var(--persona-muted, #9ca3af)";
40
+ iconBox.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>`;
41
+
42
+ // Title and subtitle
43
+ const meta = document.createElement("div");
44
+ meta.className =
45
+ "persona-min-w-0 persona-flex-1 persona-flex persona-flex-col persona-gap-0.5";
46
+
47
+ const titleEl = document.createElement("div");
48
+ titleEl.className = "persona-truncate persona-text-sm persona-font-medium";
49
+ titleEl.style.color = "var(--persona-text, #1f2937)";
50
+ titleEl.textContent = title;
51
+
52
+ const subtitleEl = document.createElement("div");
53
+ subtitleEl.className = "persona-text-xs persona-flex persona-items-center persona-gap-1.5";
54
+ subtitleEl.style.color = "var(--persona-muted, #9ca3af)";
55
+
56
+ if (status === "streaming") {
57
+ // Pulsing dot for streaming status
58
+ const dot = document.createElement("span");
59
+ dot.className = "persona-inline-block persona-w-1.5 persona-h-1.5 persona-rounded-full";
60
+ dot.style.backgroundColor = "var(--persona-primary, #3b82f6)";
61
+ dot.style.animation = "persona-pulse 1.5s ease-in-out infinite";
62
+ subtitleEl.appendChild(dot);
63
+
64
+ const statusText = document.createElement("span");
65
+ statusText.textContent = `Generating ${subtitle.toLowerCase()}...`;
66
+ subtitleEl.appendChild(statusText);
67
+ } else {
68
+ subtitleEl.textContent = subtitle;
69
+ }
70
+
71
+ meta.append(titleEl, subtitleEl);
72
+ root.append(iconBox, meta);
73
+
74
+ // Download button (visible when artifact is complete)
75
+ if (status === "complete") {
76
+ const dl = document.createElement("button");
77
+ dl.type = "button";
78
+ dl.textContent = "Download";
79
+ dl.title = `Download ${title}`;
80
+ dl.className =
81
+ "persona-flex-shrink-0 persona-rounded-md persona-px-3 persona-py-1.5 persona-text-xs persona-font-medium";
82
+ dl.style.border = "1px solid var(--persona-border, #e5e7eb)";
83
+ dl.style.color = "var(--persona-text, #1f2937)";
84
+ dl.style.backgroundColor = "transparent";
85
+ dl.style.cursor = "pointer";
86
+ dl.setAttribute("data-download-artifact", artifactId);
87
+ root.append(dl);
88
+ }
89
+
90
+ return root;
91
+ };
@@ -0,0 +1,501 @@
1
+ import { createElement } from "../utils/dom";
2
+ import type { AgentWidgetConfig, AgentWidgetMessage, PersonaArtifactRecord } from "../types";
3
+ import { escapeHtml, createMarkdownProcessorFromConfig } from "../postprocessors";
4
+ import { componentRegistry, type ComponentContext } from "./registry";
5
+ import { renderLucideIcon } from "../utils/icons";
6
+
7
+ export type ArtifactPaneApi = {
8
+ element: HTMLElement;
9
+ /** Backdrop for mobile drawer (optional separate layer) */
10
+ backdrop: HTMLElement | null;
11
+ update: (state: { artifacts: PersonaArtifactRecord[]; selectedId: string | null }) => void;
12
+ setMobileOpen: (open: boolean) => void;
13
+ };
14
+
15
+ function fallbackComponentCard(sel: PersonaArtifactRecord): HTMLElement {
16
+ const card = createElement(
17
+ "div",
18
+ "persona-rounded-lg persona-border persona-border-persona-border persona-p-3 persona-text-persona-primary"
19
+ );
20
+ const title = createElement("div", "persona-font-semibold persona-text-sm persona-mb-2");
21
+ title.textContent = sel.component ? `Component: ${sel.component}` : "Component";
22
+ const pre = createElement("pre", "persona-font-mono persona-text-xs persona-whitespace-pre-wrap persona-overflow-x-auto");
23
+ pre.textContent = JSON.stringify(sel.props ?? {}, null, 2);
24
+ card.appendChild(title);
25
+ card.appendChild(pre);
26
+ return card;
27
+ }
28
+
29
+ function iconButton(iconName: string, label: string, extraClass = ""): HTMLButtonElement {
30
+ const btn = createElement(
31
+ "button",
32
+ `persona-inline-flex persona-items-center persona-justify-center persona-rounded-md persona-border persona-border-persona-border persona-bg-persona-surface persona-p-1 persona-text-persona-primary hover:persona-bg-persona-container ${extraClass}`
33
+ ) as HTMLButtonElement;
34
+ btn.type = "button";
35
+ btn.setAttribute("aria-label", label);
36
+ btn.title = label;
37
+ const icon = renderLucideIcon(iconName, 16, "currentColor", 2);
38
+ if (icon) btn.appendChild(icon);
39
+ return btn;
40
+ }
41
+
42
+ function documentToolbarIconButton(
43
+ iconName: string,
44
+ label: string,
45
+ extraClass: string
46
+ ): HTMLButtonElement {
47
+ const btn = createElement(
48
+ "button",
49
+ `persona-artifact-doc-icon-btn ${extraClass}`.trim()
50
+ ) as HTMLButtonElement;
51
+ btn.type = "button";
52
+ btn.setAttribute("aria-label", label);
53
+ btn.title = label;
54
+ const icon = renderLucideIcon(iconName, 16, "currentColor", 2);
55
+ if (icon) btn.appendChild(icon);
56
+ return btn;
57
+ }
58
+
59
+ function documentToolbarCopyMainButton(showLabel: boolean): HTMLButtonElement {
60
+ const btn = createElement("button", "persona-artifact-doc-copy-btn") as HTMLButtonElement;
61
+ btn.type = "button";
62
+ btn.setAttribute("aria-label", "Copy");
63
+ btn.title = "Copy";
64
+ const icon = renderLucideIcon("copy", showLabel ? 14 : 16, "currentColor", 2);
65
+ if (icon) btn.appendChild(icon);
66
+ if (showLabel) {
67
+ const span = createElement("span", "persona-artifact-doc-copy-label");
68
+ span.textContent = "Copy";
69
+ btn.appendChild(span);
70
+ }
71
+ return btn;
72
+ }
73
+
74
+ function documentToolbarChevronMenuButton(): HTMLButtonElement {
75
+ const btn = createElement(
76
+ "button",
77
+ "persona-artifact-doc-copy-menu-chevron persona-artifact-doc-icon-btn"
78
+ ) as HTMLButtonElement;
79
+ btn.type = "button";
80
+ btn.setAttribute("aria-label", "More copy options");
81
+ btn.setAttribute("aria-haspopup", "true");
82
+ btn.setAttribute("aria-expanded", "false");
83
+ const chev = renderLucideIcon("chevron-down", 14, "currentColor", 2);
84
+ if (chev) btn.appendChild(chev);
85
+ return btn;
86
+ }
87
+
88
+ /**
89
+ * Right-hand artifact sidebar / mobile drawer content.
90
+ */
91
+ export function createArtifactPane(
92
+ config: AgentWidgetConfig,
93
+ options: {
94
+ onSelect: (id: string) => void;
95
+ /** User closed the pane (mobile drawer or split sidebar) — parent should persist “hidden until reopened”. */
96
+ onDismiss?: () => void;
97
+ }
98
+ ): ArtifactPaneApi {
99
+ const layout = config.features?.artifacts?.layout;
100
+ const toolbarPreset = layout?.toolbarPreset ?? "default";
101
+ const documentChrome = toolbarPreset === "document";
102
+ const panePadding = layout?.panePadding?.trim();
103
+
104
+ const md = config.markdown ? createMarkdownProcessorFromConfig(config.markdown) : null;
105
+ const toHtml = (text: string) => (md ? md(text) : escapeHtml(text));
106
+
107
+ const backdrop =
108
+ typeof document !== "undefined"
109
+ ? createElement(
110
+ "div",
111
+ "persona-artifact-backdrop persona-fixed persona-inset-0 persona-z-[55] persona-bg-black/30 persona-hidden md:persona-hidden"
112
+ )
113
+ : null;
114
+ const dismissLocalUi = () => {
115
+ backdrop?.classList.add("persona-hidden");
116
+ shell.classList.remove("persona-artifact-drawer-open");
117
+ };
118
+
119
+ if (backdrop) {
120
+ backdrop.addEventListener("click", () => {
121
+ dismissLocalUi();
122
+ options.onDismiss?.();
123
+ });
124
+ }
125
+
126
+ const shell = createElement(
127
+ "aside",
128
+ "persona-artifact-pane persona-flex persona-flex-col persona-min-h-0 persona-min-w-0 persona-bg-persona-surface persona-text-persona-primary persona-border-l persona-border-persona-border"
129
+ );
130
+ if (documentChrome) {
131
+ shell.classList.add("persona-artifact-pane-document");
132
+ }
133
+
134
+ const toolbar = createElement(
135
+ "div",
136
+ "persona-artifact-toolbar persona-flex persona-items-center persona-justify-between persona-gap-2 persona-px-2 persona-py-2 persona-border-b persona-border-persona-border persona-shrink-0"
137
+ );
138
+ if (documentChrome) {
139
+ toolbar.classList.add("persona-artifact-toolbar-document");
140
+ }
141
+ const titleEl = createElement("span", "persona-text-xs persona-font-medium persona-truncate");
142
+ titleEl.textContent = "Artifacts";
143
+
144
+ const closeBtn = createElement(
145
+ "button",
146
+ "persona-rounded-md persona-border persona-border-persona-border persona-px-2 persona-py-1 persona-text-xs persona-bg-persona-surface"
147
+ );
148
+ closeBtn.type = "button";
149
+ closeBtn.textContent = "Close";
150
+ closeBtn.setAttribute("aria-label", "Close artifacts panel");
151
+ closeBtn.addEventListener("click", () => {
152
+ dismissLocalUi();
153
+ options.onDismiss?.();
154
+ });
155
+
156
+ /** Document preset: view vs raw source */
157
+ let viewMode: "rendered" | "source" = "rendered";
158
+ const leftTools = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
159
+ const viewBtn = documentChrome
160
+ ? documentToolbarIconButton("eye", "Rendered view", "persona-artifact-view-btn")
161
+ : iconButton("eye", "Rendered view", "");
162
+ const codeBtn = documentChrome
163
+ ? documentToolbarIconButton("code-2", "Source", "persona-artifact-code-btn")
164
+ : iconButton("code-2", "Source", "");
165
+ const actionsRight = createElement("div", "persona-flex persona-items-center persona-gap-1 persona-shrink-0");
166
+ const showCopyLabel = layout?.documentToolbarShowCopyLabel === true;
167
+ const showCopyChevron = layout?.documentToolbarShowCopyChevron === true;
168
+ const copyMenuItems = layout?.documentToolbarCopyMenuItems;
169
+ const showCopyMenu = Boolean(showCopyChevron && copyMenuItems && copyMenuItems.length > 0);
170
+
171
+ let copyWrap: HTMLElement | null = null;
172
+ let copyBtn: HTMLButtonElement;
173
+ let copyMenuChevronBtn: HTMLButtonElement | null = null;
174
+ let copyMenuEl: HTMLElement | null = null;
175
+
176
+ if (documentChrome && (showCopyLabel || showCopyChevron) && !showCopyMenu) {
177
+ copyBtn = documentToolbarCopyMainButton(showCopyLabel);
178
+ if (showCopyChevron) {
179
+ const chev = renderLucideIcon("chevron-down", 14, "currentColor", 2);
180
+ if (chev) copyBtn.appendChild(chev);
181
+ }
182
+ } else if (documentChrome && showCopyMenu) {
183
+ copyWrap = createElement(
184
+ "div",
185
+ "persona-relative persona-inline-flex persona-items-center persona-gap-0 persona-rounded-md"
186
+ );
187
+ copyBtn = documentToolbarCopyMainButton(showCopyLabel);
188
+ copyMenuChevronBtn = documentToolbarChevronMenuButton();
189
+ copyWrap.append(copyBtn, copyMenuChevronBtn);
190
+ copyMenuEl = createElement(
191
+ "div",
192
+ "persona-artifact-doc-copy-menu persona-absolute persona-right-0 persona-top-full persona-z-20 persona-mt-1 persona-min-w-[10rem] persona-rounded-md persona-border persona-border-persona-border persona-bg-persona-surface persona-py-1 persona-shadow-md persona-hidden"
193
+ );
194
+ copyWrap.appendChild(copyMenuEl);
195
+ for (const item of copyMenuItems!) {
196
+ const opt = createElement(
197
+ "button",
198
+ "persona-block persona-w-full persona-text-left persona-px-3 persona-py-2 persona-text-xs persona-text-persona-primary hover:persona-bg-persona-container"
199
+ ) as HTMLButtonElement;
200
+ opt.type = "button";
201
+ opt.textContent = item.label;
202
+ opt.dataset.copyMenuId = item.id;
203
+ copyMenuEl.appendChild(opt);
204
+ }
205
+ } else if (documentChrome) {
206
+ copyBtn = documentToolbarIconButton("copy", "Copy", "");
207
+ } else {
208
+ copyBtn = iconButton("copy", "Copy", "");
209
+ }
210
+
211
+ const refreshBtn = documentChrome
212
+ ? documentToolbarIconButton("refresh-cw", "Refresh", "")
213
+ : iconButton("refresh-cw", "Refresh", "");
214
+ const closeIconBtn = documentChrome
215
+ ? documentToolbarIconButton("x", "Close", "")
216
+ : iconButton("x", "Close", "");
217
+
218
+ const getSelectedArtifactText = (): { markdown: string; jsonPayload: string; id: string | null } => {
219
+ const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
220
+ const id = sel?.id ?? null;
221
+ const markdown = sel?.artifactType === "markdown" ? sel.markdown ?? "" : "";
222
+ const jsonPayload = sel
223
+ ? JSON.stringify({ component: sel.component, props: sel.props }, null, 2)
224
+ : "";
225
+ return { markdown, jsonPayload, id };
226
+ };
227
+
228
+ const defaultCopy = async () => {
229
+ const { markdown, jsonPayload } = getSelectedArtifactText();
230
+ const sel = records.find((r) => r.id === selectedId) ?? records[records.length - 1];
231
+ const text =
232
+ sel?.artifactType === "markdown"
233
+ ? markdown
234
+ : sel
235
+ ? jsonPayload
236
+ : "";
237
+ try {
238
+ await navigator.clipboard.writeText(text);
239
+ } catch {
240
+ /* ignore */
241
+ }
242
+ };
243
+
244
+ copyBtn.addEventListener("click", async () => {
245
+ const handler = layout?.onDocumentToolbarCopyMenuSelect;
246
+ if (handler && showCopyMenu) {
247
+ const { markdown, jsonPayload, id } = getSelectedArtifactText();
248
+ try {
249
+ await handler({ actionId: "primary", artifactId: id, markdown, jsonPayload });
250
+ } catch {
251
+ /* ignore */
252
+ }
253
+ return;
254
+ }
255
+ await defaultCopy();
256
+ });
257
+
258
+ if (copyMenuChevronBtn && copyMenuEl) {
259
+ const closeMenu = () => {
260
+ copyMenuEl!.classList.add("persona-hidden");
261
+ copyMenuChevronBtn!.setAttribute("aria-expanded", "false");
262
+ };
263
+ copyMenuChevronBtn.addEventListener("click", (e) => {
264
+ e.stopPropagation();
265
+ const open = copyMenuEl!.classList.contains("persona-hidden");
266
+ if (open) {
267
+ copyMenuEl!.classList.remove("persona-hidden");
268
+ copyMenuChevronBtn!.setAttribute("aria-expanded", "true");
269
+ } else {
270
+ closeMenu();
271
+ }
272
+ });
273
+ if (typeof document !== "undefined") {
274
+ document.addEventListener("click", closeMenu);
275
+ }
276
+ copyMenuEl.addEventListener("click", async (e) => {
277
+ const t = (e.target as HTMLElement).closest("button[data-copy-menu-id]") as HTMLButtonElement | null;
278
+ if (!t?.dataset.copyMenuId) return;
279
+ e.stopPropagation();
280
+ const actionId = t.dataset.copyMenuId;
281
+ const { markdown, jsonPayload, id } = getSelectedArtifactText();
282
+ const handler = layout?.onDocumentToolbarCopyMenuSelect;
283
+ try {
284
+ if (handler) {
285
+ await handler({ actionId, artifactId: id, markdown, jsonPayload });
286
+ } else if (actionId === "markdown" || actionId === "md") {
287
+ await navigator.clipboard.writeText(markdown);
288
+ } else if (actionId === "json" || actionId === "source") {
289
+ await navigator.clipboard.writeText(jsonPayload);
290
+ } else {
291
+ await navigator.clipboard.writeText(markdown || jsonPayload);
292
+ }
293
+ } catch {
294
+ /* ignore */
295
+ }
296
+ closeMenu();
297
+ });
298
+ }
299
+
300
+ refreshBtn.addEventListener("click", async () => {
301
+ try {
302
+ await layout?.onDocumentToolbarRefresh?.();
303
+ } catch {
304
+ /* ignore */
305
+ }
306
+ render();
307
+ });
308
+ closeIconBtn.addEventListener("click", () => {
309
+ dismissLocalUi();
310
+ options.onDismiss?.();
311
+ });
312
+
313
+ const syncViewToggleState = () => {
314
+ if (!documentChrome) return;
315
+ viewBtn.setAttribute("aria-pressed", viewMode === "rendered" ? "true" : "false");
316
+ codeBtn.setAttribute("aria-pressed", viewMode === "source" ? "true" : "false");
317
+ };
318
+ viewBtn.addEventListener("click", () => {
319
+ viewMode = "rendered";
320
+ syncViewToggleState();
321
+ render();
322
+ });
323
+ codeBtn.addEventListener("click", () => {
324
+ viewMode = "source";
325
+ syncViewToggleState();
326
+ render();
327
+ });
328
+
329
+ const centerTitle = createElement(
330
+ "span",
331
+ "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"
332
+ );
333
+
334
+ if (documentChrome) {
335
+ toolbar.replaceChildren();
336
+ leftTools.append(viewBtn, codeBtn);
337
+ if (copyWrap) {
338
+ actionsRight.append(copyWrap, refreshBtn, closeIconBtn);
339
+ } else {
340
+ actionsRight.append(copyBtn, refreshBtn, closeIconBtn);
341
+ }
342
+ toolbar.append(leftTools, centerTitle, actionsRight);
343
+ syncViewToggleState();
344
+ } else {
345
+ toolbar.appendChild(titleEl);
346
+ toolbar.appendChild(closeBtn);
347
+ }
348
+
349
+ if (panePadding) {
350
+ toolbar.style.paddingLeft = panePadding;
351
+ toolbar.style.paddingRight = panePadding;
352
+ }
353
+
354
+ const list = createElement(
355
+ "div",
356
+ "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"
357
+ );
358
+ const content = createElement(
359
+ "div",
360
+ "persona-artifact-content persona-flex-1 persona-min-h-0 persona-overflow-y-auto persona-p-3"
361
+ );
362
+ if (panePadding) {
363
+ list.style.paddingLeft = panePadding;
364
+ list.style.paddingRight = panePadding;
365
+ content.style.padding = panePadding;
366
+ }
367
+
368
+ shell.appendChild(toolbar);
369
+ shell.appendChild(list);
370
+ shell.appendChild(content);
371
+
372
+ let records: PersonaArtifactRecord[] = [];
373
+ let selectedId: string | null = null;
374
+ let mobileOpen = false;
375
+
376
+ const render = () => {
377
+ const hideTabs = documentChrome && records.length <= 1;
378
+ list.classList.toggle("persona-hidden", hideTabs);
379
+
380
+ list.replaceChildren();
381
+ for (const r of records) {
382
+ const tab = createElement(
383
+ "button",
384
+ "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 hover:persona-bg-persona-container"
385
+ );
386
+ tab.type = "button";
387
+ tab.textContent = r.title || r.id.slice(0, 8);
388
+ if (r.id === selectedId) {
389
+ tab.classList.add("persona-bg-persona-container", "persona-border-persona-border");
390
+ }
391
+ tab.addEventListener("click", () => options.onSelect(r.id));
392
+ list.appendChild(tab);
393
+ }
394
+
395
+ content.replaceChildren();
396
+ const sel =
397
+ (selectedId && records.find((x) => x.id === selectedId)) ||
398
+ records[records.length - 1];
399
+ if (!sel) return;
400
+
401
+ if (documentChrome) {
402
+ const kind = sel.artifactType === "markdown" ? "MD" : sel.component ?? "Component";
403
+ const rawTitle = (sel.title || "Document").trim();
404
+ const baseTitle = rawTitle.replace(/\s*·\s*MD\s*$/i, "").trim() || "Document";
405
+ centerTitle.textContent = `${baseTitle} · ${kind}`;
406
+ } else {
407
+ titleEl.textContent = "Artifacts";
408
+ }
409
+
410
+ if (sel.artifactType === "markdown") {
411
+ if (documentChrome && viewMode === "source") {
412
+ const pre = createElement(
413
+ "pre",
414
+ "persona-font-mono persona-text-xs persona-whitespace-pre-wrap persona-break-words persona-text-persona-primary"
415
+ );
416
+ pre.textContent = sel.markdown ?? "";
417
+ content.appendChild(pre);
418
+ return;
419
+ }
420
+ const wrap = createElement("div", "persona-text-sm persona-leading-relaxed persona-markdown-bubble");
421
+ wrap.innerHTML = toHtml(sel.markdown ?? "");
422
+ content.appendChild(wrap);
423
+ return;
424
+ }
425
+
426
+ const renderer = sel.component ? componentRegistry.get(sel.component) : undefined;
427
+ if (renderer) {
428
+ const stubMessage: AgentWidgetMessage = {
429
+ id: sel.id,
430
+ role: "assistant",
431
+ content: "",
432
+ createdAt: new Date().toISOString()
433
+ };
434
+ const ctx: ComponentContext = {
435
+ message: stubMessage,
436
+ config,
437
+ updateProps: () => {}
438
+ };
439
+ try {
440
+ const el = renderer(sel.props ?? {}, ctx);
441
+ if (el) {
442
+ content.appendChild(el);
443
+ return;
444
+ }
445
+ } catch {
446
+ /* fall through */
447
+ }
448
+ }
449
+ content.appendChild(fallbackComponentCard(sel));
450
+ };
451
+
452
+ const applyLayoutVisibility = () => {
453
+ const has = records.length > 0;
454
+ shell.classList.toggle("persona-hidden", !has);
455
+ if (backdrop) {
456
+ const root =
457
+ typeof shell.closest === "function" ? shell.closest("#persona-root") : null;
458
+ const narrowHost = root?.classList.contains("persona-artifact-narrow-host") ?? false;
459
+ const isMobile =
460
+ narrowHost ||
461
+ (typeof window !== "undefined" && window.matchMedia("(max-width: 640px)").matches);
462
+ if (has && isMobile && mobileOpen) {
463
+ backdrop.classList.remove("persona-hidden");
464
+ shell.classList.add("persona-artifact-drawer-open");
465
+ } else if (!isMobile) {
466
+ backdrop.classList.add("persona-hidden");
467
+ shell.classList.remove("persona-artifact-drawer-open");
468
+ } else {
469
+ // isMobile && !(has && mobileOpen): e.g. dismissed drawer — keep closed chrome in sync
470
+ backdrop.classList.add("persona-hidden");
471
+ shell.classList.remove("persona-artifact-drawer-open");
472
+ }
473
+ }
474
+ };
475
+
476
+ return {
477
+ element: shell,
478
+ backdrop,
479
+ update(state: { artifacts: PersonaArtifactRecord[]; selectedId: string | null }) {
480
+ records = state.artifacts;
481
+ selectedId =
482
+ state.selectedId ??
483
+ state.artifacts[state.artifacts.length - 1]?.id ??
484
+ null;
485
+ if (records.length > 0) {
486
+ mobileOpen = true;
487
+ }
488
+ render();
489
+ applyLayoutVisibility();
490
+ },
491
+ setMobileOpen(open: boolean) {
492
+ mobileOpen = open;
493
+ if (!open && backdrop) {
494
+ backdrop.classList.add("persona-hidden");
495
+ shell.classList.remove("persona-artifact-drawer-open");
496
+ } else {
497
+ applyLayoutVisibility();
498
+ }
499
+ }
500
+ };
501
+ }