@runtypelabs/persona 4.7.0 → 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 (117) 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 +877 -172
  16. package/dist/index.d.ts +877 -172
  17. package/dist/index.global.js +81 -57
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +91 -68
  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 +186 -1
  56. package/src/client.ts +92 -9
  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/session.ts +76 -22
  80. package/src/styles/widget.css +773 -26
  81. package/src/theme-editor/preview.ts +2 -0
  82. package/src/theme-editor/sections.test.ts +26 -1
  83. package/src/theme-editor/sections.ts +10 -2
  84. package/src/theme-reference.ts +2 -2
  85. package/src/tool-call-display-defaults.test.ts +1 -0
  86. package/src/types/theme.ts +77 -0
  87. package/src/types.ts +516 -17
  88. package/src/ui.artifact-pane-gating.test.ts +636 -0
  89. package/src/ui.component-directive.test.ts +104 -0
  90. package/src/ui.composer-bar.test.ts +60 -2
  91. package/src/ui.detached-panel.test.ts +1049 -0
  92. package/src/ui.tool-display.test.ts +51 -0
  93. package/src/ui.ts +698 -109
  94. package/src/utils/artifact-custom-actions.ts +128 -0
  95. package/src/utils/artifact-display.test.ts +42 -0
  96. package/src/utils/artifact-display.ts +84 -0
  97. package/src/utils/artifact-file.test.ts +116 -0
  98. package/src/utils/artifact-file.ts +117 -0
  99. package/src/utils/artifact-gate.test.ts +112 -5
  100. package/src/utils/artifact-gate.ts +39 -14
  101. package/src/utils/artifact-loading-status.ts +55 -0
  102. package/src/utils/artifact-status-label.ts +190 -0
  103. package/src/utils/buttons.ts +7 -1
  104. package/src/utils/code-highlight.test.ts +186 -0
  105. package/src/utils/code-highlight.ts +400 -0
  106. package/src/utils/icons.ts +2 -0
  107. package/src/utils/roving-tablist.test.ts +152 -0
  108. package/src/utils/roving-tablist.ts +111 -0
  109. package/src/utils/spinner.ts +45 -0
  110. package/src/utils/theme.test.ts +48 -0
  111. package/src/utils/theme.ts +7 -0
  112. package/src/utils/tokens.ts +91 -0
  113. package/src/utils/tool-loading-animation.test.ts +32 -0
  114. package/src/utils/tool-loading-animation.ts +24 -0
  115. package/dist/chunk-DFBSCFYN.js +0 -1
  116. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  117. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,636 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import type { AgentWidgetConfig, PersonaArtifactDisplayMode } from "./types";
7
+
8
+ beforeAll(() => {
9
+ // jsdom does not implement matchMedia; the pane's layout code touches it.
10
+ if (!window.matchMedia) {
11
+ window.matchMedia = ((query: string) => ({
12
+ matches: false,
13
+ media: query,
14
+ onchange: null,
15
+ addListener: () => {},
16
+ removeListener: () => {},
17
+ addEventListener: () => {},
18
+ removeEventListener: () => {},
19
+ dispatchEvent: () => false,
20
+ })) as unknown as typeof window.matchMedia;
21
+ }
22
+ });
23
+
24
+ /**
25
+ * Regression tests for the artifact pane auto-open gating by display mode
26
+ * (docs/artifact-display-modes-spec.md, section 1 notes).
27
+ *
28
+ * The pane's own `update()` unhides its shell whenever records exist, so the
29
+ * gate in `syncArtifactPane` must re-assert `persona-hidden` for artifacts
30
+ * whose resolved mode is "card" or "inline". The original implementation only
31
+ * removed the class in the show branch and never re-added it, so inline-mode
32
+ * artifacts still auto-opened the pane.
33
+ */
34
+ describe("artifact pane auto-open gating by display mode", () => {
35
+ afterEach(() => {
36
+ document.body.innerHTML = "";
37
+ try {
38
+ window.localStorage.clear();
39
+ } catch {
40
+ /* jsdom edge cases */
41
+ }
42
+ });
43
+
44
+ function mountWithDisplay(display?: PersonaArtifactDisplayMode) {
45
+ const mount = document.createElement("div");
46
+ document.body.appendChild(mount);
47
+ const config: AgentWidgetConfig = {
48
+ apiUrl: "https://api.example.com/chat",
49
+ launcher: { enabled: false },
50
+ features: {
51
+ artifacts: {
52
+ enabled: true,
53
+ allowedTypes: ["markdown", "component"],
54
+ ...(display ? { display } : {}),
55
+ },
56
+ },
57
+ };
58
+ const controller = createAgentExperience(mount, config);
59
+ return { mount, controller };
60
+ }
61
+
62
+ function paneEl(mount: HTMLElement): HTMLElement {
63
+ const el = mount.querySelector<HTMLElement>(".persona-artifact-pane");
64
+ expect(el).not.toBeNull();
65
+ return el!;
66
+ }
67
+
68
+ function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
69
+ controller.upsertArtifact({
70
+ id: "gating-test",
71
+ title: "Gating test",
72
+ artifactType: "markdown",
73
+ content: "# Hello",
74
+ });
75
+ }
76
+
77
+ it('auto-opens the pane for "panel" mode (default)', () => {
78
+ const { mount, controller } = mountWithDisplay();
79
+ upsertSample(controller);
80
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
81
+ controller.destroy();
82
+ });
83
+
84
+ it('keeps the pane hidden for "inline" mode artifacts', () => {
85
+ const { mount, controller } = mountWithDisplay("inline");
86
+ upsertSample(controller);
87
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
88
+ controller.destroy();
89
+ });
90
+
91
+ it('keeps the pane hidden for "card" mode until an explicit open', () => {
92
+ const { mount, controller } = mountWithDisplay("card");
93
+ upsertSample(controller);
94
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
95
+
96
+ controller.showArtifacts();
97
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
98
+ controller.destroy();
99
+ });
100
+
101
+ it("does not open the pane when the card's Download button is clicked", () => {
102
+ // jsdom has no object URLs; the download handler needs both to run fully.
103
+ const urlWithBlob = URL as unknown as {
104
+ createObjectURL?: (b: Blob) => string;
105
+ revokeObjectURL?: (u: string) => void;
106
+ };
107
+ const origCreate = urlWithBlob.createObjectURL;
108
+ const origRevoke = urlWithBlob.revokeObjectURL;
109
+ urlWithBlob.createObjectURL = () => "blob:persona-test";
110
+ urlWithBlob.revokeObjectURL = () => {};
111
+ try {
112
+ const { mount, controller } = mountWithDisplay("card");
113
+ upsertSample(controller);
114
+
115
+ // Recreate the reference card the way the default renderer shapes it
116
+ // (data-open-artifact root wrapping a data-download-artifact button),
117
+ // mounted inside a real message element so clicks bubble through the
118
+ // messages-wrapper delegation in ui.ts.
119
+ controller.injectAssistantMessage({ content: "See the artifact below." });
120
+ const msgEl = mount.querySelector("[data-message-id]");
121
+ expect(msgEl).not.toBeNull();
122
+ const card = document.createElement("div");
123
+ card.setAttribute("data-open-artifact", "gating-test");
124
+ const dl = document.createElement("button");
125
+ dl.setAttribute("data-download-artifact", "gating-test");
126
+ card.appendChild(dl);
127
+ msgEl!.appendChild(card);
128
+
129
+ // Download must not double as an open: the two delegated listeners sit
130
+ // on the same element, so stopPropagation() alone cannot separate them.
131
+ dl.click();
132
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
133
+
134
+ // Clicking the card itself still opens the pane.
135
+ card.click();
136
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
137
+ controller.destroy();
138
+ } finally {
139
+ urlWithBlob.createObjectURL = origCreate;
140
+ urlWithBlob.revokeObjectURL = origRevoke;
141
+ }
142
+ });
143
+ });
144
+
145
+ /**
146
+ * Card custom actions (features.artifacts.cardActions) render on complete
147
+ * reference cards and are wired through the same messages-wrapper delegation as
148
+ * the Download button: looked up by id from fresh config at click time, and
149
+ * explicitly skipped by the card-open listener so a click never opens the pane.
150
+ */
151
+ describe("artifact card custom actions (full widget)", () => {
152
+ afterEach(() => {
153
+ document.body.innerHTML = "";
154
+ try {
155
+ window.localStorage.clear();
156
+ } catch {
157
+ /* jsdom edge cases */
158
+ }
159
+ });
160
+
161
+ type CardActions = NonNullable<
162
+ NonNullable<NonNullable<AgentWidgetConfig["features"]>["artifacts"]>["cardActions"]
163
+ >;
164
+
165
+ function configWith(cardActions: CardActions): AgentWidgetConfig {
166
+ return {
167
+ apiUrl: "https://api.example.com/chat",
168
+ launcher: { enabled: false },
169
+ features: {
170
+ artifacts: {
171
+ enabled: true,
172
+ allowedTypes: ["markdown", "component"],
173
+ display: "card",
174
+ cardActions,
175
+ },
176
+ },
177
+ };
178
+ }
179
+
180
+ function mountWith(cardActions: CardActions) {
181
+ const mount = document.createElement("div");
182
+ document.body.appendChild(mount);
183
+ const controller = createAgentExperience(mount, configWith(cardActions));
184
+ return { mount, controller };
185
+ }
186
+
187
+ function paneEl(mount: HTMLElement): HTMLElement {
188
+ const el = mount.querySelector<HTMLElement>(".persona-artifact-pane");
189
+ expect(el).not.toBeNull();
190
+ return el!;
191
+ }
192
+
193
+ function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
194
+ controller.upsertArtifact({
195
+ id: "gating-test",
196
+ title: "Gating test",
197
+ artifactType: "markdown",
198
+ content: "# Hello",
199
+ });
200
+ }
201
+
202
+ // Fabricate the reference card the way the default renderer shapes it (a
203
+ // data-open-artifact root holding a data-artifact-custom-action button),
204
+ // mounted inside a real message so clicks bubble through the ui.ts delegation.
205
+ function fabricateCard(
206
+ mount: HTMLElement,
207
+ controller: ReturnType<typeof createAgentExperience>,
208
+ actionId: string
209
+ ): HTMLButtonElement {
210
+ controller.injectAssistantMessage({ content: "See the artifact below." });
211
+ const msgEl = mount.querySelector("[data-message-id]");
212
+ expect(msgEl).not.toBeNull();
213
+ const card = document.createElement("div");
214
+ card.setAttribute("data-open-artifact", "gating-test");
215
+ const btn = document.createElement("button");
216
+ btn.setAttribute("data-artifact-custom-action", actionId);
217
+ card.appendChild(btn);
218
+ msgEl!.appendChild(card);
219
+ return btn;
220
+ }
221
+
222
+ it("invokes the action onClick with resolved context and does not open the pane", () => {
223
+ const onClick = vi.fn();
224
+ const { mount, controller } = mountWith([
225
+ { id: "save", label: "Save to Drive", icon: "star", onClick },
226
+ ]);
227
+ upsertSample(controller);
228
+ const btn = fabricateCard(mount, controller, "save");
229
+
230
+ btn.click();
231
+ expect(onClick).toHaveBeenCalledTimes(1);
232
+ const ctx = onClick.mock.calls[0][0];
233
+ expect(ctx.artifactId).toBe("gating-test");
234
+ // Content resolves from the live session record (upserted markdown).
235
+ expect(ctx.markdown).toBe("# Hello");
236
+
237
+ // Card display mode: the delegated action must not open the pane.
238
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
239
+ controller.destroy();
240
+ });
241
+
242
+ it("looks up the handler by id from fresh config on each click (live update)", () => {
243
+ const oldHandler = vi.fn();
244
+ const newHandler = vi.fn();
245
+ const { mount, controller } = mountWith([
246
+ { id: "save", label: "Save", icon: "star", onClick: oldHandler },
247
+ ]);
248
+ upsertSample(controller);
249
+
250
+ const firstBtn = fabricateCard(mount, controller, "save");
251
+ firstBtn.click();
252
+ expect(oldHandler).toHaveBeenCalledTimes(1);
253
+ expect(newHandler).not.toHaveBeenCalled();
254
+
255
+ // Swap the action of the same id via a live config update.
256
+ controller.update(
257
+ configWith([{ id: "save", label: "Save", icon: "star", onClick: newHandler }])
258
+ );
259
+
260
+ // Re-fabricate a card (the transcript may re-render on update) and click.
261
+ const secondBtn = fabricateCard(mount, controller, "save");
262
+ secondBtn.click();
263
+ expect(oldHandler).toHaveBeenCalledTimes(1);
264
+ expect(newHandler).toHaveBeenCalledTimes(1);
265
+ controller.destroy();
266
+ });
267
+ });
268
+
269
+ /**
270
+ * The expand toggle lives in the artifact pane toolbar (opt-in via
271
+ * layout.showExpandToggle). ui.ts owns the runtime-only expanded state: clicking
272
+ * the toolbar button toggles `persona-artifact-expanded` on the mount root, an
273
+ * onArtifactAction handler can intercept it, and the state resets when the pane
274
+ * is dismissed.
275
+ */
276
+ describe("artifact pane expand toggle (full widget)", () => {
277
+ afterEach(() => {
278
+ document.body.innerHTML = "";
279
+ try {
280
+ window.localStorage.clear();
281
+ } catch {
282
+ /* jsdom edge cases */
283
+ }
284
+ });
285
+
286
+ function mount(
287
+ onArtifactAction?: NonNullable<
288
+ NonNullable<AgentWidgetConfig["features"]>["artifacts"]
289
+ >["onArtifactAction"]
290
+ ) {
291
+ const mountEl = document.createElement("div");
292
+ document.body.appendChild(mountEl);
293
+ const config: AgentWidgetConfig = {
294
+ apiUrl: "https://api.example.com/chat",
295
+ launcher: { enabled: false },
296
+ features: {
297
+ artifacts: {
298
+ enabled: true,
299
+ allowedTypes: ["markdown", "component"],
300
+ layout: { showExpandToggle: true },
301
+ ...(onArtifactAction ? { onArtifactAction } : {}),
302
+ },
303
+ },
304
+ };
305
+ const controller = createAgentExperience(mountEl, config);
306
+ return { mount: mountEl, controller };
307
+ }
308
+
309
+ function paneEl(mountEl: HTMLElement): HTMLElement {
310
+ const el = mountEl.querySelector<HTMLElement>(".persona-artifact-pane");
311
+ expect(el).not.toBeNull();
312
+ return el!;
313
+ }
314
+
315
+ function expandBtn(mountEl: HTMLElement): HTMLButtonElement {
316
+ const el = paneEl(mountEl).querySelector<HTMLButtonElement>(
317
+ ".persona-artifact-expand-btn"
318
+ );
319
+ expect(el).not.toBeNull();
320
+ return el!;
321
+ }
322
+
323
+ function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
324
+ controller.upsertArtifact({
325
+ id: "expand-test",
326
+ title: "Expand test",
327
+ artifactType: "markdown",
328
+ content: "# Hello",
329
+ });
330
+ }
331
+
332
+ it("toggles persona-artifact-expanded on the mount root when the button is clicked", () => {
333
+ const { mount: mountEl, controller } = mount();
334
+ upsertSample(controller);
335
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
336
+
337
+ expandBtn(mountEl).click();
338
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(true);
339
+
340
+ expandBtn(mountEl).click();
341
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
342
+ controller.destroy();
343
+ });
344
+
345
+ it("does not add the class when onArtifactAction returns true for type 'expand'", () => {
346
+ const { mount: mountEl, controller } = mount((action) =>
347
+ action.type === "expand" ? true : undefined
348
+ );
349
+ upsertSample(controller);
350
+
351
+ expandBtn(mountEl).click();
352
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
353
+ controller.destroy();
354
+ });
355
+
356
+ it("clears persona-artifact-expanded when the pane is dismissed", () => {
357
+ const { mount: mountEl, controller } = mount();
358
+ upsertSample(controller);
359
+
360
+ expandBtn(mountEl).click();
361
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(true);
362
+
363
+ // Default toolbar preset's Close control dismisses the pane.
364
+ const close = paneEl(mountEl).querySelector<HTMLButtonElement>(
365
+ '[aria-label="Close"]'
366
+ );
367
+ expect(close).not.toBeNull();
368
+ close!.click();
369
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
370
+ controller.destroy();
371
+ });
372
+
373
+ it("reveals the toggle via a live config update and collapses when disabled again", () => {
374
+ const mountEl = document.createElement("div");
375
+ document.body.appendChild(mountEl);
376
+ const base: AgentWidgetConfig = {
377
+ apiUrl: "https://api.example.com/chat",
378
+ launcher: { enabled: false },
379
+ features: {
380
+ artifacts: { enabled: true, allowedTypes: ["markdown", "component"] },
381
+ },
382
+ };
383
+ const controller = createAgentExperience(mountEl, base);
384
+ upsertSample(controller);
385
+ // The pane is built once at mount, so the button exists but stays hidden
386
+ // until a config carrying showExpandToggle arrives via update().
387
+ expect(expandBtn(mountEl).classList.contains("persona-hidden")).toBe(true);
388
+
389
+ controller.update({
390
+ ...base,
391
+ features: {
392
+ artifacts: {
393
+ enabled: true,
394
+ allowedTypes: ["markdown", "component"],
395
+ layout: { showExpandToggle: true },
396
+ },
397
+ },
398
+ });
399
+ expect(expandBtn(mountEl).classList.contains("persona-hidden")).toBe(false);
400
+
401
+ expandBtn(mountEl).click();
402
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(true);
403
+
404
+ // Turning the toggle back off hides the button and collapses the pane.
405
+ controller.update(base);
406
+ expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
407
+ expect(expandBtn(mountEl).classList.contains("persona-hidden")).toBe(true);
408
+ controller.destroy();
409
+ });
410
+ });
411
+
412
+ /**
413
+ * Inline chrome delegation (display: "inline"). The inline block carries
414
+ * data-artifact-inline; its custom-action buttons resolve from
415
+ * features.artifacts.inlineActions (not cardActions), and its Expand button
416
+ * opens the pane through the same open path as a card click, interceptable via
417
+ * onArtifactAction({ type: "open" }).
418
+ */
419
+ describe("inline artifact chrome delegation (full widget)", () => {
420
+ afterEach(() => {
421
+ document.body.innerHTML = "";
422
+ try {
423
+ window.localStorage.clear();
424
+ } catch {
425
+ /* jsdom edge cases */
426
+ }
427
+ });
428
+
429
+ type Artifacts = NonNullable<
430
+ NonNullable<AgentWidgetConfig["features"]>["artifacts"]
431
+ >;
432
+
433
+ function mountInline(artifacts: Partial<Artifacts>) {
434
+ const mount = document.createElement("div");
435
+ document.body.appendChild(mount);
436
+ const config: AgentWidgetConfig = {
437
+ apiUrl: "https://api.example.com/chat",
438
+ launcher: { enabled: false },
439
+ features: {
440
+ artifacts: {
441
+ enabled: true,
442
+ allowedTypes: ["markdown", "component"],
443
+ display: "inline",
444
+ ...artifacts,
445
+ },
446
+ },
447
+ };
448
+ const controller = createAgentExperience(mount, config);
449
+ return { mount, controller };
450
+ }
451
+
452
+ function paneEl(mount: HTMLElement): HTMLElement {
453
+ const el = mount.querySelector<HTMLElement>(".persona-artifact-pane");
454
+ expect(el).not.toBeNull();
455
+ return el!;
456
+ }
457
+
458
+ function upsertSample(controller: ReturnType<typeof createAgentExperience>) {
459
+ controller.upsertArtifact({
460
+ id: "inline-test",
461
+ title: "Inline test",
462
+ artifactType: "markdown",
463
+ content: "# Hello",
464
+ });
465
+ }
466
+
467
+ // Fabricate an inline block the way the default renderer shapes it: a
468
+ // data-artifact-inline root holding a delegated button, mounted inside a real
469
+ // message so clicks bubble through the ui.ts delegation.
470
+ function fabricateInline(
471
+ mount: HTMLElement,
472
+ controller: ReturnType<typeof createAgentExperience>,
473
+ attr: string,
474
+ value: string
475
+ ): HTMLButtonElement {
476
+ controller.injectAssistantMessage({ content: "See the artifact above." });
477
+ const msgEl = mount.querySelector("[data-message-id]");
478
+ expect(msgEl).not.toBeNull();
479
+ const block = document.createElement("div");
480
+ block.setAttribute("data-artifact-inline", "inline-test");
481
+ const btn = document.createElement("button");
482
+ btn.setAttribute(attr, value);
483
+ block.appendChild(btn);
484
+ msgEl!.appendChild(block);
485
+ return btn;
486
+ }
487
+
488
+ it("resolves a custom action from inlineActions (not cardActions) with content", () => {
489
+ const inlineHandler = vi.fn();
490
+ const cardHandler = vi.fn();
491
+ const { mount, controller } = mountInline({
492
+ inlineActions: [
493
+ { id: "log", label: "Log", icon: "star", onClick: inlineHandler },
494
+ ],
495
+ cardActions: [
496
+ { id: "log", label: "Log", icon: "star", onClick: cardHandler },
497
+ ],
498
+ });
499
+ upsertSample(controller);
500
+ const btn = fabricateInline(mount, controller, "data-artifact-custom-action", "log");
501
+
502
+ btn.click();
503
+ // The inline-surface list wins over cardActions for the same id.
504
+ expect(inlineHandler).toHaveBeenCalledTimes(1);
505
+ expect(cardHandler).not.toHaveBeenCalled();
506
+ const ctx = inlineHandler.mock.calls[0][0];
507
+ expect(ctx.artifactId).toBe("inline-test");
508
+ expect(ctx.markdown).toBe("# Hello");
509
+ controller.destroy();
510
+ });
511
+
512
+ it("opens the pane fullscreen when the Expand button is clicked", () => {
513
+ // The inline block already shows the full preview at chat width, so
514
+ // Expand means "fullscreen this file": it skips the redundant split view
515
+ // even without layout.showExpandToggle (the pinned expansion survives
516
+ // the toggle gate; Close is the exit).
517
+ const { mount, controller } = mountInline({});
518
+ upsertSample(controller);
519
+ // Inline mode keeps the pane closed until an explicit open.
520
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
521
+
522
+ const btn = fabricateInline(
523
+ mount,
524
+ controller,
525
+ "data-expand-artifact-inline",
526
+ "inline-test"
527
+ );
528
+ btn.click();
529
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(false);
530
+ expect(mount.classList.contains("persona-artifact-expanded")).toBe(true);
531
+ controller.destroy();
532
+ });
533
+
534
+ it("Close exits the fullscreen pane and clears the pinned expansion", () => {
535
+ const { mount, controller } = mountInline({});
536
+ upsertSample(controller);
537
+ const btn = fabricateInline(
538
+ mount,
539
+ controller,
540
+ "data-expand-artifact-inline",
541
+ "inline-test"
542
+ );
543
+ btn.click();
544
+ expect(mount.classList.contains("persona-artifact-expanded")).toBe(true);
545
+
546
+ const close = paneEl(mount).querySelector<HTMLButtonElement>(
547
+ '[aria-label="Close"]'
548
+ );
549
+ expect(close).not.toBeNull();
550
+ close!.click();
551
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
552
+ expect(mount.classList.contains("persona-artifact-expanded")).toBe(false);
553
+ controller.destroy();
554
+ });
555
+
556
+ it("does not open the pane when onArtifactAction intercepts the open", () => {
557
+ const { mount, controller } = mountInline({
558
+ onArtifactAction: (action) =>
559
+ action.type === "open" ? true : undefined,
560
+ });
561
+ upsertSample(controller);
562
+ const btn = fabricateInline(
563
+ mount,
564
+ controller,
565
+ "data-expand-artifact-inline",
566
+ "inline-test"
567
+ );
568
+ btn.click();
569
+ expect(paneEl(mount).classList.contains("persona-hidden")).toBe(true);
570
+ controller.destroy();
571
+ });
572
+ });
573
+
574
+ /**
575
+ * Lazy pane rendering regression (double artifact-script execution). With
576
+ * display:"inline" a streamed HTML file artifact used to build TWO sandboxed
577
+ * srcdoc iframes at once: one in the inline transcript block (correct) and one
578
+ * inside the hidden .persona-artifact-pane, because the pane's update()
579
+ * unconditionally rendered its preview body. That executed the artifact's
580
+ * scripts twice. The pane must render its preview lazily and skip it while the
581
+ * surface is hidden.
582
+ */
583
+ describe("artifact pane lazy preview (no double iframe in hidden inline pane)", () => {
584
+ afterEach(() => {
585
+ document.body.innerHTML = "";
586
+ try {
587
+ window.localStorage.clear();
588
+ } catch {
589
+ /* jsdom edge cases */
590
+ }
591
+ });
592
+
593
+ function mountInlineFile() {
594
+ const mount = document.createElement("div");
595
+ document.body.appendChild(mount);
596
+ const config: AgentWidgetConfig = {
597
+ apiUrl: "https://api.example.com/chat",
598
+ launcher: { enabled: false },
599
+ sanitize: false,
600
+ features: {
601
+ artifacts: {
602
+ enabled: true,
603
+ allowedTypes: ["markdown", "component"],
604
+ display: "inline",
605
+ // loading:false → an iframe would be built synchronously if rendered.
606
+ filePreview: { loading: false },
607
+ },
608
+ },
609
+ };
610
+ const controller = createAgentExperience(mount, config);
611
+ return { mount, controller };
612
+ }
613
+
614
+ it("keeps the hidden inline pane free of a preview iframe (only the transcript renders one)", () => {
615
+ const { mount, controller } = mountInlineFile();
616
+ controller.upsertArtifact({
617
+ id: "cat",
618
+ artifactType: "markdown",
619
+ title: "outputs/cat.html",
620
+ content: "```html\n<h1>hi</h1>\n```",
621
+ file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" },
622
+ });
623
+
624
+ const pane = mount.querySelector<HTMLElement>(".persona-artifact-pane")!;
625
+ expect(pane.classList.contains("persona-hidden")).toBe(true);
626
+ // The pane surface is hidden in inline mode, so it must not have built a
627
+ // second sandboxed iframe next to the inline transcript preview.
628
+ expect(pane.querySelector("iframe")).toBeNull();
629
+
630
+ // Opening the pane (explicit reveal) renders the preview from recorded state.
631
+ controller.showArtifacts();
632
+ expect(pane.classList.contains("persona-hidden")).toBe(false);
633
+ expect(pane.querySelector("iframe.persona-artifact-iframe")).not.toBeNull();
634
+ controller.destroy();
635
+ });
636
+ });