@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.
- package/dist/animations/glyph-cycle.cjs +2 -2
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/glyph-cycle.js +2 -2
- package/dist/animations/{types-CSmiKRVa.d.cts → types-BsZtXPKK.d.cts} +43 -3
- package/dist/animations/{types-CSmiKRVa.d.ts → types-BsZtXPKK.d.ts} +43 -3
- package/dist/animations/wipe.cjs +2 -2
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/chunk-5EIIHQLQ.js +1 -0
- package/dist/codegen.cjs +12 -12
- package/dist/codegen.js +14 -14
- package/dist/index.cjs +91 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +877 -172
- package/dist/index.d.ts +877 -172
- package/dist/index.global.js +81 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +91 -68
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +3 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +24 -24
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.js +1 -1
- package/dist/runtype-tts-entry-UJAEF7NZ.js +1 -0
- package/dist/runtype-tts.js +1 -1
- package/dist/session-reconnect-JKIJBHS5.js +1 -0
- package/dist/smart-dom-reader.cjs +17 -17
- package/dist/smart-dom-reader.d.cts +753 -15
- package/dist/smart-dom-reader.d.ts +753 -15
- package/dist/smart-dom-reader.js +17 -17
- package/dist/testing.cjs +3 -3
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +81 -58
- package/dist/theme-editor-preview.d.cts +761 -15
- package/dist/theme-editor-preview.d.ts +761 -15
- package/dist/theme-editor-preview.js +81 -58
- package/dist/theme-editor.cjs +6 -6
- package/dist/theme-editor.d.cts +753 -15
- package/dist/theme-editor.d.ts +753 -15
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +74 -0
- package/dist/theme-reference.d.ts +74 -0
- package/dist/theme-reference.js +1 -1
- package/dist/voice-worklet-player.cjs +2 -2
- package/dist/voice-worklet-player.js +2 -2
- package/dist/webmcp-polyfill.js +2 -2
- package/dist/widget.css +1 -1
- package/package.json +2 -3
- package/src/artifacts-session.test.ts +178 -0
- package/src/client.test.ts +186 -1
- package/src/client.ts +92 -9
- package/src/components/artifact-card.test.ts +333 -0
- package/src/components/artifact-card.ts +75 -28
- package/src/components/artifact-inline.test.ts +1328 -0
- package/src/components/artifact-inline.ts +920 -0
- package/src/components/artifact-pane.test.ts +1042 -0
- package/src/components/artifact-pane.ts +440 -131
- package/src/components/artifact-preview.test.ts +1155 -0
- package/src/components/artifact-preview.ts +994 -0
- package/src/components/pill-composer-builder.test.ts +6 -2
- package/src/components/pill-composer-builder.ts +6 -6
- package/src/components/reasoning-bubble.ts +1 -13
- package/src/components/registry.ts +38 -3
- package/src/components/tool-bubble.ts +1 -13
- package/src/defaults.ts +1 -0
- package/src/generated/runtype-openapi-contract.ts +55 -3
- package/src/index-core.ts +20 -1
- package/src/index.ts +8 -0
- package/src/markdown-parsers-loader.test.ts +158 -0
- package/src/markdown-parsers-loader.ts +74 -9
- package/src/runtime/host-layout.test.ts +163 -0
- package/src/runtime/host-layout.ts +110 -7
- package/src/runtime/init.ts +18 -61
- package/src/session.ts +76 -22
- package/src/styles/widget.css +773 -26
- package/src/theme-editor/preview.ts +2 -0
- package/src/theme-editor/sections.test.ts +26 -1
- package/src/theme-editor/sections.ts +10 -2
- package/src/theme-reference.ts +2 -2
- package/src/tool-call-display-defaults.test.ts +1 -0
- package/src/types/theme.ts +77 -0
- package/src/types.ts +516 -17
- package/src/ui.artifact-pane-gating.test.ts +636 -0
- package/src/ui.component-directive.test.ts +104 -0
- package/src/ui.composer-bar.test.ts +60 -2
- package/src/ui.detached-panel.test.ts +1049 -0
- package/src/ui.tool-display.test.ts +51 -0
- package/src/ui.ts +698 -109
- package/src/utils/artifact-custom-actions.ts +128 -0
- package/src/utils/artifact-display.test.ts +42 -0
- package/src/utils/artifact-display.ts +84 -0
- package/src/utils/artifact-file.test.ts +116 -0
- package/src/utils/artifact-file.ts +117 -0
- package/src/utils/artifact-gate.test.ts +112 -5
- package/src/utils/artifact-gate.ts +39 -14
- package/src/utils/artifact-loading-status.ts +55 -0
- package/src/utils/artifact-status-label.ts +190 -0
- package/src/utils/buttons.ts +7 -1
- package/src/utils/code-highlight.test.ts +186 -0
- package/src/utils/code-highlight.ts +400 -0
- package/src/utils/icons.ts +2 -0
- package/src/utils/roving-tablist.test.ts +152 -0
- package/src/utils/roving-tablist.ts +111 -0
- package/src/utils/spinner.ts +45 -0
- package/src/utils/theme.test.ts +48 -0
- package/src/utils/theme.ts +7 -0
- package/src/utils/tokens.ts +91 -0
- package/src/utils/tool-loading-animation.test.ts +32 -0
- package/src/utils/tool-loading-animation.ts +24 -0
- package/dist/chunk-DFBSCFYN.js +0 -1
- package/dist/runtype-tts-entry-HFUV2UF7.js +0 -1
- package/dist/session-reconnect-U77QFUR7.js +0 -1
|
@@ -0,0 +1,1328 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
PersonaArtifactInline,
|
|
6
|
+
resolveInlineBody,
|
|
7
|
+
updateInlineArtifactBlocks
|
|
8
|
+
} from "./artifact-inline";
|
|
9
|
+
import { componentRegistry } from "./registry";
|
|
10
|
+
import type {
|
|
11
|
+
AgentWidgetArtifactsFeature,
|
|
12
|
+
AgentWidgetConfig,
|
|
13
|
+
AgentWidgetMessage,
|
|
14
|
+
PersonaArtifactRecord,
|
|
15
|
+
PersonaArtifactStatusLabelContext
|
|
16
|
+
} from "../types";
|
|
17
|
+
import type { ComponentContext } from "./registry";
|
|
18
|
+
|
|
19
|
+
const ZWSP = "\u200b";
|
|
20
|
+
const HTML_RAW = "<h1>hi</h1>\n";
|
|
21
|
+
const wireFor = (raw: string, lang: string): string =>
|
|
22
|
+
"```" + lang + "\n" + raw.split("```").join("`" + ZWSP + "``") + "\n```";
|
|
23
|
+
|
|
24
|
+
const makeConfig = (
|
|
25
|
+
artifacts: Record<string, unknown> = {}
|
|
26
|
+
): AgentWidgetConfig =>
|
|
27
|
+
({
|
|
28
|
+
sanitize: false,
|
|
29
|
+
features: { artifacts: { enabled: true, ...artifacts } }
|
|
30
|
+
}) as AgentWidgetConfig;
|
|
31
|
+
|
|
32
|
+
const makeContext = (config: AgentWidgetConfig): ComponentContext => {
|
|
33
|
+
const message: AgentWidgetMessage = {
|
|
34
|
+
id: "artifact-ref-a1",
|
|
35
|
+
role: "assistant",
|
|
36
|
+
content: "",
|
|
37
|
+
createdAt: new Date().toISOString()
|
|
38
|
+
};
|
|
39
|
+
return { message, config, updateProps: () => {} };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const streamingProps = (): Record<string, unknown> => ({
|
|
43
|
+
artifactId: "a1",
|
|
44
|
+
title: "Notes",
|
|
45
|
+
artifactType: "markdown",
|
|
46
|
+
status: "streaming"
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const completeProps = (): Record<string, unknown> => ({
|
|
50
|
+
artifactId: "a1",
|
|
51
|
+
title: "Notes",
|
|
52
|
+
artifactType: "markdown",
|
|
53
|
+
status: "complete",
|
|
54
|
+
markdown: "# Done"
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("PersonaArtifactInline default render", () => {
|
|
58
|
+
it("renders the shared preview body inside a tagged inline block", () => {
|
|
59
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(makeConfig()));
|
|
60
|
+
expect(el.classList.contains("persona-artifact-inline")).toBe(true);
|
|
61
|
+
expect(el.getAttribute("data-artifact-inline")).toBe("a1");
|
|
62
|
+
expect(el.querySelector(".persona-artifact-preview-body")).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("applies registry records via updateInlineArtifactBlocks (streaming deltas)", () => {
|
|
66
|
+
const container = document.createElement("div");
|
|
67
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(makeConfig()));
|
|
68
|
+
container.appendChild(el);
|
|
69
|
+
|
|
70
|
+
const record: PersonaArtifactRecord = {
|
|
71
|
+
id: "a1",
|
|
72
|
+
artifactType: "markdown",
|
|
73
|
+
title: "Notes",
|
|
74
|
+
status: "streaming",
|
|
75
|
+
markdown: "# Hello"
|
|
76
|
+
};
|
|
77
|
+
updateInlineArtifactBlocks(container, [record]);
|
|
78
|
+
expect(el.textContent).toContain("Hello");
|
|
79
|
+
|
|
80
|
+
updateInlineArtifactBlocks(container, [
|
|
81
|
+
{ ...record, markdown: "# Hello world" }
|
|
82
|
+
]);
|
|
83
|
+
expect(el.textContent).toContain("Hello world");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("streaming→complete View Transition gating", () => {
|
|
87
|
+
// Typed loosely (cast through unknown) because lib.dom's own
|
|
88
|
+
// startViewTransition signature differs from this minimal stub.
|
|
89
|
+
type VTStub = {
|
|
90
|
+
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const stubViewTransition = () => {
|
|
94
|
+
const start = vi.fn((cb: () => void) => {
|
|
95
|
+
cb();
|
|
96
|
+
return { finished: Promise.resolve() };
|
|
97
|
+
});
|
|
98
|
+
(document as unknown as VTStub).startViewTransition = start;
|
|
99
|
+
return start;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
delete (document as unknown as VTStub).startViewTransition;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const setupBlock = () => {
|
|
107
|
+
const container = document.createElement("div");
|
|
108
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(makeConfig()));
|
|
109
|
+
container.appendChild(el);
|
|
110
|
+
const completeRecord: PersonaArtifactRecord = {
|
|
111
|
+
id: "a1",
|
|
112
|
+
artifactType: "markdown",
|
|
113
|
+
title: "Notes",
|
|
114
|
+
status: "complete",
|
|
115
|
+
markdown: "# Done"
|
|
116
|
+
};
|
|
117
|
+
return { container, el, completeRecord };
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
it("animates the boundary swap via startViewTransition by default", async () => {
|
|
121
|
+
const start = stubViewTransition();
|
|
122
|
+
const { container, el, completeRecord } = setupBlock();
|
|
123
|
+
|
|
124
|
+
updateInlineArtifactBlocks(container, [
|
|
125
|
+
{ ...completeRecord, status: "streaming" }
|
|
126
|
+
]);
|
|
127
|
+
expect(start).not.toHaveBeenCalled();
|
|
128
|
+
|
|
129
|
+
updateInlineArtifactBlocks(container, [completeRecord]);
|
|
130
|
+
expect(start).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(el.textContent).toContain("Done");
|
|
132
|
+
// Let `finished` settle so the module-level in-flight guard resets.
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("swaps instantly when suppressTransition is set (transcript still streaming)", () => {
|
|
137
|
+
const start = stubViewTransition();
|
|
138
|
+
const { container, el, completeRecord } = setupBlock();
|
|
139
|
+
|
|
140
|
+
updateInlineArtifactBlocks(container, [completeRecord], {
|
|
141
|
+
suppressTransition: true
|
|
142
|
+
});
|
|
143
|
+
expect(start).not.toHaveBeenCalled();
|
|
144
|
+
expect(el.textContent).toContain("Done");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("ignores records for other artifact ids and empty registry states", () => {
|
|
149
|
+
const container = document.createElement("div");
|
|
150
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(makeConfig()));
|
|
151
|
+
container.appendChild(el);
|
|
152
|
+
const before = el.innerHTML;
|
|
153
|
+
|
|
154
|
+
updateInlineArtifactBlocks(container, []);
|
|
155
|
+
updateInlineArtifactBlocks(container, [
|
|
156
|
+
{
|
|
157
|
+
id: "other",
|
|
158
|
+
artifactType: "markdown",
|
|
159
|
+
status: "streaming",
|
|
160
|
+
markdown: "# Nope"
|
|
161
|
+
}
|
|
162
|
+
]);
|
|
163
|
+
expect(el.innerHTML).toBe(before);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("PersonaArtifactInline hydration from props", () => {
|
|
168
|
+
it("re-renders a completed markdown artifact from props alone (no registry record)", () => {
|
|
169
|
+
const el = PersonaArtifactInline(
|
|
170
|
+
{
|
|
171
|
+
artifactId: "a1",
|
|
172
|
+
title: "Notes",
|
|
173
|
+
artifactType: "markdown",
|
|
174
|
+
status: "complete",
|
|
175
|
+
markdown: "# Restored"
|
|
176
|
+
},
|
|
177
|
+
makeContext(makeConfig())
|
|
178
|
+
);
|
|
179
|
+
expect(el.textContent).toContain("Restored");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("re-invokes the registered renderer with embedded component props", () => {
|
|
183
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
184
|
+
const name = "TestInlineHydrationChart";
|
|
185
|
+
componentRegistry.register(name, (props) => {
|
|
186
|
+
seen.push(props);
|
|
187
|
+
const node = document.createElement("div");
|
|
188
|
+
node.className = "test-inline-chart";
|
|
189
|
+
node.textContent = `series:${JSON.stringify(props.series)}`;
|
|
190
|
+
return node;
|
|
191
|
+
});
|
|
192
|
+
try {
|
|
193
|
+
const el = PersonaArtifactInline(
|
|
194
|
+
{
|
|
195
|
+
artifactId: "a1",
|
|
196
|
+
title: "Chart",
|
|
197
|
+
artifactType: "component",
|
|
198
|
+
status: "complete",
|
|
199
|
+
component: name,
|
|
200
|
+
componentProps: { series: [1, 2, 3] }
|
|
201
|
+
},
|
|
202
|
+
makeContext(makeConfig())
|
|
203
|
+
);
|
|
204
|
+
expect(el.querySelector(".test-inline-chart")).toBeTruthy();
|
|
205
|
+
expect(seen).toHaveLength(1);
|
|
206
|
+
expect(seen[0]).toEqual({ series: [1, 2, 3] });
|
|
207
|
+
} finally {
|
|
208
|
+
componentRegistry.unregister(name);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("re-renders a completed file artifact from props as a sandboxed iframe", () => {
|
|
213
|
+
const el = PersonaArtifactInline(
|
|
214
|
+
{
|
|
215
|
+
artifactId: "a1",
|
|
216
|
+
title: "outputs/cat.html",
|
|
217
|
+
artifactType: "markdown",
|
|
218
|
+
status: "complete",
|
|
219
|
+
markdown: wireFor(HTML_RAW, "html"),
|
|
220
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
221
|
+
},
|
|
222
|
+
makeContext(makeConfig())
|
|
223
|
+
);
|
|
224
|
+
const iframe = el.querySelector(
|
|
225
|
+
"iframe.persona-artifact-iframe"
|
|
226
|
+
) as HTMLIFrameElement;
|
|
227
|
+
expect(iframe).toBeTruthy();
|
|
228
|
+
// The iframe is nested in the positioned preview frame (overlay host); the
|
|
229
|
+
// geometry classes detect it through the wrapper via a descendant query.
|
|
230
|
+
expect(iframe.parentElement?.classList.contains("persona-artifact-frame")).toBe(
|
|
231
|
+
true
|
|
232
|
+
);
|
|
233
|
+
expect(iframe.getAttribute("sandbox")).toBe("allow-scripts");
|
|
234
|
+
// Default loading appends the ready reporter, so the raw source is the prefix.
|
|
235
|
+
expect(iframe.srcdoc.startsWith(HTML_RAW)).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("PersonaArtifactInline renderInline override", () => {
|
|
240
|
+
it("uses the custom element when renderInline returns one", () => {
|
|
241
|
+
const renderInline = vi.fn().mockImplementation(() => {
|
|
242
|
+
const custom = document.createElement("div");
|
|
243
|
+
custom.className = "my-inline";
|
|
244
|
+
return custom;
|
|
245
|
+
});
|
|
246
|
+
const el = PersonaArtifactInline(
|
|
247
|
+
streamingProps(),
|
|
248
|
+
makeContext(makeConfig({ renderInline }))
|
|
249
|
+
);
|
|
250
|
+
expect(el.classList.contains("my-inline")).toBe(true);
|
|
251
|
+
expect(renderInline).toHaveBeenCalledWith(
|
|
252
|
+
expect.objectContaining({
|
|
253
|
+
artifact: {
|
|
254
|
+
artifactId: "a1",
|
|
255
|
+
title: "Notes",
|
|
256
|
+
artifactType: "markdown",
|
|
257
|
+
status: "streaming"
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("falls back to the default block when renderInline returns null", () => {
|
|
264
|
+
const renderInline = vi.fn().mockReturnValue(null);
|
|
265
|
+
const el = PersonaArtifactInline(
|
|
266
|
+
streamingProps(),
|
|
267
|
+
makeContext(makeConfig({ renderInline }))
|
|
268
|
+
);
|
|
269
|
+
expect(renderInline).toHaveBeenCalled();
|
|
270
|
+
expect(el.classList.contains("persona-artifact-inline")).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("exposes a working defaultRenderer to the override", () => {
|
|
274
|
+
let defaultEl: HTMLElement | null = null;
|
|
275
|
+
const renderInline = vi.fn().mockImplementation(
|
|
276
|
+
(ctx: { defaultRenderer: () => HTMLElement }) => {
|
|
277
|
+
defaultEl = ctx.defaultRenderer();
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
PersonaArtifactInline(streamingProps(), makeContext(makeConfig({ renderInline })));
|
|
282
|
+
expect(defaultEl).not.toBeNull();
|
|
283
|
+
expect(
|
|
284
|
+
(defaultEl as unknown as HTMLElement).classList.contains(
|
|
285
|
+
"persona-artifact-inline"
|
|
286
|
+
)
|
|
287
|
+
).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("wins over inlineChrome / inlineActions config when it returns an element", () => {
|
|
291
|
+
const renderInline = vi.fn().mockImplementation(() => {
|
|
292
|
+
const custom = document.createElement("div");
|
|
293
|
+
custom.className = "my-inline";
|
|
294
|
+
return custom;
|
|
295
|
+
});
|
|
296
|
+
const el = PersonaArtifactInline(
|
|
297
|
+
completeProps(),
|
|
298
|
+
makeContext(
|
|
299
|
+
makeConfig({
|
|
300
|
+
renderInline,
|
|
301
|
+
inlineChrome: true,
|
|
302
|
+
inlineActions: [{ id: "log", label: "Log", onClick: () => {} }]
|
|
303
|
+
})
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
// No default chrome / body leaked through; the custom element is all there is.
|
|
307
|
+
expect(el.classList.contains("my-inline")).toBe(true);
|
|
308
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).toBeNull();
|
|
309
|
+
expect(el.querySelector("[data-artifact-custom-action]")).toBeNull();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe("PersonaArtifactInline file-preview chrome", () => {
|
|
314
|
+
it("renders the chrome bar with title, type label, zones, and body wrapper on complete", () => {
|
|
315
|
+
const el = PersonaArtifactInline(completeProps(), makeContext(makeConfig()));
|
|
316
|
+
|
|
317
|
+
// Frame + chrome theme zones.
|
|
318
|
+
expect(el.getAttribute("data-persona-theme-zone")).toBe("artifact-inline");
|
|
319
|
+
const chrome = el.querySelector(
|
|
320
|
+
".persona-artifact-inline-chrome"
|
|
321
|
+
) as HTMLElement | null;
|
|
322
|
+
expect(chrome).not.toBeNull();
|
|
323
|
+
expect(chrome!.getAttribute("data-persona-theme-zone")).toBe(
|
|
324
|
+
"artifact-inline-chrome"
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
// Title + type label.
|
|
328
|
+
const title = el.querySelector(".persona-artifact-inline-title");
|
|
329
|
+
expect(title?.textContent).toBe("Notes");
|
|
330
|
+
const type = el.querySelector(".persona-artifact-inline-type");
|
|
331
|
+
expect(type?.textContent).toBe("Document");
|
|
332
|
+
|
|
333
|
+
// Body wrapper wraps the shared preview body.
|
|
334
|
+
const body = el.querySelector(".persona-artifact-inline-body");
|
|
335
|
+
expect(body).not.toBeNull();
|
|
336
|
+
expect(body!.querySelector(".persona-artifact-preview-body")).toBeTruthy();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("shows a streaming status and hides copy + custom actions while streaming", () => {
|
|
340
|
+
const el = PersonaArtifactInline(
|
|
341
|
+
streamingProps(),
|
|
342
|
+
makeContext(
|
|
343
|
+
makeConfig({
|
|
344
|
+
loadingAnimation: "none",
|
|
345
|
+
inlineActions: [{ id: "log", label: "Log", onClick: () => {} }]
|
|
346
|
+
})
|
|
347
|
+
)
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// Streaming status replaces the type label.
|
|
351
|
+
const status = el.querySelector(".persona-artifact-inline-status");
|
|
352
|
+
expect(status).not.toBeNull();
|
|
353
|
+
expect(status!.textContent).toBe("Generating document...");
|
|
354
|
+
expect(el.querySelector(".persona-artifact-inline-type")).toBeNull();
|
|
355
|
+
|
|
356
|
+
// Copy exists but is hidden; no custom actions rendered mid-stream.
|
|
357
|
+
const copy = el.querySelector("[data-copy-artifact]") as HTMLElement | null;
|
|
358
|
+
expect(copy).not.toBeNull();
|
|
359
|
+
expect(copy!.classList.contains("persona-hidden")).toBe(true);
|
|
360
|
+
expect(el.querySelector("[data-artifact-custom-action]")).toBeNull();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("shows the type label, copy, expand, and custom actions on complete", () => {
|
|
364
|
+
const el = PersonaArtifactInline(
|
|
365
|
+
completeProps(),
|
|
366
|
+
makeContext(
|
|
367
|
+
makeConfig({
|
|
368
|
+
inlineActions: [{ id: "log", label: "Log", onClick: () => {} }]
|
|
369
|
+
})
|
|
370
|
+
)
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const copy = el.querySelector("[data-copy-artifact]") as HTMLElement | null;
|
|
374
|
+
expect(copy).not.toBeNull();
|
|
375
|
+
expect(copy!.classList.contains("persona-hidden")).toBe(false);
|
|
376
|
+
expect(copy!.getAttribute("data-copy-artifact")).toBe("a1");
|
|
377
|
+
|
|
378
|
+
const expand = el.querySelector(
|
|
379
|
+
"[data-expand-artifact-inline]"
|
|
380
|
+
) as HTMLElement | null;
|
|
381
|
+
expect(expand).not.toBeNull();
|
|
382
|
+
expect(expand!.getAttribute("data-expand-artifact-inline")).toBe("a1");
|
|
383
|
+
|
|
384
|
+
const custom = el.querySelector(
|
|
385
|
+
'[data-artifact-custom-action="log"]'
|
|
386
|
+
) as HTMLElement | null;
|
|
387
|
+
expect(custom).not.toBeNull();
|
|
388
|
+
// Custom actions render before copy / expand.
|
|
389
|
+
expect(
|
|
390
|
+
custom!.compareDocumentPosition(copy!) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
391
|
+
).toBeTruthy();
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("flips streaming -> complete in place on the same root element (no remount)", () => {
|
|
395
|
+
const container = document.createElement("div");
|
|
396
|
+
const el = PersonaArtifactInline(
|
|
397
|
+
streamingProps(),
|
|
398
|
+
makeContext(
|
|
399
|
+
makeConfig({
|
|
400
|
+
loadingAnimation: "none",
|
|
401
|
+
inlineActions: [{ id: "log", label: "Log", onClick: () => {} }]
|
|
402
|
+
})
|
|
403
|
+
)
|
|
404
|
+
);
|
|
405
|
+
container.appendChild(el);
|
|
406
|
+
const chromeBefore = el.querySelector(".persona-artifact-inline-chrome");
|
|
407
|
+
expect(el.querySelector("[data-artifact-custom-action]")).toBeNull();
|
|
408
|
+
|
|
409
|
+
updateInlineArtifactBlocks(container, [
|
|
410
|
+
{
|
|
411
|
+
id: "a1",
|
|
412
|
+
artifactType: "markdown",
|
|
413
|
+
title: "Notes",
|
|
414
|
+
status: "complete",
|
|
415
|
+
markdown: "# Done"
|
|
416
|
+
}
|
|
417
|
+
]);
|
|
418
|
+
|
|
419
|
+
// Same root + same chrome node: updated in place, not re-rendered.
|
|
420
|
+
expect(container.firstElementChild).toBe(el);
|
|
421
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).toBe(chromeBefore);
|
|
422
|
+
// Streaming status gave way to the type label and the complete-gated actions.
|
|
423
|
+
expect(el.querySelector(".persona-artifact-inline-status")).toBeNull();
|
|
424
|
+
expect(el.querySelector(".persona-artifact-inline-type")?.textContent).toBe(
|
|
425
|
+
"Document"
|
|
426
|
+
);
|
|
427
|
+
const copy = el.querySelector("[data-copy-artifact]") as HTMLElement | null;
|
|
428
|
+
expect(copy!.classList.contains("persona-hidden")).toBe(false);
|
|
429
|
+
expect(el.querySelector('[data-artifact-custom-action="log"]')).toBeTruthy();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("omits the chrome bar but keeps the body wrapper when inlineChrome is false", () => {
|
|
433
|
+
const el = PersonaArtifactInline(
|
|
434
|
+
completeProps(),
|
|
435
|
+
makeContext(makeConfig({ inlineChrome: false }))
|
|
436
|
+
);
|
|
437
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).toBeNull();
|
|
438
|
+
expect(el.querySelector("[data-copy-artifact]")).toBeNull();
|
|
439
|
+
expect(el.querySelector("[data-expand-artifact-inline]")).toBeNull();
|
|
440
|
+
// The body wrapper is always present (it carries the frame padding).
|
|
441
|
+
const body = el.querySelector(".persona-artifact-inline-body");
|
|
442
|
+
expect(body).not.toBeNull();
|
|
443
|
+
expect(body!.querySelector(".persona-artifact-preview-body")).toBeTruthy();
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it("drops only the copy button for inlineChrome { showCopy: false }", () => {
|
|
447
|
+
const el = PersonaArtifactInline(
|
|
448
|
+
completeProps(),
|
|
449
|
+
makeContext(makeConfig({ inlineChrome: { showCopy: false } }))
|
|
450
|
+
);
|
|
451
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
|
|
452
|
+
expect(el.querySelector("[data-copy-artifact]")).toBeNull();
|
|
453
|
+
expect(el.querySelector("[data-expand-artifact-inline]")).not.toBeNull();
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("drops only the expand button for inlineChrome { showExpand: false }", () => {
|
|
457
|
+
const el = PersonaArtifactInline(
|
|
458
|
+
completeProps(),
|
|
459
|
+
makeContext(makeConfig({ inlineChrome: { showExpand: false } }))
|
|
460
|
+
);
|
|
461
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
|
|
462
|
+
expect(el.querySelector("[data-copy-artifact]")).not.toBeNull();
|
|
463
|
+
expect(el.querySelector("[data-expand-artifact-inline]")).toBeNull();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it("respects each inline action's visible() gate", () => {
|
|
467
|
+
const el = PersonaArtifactInline(
|
|
468
|
+
completeProps(),
|
|
469
|
+
makeContext(
|
|
470
|
+
makeConfig({
|
|
471
|
+
inlineActions: [
|
|
472
|
+
{ id: "shown", label: "Shown", onClick: () => {} },
|
|
473
|
+
{
|
|
474
|
+
id: "hidden",
|
|
475
|
+
label: "Hidden",
|
|
476
|
+
visible: () => false,
|
|
477
|
+
onClick: () => {}
|
|
478
|
+
}
|
|
479
|
+
]
|
|
480
|
+
})
|
|
481
|
+
)
|
|
482
|
+
);
|
|
483
|
+
expect(el.querySelector('[data-artifact-custom-action="shown"]')).toBeTruthy();
|
|
484
|
+
expect(el.querySelector('[data-artifact-custom-action="hidden"]')).toBeNull();
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
describe("PersonaArtifactInline inlineBody height model", () => {
|
|
489
|
+
const BODY_HEIGHT_VAR = "--persona-artifact-inline-body-height";
|
|
490
|
+
|
|
491
|
+
const fileStreamingProps = (): Record<string, unknown> => ({
|
|
492
|
+
artifactId: "f1",
|
|
493
|
+
title: "outputs/cat.html",
|
|
494
|
+
artifactType: "markdown",
|
|
495
|
+
status: "streaming",
|
|
496
|
+
markdown: "```html\n<h1>hi",
|
|
497
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const fileCompleteRecord = (): PersonaArtifactRecord => ({
|
|
501
|
+
id: "f1",
|
|
502
|
+
artifactType: "markdown",
|
|
503
|
+
title: "outputs/cat.html",
|
|
504
|
+
status: "complete",
|
|
505
|
+
markdown: wireFor(HTML_RAW, "html"),
|
|
506
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("defaults to a fixed 320px streaming source window", () => {
|
|
510
|
+
const el = PersonaArtifactInline(fileStreamingProps(), makeContext(makeConfig()));
|
|
511
|
+
expect(el.style.getPropertyValue(BODY_HEIGHT_VAR)).toBe("320px");
|
|
512
|
+
expect(el.querySelector(".persona-artifact-source-window--fixed")).toBeTruthy();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("applies a scalar numeric height to both states", () => {
|
|
516
|
+
const el = PersonaArtifactInline(
|
|
517
|
+
fileStreamingProps(),
|
|
518
|
+
makeContext(makeConfig({ inlineBody: { height: 200 } }))
|
|
519
|
+
);
|
|
520
|
+
expect(el.style.getPropertyValue(BODY_HEIGHT_VAR)).toBe("200px");
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("applies object heights per state and updates the var on complete", () => {
|
|
524
|
+
const container = document.createElement("div");
|
|
525
|
+
const el = PersonaArtifactInline(
|
|
526
|
+
fileStreamingProps(),
|
|
527
|
+
makeContext(makeConfig({ inlineBody: { height: { streaming: 150, complete: 400 } } }))
|
|
528
|
+
);
|
|
529
|
+
container.appendChild(el);
|
|
530
|
+
expect(el.style.getPropertyValue(BODY_HEIGHT_VAR)).toBe("150px");
|
|
531
|
+
|
|
532
|
+
updateInlineArtifactBlocks(container, [fileCompleteRecord()]);
|
|
533
|
+
expect(el.style.getPropertyValue(BODY_HEIGHT_VAR)).toBe("400px");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("leaves the height var unset (no fixed window) for height 'auto'", () => {
|
|
537
|
+
const el = PersonaArtifactInline(
|
|
538
|
+
fileStreamingProps(),
|
|
539
|
+
makeContext(makeConfig({ inlineBody: { height: "auto" } }))
|
|
540
|
+
);
|
|
541
|
+
expect(el.style.getPropertyValue(BODY_HEIGHT_VAR)).toBe("");
|
|
542
|
+
expect(el.querySelector(".persona-artifact-source-window--fixed")).toBeNull();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("renders the status placeholder while streaming with streamingView 'status'", () => {
|
|
546
|
+
const el = PersonaArtifactInline(
|
|
547
|
+
fileStreamingProps(),
|
|
548
|
+
makeContext(
|
|
549
|
+
makeConfig({ inlineBody: { streamingView: "status" }, loadingAnimation: "none" })
|
|
550
|
+
)
|
|
551
|
+
);
|
|
552
|
+
const status = el.querySelector(".persona-artifact-status-view");
|
|
553
|
+
expect(status).toBeTruthy();
|
|
554
|
+
expect(status!.textContent).toContain("Generating");
|
|
555
|
+
expect(el.querySelector(".persona-artifact-source-window")).toBeNull();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("caps a non-iframe complete body and leaves the iframe body uncapped", () => {
|
|
559
|
+
// Non-file markdown complete → capped (no iframe).
|
|
560
|
+
const capped = PersonaArtifactInline(completeProps(), makeContext(makeConfig()));
|
|
561
|
+
expect(capped.querySelector(".persona-artifact-inline-body--cap")).toBeTruthy();
|
|
562
|
+
|
|
563
|
+
// File complete → iframe, sized by the var, so not capped.
|
|
564
|
+
const iframeEl = PersonaArtifactInline(
|
|
565
|
+
{
|
|
566
|
+
artifactId: "f1",
|
|
567
|
+
title: "outputs/cat.html",
|
|
568
|
+
artifactType: "markdown",
|
|
569
|
+
status: "complete",
|
|
570
|
+
markdown: wireFor(HTML_RAW, "html"),
|
|
571
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
572
|
+
},
|
|
573
|
+
makeContext(makeConfig())
|
|
574
|
+
);
|
|
575
|
+
expect(iframeEl.querySelector("iframe")).toBeTruthy();
|
|
576
|
+
expect(iframeEl.querySelector(".persona-artifact-inline-body--cap")).toBeNull();
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe("resolveInlineBody fadeMask defaults", () => {
|
|
581
|
+
it("defaults to top+bottom fade when fadeMask is undefined", () => {
|
|
582
|
+
const layout = resolveInlineBody({} as AgentWidgetArtifactsFeature);
|
|
583
|
+
expect(layout.fadeTop).toBe(true);
|
|
584
|
+
expect(layout.fadeBottom).toBe(true);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("keeps an explicit { top: true } as top-only (bottom off)", () => {
|
|
588
|
+
const layout = resolveInlineBody({
|
|
589
|
+
inlineBody: { fadeMask: { top: true } }
|
|
590
|
+
} as AgentWidgetArtifactsFeature);
|
|
591
|
+
expect(layout.fadeTop).toBe(true);
|
|
592
|
+
expect(layout.fadeBottom).toBe(false);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("disables both fades for fadeMask false", () => {
|
|
596
|
+
const layout = resolveInlineBody({
|
|
597
|
+
inlineBody: { fadeMask: false }
|
|
598
|
+
} as AgentWidgetArtifactsFeature);
|
|
599
|
+
expect(layout.fadeTop).toBe(false);
|
|
600
|
+
expect(layout.fadeBottom).toBe(false);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("enables both fades for fadeMask true", () => {
|
|
604
|
+
const layout = resolveInlineBody({
|
|
605
|
+
inlineBody: { fadeMask: true }
|
|
606
|
+
} as AgentWidgetArtifactsFeature);
|
|
607
|
+
expect(layout.fadeTop).toBe(true);
|
|
608
|
+
expect(layout.fadeBottom).toBe(true);
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
describe("resolveInlineBody overflow precedence", () => {
|
|
613
|
+
it("defaults overflow to 'scroll' with tail-follow on and top+bottom fade", () => {
|
|
614
|
+
const layout = resolveInlineBody({} as AgentWidgetArtifactsFeature);
|
|
615
|
+
expect(layout.overflow).toBe("scroll");
|
|
616
|
+
expect(layout.followOutput).toBe(true);
|
|
617
|
+
expect(layout.fadeTop).toBe(true);
|
|
618
|
+
expect(layout.fadeBottom).toBe(true);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it("ignores followOutput in clip mode (forced off) even when set true", () => {
|
|
622
|
+
const layout = resolveInlineBody({
|
|
623
|
+
inlineBody: { overflow: "clip", followOutput: true }
|
|
624
|
+
} as AgentWidgetArtifactsFeature);
|
|
625
|
+
expect(layout.overflow).toBe("clip");
|
|
626
|
+
expect(layout.followOutput).toBe(false);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("defaults clip mode to bottom-only fade (no top fade)", () => {
|
|
630
|
+
const layout = resolveInlineBody({
|
|
631
|
+
inlineBody: { overflow: "clip" }
|
|
632
|
+
} as AgentWidgetArtifactsFeature);
|
|
633
|
+
expect(layout.fadeTop).toBe(false);
|
|
634
|
+
expect(layout.fadeBottom).toBe(true);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("lets an explicit fadeMask win over the clip-mode fade default", () => {
|
|
638
|
+
const layout = resolveInlineBody({
|
|
639
|
+
inlineBody: { overflow: "clip", fadeMask: { top: true, bottom: false } }
|
|
640
|
+
} as AgentWidgetArtifactsFeature);
|
|
641
|
+
expect(layout.fadeTop).toBe(true);
|
|
642
|
+
expect(layout.fadeBottom).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
describe("PersonaArtifactInline inlineBody viewMode", () => {
|
|
647
|
+
const htmlFileProps = (status: "streaming" | "complete"): Record<string, unknown> => ({
|
|
648
|
+
artifactId: "f1",
|
|
649
|
+
title: "outputs/cat.html",
|
|
650
|
+
artifactType: "markdown",
|
|
651
|
+
status,
|
|
652
|
+
markdown: wireFor(HTML_RAW, "html"),
|
|
653
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("keeps the rendered default: complete html file previews in an iframe", () => {
|
|
657
|
+
const el = PersonaArtifactInline(htmlFileProps("complete"), makeContext(makeConfig()));
|
|
658
|
+
expect(el.querySelector("iframe")).toBeTruthy();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("viewMode 'source' shows highlighted source instead of the iframe preview", () => {
|
|
662
|
+
const el = PersonaArtifactInline(
|
|
663
|
+
htmlFileProps("complete"),
|
|
664
|
+
makeContext(makeConfig({ inlineBody: { viewMode: "source" } }))
|
|
665
|
+
);
|
|
666
|
+
expect(el.querySelector("iframe")).toBeNull();
|
|
667
|
+
expect(el.querySelector(".persona-code-pre")).toBeTruthy();
|
|
668
|
+
// The complete source view keeps the same sized wrapper + inner window the
|
|
669
|
+
// streaming state uses (geometry-identical swap): full-bleed like the
|
|
670
|
+
// pane's source view, no padded-wrapper cap.
|
|
671
|
+
expect(el.querySelector(".persona-artifact-source-window--fixed")).toBeTruthy();
|
|
672
|
+
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
|
|
673
|
+
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(true);
|
|
674
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
|
|
675
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--cap")).toBe(false);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("viewMode 'source' covers markdown-kind files (no rendered markdown)", () => {
|
|
679
|
+
const MD_RAW = "# Title\n";
|
|
680
|
+
const el = PersonaArtifactInline(
|
|
681
|
+
{
|
|
682
|
+
artifactId: "m1",
|
|
683
|
+
title: "notes.md",
|
|
684
|
+
artifactType: "markdown",
|
|
685
|
+
status: "complete",
|
|
686
|
+
markdown: wireFor(MD_RAW, "markdown"),
|
|
687
|
+
file: { path: "notes.md", mimeType: "text/markdown", language: "markdown" }
|
|
688
|
+
},
|
|
689
|
+
makeContext(makeConfig({ inlineBody: { viewMode: "source" } }))
|
|
690
|
+
);
|
|
691
|
+
expect(el.querySelector(".persona-markdown-bubble")).toBeNull();
|
|
692
|
+
const pre = el.querySelector(".persona-code-pre");
|
|
693
|
+
expect(pre).toBeTruthy();
|
|
694
|
+
expect(pre!.textContent).toContain("# Title");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("viewMode 'source' covers plain markdown artifacts", () => {
|
|
698
|
+
const el = PersonaArtifactInline(
|
|
699
|
+
completeProps(),
|
|
700
|
+
makeContext(makeConfig({ inlineBody: { viewMode: "source" } }))
|
|
701
|
+
);
|
|
702
|
+
expect(el.querySelector(".persona-markdown-bubble")).toBeNull();
|
|
703
|
+
expect(el.querySelector(".persona-code-pre")).toBeTruthy();
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("viewMode 'source' keeps the streaming source window and swaps nothing on complete", () => {
|
|
707
|
+
const container = document.createElement("div");
|
|
708
|
+
const el = PersonaArtifactInline(
|
|
709
|
+
htmlFileProps("streaming"),
|
|
710
|
+
makeContext(makeConfig({ inlineBody: { viewMode: "source" } }))
|
|
711
|
+
);
|
|
712
|
+
container.appendChild(el);
|
|
713
|
+
const preBefore = el.querySelector(".persona-code-pre");
|
|
714
|
+
expect(preBefore).toBeTruthy();
|
|
715
|
+
|
|
716
|
+
updateInlineArtifactBlocks(container, [
|
|
717
|
+
{
|
|
718
|
+
id: "f1",
|
|
719
|
+
artifactType: "markdown",
|
|
720
|
+
title: "outputs/cat.html",
|
|
721
|
+
status: "complete",
|
|
722
|
+
markdown: wireFor(HTML_RAW, "html"),
|
|
723
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
724
|
+
}
|
|
725
|
+
]);
|
|
726
|
+
expect(el.querySelector("iframe")).toBeNull();
|
|
727
|
+
// Same <pre> node survives the streaming→complete boundary (in-place update).
|
|
728
|
+
expect(el.querySelector(".persona-code-pre")).toBe(preBefore);
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
describe("PersonaArtifactInline inlineBody flush and sized wrappers", () => {
|
|
733
|
+
it("streams source full-bleed in a sized wrapper (pane-style flush code)", () => {
|
|
734
|
+
const el = PersonaArtifactInline(
|
|
735
|
+
{
|
|
736
|
+
artifactId: "f1",
|
|
737
|
+
title: "outputs/cat.html",
|
|
738
|
+
artifactType: "markdown",
|
|
739
|
+
status: "streaming",
|
|
740
|
+
markdown: "```html\n<h1>hi",
|
|
741
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
742
|
+
},
|
|
743
|
+
makeContext(makeConfig())
|
|
744
|
+
);
|
|
745
|
+
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
|
|
746
|
+
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(true);
|
|
747
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("keeps rendered markdown padded (no flush, no sized wrapper)", () => {
|
|
751
|
+
const el = PersonaArtifactInline(completeProps(), makeContext(makeConfig()));
|
|
752
|
+
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
|
|
753
|
+
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(false);
|
|
754
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(false);
|
|
755
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--cap")).toBe(true);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("sizes the wrapper for the complete iframe (padded, same outer box)", () => {
|
|
759
|
+
const el = PersonaArtifactInline(
|
|
760
|
+
{
|
|
761
|
+
artifactId: "f1",
|
|
762
|
+
title: "outputs/cat.html",
|
|
763
|
+
artifactType: "markdown",
|
|
764
|
+
status: "complete",
|
|
765
|
+
markdown: wireFor(HTML_RAW, "html"),
|
|
766
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
767
|
+
},
|
|
768
|
+
makeContext(makeConfig())
|
|
769
|
+
);
|
|
770
|
+
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
|
|
771
|
+
expect(el.querySelector("iframe")).toBeTruthy();
|
|
772
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
|
|
773
|
+
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(false);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
describe("PersonaArtifactInline view toggle", () => {
|
|
778
|
+
// The toggle is a segmented group (createToggleGroup) matching the pane: two
|
|
779
|
+
// always-visible buttons — "Rendered view" (eye) and "Source" (code-xml) — with
|
|
780
|
+
// the active one carrying aria-pressed="true". The whole group is hidden via
|
|
781
|
+
// persona-hidden when the artifact has no rendered alternative, and omitted
|
|
782
|
+
// entirely for showViewToggle: false.
|
|
783
|
+
const groupOf = (el: HTMLElement): HTMLElement | null =>
|
|
784
|
+
el.querySelector(".persona-artifact-toggle-group");
|
|
785
|
+
const renderedBtn = (el: HTMLElement): HTMLElement =>
|
|
786
|
+
el.querySelector('button[aria-label="Rendered view"]') as HTMLElement;
|
|
787
|
+
const sourceBtn = (el: HTMLElement): HTMLElement =>
|
|
788
|
+
el.querySelector('button[aria-label="Source"]') as HTMLElement;
|
|
789
|
+
const isHidden = (node: HTMLElement | null): boolean =>
|
|
790
|
+
!!node && node.classList.contains("persona-hidden");
|
|
791
|
+
const pressed = (btn: HTMLElement): boolean =>
|
|
792
|
+
btn.getAttribute("aria-pressed") === "true";
|
|
793
|
+
|
|
794
|
+
const htmlFileProps = (
|
|
795
|
+
status: "streaming" | "complete"
|
|
796
|
+
): Record<string, unknown> => ({
|
|
797
|
+
artifactId: "f1",
|
|
798
|
+
title: "outputs/cat.html",
|
|
799
|
+
artifactType: "markdown",
|
|
800
|
+
status,
|
|
801
|
+
markdown: status === "streaming" ? "```html\n<h1>hi" : wireFor(HTML_RAW, "html"),
|
|
802
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
const htmlCompleteRecord = (): PersonaArtifactRecord => ({
|
|
806
|
+
id: "f1",
|
|
807
|
+
artifactType: "markdown",
|
|
808
|
+
title: "outputs/cat.html",
|
|
809
|
+
status: "complete",
|
|
810
|
+
markdown: wireFor(HTML_RAW, "html"),
|
|
811
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("shows the segmented toggle on a complete html file artifact", () => {
|
|
815
|
+
const el = PersonaArtifactInline(
|
|
816
|
+
htmlFileProps("complete"),
|
|
817
|
+
makeContext(makeConfig())
|
|
818
|
+
);
|
|
819
|
+
const group = groupOf(el);
|
|
820
|
+
expect(group).not.toBeNull();
|
|
821
|
+
expect(isHidden(group)).toBe(false);
|
|
822
|
+
// Both segments present; rendered is the default active segment.
|
|
823
|
+
expect(renderedBtn(el)).not.toBeNull();
|
|
824
|
+
expect(sourceBtn(el)).not.toBeNull();
|
|
825
|
+
expect(pressed(renderedBtn(el))).toBe(true);
|
|
826
|
+
expect(pressed(sourceBtn(el))).toBe(false);
|
|
827
|
+
// Placed between custom actions and the copy button.
|
|
828
|
+
const copy = el.querySelector("[data-copy-artifact]") as HTMLElement;
|
|
829
|
+
expect(
|
|
830
|
+
group!.compareDocumentPosition(copy) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
831
|
+
).toBeTruthy();
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it("hides the group while streaming", () => {
|
|
835
|
+
const el = PersonaArtifactInline(
|
|
836
|
+
htmlFileProps("streaming"),
|
|
837
|
+
makeContext(makeConfig({ loadingAnimation: "none" }))
|
|
838
|
+
);
|
|
839
|
+
expect(isHidden(groupOf(el))).toBe(true);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("hides the group for plain markdown artifacts", () => {
|
|
843
|
+
const el = PersonaArtifactInline(completeProps(), makeContext(makeConfig()));
|
|
844
|
+
expect(isHidden(groupOf(el))).toBe(true);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("hides the group for component artifacts", () => {
|
|
848
|
+
const name = "TestInlineToggleChart";
|
|
849
|
+
componentRegistry.register(name, () => {
|
|
850
|
+
const node = document.createElement("div");
|
|
851
|
+
node.className = "test-toggle-chart";
|
|
852
|
+
return node;
|
|
853
|
+
});
|
|
854
|
+
try {
|
|
855
|
+
const el = PersonaArtifactInline(
|
|
856
|
+
{
|
|
857
|
+
artifactId: "c1",
|
|
858
|
+
title: "Chart",
|
|
859
|
+
artifactType: "component",
|
|
860
|
+
status: "complete",
|
|
861
|
+
component: name,
|
|
862
|
+
componentProps: {}
|
|
863
|
+
},
|
|
864
|
+
makeContext(makeConfig())
|
|
865
|
+
);
|
|
866
|
+
expect(isHidden(groupOf(el))).toBe(true);
|
|
867
|
+
} finally {
|
|
868
|
+
componentRegistry.unregister(name);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it("hides the group for a source-only (kind 'other') file artifact", () => {
|
|
873
|
+
const el = PersonaArtifactInline(
|
|
874
|
+
{
|
|
875
|
+
artifactId: "o1",
|
|
876
|
+
title: "script.py",
|
|
877
|
+
artifactType: "markdown",
|
|
878
|
+
status: "complete",
|
|
879
|
+
markdown: wireFor("print(1)\n", "python"),
|
|
880
|
+
file: { path: "script.py", mimeType: "text/x-python", language: "python" }
|
|
881
|
+
},
|
|
882
|
+
makeContext(makeConfig())
|
|
883
|
+
);
|
|
884
|
+
expect(isHidden(groupOf(el))).toBe(true);
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
it("hides the group when filePreview is disabled for an html file", () => {
|
|
888
|
+
const el = PersonaArtifactInline(
|
|
889
|
+
htmlFileProps("complete"),
|
|
890
|
+
makeContext(makeConfig({ filePreview: { enabled: false } }))
|
|
891
|
+
);
|
|
892
|
+
expect(isHidden(groupOf(el))).toBe(true);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it("hides the group when inlineBody.viewMode is 'source'", () => {
|
|
896
|
+
const el = PersonaArtifactInline(
|
|
897
|
+
htmlFileProps("complete"),
|
|
898
|
+
makeContext(makeConfig({ inlineBody: { viewMode: "source" } }))
|
|
899
|
+
);
|
|
900
|
+
expect(isHidden(groupOf(el))).toBe(true);
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it("omits the group entirely for inlineChrome { showViewToggle: false }", () => {
|
|
904
|
+
const el = PersonaArtifactInline(
|
|
905
|
+
htmlFileProps("complete"),
|
|
906
|
+
makeContext(makeConfig({ inlineChrome: { showViewToggle: false } }))
|
|
907
|
+
);
|
|
908
|
+
expect(groupOf(el)).toBeNull();
|
|
909
|
+
// Copy + expand remain.
|
|
910
|
+
expect(el.querySelector("[data-copy-artifact]")).not.toBeNull();
|
|
911
|
+
expect(el.querySelector("[data-expand-artifact-inline]")).not.toBeNull();
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("flips iframe -> source and back when clicking the inactive segment", () => {
|
|
915
|
+
const el = PersonaArtifactInline(
|
|
916
|
+
htmlFileProps("complete"),
|
|
917
|
+
makeContext(makeConfig())
|
|
918
|
+
);
|
|
919
|
+
expect(el.querySelector("iframe")).toBeTruthy();
|
|
920
|
+
const bodyEl = el.querySelector(".persona-artifact-inline-body")!;
|
|
921
|
+
|
|
922
|
+
// rendered -> source
|
|
923
|
+
sourceBtn(el).click();
|
|
924
|
+
expect(el.querySelector("iframe")).toBeNull();
|
|
925
|
+
expect(el.querySelector(".persona-code-pre")).toBeTruthy();
|
|
926
|
+
expect(pressed(sourceBtn(el))).toBe(true);
|
|
927
|
+
expect(pressed(renderedBtn(el))).toBe(false);
|
|
928
|
+
// Source view recomputes to full-bleed + sized.
|
|
929
|
+
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(true);
|
|
930
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
|
|
931
|
+
|
|
932
|
+
// source -> rendered
|
|
933
|
+
renderedBtn(el).click();
|
|
934
|
+
expect(el.querySelector("iframe")).toBeTruthy();
|
|
935
|
+
expect(el.querySelector(".persona-code-pre")).toBeNull();
|
|
936
|
+
expect(pressed(renderedBtn(el))).toBe(true);
|
|
937
|
+
expect(pressed(sourceBtn(el))).toBe(false);
|
|
938
|
+
// Preview iframe is padded (not flush) but still sized (same outer box).
|
|
939
|
+
expect(bodyEl.classList.contains("persona-artifact-content-flush")).toBe(false);
|
|
940
|
+
expect(bodyEl.classList.contains("persona-artifact-inline-body--sized")).toBe(true);
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it("is a no-op when clicking the already-active segment", () => {
|
|
944
|
+
const el = PersonaArtifactInline(
|
|
945
|
+
htmlFileProps("complete"),
|
|
946
|
+
makeContext(makeConfig())
|
|
947
|
+
);
|
|
948
|
+
const iframeBefore = el.querySelector("iframe");
|
|
949
|
+
expect(iframeBefore).toBeTruthy();
|
|
950
|
+
|
|
951
|
+
// Clicking the active (rendered) segment does not re-render the body: the
|
|
952
|
+
// same iframe node survives and the source view is never built.
|
|
953
|
+
renderedBtn(el).click();
|
|
954
|
+
expect(el.querySelector("iframe")).toBe(iframeBefore);
|
|
955
|
+
expect(el.querySelector(".persona-code-pre")).toBeNull();
|
|
956
|
+
expect(pressed(renderedBtn(el))).toBe(true);
|
|
957
|
+
expect(pressed(sourceBtn(el))).toBe(false);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it("resets to the configured default view when the block restarts streaming", () => {
|
|
961
|
+
const container = document.createElement("div");
|
|
962
|
+
const el = PersonaArtifactInline(
|
|
963
|
+
htmlFileProps("complete"),
|
|
964
|
+
makeContext(makeConfig({ loadingAnimation: "none" }))
|
|
965
|
+
);
|
|
966
|
+
container.appendChild(el);
|
|
967
|
+
|
|
968
|
+
// Toggle to source, then a streaming restart clears the choice.
|
|
969
|
+
sourceBtn(el).click();
|
|
970
|
+
expect(el.querySelector(".persona-code-pre")).toBeTruthy();
|
|
971
|
+
|
|
972
|
+
updateInlineArtifactBlocks(container, [
|
|
973
|
+
{ ...htmlCompleteRecord(), status: "streaming", markdown: "```html\n<h1>hi" }
|
|
974
|
+
]);
|
|
975
|
+
updateInlineArtifactBlocks(container, [htmlCompleteRecord()]);
|
|
976
|
+
|
|
977
|
+
// Back on the rendered default (iframe), the group's selection reset.
|
|
978
|
+
expect(el.querySelector("iframe")).toBeTruthy();
|
|
979
|
+
expect(pressed(renderedBtn(el))).toBe(true);
|
|
980
|
+
expect(pressed(sourceBtn(el))).toBe(false);
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
describe("resolveInlineBody completeDisplay", () => {
|
|
985
|
+
it("defaults completeDisplay to 'inline'", () => {
|
|
986
|
+
const layout = resolveInlineBody({} as AgentWidgetArtifactsFeature);
|
|
987
|
+
expect(layout.completeDisplay).toBe("inline");
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
it("resolves completeDisplay 'card' when set", () => {
|
|
991
|
+
const layout = resolveInlineBody({
|
|
992
|
+
inlineBody: { completeDisplay: "card" }
|
|
993
|
+
} as AgentWidgetArtifactsFeature);
|
|
994
|
+
expect(layout.completeDisplay).toBe("card");
|
|
995
|
+
});
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
describe("PersonaArtifactInline completeDisplay 'card'", () => {
|
|
999
|
+
const cardConfig = () => makeConfig({ inlineBody: { completeDisplay: "card" } });
|
|
1000
|
+
|
|
1001
|
+
it("keeps the streamed inline body while streaming (no card yet)", () => {
|
|
1002
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
|
|
1003
|
+
// Streaming phase renders the normal inline chrome + body, not the card.
|
|
1004
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
|
|
1005
|
+
expect(el.querySelector(".persona-artifact-inline-body")).not.toBeNull();
|
|
1006
|
+
expect(el.querySelector(".persona-artifact-card")).toBeNull();
|
|
1007
|
+
expect(el.classList.contains("persona-artifact-inline--card")).toBe(false);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
it("collapses chrome + body to the card on the streaming→complete boundary, same root", () => {
|
|
1011
|
+
const container = document.createElement("div");
|
|
1012
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
|
|
1013
|
+
container.appendChild(el);
|
|
1014
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
|
|
1015
|
+
|
|
1016
|
+
updateInlineArtifactBlocks(container, [
|
|
1017
|
+
{
|
|
1018
|
+
id: "a1",
|
|
1019
|
+
artifactType: "markdown",
|
|
1020
|
+
title: "Notes",
|
|
1021
|
+
status: "complete",
|
|
1022
|
+
markdown: "# Done"
|
|
1023
|
+
}
|
|
1024
|
+
]);
|
|
1025
|
+
|
|
1026
|
+
// Same root element, data-artifact-inline + theme zone preserved.
|
|
1027
|
+
expect(container.firstElementChild).toBe(el);
|
|
1028
|
+
expect(el.getAttribute("data-artifact-inline")).toBe("a1");
|
|
1029
|
+
expect(el.getAttribute("data-persona-theme-zone")).toBe("artifact-inline");
|
|
1030
|
+
// Chrome + body replaced by the reference card.
|
|
1031
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).toBeNull();
|
|
1032
|
+
expect(el.querySelector(".persona-artifact-inline-body")).toBeNull();
|
|
1033
|
+
const card = el.querySelector(".persona-artifact-card") as HTMLElement | null;
|
|
1034
|
+
expect(card).not.toBeNull();
|
|
1035
|
+
expect(card!.getAttribute("data-open-artifact")).toBe("a1");
|
|
1036
|
+
expect(card!.textContent).toContain("Notes");
|
|
1037
|
+
// Frame styling neutralized so the nested card is the sole visual.
|
|
1038
|
+
expect(el.classList.contains("persona-artifact-inline--card")).toBe(true);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
it("does not run the View Transition on the card-collapse boundary", () => {
|
|
1042
|
+
type VTStub = {
|
|
1043
|
+
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
|
1044
|
+
};
|
|
1045
|
+
const start = vi.fn((cb: () => void) => {
|
|
1046
|
+
cb();
|
|
1047
|
+
return { finished: Promise.resolve() };
|
|
1048
|
+
});
|
|
1049
|
+
(document as unknown as VTStub).startViewTransition = start;
|
|
1050
|
+
try {
|
|
1051
|
+
const container = document.createElement("div");
|
|
1052
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
|
|
1053
|
+
container.appendChild(el);
|
|
1054
|
+
updateInlineArtifactBlocks(container, [
|
|
1055
|
+
{
|
|
1056
|
+
id: "a1",
|
|
1057
|
+
artifactType: "markdown",
|
|
1058
|
+
title: "Notes",
|
|
1059
|
+
status: "complete",
|
|
1060
|
+
markdown: "# Done"
|
|
1061
|
+
}
|
|
1062
|
+
]);
|
|
1063
|
+
// The plain-CSS collapse replaces the body View Transition.
|
|
1064
|
+
expect(start).not.toHaveBeenCalled();
|
|
1065
|
+
expect(el.querySelector(".persona-artifact-card")).not.toBeNull();
|
|
1066
|
+
} finally {
|
|
1067
|
+
delete (document as unknown as VTStub).startViewTransition;
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it("hydrates an already-complete block straight to the card (no inline body flash)", () => {
|
|
1072
|
+
const el = PersonaArtifactInline(completeProps(), makeContext(cardConfig()));
|
|
1073
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).toBeNull();
|
|
1074
|
+
expect(el.querySelector(".persona-artifact-inline-body")).toBeNull();
|
|
1075
|
+
const card = el.querySelector(".persona-artifact-card") as HTMLElement | null;
|
|
1076
|
+
expect(card).not.toBeNull();
|
|
1077
|
+
expect(card!.getAttribute("data-open-artifact")).toBe("a1");
|
|
1078
|
+
expect(el.classList.contains("persona-artifact-inline--card")).toBe(true);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it("keeps routing records to the block after the collapse (card stays in sync)", () => {
|
|
1082
|
+
const container = document.createElement("div");
|
|
1083
|
+
const el = PersonaArtifactInline(
|
|
1084
|
+
completeProps(),
|
|
1085
|
+
makeContext(cardConfig())
|
|
1086
|
+
);
|
|
1087
|
+
container.appendChild(el);
|
|
1088
|
+
expect(el.querySelector(".persona-artifact-card")?.textContent).toContain(
|
|
1089
|
+
"Notes"
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
updateInlineArtifactBlocks(container, [
|
|
1093
|
+
{
|
|
1094
|
+
id: "a1",
|
|
1095
|
+
artifactType: "markdown",
|
|
1096
|
+
title: "Renamed",
|
|
1097
|
+
status: "complete",
|
|
1098
|
+
markdown: "# Done"
|
|
1099
|
+
}
|
|
1100
|
+
]);
|
|
1101
|
+
const card = el.querySelector(".persona-artifact-card") as HTMLElement | null;
|
|
1102
|
+
expect(card).not.toBeNull();
|
|
1103
|
+
expect(card!.textContent).toContain("Renamed");
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("leaves no explicit height on the root in the jsdom instant path", () => {
|
|
1107
|
+
const container = document.createElement("div");
|
|
1108
|
+
// Detached container → root is not connected → collapse degrades to an
|
|
1109
|
+
// instant swap, leaving no inline height/overflow/transition behind.
|
|
1110
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(cardConfig()));
|
|
1111
|
+
container.appendChild(el);
|
|
1112
|
+
updateInlineArtifactBlocks(container, [
|
|
1113
|
+
{
|
|
1114
|
+
id: "a1",
|
|
1115
|
+
artifactType: "markdown",
|
|
1116
|
+
title: "Notes",
|
|
1117
|
+
status: "complete",
|
|
1118
|
+
markdown: "# Done"
|
|
1119
|
+
}
|
|
1120
|
+
]);
|
|
1121
|
+
expect(el.style.height).toBe("");
|
|
1122
|
+
expect(el.style.overflow).toBe("");
|
|
1123
|
+
expect(el.style.transition).toBe("");
|
|
1124
|
+
expect(
|
|
1125
|
+
el.style.getPropertyValue("--persona-artifact-inline-body-height")
|
|
1126
|
+
).toBe("");
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
it("default 'inline' completeDisplay stays inline through completion (no card)", () => {
|
|
1130
|
+
const container = document.createElement("div");
|
|
1131
|
+
const el = PersonaArtifactInline(streamingProps(), makeContext(makeConfig()));
|
|
1132
|
+
container.appendChild(el);
|
|
1133
|
+
updateInlineArtifactBlocks(container, [
|
|
1134
|
+
{
|
|
1135
|
+
id: "a1",
|
|
1136
|
+
artifactType: "markdown",
|
|
1137
|
+
title: "Notes",
|
|
1138
|
+
status: "complete",
|
|
1139
|
+
markdown: "# Done"
|
|
1140
|
+
}
|
|
1141
|
+
]);
|
|
1142
|
+
expect(el.querySelector(".persona-artifact-card")).toBeNull();
|
|
1143
|
+
expect(el.querySelector(".persona-artifact-inline-chrome")).not.toBeNull();
|
|
1144
|
+
expect(el.querySelector(".persona-artifact-inline-body")).not.toBeNull();
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
describe("PersonaArtifactInline clip-mode expand hitbox", () => {
|
|
1149
|
+
const fileStreamingProps = (): Record<string, unknown> => ({
|
|
1150
|
+
artifactId: "f1",
|
|
1151
|
+
title: "outputs/cat.html",
|
|
1152
|
+
artifactType: "markdown",
|
|
1153
|
+
status: "streaming",
|
|
1154
|
+
markdown: "```html\n<h1>hi",
|
|
1155
|
+
file: { path: "outputs/cat.html", mimeType: "text/html", language: "html" }
|
|
1156
|
+
});
|
|
1157
|
+
const bodyOf = (el: HTMLElement): HTMLElement =>
|
|
1158
|
+
el.querySelector(".persona-artifact-inline-body") as HTMLElement;
|
|
1159
|
+
|
|
1160
|
+
it("marks the body as an expand hitbox in clip mode with expand enabled", () => {
|
|
1161
|
+
const el = PersonaArtifactInline(
|
|
1162
|
+
fileStreamingProps(),
|
|
1163
|
+
makeContext(makeConfig({ inlineBody: { overflow: "clip" } }))
|
|
1164
|
+
);
|
|
1165
|
+
const body = bodyOf(el);
|
|
1166
|
+
expect(body.getAttribute("data-expand-artifact-inline")).toBe("f1");
|
|
1167
|
+
expect(body.getAttribute("role")).toBe("button");
|
|
1168
|
+
expect(body.getAttribute("tabindex")).toBe("0");
|
|
1169
|
+
expect(body.classList.contains("persona-cursor-pointer")).toBe(true);
|
|
1170
|
+
// File artifacts use the basename as the chrome title.
|
|
1171
|
+
expect(body.getAttribute("aria-label")).toBe("Open cat.html in panel");
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it("does not mark the body when overflow is 'scroll' (default)", () => {
|
|
1175
|
+
const el = PersonaArtifactInline(
|
|
1176
|
+
fileStreamingProps(),
|
|
1177
|
+
makeContext(makeConfig({ inlineBody: { overflow: "scroll" } }))
|
|
1178
|
+
);
|
|
1179
|
+
expect(
|
|
1180
|
+
bodyOf(el).hasAttribute("data-expand-artifact-inline")
|
|
1181
|
+
).toBe(false);
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
it("does not mark the body in clip mode when expand is disabled", () => {
|
|
1185
|
+
const el = PersonaArtifactInline(
|
|
1186
|
+
fileStreamingProps(),
|
|
1187
|
+
makeContext(
|
|
1188
|
+
makeConfig({
|
|
1189
|
+
inlineBody: { overflow: "clip" },
|
|
1190
|
+
inlineChrome: { showExpand: false }
|
|
1191
|
+
})
|
|
1192
|
+
)
|
|
1193
|
+
);
|
|
1194
|
+
expect(
|
|
1195
|
+
bodyOf(el).hasAttribute("data-expand-artifact-inline")
|
|
1196
|
+
).toBe(false);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
it("does not mark the body in clip mode when chrome is off (no expand control)", () => {
|
|
1200
|
+
const el = PersonaArtifactInline(
|
|
1201
|
+
fileStreamingProps(),
|
|
1202
|
+
makeContext(
|
|
1203
|
+
makeConfig({ inlineBody: { overflow: "clip" }, inlineChrome: false })
|
|
1204
|
+
)
|
|
1205
|
+
);
|
|
1206
|
+
expect(
|
|
1207
|
+
bodyOf(el).hasAttribute("data-expand-artifact-inline")
|
|
1208
|
+
).toBe(false);
|
|
1209
|
+
});
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
describe("PersonaArtifactInline statusLabel (inline chrome)", () => {
|
|
1213
|
+
const rec = (markdown: string): PersonaArtifactRecord => ({
|
|
1214
|
+
id: "a1",
|
|
1215
|
+
artifactType: "markdown",
|
|
1216
|
+
title: "Notes",
|
|
1217
|
+
status: "streaming",
|
|
1218
|
+
markdown
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
const mount = (artifacts: Record<string, unknown>) => {
|
|
1222
|
+
const el = PersonaArtifactInline(
|
|
1223
|
+
streamingProps(),
|
|
1224
|
+
makeContext(makeConfig(artifacts))
|
|
1225
|
+
);
|
|
1226
|
+
const container = document.createElement("div");
|
|
1227
|
+
container.appendChild(el);
|
|
1228
|
+
return { el, container };
|
|
1229
|
+
};
|
|
1230
|
+
|
|
1231
|
+
const labelEl = (el: HTMLElement): HTMLElement | null =>
|
|
1232
|
+
el.querySelector(
|
|
1233
|
+
".persona-artifact-inline-status .persona-artifact-status-label"
|
|
1234
|
+
);
|
|
1235
|
+
const detailEl = (el: HTMLElement): HTMLElement | null =>
|
|
1236
|
+
el.querySelector(
|
|
1237
|
+
".persona-artifact-inline-status .persona-artifact-status-detail"
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
it("passes the inline-chrome surface and streamed counts; content() is lazy", () => {
|
|
1241
|
+
const calls: PersonaArtifactStatusLabelContext[] = [];
|
|
1242
|
+
const { el, container } = mount({
|
|
1243
|
+
loadingAnimation: "none",
|
|
1244
|
+
statusLabel: (ctx: PersonaArtifactStatusLabelContext) => {
|
|
1245
|
+
calls.push(ctx);
|
|
1246
|
+
return { label: "Writing", detail: `${ctx.lines} lines` };
|
|
1247
|
+
}
|
|
1248
|
+
});
|
|
1249
|
+
updateInlineArtifactBlocks(container, [rec("line one\nline two")]);
|
|
1250
|
+
const last = calls[calls.length - 1];
|
|
1251
|
+
expect(last.surface).toBe("inline-chrome");
|
|
1252
|
+
expect(last.artifactId).toBe("a1");
|
|
1253
|
+
expect(last.artifactType).toBe("markdown");
|
|
1254
|
+
expect(last.chars).toBe("line one\nline two".length);
|
|
1255
|
+
expect(last.lines).toBe(2);
|
|
1256
|
+
// content() returns the accumulated markdown only when the host calls it.
|
|
1257
|
+
expect(last.content()).toBe("line one\nline two");
|
|
1258
|
+
expect(detailEl(el)?.textContent).toBe("2 lines");
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("updates the detail per delta while keeping the animated label element stable", () => {
|
|
1262
|
+
const { el, container } = mount({
|
|
1263
|
+
loadingAnimation: "none",
|
|
1264
|
+
statusLabel: (ctx: PersonaArtifactStatusLabelContext) => ({
|
|
1265
|
+
label: "Writing",
|
|
1266
|
+
detail: `${ctx.chars}`
|
|
1267
|
+
})
|
|
1268
|
+
});
|
|
1269
|
+
updateInlineArtifactBlocks(container, [rec("Hello")]);
|
|
1270
|
+
const label1 = labelEl(el);
|
|
1271
|
+
expect(detailEl(el)?.textContent).toBe("5");
|
|
1272
|
+
updateInlineArtifactBlocks(container, [rec("Hello world")]);
|
|
1273
|
+
const label2 = labelEl(el);
|
|
1274
|
+
expect(detailEl(el)?.textContent).toBe("11");
|
|
1275
|
+
// Same node: an unchanged label is never re-applied, so the loading
|
|
1276
|
+
// animation does not restart on a detail-only update.
|
|
1277
|
+
expect(label1).not.toBeNull();
|
|
1278
|
+
expect(label1).toBe(label2);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
it("re-applies the label element when the label text changes", () => {
|
|
1282
|
+
const { el, container } = mount({
|
|
1283
|
+
loadingAnimation: "none",
|
|
1284
|
+
statusLabel: (ctx: PersonaArtifactStatusLabelContext) => ({
|
|
1285
|
+
label: ctx.chars > 6 ? "Almost done" : "Writing"
|
|
1286
|
+
})
|
|
1287
|
+
});
|
|
1288
|
+
updateInlineArtifactBlocks(container, [rec("short")]);
|
|
1289
|
+
expect(labelEl(el)?.textContent).toBe("Writing");
|
|
1290
|
+
updateInlineArtifactBlocks(container, [rec("a longer body")]);
|
|
1291
|
+
expect(labelEl(el)?.textContent).toBe("Almost done");
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
it("replaces the default status with a plain string", () => {
|
|
1295
|
+
const { el, container } = mount({
|
|
1296
|
+
loadingAnimation: "none",
|
|
1297
|
+
statusLabel: "One moment..."
|
|
1298
|
+
});
|
|
1299
|
+
updateInlineArtifactBlocks(container, [rec("Hi")]);
|
|
1300
|
+
expect(labelEl(el)?.textContent).toBe("One moment...");
|
|
1301
|
+
expect(
|
|
1302
|
+
el.querySelector(".persona-artifact-inline-status")?.textContent
|
|
1303
|
+
).not.toContain("Generating");
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
it("falls back to the default label when the function throws", () => {
|
|
1307
|
+
const { el, container } = mount({
|
|
1308
|
+
loadingAnimation: "none",
|
|
1309
|
+
statusLabel: () => {
|
|
1310
|
+
throw new Error("boom");
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
updateInlineArtifactBlocks(container, [rec("Hi")]);
|
|
1314
|
+
// Plain markdown artifact (no file) → typeLabel "Document".
|
|
1315
|
+
expect(labelEl(el)?.textContent).toBe("Generating document...");
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
it("still applies the loading animation to the label span by default", () => {
|
|
1319
|
+
const { el, container } = mount({
|
|
1320
|
+
statusLabel: () => ({ label: "Writing", detail: "x" })
|
|
1321
|
+
});
|
|
1322
|
+
updateInlineArtifactBlocks(container, [rec("Hi")]);
|
|
1323
|
+
const label = labelEl(el);
|
|
1324
|
+
expect(label?.classList.contains("persona-tool-loading-shimmer")).toBe(true);
|
|
1325
|
+
// The detail span never carries the animation.
|
|
1326
|
+
expect(detailEl(el)?.querySelector(".persona-tool-char")).toBeNull();
|
|
1327
|
+
});
|
|
1328
|
+
});
|