@runtypelabs/persona 4.7.0 → 4.9.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 (185) hide show
  1. package/README.md +1 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -2
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -2
  6. package/dist/animations/{types-CSmiKRVa.d.cts → types-4ROVJ1gA.d.cts} +85 -3
  7. package/dist/animations/{types-CSmiKRVa.d.ts → types-4ROVJ1gA.d.ts} +85 -3
  8. package/dist/animations/wipe.cjs +2 -2
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/chunk-5EIIHQLQ.js +1 -0
  12. package/dist/chunk-IO5VVUKP.js +3 -0
  13. package/dist/chunk-IPVK3KOM.js +1 -0
  14. package/dist/chunk-UPO4GUFC.js +1 -0
  15. package/dist/codegen.cjs +13 -13
  16. package/dist/codegen.js +14 -14
  17. package/dist/context-mentions-7S5KVUTG.js +169 -0
  18. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  19. package/dist/context-mentions-inline.cjs +4 -0
  20. package/dist/context-mentions-inline.d.cts +203 -0
  21. package/dist/context-mentions-inline.d.ts +203 -0
  22. package/dist/context-mentions-inline.js +4 -0
  23. package/dist/context-mentions.cjs +295 -0
  24. package/dist/context-mentions.d.cts +7025 -0
  25. package/dist/context-mentions.d.ts +7025 -0
  26. package/dist/context-mentions.js +295 -0
  27. package/dist/index.cjs +99 -68
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +1507 -182
  30. package/dist/index.d.ts +1507 -182
  31. package/dist/index.global.js +88 -56
  32. package/dist/index.global.js.map +1 -1
  33. package/dist/index.js +99 -68
  34. package/dist/index.js.map +1 -1
  35. package/dist/install.global.js +1 -1
  36. package/dist/install.global.js.map +1 -1
  37. package/dist/launcher.global.js +3 -2
  38. package/dist/launcher.global.js.map +1 -1
  39. package/dist/markdown-parsers.js +24 -24
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +17 -0
  42. package/dist/plugin-kit.d.ts +17 -0
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
  45. package/dist/runtype-tts.js +1 -1
  46. package/dist/session-reconnect-JKIJBHS5.js +1 -0
  47. package/dist/smart-dom-reader.cjs +18 -17
  48. package/dist/smart-dom-reader.d.cts +1260 -16
  49. package/dist/smart-dom-reader.d.ts +1260 -16
  50. package/dist/smart-dom-reader.js +18 -17
  51. package/dist/testing.cjs +3 -3
  52. package/dist/testing.js +3 -3
  53. package/dist/theme-editor-preview.cjs +260 -58
  54. package/dist/theme-editor-preview.d.cts +1234 -15
  55. package/dist/theme-editor-preview.d.ts +1234 -15
  56. package/dist/theme-editor-preview.js +87 -58
  57. package/dist/theme-editor.cjs +10 -10
  58. package/dist/theme-editor.d.cts +1226 -15
  59. package/dist/theme-editor.d.ts +1226 -15
  60. package/dist/theme-editor.js +10 -10
  61. package/dist/theme-reference.cjs +1 -1
  62. package/dist/theme-reference.d.cts +76 -0
  63. package/dist/theme-reference.d.ts +76 -0
  64. package/dist/theme-reference.js +1 -1
  65. package/dist/voice-worklet-player.cjs +2 -2
  66. package/dist/voice-worklet-player.js +2 -2
  67. package/dist/webmcp-polyfill.js +2 -2
  68. package/dist/widget.css +1 -1
  69. package/package.json +16 -5
  70. package/src/artifacts-session.test.ts +178 -0
  71. package/src/client.test.ts +255 -1
  72. package/src/client.ts +157 -60
  73. package/src/components/artifact-card.test.ts +333 -0
  74. package/src/components/artifact-card.ts +75 -28
  75. package/src/components/artifact-inline.test.ts +1328 -0
  76. package/src/components/artifact-inline.ts +920 -0
  77. package/src/components/artifact-pane.test.ts +1042 -0
  78. package/src/components/artifact-pane.ts +440 -131
  79. package/src/components/artifact-preview.test.ts +1155 -0
  80. package/src/components/artifact-preview.ts +994 -0
  81. package/src/components/composer-parts.ts +3 -12
  82. package/src/components/context-mention-button.test.ts +70 -0
  83. package/src/components/context-mention-button.ts +82 -0
  84. package/src/components/context-mention-chip.ts +134 -0
  85. package/src/components/context-mention-menu.test.ts +508 -0
  86. package/src/components/context-mention-menu.ts +0 -0
  87. package/src/components/message-bubble.test.ts +175 -0
  88. package/src/components/message-bubble.ts +177 -19
  89. package/src/components/panel.ts +7 -10
  90. package/src/components/pill-composer-builder.test.ts +6 -2
  91. package/src/components/pill-composer-builder.ts +6 -6
  92. package/src/components/reasoning-bubble.ts +1 -13
  93. package/src/components/registry.ts +38 -3
  94. package/src/components/tool-bubble.ts +1 -13
  95. package/src/context-mentions-bundle.test.ts +163 -0
  96. package/src/context-mentions-entry.ts +185 -0
  97. package/src/context-mentions-inline-entry.test.ts +136 -0
  98. package/src/context-mentions-inline-entry.ts +226 -0
  99. package/src/context-mentions-inline-loader.test.ts +30 -0
  100. package/src/context-mentions-inline-loader.ts +36 -0
  101. package/src/context-mentions-inline.ts +15 -0
  102. package/src/context-mentions-loader.ts +32 -0
  103. package/src/context-mentions.ts +16 -0
  104. package/src/defaults.ts +1 -0
  105. package/src/generated/runtype-openapi-contract.ts +55 -3
  106. package/src/index-core.ts +46 -1
  107. package/src/index-global.ts +51 -0
  108. package/src/index.ts +8 -0
  109. package/src/markdown-parsers-loader.test.ts +158 -0
  110. package/src/markdown-parsers-loader.ts +83 -27
  111. package/src/plugin-kit.test.ts +40 -0
  112. package/src/plugin-kit.ts +39 -5
  113. package/src/runtime/host-layout.test.ts +163 -0
  114. package/src/runtime/host-layout.ts +110 -7
  115. package/src/runtime/init.ts +18 -61
  116. package/src/session.mentions.test.ts +175 -0
  117. package/src/session.test.ts +52 -4
  118. package/src/session.ts +197 -27
  119. package/src/smart-dom-reader.test.ts +129 -2
  120. package/src/smart-dom-reader.ts +127 -1
  121. package/src/styles/context-mention-menu-css.ts +176 -0
  122. package/src/styles/widget.css +1002 -152
  123. package/src/theme-editor/preview.ts +2 -0
  124. package/src/theme-editor/sections.test.ts +26 -1
  125. package/src/theme-editor/sections.ts +13 -5
  126. package/src/theme-reference.ts +2 -2
  127. package/src/tool-call-display-defaults.test.ts +1 -0
  128. package/src/types/theme.ts +79 -0
  129. package/src/types.ts +1041 -17
  130. package/src/ui.artifact-pane-gating.test.ts +636 -0
  131. package/src/ui.component-directive.test.ts +104 -0
  132. package/src/ui.composer-bar.test.ts +60 -2
  133. package/src/ui.detached-panel.test.ts +1049 -0
  134. package/src/ui.mention-submit.test.ts +235 -0
  135. package/src/ui.tool-display.test.ts +51 -0
  136. package/src/ui.ts +998 -159
  137. package/src/utils/artifact-custom-actions.ts +128 -0
  138. package/src/utils/artifact-display.test.ts +42 -0
  139. package/src/utils/artifact-display.ts +84 -0
  140. package/src/utils/artifact-file.test.ts +116 -0
  141. package/src/utils/artifact-file.ts +117 -0
  142. package/src/utils/artifact-gate.test.ts +112 -5
  143. package/src/utils/artifact-gate.ts +39 -14
  144. package/src/utils/artifact-loading-status.ts +55 -0
  145. package/src/utils/artifact-status-label.ts +190 -0
  146. package/src/utils/buttons.ts +7 -1
  147. package/src/utils/chunk-loader.test.ts +97 -0
  148. package/src/utils/chunk-loader.ts +88 -0
  149. package/src/utils/code-highlight.test.ts +186 -0
  150. package/src/utils/code-highlight.ts +400 -0
  151. package/src/utils/composer-contenteditable.test.ts +507 -0
  152. package/src/utils/composer-contenteditable.ts +626 -0
  153. package/src/utils/composer-document.test.ts +280 -0
  154. package/src/utils/composer-document.ts +293 -0
  155. package/src/utils/composer-history.test.ts +35 -7
  156. package/src/utils/composer-history.ts +30 -20
  157. package/src/utils/composer-input.ts +159 -0
  158. package/src/utils/context-mention-controller.test.ts +1215 -0
  159. package/src/utils/context-mention-controller.ts +1186 -0
  160. package/src/utils/context-mention-manager.test.ts +422 -0
  161. package/src/utils/context-mention-manager.ts +410 -0
  162. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  163. package/src/utils/context-mention-orchestrator.ts +348 -0
  164. package/src/utils/icons.ts +2 -0
  165. package/src/utils/live-region.test.ts +108 -0
  166. package/src/utils/live-region.ts +94 -0
  167. package/src/utils/mention-channels.ts +63 -0
  168. package/src/utils/mention-llm-format.test.ts +91 -0
  169. package/src/utils/mention-llm-format.ts +79 -0
  170. package/src/utils/mention-matcher.test.ts +86 -0
  171. package/src/utils/mention-matcher.ts +221 -0
  172. package/src/utils/mention-token.ts +72 -0
  173. package/src/utils/mention-trigger.test.ts +155 -0
  174. package/src/utils/mention-trigger.ts +156 -0
  175. package/src/utils/roving-tablist.test.ts +152 -0
  176. package/src/utils/roving-tablist.ts +111 -0
  177. package/src/utils/spinner.ts +45 -0
  178. package/src/utils/theme.test.ts +76 -4
  179. package/src/utils/theme.ts +7 -0
  180. package/src/utils/tokens.ts +118 -11
  181. package/src/utils/tool-loading-animation.test.ts +32 -0
  182. package/src/utils/tool-loading-animation.ts +24 -0
  183. package/dist/chunk-DFBSCFYN.js +0 -1
  184. package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
  185. package/dist/session-reconnect-U77QFUR7.js +0 -1
@@ -0,0 +1,1042 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createArtifactPane } from "./artifact-pane";
6
+ import type {
7
+ AgentWidgetConfig,
8
+ PersonaArtifactCustomAction,
9
+ PersonaArtifactRecord,
10
+ } from "../types";
11
+
12
+ beforeAll(() => {
13
+ // jsdom does not implement matchMedia; the pane's layout code touches it.
14
+ if (!window.matchMedia) {
15
+ window.matchMedia = ((query: string) => ({
16
+ matches: false,
17
+ media: query,
18
+ onchange: null,
19
+ addListener: () => {},
20
+ removeListener: () => {},
21
+ addEventListener: () => {},
22
+ removeEventListener: () => {},
23
+ dispatchEvent: () => false,
24
+ })) as unknown as typeof window.matchMedia;
25
+ }
26
+ });
27
+
28
+ describe("createArtifactPane toolbar copy", () => {
29
+ beforeEach(() => {
30
+ document.body.innerHTML = "";
31
+ });
32
+
33
+ it("uses configurable product-facing toolbar labels", () => {
34
+ const pane = createArtifactPane(
35
+ {
36
+ apiUrl: "/api/chat",
37
+ features: {
38
+ artifacts: {
39
+ enabled: true,
40
+ layout: {
41
+ toolbarTitle: "Analysis",
42
+ closeButtonLabel: "Back to chat",
43
+ },
44
+ },
45
+ },
46
+ },
47
+ { onSelect: () => undefined },
48
+ );
49
+
50
+ document.body.appendChild(pane.element);
51
+
52
+ expect(pane.element.textContent).toContain("Analysis");
53
+ expect(pane.element.querySelector('[aria-label="Back to chat"]')).not.toBeNull();
54
+ });
55
+ });
56
+
57
+ const ZWSP = "\u200b";
58
+ const HTML_RAW = "<h1>hi</h1>\n<script>window.x = 1;</script>\n";
59
+ const wireFor = (raw: string, lang: string): string =>
60
+ "```" + lang + "\n" + raw.split("```").join("`" + ZWSP + "``") + "\n```";
61
+
62
+ const makeConfig = (filePreview?: {
63
+ enabled?: boolean;
64
+ iframeSandbox?: string;
65
+ loading?:
66
+ | boolean
67
+ | {
68
+ delayMs?: number;
69
+ minVisibleMs?: number;
70
+ timeoutMs?: number;
71
+ injectReadySignal?: boolean;
72
+ };
73
+ }): AgentWidgetConfig =>
74
+ ({
75
+ sanitize: false,
76
+ features: { artifacts: { enabled: true, ...(filePreview ? { filePreview } : {}) } },
77
+ }) as AgentWidgetConfig;
78
+
79
+ const fileRecord = (
80
+ overrides: Partial<PersonaArtifactRecord> = {}
81
+ ): PersonaArtifactRecord => ({
82
+ id: "a1",
83
+ artifactType: "markdown",
84
+ title: "outputs/cat.html",
85
+ status: "complete",
86
+ markdown: wireFor(HTML_RAW, "html"),
87
+ file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" },
88
+ ...overrides,
89
+ });
90
+
91
+ const contentEl = (pane: ReturnType<typeof createArtifactPane>): HTMLElement =>
92
+ pane.element.querySelector(".persona-artifact-content") as HTMLElement;
93
+
94
+ describe("artifact-pane file preview", () => {
95
+ it("renders a sandboxed iframe (allow-scripts, no allow-same-origin) with srcdoc = raw source", () => {
96
+ // loading:false → no injected reporter, so srcdoc is exactly the raw source.
97
+ const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
98
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
99
+
100
+ const iframe = contentEl(pane).querySelector(
101
+ "iframe.persona-artifact-iframe"
102
+ ) as HTMLIFrameElement;
103
+ expect(iframe).toBeTruthy();
104
+ // Pane path also wraps the iframe in the positioned frame.
105
+ expect(iframe.parentElement?.classList.contains("persona-artifact-frame")).toBe(
106
+ true
107
+ );
108
+ expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
109
+ expect(iframe.getAttribute("sandbox")).not.toContain("allow-same-origin");
110
+ // srcdoc is the raw, unfenced source assigned as a property.
111
+ expect(iframe.srcdoc).toBe(HTML_RAW);
112
+ });
113
+
114
+ it("honors a custom iframeSandbox override", () => {
115
+ const pane = createArtifactPane(makeConfig({ iframeSandbox: "allow-scripts allow-forms" }), {
116
+ onSelect: () => {},
117
+ });
118
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
119
+ const iframe = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
120
+ expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-forms");
121
+ });
122
+
123
+ it("shows source (no iframe) while streaming", () => {
124
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
125
+ pane.update({
126
+ artifacts: [fileRecord({ status: "streaming", markdown: "```html\n<h1>hi" })],
127
+ selectedId: "a1",
128
+ });
129
+ const content = contentEl(pane);
130
+ expect(content.querySelector("iframe")).toBeNull();
131
+ const pre = content.querySelector("pre");
132
+ expect(pre).toBeTruthy();
133
+ expect(pre?.textContent).toBe("<h1>hi");
134
+ });
135
+
136
+ it("renders a markdown file through the markdown pipeline (no iframe)", () => {
137
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
138
+ pane.update({
139
+ artifacts: [
140
+ fileRecord({
141
+ title: "notes.md",
142
+ markdown: wireFor("# Hello\n", "md"),
143
+ file: { path: "notes.md", mimeType: "text/markdown", language: "md" },
144
+ }),
145
+ ],
146
+ selectedId: "a1",
147
+ });
148
+ const content = contentEl(pane);
149
+ expect(content.querySelector("iframe")).toBeNull();
150
+ expect(content.querySelector("pre")).toBeNull();
151
+ expect(content.querySelector(".persona-markdown-bubble")).toBeTruthy();
152
+ });
153
+
154
+ it("forces source view (no iframe) when filePreview.enabled is false", () => {
155
+ const pane = createArtifactPane(makeConfig({ enabled: false }), { onSelect: () => {} });
156
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
157
+ const content = contentEl(pane);
158
+ expect(content.querySelector("iframe")).toBeNull();
159
+ const pre = content.querySelector("pre");
160
+ expect(pre).toBeTruthy();
161
+ expect(pre?.textContent).toBe(HTML_RAW);
162
+ });
163
+
164
+ it("renders non-file markdown artifacts unchanged (markdown bubble, no iframe)", () => {
165
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
166
+ pane.update({
167
+ artifacts: [
168
+ {
169
+ id: "m1",
170
+ artifactType: "markdown",
171
+ title: "Plain",
172
+ status: "complete",
173
+ markdown: "## Plain doc",
174
+ },
175
+ ],
176
+ selectedId: "m1",
177
+ });
178
+ const content = contentEl(pane);
179
+ expect(content.querySelector("iframe")).toBeNull();
180
+ expect(content.querySelector(".persona-markdown-bubble")).toBeTruthy();
181
+ });
182
+ });
183
+
184
+ describe("artifact-pane lazy rendering (hidden pane)", () => {
185
+ it("does not build the preview iframe while the pane is hidden", () => {
186
+ // loading:false → an iframe would be built immediately if we rendered.
187
+ const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
188
+ pane.setVisible(false);
189
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
190
+
191
+ const content = contentEl(pane);
192
+ // No preview DOM at all while hidden: no iframe, and not even the source pre.
193
+ expect(content.querySelector("iframe")).toBeNull();
194
+ expect(content.querySelector("pre")).toBeNull();
195
+ });
196
+
197
+ it("renders the current recorded state on reveal (after lazy-skipped updates)", () => {
198
+ const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
199
+ pane.setVisible(false);
200
+ // Two hidden updates; the second one is the state that must render on reveal.
201
+ pane.update({ artifacts: [fileRecord({ id: "a1", title: "outputs/one.html" })], selectedId: "a1" });
202
+ pane.update({
203
+ artifacts: [
204
+ fileRecord({ id: "a1", title: "outputs/one.html" }),
205
+ fileRecord({
206
+ id: "a2",
207
+ title: "outputs/two.html",
208
+ file: { path: "outputs/two.html", mimeType: "text/html", language: "html" },
209
+ }),
210
+ ],
211
+ selectedId: "a2",
212
+ });
213
+ expect(contentEl(pane).querySelector("iframe")).toBeNull();
214
+
215
+ pane.setVisible(true);
216
+ const iframe = contentEl(pane).querySelector(
217
+ "iframe.persona-artifact-iframe"
218
+ ) as HTMLIFrameElement;
219
+ expect(iframe).toBeTruthy();
220
+ // Latest state won: the selected artifact is a2, so its id rode onto the iframe.
221
+ expect(iframe.getAttribute("data-artifact-id")).toBe("a2");
222
+ });
223
+
224
+ it("renders eagerly by default (visible path) when constructed directly — panel mode is unaffected", () => {
225
+ // No setVisible call: the pane defaults to visible, matching panel display
226
+ // mode where ui.ts drives setVisible(true) whenever records exist.
227
+ const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
228
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
229
+ expect(contentEl(pane).querySelector("iframe")).toBeTruthy();
230
+ });
231
+
232
+ it("keeps the already-rendered preview iframe (same node) across hide/show", () => {
233
+ const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
234
+ pane.setVisible(true);
235
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
236
+ const first = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
237
+ expect(first).toBeTruthy();
238
+
239
+ // Hide (user collapses): the mounted preview must survive so re-open does
240
+ // not reload the iframe.
241
+ pane.setVisible(false);
242
+ expect(contentEl(pane).querySelector("iframe")).toBe(first);
243
+
244
+ // Reveal again with the same artifact: same node, not a rebuild.
245
+ pane.setVisible(true);
246
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
247
+ expect(contentEl(pane).querySelector("iframe")).toBe(first);
248
+ });
249
+
250
+ it("does not build a NEW artifact's iframe while hidden, then renders it on reveal", () => {
251
+ const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
252
+ pane.setVisible(true);
253
+ pane.update({ artifacts: [fileRecord({ id: "a1" })], selectedId: "a1" });
254
+ const first = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
255
+ expect(first.getAttribute("data-artifact-id")).toBe("a1");
256
+
257
+ // Hide, then a NEW artifact arrives while hidden: no iframe rebuild yet.
258
+ pane.setVisible(false);
259
+ pane.update({
260
+ artifacts: [
261
+ fileRecord({ id: "a1" }),
262
+ fileRecord({
263
+ id: "a2",
264
+ title: "outputs/two.html",
265
+ file: { path: "outputs/two.html", mimeType: "text/html", language: "html" },
266
+ }),
267
+ ],
268
+ selectedId: "a2",
269
+ });
270
+ // Still the old node (no render happened while hidden).
271
+ expect(contentEl(pane).querySelector("iframe")).toBe(first);
272
+
273
+ pane.setVisible(true);
274
+ const shown = contentEl(pane).querySelector("iframe") as HTMLIFrameElement;
275
+ expect(shown.getAttribute("data-artifact-id")).toBe("a2");
276
+ });
277
+
278
+ it("renders a streaming artifact live after reveal (source while streaming, iframe on complete)", () => {
279
+ const pane = createArtifactPane(makeConfig({ loading: false }), { onSelect: () => {} });
280
+ pane.setVisible(false);
281
+ // Still streaming when the pane opens.
282
+ pane.update({
283
+ artifacts: [fileRecord({ status: "streaming", markdown: "```html\n<h1>hi" })],
284
+ selectedId: "a1",
285
+ });
286
+ pane.setVisible(true);
287
+ // Streaming → raw source, no iframe yet.
288
+ expect(contentEl(pane).querySelector("iframe")).toBeNull();
289
+ expect(contentEl(pane).querySelector("pre")?.textContent).toBe("<h1>hi");
290
+
291
+ // Continues receiving updates while visible; completion swaps to the iframe.
292
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
293
+ expect(contentEl(pane).querySelector("pre")).toBeNull();
294
+ expect(contentEl(pane).querySelector("iframe.persona-artifact-iframe")).toBeTruthy();
295
+ });
296
+ });
297
+
298
+ const toggleBtn = (
299
+ pane: ReturnType<typeof createArtifactPane>,
300
+ label: string
301
+ ): HTMLButtonElement =>
302
+ pane.element.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement;
303
+
304
+ describe("artifact-pane view/source toggle", () => {
305
+ it("renders an inline SVG icon in both toggle buttons (icon-registry guard)", () => {
306
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
307
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
308
+
309
+ const rendered = toggleBtn(pane, "Rendered view");
310
+ const source = toggleBtn(pane, "Source");
311
+ expect(rendered).toBeTruthy();
312
+ expect(source).toBeTruthy();
313
+ // A missing/renamed icon would leave renderLucideIcon returning null and the
314
+ // button rendering empty, so guard that both carry an inline <svg>.
315
+ expect(rendered.querySelector("svg")).toBeTruthy();
316
+ expect(source.querySelector("svg")).toBeTruthy();
317
+ });
318
+
319
+ it("flips aria-pressed between the buttons when Source is clicked", () => {
320
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
321
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
322
+
323
+ const rendered = toggleBtn(pane, "Rendered view");
324
+ const source = toggleBtn(pane, "Source");
325
+ expect(rendered.getAttribute("aria-pressed")).toBe("true");
326
+ expect(source.getAttribute("aria-pressed")).toBe("false");
327
+
328
+ source.click();
329
+ expect(rendered.getAttribute("aria-pressed")).toBe("false");
330
+ expect(source.getAttribute("aria-pressed")).toBe("true");
331
+ });
332
+
333
+ it("switches a previewable file artifact to source view when Source is clicked", () => {
334
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
335
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
336
+
337
+ const content = contentEl(pane);
338
+ // Rendered view: sandboxed iframe.
339
+ expect(content.querySelector("iframe")).toBeTruthy();
340
+
341
+ toggleBtn(pane, "Source").click();
342
+ expect(content.querySelector("iframe")).toBeNull();
343
+ const pre = content.querySelector("pre");
344
+ expect(pre).toBeTruthy();
345
+ expect(pre?.textContent).toBe(HTML_RAW);
346
+ });
347
+
348
+ it("drops the content padding (flush class) in source view and restores it when rendered", () => {
349
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
350
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
351
+
352
+ const content = contentEl(pane);
353
+ // Rendered view (iframe): padded as usual.
354
+ expect(content.classList.contains("persona-artifact-content-flush")).toBe(false);
355
+
356
+ toggleBtn(pane, "Source").click();
357
+ expect(content.classList.contains("persona-artifact-content-flush")).toBe(true);
358
+
359
+ toggleBtn(pane, "Rendered view").click();
360
+ expect(content.classList.contains("persona-artifact-content-flush")).toBe(false);
361
+ });
362
+ });
363
+
364
+ describe("artifact-pane default toolbar", () => {
365
+ it("renders an icon-button Close control labelled for the panel", () => {
366
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
367
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
368
+
369
+ const close = toggleBtn(pane, "Close");
370
+ expect(close).toBeTruthy();
371
+ expect(close.tagName).toBe("BUTTON");
372
+ expect(close.classList.contains("persona-icon-btn")).toBe(true);
373
+ expect(close.querySelector("svg")).toBeTruthy();
374
+ });
375
+ });
376
+
377
+ const makeExpandConfig = (): AgentWidgetConfig =>
378
+ ({
379
+ sanitize: false,
380
+ features: { artifacts: { enabled: true, layout: { showExpandToggle: true } } },
381
+ }) as AgentWidgetConfig;
382
+
383
+ const expandBtnOf = (
384
+ pane: ReturnType<typeof createArtifactPane>
385
+ ): HTMLButtonElement | null =>
386
+ pane.element.querySelector(".persona-artifact-expand-btn") as HTMLButtonElement | null;
387
+
388
+ describe("artifact-pane expand toggle", () => {
389
+ it("hides the expand button by default", () => {
390
+ // The button is always built (so a live config update can reveal it via
391
+ // setExpandToggleVisible) but starts hidden without showExpandToggle.
392
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
393
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
394
+ expect(expandBtnOf(pane)!.classList.contains("persona-hidden")).toBe(true);
395
+ });
396
+
397
+ it("setExpandToggleVisible reveals and re-hides the button", () => {
398
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
399
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
400
+ pane.setExpandToggleVisible(true);
401
+ expect(expandBtnOf(pane)!.classList.contains("persona-hidden")).toBe(false);
402
+ pane.setExpandToggleVisible(false);
403
+ expect(expandBtnOf(pane)!.classList.contains("persona-hidden")).toBe(true);
404
+ });
405
+
406
+ it("renders the expand button (labelled, with an svg) when layout.showExpandToggle is true", () => {
407
+ const pane = createArtifactPane(makeExpandConfig(), { onSelect: () => {} });
408
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
409
+ const btn = expandBtnOf(pane);
410
+ expect(btn).toBeTruthy();
411
+ expect(btn?.getAttribute("aria-label")).toBe("Expand artifacts panel");
412
+ expect(btn?.querySelector("svg")).toBeTruthy();
413
+ });
414
+
415
+ it("invokes the onToggleExpand callback when clicked", () => {
416
+ const onToggleExpand = vi.fn();
417
+ const pane = createArtifactPane(makeExpandConfig(), { onSelect: () => {}, onToggleExpand });
418
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
419
+ expandBtnOf(pane)!.click();
420
+ expect(onToggleExpand).toHaveBeenCalledTimes(1);
421
+ });
422
+
423
+ it("setExpanded flips the label/title and keeps an svg icon", () => {
424
+ const pane = createArtifactPane(makeExpandConfig(), { onSelect: () => {} });
425
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
426
+ const btn = expandBtnOf(pane)!;
427
+
428
+ pane.setExpanded(true);
429
+ expect(btn.getAttribute("aria-label")).toBe("Collapse artifacts panel");
430
+ expect(btn.title).toBe("Collapse artifacts panel");
431
+ // A missing/renamed "minimize" icon would leave the button empty.
432
+ expect(btn.querySelector("svg")).toBeTruthy();
433
+
434
+ pane.setExpanded(false);
435
+ expect(btn.getAttribute("aria-label")).toBe("Expand artifacts panel");
436
+ expect(btn.title).toBe("Expand artifacts panel");
437
+ expect(btn.querySelector("svg")).toBeTruthy();
438
+ });
439
+ });
440
+
441
+ const makeCopyButtonConfig = (): AgentWidgetConfig =>
442
+ ({
443
+ sanitize: false,
444
+ features: { artifacts: { enabled: true, layout: { showCopyButton: true } } },
445
+ }) as AgentWidgetConfig;
446
+
447
+ const copyBtnOf = (
448
+ pane: ReturnType<typeof createArtifactPane>
449
+ ): HTMLButtonElement | null =>
450
+ pane.element.querySelector('[aria-label="Copy"]') as HTMLButtonElement | null;
451
+
452
+ describe("artifact-pane default-toolbar copy button", () => {
453
+ it("hides the copy button by default", () => {
454
+ // Like the expand toggle, the button is always built (so a live config
455
+ // update can reveal it via setCopyButtonVisible) but starts hidden
456
+ // without layout.showCopyButton.
457
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
458
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
459
+ expect(copyBtnOf(pane)!.classList.contains("persona-hidden")).toBe(true);
460
+ });
461
+
462
+ it("renders the copy button when layout.showCopyButton is true", () => {
463
+ const pane = createArtifactPane(makeCopyButtonConfig(), { onSelect: () => {} });
464
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
465
+ const btn = copyBtnOf(pane);
466
+ expect(btn).toBeTruthy();
467
+ expect(btn!.classList.contains("persona-hidden")).toBe(false);
468
+ expect(btn!.querySelector("svg")).toBeTruthy();
469
+ });
470
+
471
+ it("setCopyButtonVisible reveals and re-hides the button", () => {
472
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
473
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
474
+ pane.setCopyButtonVisible(true);
475
+ expect(copyBtnOf(pane)!.classList.contains("persona-hidden")).toBe(false);
476
+ pane.setCopyButtonVisible(false);
477
+ expect(copyBtnOf(pane)!.classList.contains("persona-hidden")).toBe(true);
478
+ });
479
+ });
480
+
481
+ const makeToolbarActionsConfig = (
482
+ actions: PersonaArtifactCustomAction[]
483
+ ): AgentWidgetConfig =>
484
+ ({
485
+ sanitize: false,
486
+ features: { artifacts: { enabled: true, toolbarActions: actions } },
487
+ }) as AgentWidgetConfig;
488
+
489
+ const customActionsContainer = (
490
+ pane: ReturnType<typeof createArtifactPane>
491
+ ): HTMLElement =>
492
+ pane.element.querySelector(
493
+ ".persona-artifact-toolbar-custom-actions"
494
+ ) as HTMLElement;
495
+
496
+ const customActionBtns = (
497
+ pane: ReturnType<typeof createArtifactPane>
498
+ ): HTMLButtonElement[] =>
499
+ Array.from(
500
+ pane.element.querySelectorAll(".persona-artifact-custom-action-btn")
501
+ ) as HTMLButtonElement[];
502
+
503
+ const markdownRecord = (
504
+ overrides: Partial<PersonaArtifactRecord> = {}
505
+ ): PersonaArtifactRecord => ({
506
+ id: "a1",
507
+ artifactType: "markdown",
508
+ title: "Doc",
509
+ status: "complete",
510
+ markdown: "# Hi",
511
+ ...overrides,
512
+ });
513
+
514
+ describe("artifact-pane toolbar custom actions", () => {
515
+ it("renders no custom-action buttons by default", () => {
516
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
517
+ pane.update({ artifacts: [markdownRecord()], selectedId: "a1" });
518
+ // The host container is always built, but stays empty without config.
519
+ expect(customActionsContainer(pane)).toBeTruthy();
520
+ expect(customActionBtns(pane).length).toBe(0);
521
+ });
522
+
523
+ it("renders a toolbar action and invokes onClick with the selected record's context", () => {
524
+ const onClick = vi.fn();
525
+ const pane = createArtifactPane(
526
+ makeToolbarActionsConfig([
527
+ { id: "save", label: "Save to Drive", icon: "star", onClick },
528
+ ]),
529
+ { onSelect: () => {} }
530
+ );
531
+ pane.update({
532
+ artifacts: [markdownRecord({ markdown: "# Report" })],
533
+ selectedId: "a1",
534
+ });
535
+
536
+ const btn = customActionsContainer(pane).querySelector(
537
+ ".persona-artifact-custom-action-btn"
538
+ ) as HTMLButtonElement;
539
+ expect(btn).toBeTruthy();
540
+ expect(btn.getAttribute("aria-label")).toBe("Save to Drive");
541
+ expect(btn.querySelector("svg")).toBeTruthy();
542
+
543
+ btn.click();
544
+ expect(onClick).toHaveBeenCalledTimes(1);
545
+ const ctx = onClick.mock.calls[0][0];
546
+ expect(ctx.artifactId).toBe("a1");
547
+ expect(ctx.markdown).toBe("# Report");
548
+ });
549
+
550
+ it("renders a factory icon element verbatim inside the button", () => {
551
+ const custom = document.createElement("span");
552
+ custom.className = "my-brand-icon";
553
+ const pane = createArtifactPane(
554
+ makeToolbarActionsConfig([
555
+ { id: "brand", label: "Brand", icon: () => custom, onClick: () => {} },
556
+ ]),
557
+ { onSelect: () => {} }
558
+ );
559
+ pane.update({ artifacts: [markdownRecord()], selectedId: "a1" });
560
+ const btn = customActionBtns(pane)[0];
561
+ expect(btn).toBeTruthy();
562
+ // The exact author-provided element is slotted in, not a copy.
563
+ expect(btn.querySelector(".my-brand-icon")).toBe(custom);
564
+ });
565
+
566
+ it("gates a button with visible(ctx) on the selected record", () => {
567
+ const action: PersonaArtifactCustomAction = {
568
+ id: "file-only",
569
+ label: "File only",
570
+ icon: "star",
571
+ visible: (ctx) => Boolean(ctx.file),
572
+ onClick: () => {},
573
+ };
574
+ const pane = createArtifactPane(makeToolbarActionsConfig([action]), {
575
+ onSelect: () => {},
576
+ });
577
+
578
+ // File artifact: ctx.file is set, so the button renders.
579
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
580
+ expect(customActionBtns(pane).length).toBe(1);
581
+
582
+ // Plain markdown artifact: no file, so the gate hides the button.
583
+ pane.update({ artifacts: [markdownRecord({ id: "m1" })], selectedId: "m1" });
584
+ expect(customActionBtns(pane).length).toBe(0);
585
+ });
586
+
587
+ it("setCustomActions removes and re-adds buttons (live config update)", () => {
588
+ const action: PersonaArtifactCustomAction = {
589
+ id: "save",
590
+ label: "Save",
591
+ icon: "star",
592
+ onClick: () => {},
593
+ };
594
+ const pane = createArtifactPane(makeToolbarActionsConfig([action]), {
595
+ onSelect: () => {},
596
+ });
597
+ pane.update({ artifacts: [markdownRecord()], selectedId: "a1" });
598
+ expect(customActionBtns(pane).length).toBe(1);
599
+
600
+ pane.setCustomActions([]);
601
+ expect(customActionBtns(pane).length).toBe(0);
602
+
603
+ pane.setCustomActions([action]);
604
+ expect(customActionBtns(pane).length).toBe(1);
605
+ });
606
+ });
607
+
608
+ const tabsIn = (pane: ReturnType<typeof createArtifactPane>): HTMLButtonElement[] =>
609
+ Array.from(
610
+ pane.element.querySelectorAll(".persona-artifact-tab")
611
+ ) as HTMLButtonElement[];
612
+
613
+ describe("artifact-pane tabs", () => {
614
+ it("labels file tabs by basename and keeps the full path in a tooltip", () => {
615
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
616
+ pane.update({ artifacts: [fileRecord()], selectedId: "a1" });
617
+ const [tab] = tabsIn(pane);
618
+ expect(tab.textContent).toBe("cat.html");
619
+ expect(tab.title).toBe("outputs/cat.html");
620
+ expect(tab.getAttribute("aria-label")).toBe("outputs/cat.html");
621
+ });
622
+
623
+ it("falls back to the title for non-file artifacts", () => {
624
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
625
+ pane.update({
626
+ artifacts: [
627
+ {
628
+ id: "m1",
629
+ artifactType: "markdown",
630
+ title: "Plain",
631
+ status: "complete",
632
+ markdown: "## Plain",
633
+ },
634
+ ],
635
+ selectedId: "m1",
636
+ });
637
+ const [tab] = tabsIn(pane);
638
+ expect(tab.textContent).toBe("Plain");
639
+ expect(tab.title).toBe("Plain");
640
+ });
641
+
642
+ it("renders one tab per artifact and marks the selected one active", () => {
643
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
644
+ pane.update({
645
+ artifacts: [
646
+ fileRecord({
647
+ id: "a1",
648
+ title: "outputs/one.html",
649
+ file: { path: "outputs/one.html", mimeType: "text/html", language: "html" },
650
+ }),
651
+ fileRecord({
652
+ id: "a2",
653
+ title: "outputs/two.html",
654
+ file: { path: "outputs/two.html", mimeType: "text/html", language: "html" },
655
+ }),
656
+ ],
657
+ selectedId: "a2",
658
+ });
659
+ const tabs = tabsIn(pane);
660
+ expect(tabs.map((t) => t.textContent)).toEqual(["one.html", "two.html"]);
661
+ expect(tabs[1].classList.contains("persona-bg-persona-container")).toBe(true);
662
+ expect(tabs[0].classList.contains("persona-bg-persona-container")).toBe(false);
663
+ });
664
+
665
+ it("scrolls the selected tab into view only when the selection changes", () => {
666
+ const calls: HTMLElement[] = [];
667
+ const orig = Element.prototype.scrollIntoView;
668
+ Element.prototype.scrollIntoView = function (this: HTMLElement) {
669
+ calls.push(this);
670
+ } as typeof Element.prototype.scrollIntoView;
671
+ try {
672
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
673
+ const a1 = fileRecord({
674
+ id: "a1",
675
+ title: "outputs/one.html",
676
+ file: { path: "outputs/one.html", mimeType: "text/html", language: "html" },
677
+ });
678
+ const a2 = fileRecord({
679
+ id: "a2",
680
+ title: "outputs/two.html",
681
+ file: { path: "outputs/two.html", mimeType: "text/html", language: "html" },
682
+ });
683
+
684
+ pane.update({ artifacts: [a1], selectedId: "a1" });
685
+ expect(calls.length).toBeGreaterThan(0);
686
+
687
+ const beforeSelect = calls.length;
688
+ pane.update({ artifacts: [a1, a2], selectedId: "a2" });
689
+ expect(calls.length).toBeGreaterThan(beforeSelect);
690
+ expect(calls[calls.length - 1].textContent).toBe("two.html");
691
+
692
+ // Re-render with the same selection must not re-scroll (don't fight a
693
+ // user who scrolled the strip manually).
694
+ const beforeIdle = calls.length;
695
+ pane.update({ artifacts: [a1, a2], selectedId: "a2" });
696
+ expect(calls.length).toBe(beforeIdle);
697
+ } finally {
698
+ Element.prototype.scrollIntoView = orig;
699
+ }
700
+ });
701
+ });
702
+
703
+ const listIn = (pane: ReturnType<typeof createArtifactPane>): HTMLElement =>
704
+ pane.element.querySelector(".persona-artifact-list") as HTMLElement;
705
+
706
+ const setGeom = (el: HTMLElement, scrollWidth: number, clientWidth: number, scrollLeft: number) => {
707
+ Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth });
708
+ Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth });
709
+ Object.defineProperty(el, "scrollLeft", { configurable: true, writable: true, value: scrollLeft });
710
+ };
711
+
712
+ const twoFileRecords = (): PersonaArtifactRecord[] => [
713
+ fileRecord({
714
+ id: "a1",
715
+ title: "outputs/one.html",
716
+ file: { path: "outputs/one.html", mimeType: "text/html", language: "html" },
717
+ }),
718
+ fileRecord({
719
+ id: "a2",
720
+ title: "outputs/two.html",
721
+ file: { path: "outputs/two.html", mimeType: "text/html", language: "html" },
722
+ }),
723
+ ];
724
+
725
+ describe("artifact-pane tab edge fade", () => {
726
+ beforeEach(() => {
727
+ // Run the rAF-throttled scroll recompute synchronously for determinism.
728
+ vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
729
+ cb(0);
730
+ return 0;
731
+ });
732
+ });
733
+
734
+ afterEach(() => {
735
+ vi.unstubAllGlobals();
736
+ });
737
+
738
+ it("shows the end fade only when scrolled to the start of an overflowing strip", () => {
739
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
740
+ const list = listIn(pane);
741
+ setGeom(list, 400, 200, 0);
742
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
743
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(true);
744
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(false);
745
+ });
746
+
747
+ it("shows both fades mid-scroll", () => {
748
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
749
+ const list = listIn(pane);
750
+ setGeom(list, 400, 200, 0);
751
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
752
+ setGeom(list, 400, 200, 100);
753
+ list.dispatchEvent(new Event("scroll"));
754
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(true);
755
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(true);
756
+ });
757
+
758
+ it("shows the start fade only at the end of the scroll", () => {
759
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
760
+ const list = listIn(pane);
761
+ setGeom(list, 400, 200, 0);
762
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
763
+ setGeom(list, 400, 200, 200);
764
+ list.dispatchEvent(new Event("scroll"));
765
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(true);
766
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(false);
767
+ });
768
+
769
+ it("shows neither fade when the strip does not overflow", () => {
770
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
771
+ const list = listIn(pane);
772
+ setGeom(list, 200, 200, 0);
773
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
774
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(false);
775
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(false);
776
+ });
777
+ });
778
+
779
+ const fadeConfig = (tabFade: boolean | { start?: boolean; end?: boolean }): AgentWidgetConfig =>
780
+ ({
781
+ sanitize: false,
782
+ features: { artifacts: { enabled: true, layout: { tabFade } } },
783
+ }) as AgentWidgetConfig;
784
+
785
+ describe("artifact-pane tabFade / tabFadeSize config", () => {
786
+ beforeEach(() => {
787
+ vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
788
+ cb(0);
789
+ return 0;
790
+ });
791
+ });
792
+ afterEach(() => {
793
+ vi.unstubAllGlobals();
794
+ });
795
+
796
+ it("never adds either fade class when tabFade is false", () => {
797
+ const pane = createArtifactPane(fadeConfig(false), { onSelect: () => {} });
798
+ const list = listIn(pane);
799
+ setGeom(list, 400, 200, 0);
800
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
801
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(false);
802
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(false);
803
+ // Still false after a mid-scroll where the default config would show both.
804
+ setGeom(list, 400, 200, 100);
805
+ list.dispatchEvent(new Event("scroll"));
806
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(false);
807
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(false);
808
+ });
809
+
810
+ it("gates only the start edge when tabFade.start is false", () => {
811
+ const pane = createArtifactPane(fadeConfig({ start: false }), { onSelect: () => {} });
812
+ const list = listIn(pane);
813
+ // Mid-scroll: default would show both, but start is disabled.
814
+ setGeom(list, 400, 200, 100);
815
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
816
+ list.dispatchEvent(new Event("scroll"));
817
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(false);
818
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(true);
819
+ // At the far end the end fade also drops; start stays off.
820
+ setGeom(list, 400, 200, 200);
821
+ list.dispatchEvent(new Event("scroll"));
822
+ expect(list.classList.contains("persona-artifact-tab-fade-start")).toBe(false);
823
+ expect(list.classList.contains("persona-artifact-tab-fade-end")).toBe(false);
824
+ });
825
+
826
+ it("sets --persona-artifact-tab-fade-size from tabFadeSize", () => {
827
+ const pane = createArtifactPane(
828
+ {
829
+ sanitize: false,
830
+ features: { artifacts: { enabled: true, layout: { tabFadeSize: "32px" } } },
831
+ } as AgentWidgetConfig,
832
+ { onSelect: () => {} }
833
+ );
834
+ const list = listIn(pane);
835
+ expect(list.style.getPropertyValue("--persona-artifact-tab-fade-size")).toBe("32px");
836
+ });
837
+ });
838
+
839
+ describe("artifact-pane renderTabBar hook", () => {
840
+ beforeEach(() => {
841
+ document.body.innerHTML = "";
842
+ });
843
+
844
+ const renderBarConfig = (
845
+ renderTabBar: NonNullable<
846
+ NonNullable<AgentWidgetConfig["features"]>["artifacts"]
847
+ >["renderTabBar"]
848
+ ): AgentWidgetConfig =>
849
+ ({
850
+ sanitize: false,
851
+ features: { artifacts: { enabled: true, renderTabBar } },
852
+ }) as AgentWidgetConfig;
853
+
854
+ it("hides the built-in list and mounts the custom bar between toolbar and content", () => {
855
+ const bar = document.createElement("nav");
856
+ bar.className = "my-custom-bar";
857
+ const pane = createArtifactPane(renderBarConfig(() => bar), { onSelect: () => {} });
858
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
859
+
860
+ // Built-in strip exists but is hidden; the custom mount is shown.
861
+ const list = pane.element.querySelector(".persona-artifact-list") as HTMLElement;
862
+ expect(list.classList.contains("persona-hidden")).toBe(true);
863
+ // The custom bar is mounted inside the stable host.
864
+ const mount = pane.element.querySelector(".persona-artifact-tab-custom") as HTMLElement;
865
+ expect(mount).toBeTruthy();
866
+ expect(mount.classList.contains("persona-hidden")).toBe(false);
867
+ expect(mount.querySelector(".my-custom-bar")).toBe(bar);
868
+ // Order: toolbar, then the bar mount, then content.
869
+ const toolbar = pane.element.querySelector(".persona-artifact-toolbar");
870
+ const content = pane.element.querySelector(".persona-artifact-content");
871
+ const children = Array.from(pane.element.children);
872
+ expect(children.indexOf(toolbar as Element)).toBeLessThan(children.indexOf(mount));
873
+ expect(children.indexOf(mount)).toBeLessThan(children.indexOf(content as Element));
874
+ });
875
+
876
+ it("routes selection from the custom bar through onSelect", () => {
877
+ const selected: string[] = [];
878
+ const pane = createArtifactPane(
879
+ renderBarConfig((ctx) => {
880
+ const bar = document.createElement("div");
881
+ for (const r of ctx.records) {
882
+ const b = document.createElement("button");
883
+ b.textContent = r.id;
884
+ b.addEventListener("click", () => ctx.onSelect(r.id));
885
+ bar.appendChild(b);
886
+ }
887
+ return bar;
888
+ }),
889
+ { onSelect: (id) => selected.push(id) }
890
+ );
891
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
892
+
893
+ const mount = pane.element.querySelector(".persona-artifact-tab-custom") as HTMLElement;
894
+ const btns = Array.from(mount.querySelectorAll("button")) as HTMLButtonElement[];
895
+ btns[1].click();
896
+ expect(selected[selected.length - 1]).toBe("a2");
897
+ });
898
+
899
+ it("re-invokes the hook when records change but not on an identical re-render", () => {
900
+ let calls = 0;
901
+ const pane = createArtifactPane(
902
+ renderBarConfig((ctx) => {
903
+ calls += 1;
904
+ const bar = document.createElement("div");
905
+ bar.textContent = ctx.records.map((r) => r.id).join(",");
906
+ return bar;
907
+ }),
908
+ { onSelect: () => {} }
909
+ );
910
+
911
+ const [a1, a2] = twoFileRecords();
912
+ pane.update({ artifacts: [a1], selectedId: "a1" });
913
+ expect(calls).toBe(1);
914
+
915
+ // Identical re-render: same ids + selection, so the signature gate skips it.
916
+ pane.update({ artifacts: [a1], selectedId: "a1" });
917
+ expect(calls).toBe(1);
918
+
919
+ // Records changed: the hook re-runs.
920
+ pane.update({ artifacts: [a1, a2], selectedId: "a2" });
921
+ expect(calls).toBe(2);
922
+ });
923
+
924
+ it("does not remount a stable node returned across updates, preserving focus", () => {
925
+ // A renderer may return the SAME element every call (updating in place) to
926
+ // keep internal state. The pane must skip replaceChildren for an unchanged
927
+ // node; remounting detaches the subtree and would blur a focused child,
928
+ // breaking a custom bar's roving keyboard focus after one selection change.
929
+ const bar = document.createElement("div");
930
+ const child = document.createElement("button");
931
+ child.textContent = "tab";
932
+ bar.appendChild(child);
933
+ const pane = createArtifactPane(renderBarConfig(() => bar), {
934
+ onSelect: () => {},
935
+ });
936
+ document.body.appendChild(pane.element);
937
+ const [a1, a2] = twoFileRecords();
938
+ pane.update({ artifacts: [a1, a2], selectedId: "a1" });
939
+
940
+ const mount = pane.element.querySelector(
941
+ ".persona-artifact-tab-custom"
942
+ ) as HTMLElement;
943
+ expect(mount.firstElementChild).toBe(bar);
944
+ child.focus();
945
+ expect(document.activeElement).toBe(child);
946
+
947
+ // Selection changes: the hook re-runs and returns the same bar. Focus on the
948
+ // child must survive because the pane leaves the mounted node in place.
949
+ pane.update({ artifacts: [a1, a2], selectedId: "a2" });
950
+ expect(mount.firstElementChild).toBe(bar);
951
+ expect(document.activeElement).toBe(child);
952
+ });
953
+
954
+ it("swaps between the built-in strip and a custom bar via setRenderTabBar", () => {
955
+ // Starts on the built-in strip (no renderTabBar in config).
956
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
957
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
958
+ const list = pane.element.querySelector(".persona-artifact-list") as HTMLElement;
959
+ const mount = pane.element.querySelector(".persona-artifact-tab-custom") as HTMLElement;
960
+ expect(list.classList.contains("persona-hidden")).toBe(false);
961
+ expect(mount.classList.contains("persona-hidden")).toBe(true);
962
+
963
+ // Live switch to a custom bar: strip hides, custom mount shows and populates.
964
+ const bar = document.createElement("nav");
965
+ bar.className = "swapped-bar";
966
+ pane.setRenderTabBar(() => bar);
967
+ expect(list.classList.contains("persona-hidden")).toBe(true);
968
+ expect(mount.classList.contains("persona-hidden")).toBe(false);
969
+ expect(mount.querySelector(".swapped-bar")).toBe(bar);
970
+
971
+ // Live switch back to the built-in strip.
972
+ pane.setRenderTabBar(undefined);
973
+ expect(list.classList.contains("persona-hidden")).toBe(false);
974
+ expect(mount.classList.contains("persona-hidden")).toBe(true);
975
+ });
976
+ });
977
+
978
+ describe("artifact-pane tablist accessibility", () => {
979
+ it("exposes the strip as a tablist with role=tab and aria-selected per tab", () => {
980
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
981
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a2" });
982
+ expect(listIn(pane).getAttribute("role")).toBe("tablist");
983
+ const tabs = tabsIn(pane);
984
+ expect(tabs.every((t) => t.getAttribute("role") === "tab")).toBe(true);
985
+ expect(tabs[0].getAttribute("aria-selected")).toBe("false");
986
+ expect(tabs[1].getAttribute("aria-selected")).toBe("true");
987
+ });
988
+
989
+ it("keeps a single roving tab stop on the selected tab", () => {
990
+ const pane = createArtifactPane(makeConfig(), { onSelect: () => {} });
991
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a2" });
992
+ const tabs = tabsIn(pane);
993
+ expect(tabs.filter((t) => t.tabIndex === 0).length).toBe(1);
994
+ expect(tabs[1].tabIndex).toBe(0);
995
+ expect(tabs[0].tabIndex).toBe(-1);
996
+ });
997
+
998
+ it("moves selection with Arrow, Home and End keys via onSelect", () => {
999
+ const selected: string[] = [];
1000
+ const pane = createArtifactPane(makeConfig(), {
1001
+ onSelect: (id) => selected.push(id),
1002
+ });
1003
+ pane.update({ artifacts: twoFileRecords(), selectedId: "a1" });
1004
+ const tabs = tabsIn(pane);
1005
+
1006
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
1007
+ expect(selected[selected.length - 1]).toBe("a2");
1008
+
1009
+ tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true }));
1010
+ expect(selected[selected.length - 1]).toBe("a1");
1011
+
1012
+ tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true }));
1013
+ expect(selected[selected.length - 1]).toBe("a2");
1014
+ });
1015
+
1016
+ it("keeps keyboard focus on the newly selected tab across the selection re-render", () => {
1017
+ // The host re-renders the strip on selection (update -> render ->
1018
+ // replaceChildren). Focus must land on the new selected tab or arrow nav
1019
+ // dies after one press.
1020
+ let selectedId = "a1";
1021
+ const pane = createArtifactPane(makeConfig(), {
1022
+ onSelect: (id) => {
1023
+ selectedId = id;
1024
+ pane.update({ artifacts: twoFileRecords(), selectedId });
1025
+ },
1026
+ });
1027
+ document.body.appendChild(pane.element);
1028
+ pane.update({ artifacts: twoFileRecords(), selectedId });
1029
+
1030
+ const first = tabsIn(pane)[0];
1031
+ first.focus();
1032
+ expect(document.activeElement).toBe(first);
1033
+
1034
+ first.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
1035
+
1036
+ const after = tabsIn(pane);
1037
+ expect(after[1].getAttribute("aria-selected")).toBe("true");
1038
+ // Focus survived the rebuild and moved to the new selected tab, so a second
1039
+ // arrow keeps working.
1040
+ expect(document.activeElement).toBe(after[1]);
1041
+ });
1042
+ });