@runtypelabs/persona 4.9.0 → 4.10.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/README.md +13 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +68 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -2
- package/dist/index.d.ts +13 -2
- package/dist/index.global.js +46 -46
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +58 -58
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/theme-editor-preview.cjs +61 -61
- package/dist/theme-editor-preview.d.cts +12 -1
- package/dist/theme-editor-preview.d.ts +12 -1
- package/dist/theme-editor-preview.js +47 -47
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/components/artifact-pane.test.ts +47 -0
- package/src/components/artifact-pane.ts +25 -2
- package/src/index-core.ts +1 -0
- package/src/runtime/init-update-reset.test.ts +81 -0
- package/src/runtime/init.test.ts +62 -0
- package/src/runtime/init.ts +7 -14
- package/src/styles/widget.css +14 -0
- package/src/types.ts +17 -0
- package/src/ui.artifact-pane-gating.test.ts +11 -1
- package/src/ui.attachments-drop.test.ts +90 -0
- package/src/ui.header-update-stability.test.ts +149 -0
- package/src/ui.launcher-update-merge.test.ts +83 -0
- package/src/ui.send-button-stream-update.test.ts +69 -0
- package/src/ui.ts +78 -33
- package/src/utils/config-merge.test.ts +131 -0
- package/src/utils/config-merge.ts +61 -0
- package/src/utils/theme.test.ts +26 -0
- package/src/utils/theme.ts +6 -3
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
// End-to-end handle.update() through the REAL ui module (init.test.ts mocks it,
|
|
4
|
+
// so it cannot see the controller's own patch merge). Regression: explicit
|
|
5
|
+
// undefined resets must reach the controller, not be materialized as absent
|
|
6
|
+
// keys by the handle's pre-merge.
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { initAgentWidget } from "./init";
|
|
11
|
+
|
|
12
|
+
describe("handle.update explicit-undefined reset reaches the controller", () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
document.body.innerHTML = "";
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("round-trips artifact pane appearance seamless -> panel via explicit undefined", () => {
|
|
19
|
+
window.scrollTo = vi.fn();
|
|
20
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
21
|
+
|
|
22
|
+
const handle = initAgentWidget({
|
|
23
|
+
target: "#target",
|
|
24
|
+
config: {
|
|
25
|
+
apiUrl: "https://api.example.com/chat",
|
|
26
|
+
launcher: { enabled: false },
|
|
27
|
+
features: { artifacts: { enabled: true } },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const root = () => document.querySelector<HTMLElement>("[data-persona-root]")!;
|
|
32
|
+
expect(root().classList.contains("persona-artifact-appearance-panel")).toBe(true);
|
|
33
|
+
|
|
34
|
+
handle.update({
|
|
35
|
+
features: {
|
|
36
|
+
artifacts: {
|
|
37
|
+
layout: { paneAppearance: "seamless", splitGap: "0", paneShadow: "none" },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
expect(root().classList.contains("persona-artifact-appearance-seamless")).toBe(true);
|
|
42
|
+
|
|
43
|
+
handle.update({
|
|
44
|
+
features: {
|
|
45
|
+
artifacts: {
|
|
46
|
+
layout: { paneAppearance: undefined, splitGap: undefined, paneShadow: undefined },
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
expect(root().classList.contains("persona-artifact-appearance-panel")).toBe(true);
|
|
51
|
+
expect(root().classList.contains("persona-artifact-appearance-seamless")).toBe(false);
|
|
52
|
+
expect(root().style.getPropertyValue("--persona-artifact-pane-shadow")).toBe("");
|
|
53
|
+
|
|
54
|
+
handle.destroy();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("handle and controller stored configs converge on the same merged result", () => {
|
|
58
|
+
window.scrollTo = vi.fn();
|
|
59
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
60
|
+
|
|
61
|
+
const handle = initAgentWidget({
|
|
62
|
+
target: "#target",
|
|
63
|
+
config: {
|
|
64
|
+
apiUrl: "https://api.example.com/chat",
|
|
65
|
+
launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Sibling override must survive an unrelated patch in BOTH layers: the
|
|
70
|
+
// controller renders it now, and the handle's stored config must carry it
|
|
71
|
+
// into a mount-mode rebuild. tooltipText also renames the aria-label.
|
|
72
|
+
handle.update({ launcher: { clearChat: { tooltipText: "Wipe" } } });
|
|
73
|
+
const clear = () => document.querySelector<HTMLButtonElement>('button[aria-label="Wipe"]')!;
|
|
74
|
+
expect(clear().style.backgroundColor).toBe("rgb(18, 52, 86)");
|
|
75
|
+
|
|
76
|
+
handle.update({ launcher: { mountMode: "docked" } });
|
|
77
|
+
expect(clear().style.backgroundColor).toBe("rgb(18, 52, 86)");
|
|
78
|
+
|
|
79
|
+
handle.destroy();
|
|
80
|
+
});
|
|
81
|
+
});
|
package/src/runtime/init.test.ts
CHANGED
|
@@ -483,3 +483,65 @@ describe("initAgentWidget docked mode", () => {
|
|
|
483
483
|
handleB.destroy();
|
|
484
484
|
});
|
|
485
485
|
});
|
|
486
|
+
|
|
487
|
+
describe("initAgentWidget update merge policy", () => {
|
|
488
|
+
beforeEach(() => {
|
|
489
|
+
document.body.innerHTML = "";
|
|
490
|
+
createAgentExperienceMock.mockReset();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("passes the raw patch to the controller so explicit-undefined resets survive", async () => {
|
|
494
|
+
const { initAgentWidget } = await import("./init");
|
|
495
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
496
|
+
|
|
497
|
+
let controllerRef: ReturnType<typeof createMockController> | undefined;
|
|
498
|
+
createAgentExperienceMock.mockImplementation((_mount, config) => {
|
|
499
|
+
controllerRef = createMockController(config);
|
|
500
|
+
return controllerRef;
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const handle = initAgentWidget({
|
|
504
|
+
target: "#target",
|
|
505
|
+
config: {
|
|
506
|
+
apiUrl: "https://api.example.com/chat",
|
|
507
|
+
launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } },
|
|
508
|
+
},
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// The merged config materializes explicit-undefined as absence, which the
|
|
512
|
+
// controller's patch merge would preserve; only the raw patch carries resets.
|
|
513
|
+
const patch = { launcher: { clearChat: { tooltipText: "Clear" } } };
|
|
514
|
+
handle.update(patch);
|
|
515
|
+
|
|
516
|
+
const passed = controllerRef!.update.mock.calls.at(-1)?.[0] as any;
|
|
517
|
+
expect(passed).toBe(patch);
|
|
518
|
+
|
|
519
|
+
handle.destroy();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("retains prior nested overrides when a mount-mode change rebuilds the controller", async () => {
|
|
523
|
+
const { initAgentWidget } = await import("./init");
|
|
524
|
+
document.body.innerHTML = `<div id="target"></div>`;
|
|
525
|
+
|
|
526
|
+
createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
|
|
527
|
+
|
|
528
|
+
const handle = initAgentWidget({
|
|
529
|
+
target: "#target",
|
|
530
|
+
config: {
|
|
531
|
+
apiUrl: "https://api.example.com/chat",
|
|
532
|
+
launcher: { enabled: false, clearChat: { backgroundColor: "#123456" } },
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// Patch a sibling, then flip the mount mode to force a rebuild.
|
|
537
|
+
handle.update({ launcher: { clearChat: { tooltipText: "Clear" } } });
|
|
538
|
+
handle.update({ launcher: { mountMode: "docked" } });
|
|
539
|
+
|
|
540
|
+
const rebuildConfig = createAgentExperienceMock.mock.calls.at(-1)?.[1] as any;
|
|
541
|
+
expect(rebuildConfig.launcher.clearChat.backgroundColor).toBe("#123456");
|
|
542
|
+
expect(rebuildConfig.launcher.clearChat.tooltipText).toBe("Clear");
|
|
543
|
+
expect(rebuildConfig.launcher.mountMode).toBe("docked");
|
|
544
|
+
|
|
545
|
+
handle.destroy();
|
|
546
|
+
});
|
|
547
|
+
});
|
package/src/runtime/init.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createAgentExperience, AgentWidgetController } from "../ui";
|
|
2
|
-
import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetInitOptions, AgentWidgetEvent as _AgentWidgetEvent } from "../types";
|
|
2
|
+
import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetConfigPatch, AgentWidgetInitOptions, AgentWidgetEvent as _AgentWidgetEvent } from "../types";
|
|
3
3
|
import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
|
|
4
|
+
import { mergeConfigUpdate } from "../utils/config-merge";
|
|
4
5
|
import { createWidgetHostLayout } from "./host-layout";
|
|
5
6
|
|
|
6
7
|
const ensureTarget = (target: string | HTMLElement): HTMLElement => {
|
|
@@ -126,19 +127,8 @@ export const initAgentWidget = (
|
|
|
126
127
|
};
|
|
127
128
|
|
|
128
129
|
const handleBase = {
|
|
129
|
-
update(nextConfig:
|
|
130
|
-
const mergedConfig = {
|
|
131
|
-
...config,
|
|
132
|
-
...nextConfig,
|
|
133
|
-
launcher: {
|
|
134
|
-
...(config?.launcher ?? {}),
|
|
135
|
-
...(nextConfig?.launcher ?? {}),
|
|
136
|
-
dock: {
|
|
137
|
-
...(config?.launcher?.dock ?? {}),
|
|
138
|
-
...(nextConfig?.launcher?.dock ?? {}),
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
} as _AgentWidgetConfig;
|
|
130
|
+
update(nextConfig: AgentWidgetConfigPatch) {
|
|
131
|
+
const mergedConfig = mergeConfigUpdate(config ?? ({} as _AgentWidgetConfig), nextConfig);
|
|
142
132
|
const previousDocked = isDockedMountMode(config);
|
|
143
133
|
const nextDocked = isDockedMountMode(mergedConfig);
|
|
144
134
|
const previousComposerBar = isComposerBarMountMode(config);
|
|
@@ -151,6 +141,9 @@ export const initAgentWidget = (
|
|
|
151
141
|
|
|
152
142
|
config = mergedConfig;
|
|
153
143
|
hostLayout.updateConfig(config);
|
|
144
|
+
// Pass the raw patch: mergedConfig materializes explicit-undefined resets
|
|
145
|
+
// as absent keys, which the controller's patch merge would preserve
|
|
146
|
+
// instead of clearing. Both layers share mergeConfigUpdate, so they converge.
|
|
154
147
|
controller.update(nextConfig);
|
|
155
148
|
syncHostState();
|
|
156
149
|
},
|
package/src/styles/widget.css
CHANGED
|
@@ -3206,6 +3206,20 @@
|
|
|
3206
3206
|
/* Shared artifact preview body wrapper (artifact-preview.ts): generates no box
|
|
3207
3207
|
so children (e.g. the iframe's height: 100%) lay out against the host
|
|
3208
3208
|
container exactly as if they were its direct children. */
|
|
3209
|
+
[data-persona-root] .persona-artifact-content {
|
|
3210
|
+
width: 100%;
|
|
3211
|
+
min-width: 0;
|
|
3212
|
+
max-width: 100%;
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
[data-persona-root]
|
|
3216
|
+
.persona-artifact-content
|
|
3217
|
+
> .persona-artifact-preview-body
|
|
3218
|
+
> * {
|
|
3219
|
+
min-width: 0;
|
|
3220
|
+
max-width: 100%;
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3209
3223
|
[data-persona-root] .persona-artifact-preview-body {
|
|
3210
3224
|
display: contents;
|
|
3211
3225
|
}
|
package/src/types.ts
CHANGED
|
@@ -5431,6 +5431,23 @@ export type AgentWidgetConfig = {
|
|
|
5431
5431
|
loadingIndicator?: AgentWidgetLoadingIndicatorConfig;
|
|
5432
5432
|
};
|
|
5433
5433
|
|
|
5434
|
+
/**
|
|
5435
|
+
* Patch-aware deep-partial of {@link AgentWidgetConfig} accepted by `update()`.
|
|
5436
|
+
* Function types and arrays are preserved whole (not mapped over); plain-object
|
|
5437
|
+
* leaves recurse so a partial patch merges into the live config. See the merge
|
|
5438
|
+
* policy in utils/config-merge.ts (some plain-object fields still replace
|
|
5439
|
+
* wholesale at runtime; that is not encoded at the type level).
|
|
5440
|
+
*/
|
|
5441
|
+
export type AgentWidgetConfigPatch = ConfigPatch<AgentWidgetConfig>;
|
|
5442
|
+
|
|
5443
|
+
type ConfigPatch<T> = T extends (...args: never[]) => unknown
|
|
5444
|
+
? T
|
|
5445
|
+
: T extends readonly unknown[]
|
|
5446
|
+
? T
|
|
5447
|
+
: T extends object
|
|
5448
|
+
? { [K in keyof T]?: ConfigPatch<T[K]> }
|
|
5449
|
+
: T;
|
|
5450
|
+
|
|
5434
5451
|
export type AgentWidgetMessageRole = "user" | "assistant" | "system";
|
|
5435
5452
|
|
|
5436
5453
|
export type AgentWidgetReasoning = {
|
|
@@ -402,7 +402,17 @@ describe("artifact pane expand toggle (full widget)", () => {
|
|
|
402
402
|
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(true);
|
|
403
403
|
|
|
404
404
|
// Turning the toggle back off hides the button and collapses the pane.
|
|
405
|
-
|
|
405
|
+
// update() patches recursively now, so omission preserves the prior
|
|
406
|
+
// showExpandToggle: the flag must be set false explicitly to disable it.
|
|
407
|
+
controller.update({
|
|
408
|
+
features: {
|
|
409
|
+
artifacts: {
|
|
410
|
+
enabled: true,
|
|
411
|
+
allowedTypes: ["markdown", "component"],
|
|
412
|
+
layout: { showExpandToggle: false },
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
});
|
|
406
416
|
expect(mountEl.classList.contains("persona-artifact-expanded")).toBe(false);
|
|
407
417
|
expect(expandBtn(mountEl).classList.contains("persona-hidden")).toBe(true);
|
|
408
418
|
controller.destroy();
|
|
@@ -186,3 +186,93 @@ describe("createAgentExperience attachment file drop", () => {
|
|
|
186
186
|
controller.destroy();
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
describe("drop overlay live config updates", () => {
|
|
191
|
+
beforeEach(() => {
|
|
192
|
+
window.scrollTo = vi.fn();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
afterEach(() => {
|
|
196
|
+
document.body.innerHTML = "";
|
|
197
|
+
vi.restoreAllMocks();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("rebuilds the overlay with new dropOverlay values on update() (regression: built once, live updates ignored)", () => {
|
|
201
|
+
const mount = createMount();
|
|
202
|
+
const controller = createAgentExperience(mount, {
|
|
203
|
+
apiUrl: "https://api.example.com/chat",
|
|
204
|
+
launcher: { enabled: false },
|
|
205
|
+
attachments: { enabled: true, dropOverlay: { label: "Drop it", background: "#111111" } },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const overlay = () => mount.querySelector<HTMLElement>(".persona-attachment-drop-overlay")!;
|
|
209
|
+
expect(overlay()).not.toBeNull();
|
|
210
|
+
expect(overlay().querySelector(".persona-drop-overlay-label")!.textContent).toBe("Drop it");
|
|
211
|
+
expect(overlay().style.getPropertyValue("--persona-drop-overlay-bg")).toBe("#111111");
|
|
212
|
+
|
|
213
|
+
controller.update({
|
|
214
|
+
attachments: { dropOverlay: { label: "New label", background: "#222222" } },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(overlay().querySelector(".persona-drop-overlay-label")!.textContent).toBe("New label");
|
|
218
|
+
expect(overlay().style.getPropertyValue("--persona-drop-overlay-bg")).toBe("#222222");
|
|
219
|
+
// Only one overlay after the rebuild.
|
|
220
|
+
expect(mount.querySelectorAll(".persona-attachment-drop-overlay").length).toBe(1);
|
|
221
|
+
|
|
222
|
+
controller.destroy();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("clears overlay styling when dropOverlay is reset via explicit undefined", () => {
|
|
226
|
+
const mount = createMount();
|
|
227
|
+
const controller = createAgentExperience(mount, {
|
|
228
|
+
apiUrl: "https://api.example.com/chat",
|
|
229
|
+
launcher: { enabled: false },
|
|
230
|
+
attachments: { enabled: true, dropOverlay: { label: "Drop it", background: "#111111" } },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
controller.update({ attachments: { dropOverlay: undefined } });
|
|
234
|
+
|
|
235
|
+
const overlay = mount.querySelector<HTMLElement>(".persona-attachment-drop-overlay")!;
|
|
236
|
+
expect(overlay).not.toBeNull();
|
|
237
|
+
expect(overlay.querySelector(".persona-drop-overlay-label")).toBeNull();
|
|
238
|
+
expect(overlay.style.getPropertyValue("--persona-drop-overlay-bg")).toBe("");
|
|
239
|
+
|
|
240
|
+
controller.destroy();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("attachment button live config updates", () => {
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
window.scrollTo = vi.fn();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
afterEach(() => {
|
|
250
|
+
document.body.innerHTML = "";
|
|
251
|
+
vi.restoreAllMocks();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("re-renders the button icon and tooltip on update() (regression: set once at creation)", () => {
|
|
255
|
+
const mount = createMount();
|
|
256
|
+
const controller = createAgentExperience(mount, {
|
|
257
|
+
apiUrl: "https://api.example.com/chat",
|
|
258
|
+
launcher: { enabled: false },
|
|
259
|
+
attachments: { enabled: true, buttonIconName: "paperclip", buttonTooltipText: "Attach file" },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const button = () => mount.querySelector<HTMLButtonElement>(".persona-attachment-button")!;
|
|
263
|
+
expect(button()).not.toBeNull();
|
|
264
|
+
const initialSvg = button().querySelector("svg")!.outerHTML;
|
|
265
|
+
|
|
266
|
+
controller.update({
|
|
267
|
+
attachments: { buttonIconName: "camera", buttonTooltipText: "Add a photo" },
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(button().getAttribute("aria-label")).toBe("Add a photo");
|
|
271
|
+
const updatedSvg = button().querySelector("svg")!.outerHTML;
|
|
272
|
+
expect(updatedSvg).not.toBe(initialSvg);
|
|
273
|
+
const tooltip = button().parentElement!.querySelector(".persona-send-button-tooltip");
|
|
274
|
+
expect(tooltip?.textContent).toBe("Add a photo");
|
|
275
|
+
|
|
276
|
+
controller.destroy();
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
// An update must not restyle or reveal header chrome the mount deliberately
|
|
4
|
+
// set: the close button hidden on non-closeable panels, and the clear-chat
|
|
5
|
+
// icon's stroke weight (builder and updater drifted to different constants).
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
|
|
9
|
+
import { createAgentExperience } from "./ui";
|
|
10
|
+
|
|
11
|
+
const createMount = () => {
|
|
12
|
+
const mount = document.createElement("div");
|
|
13
|
+
document.body.appendChild(mount);
|
|
14
|
+
return mount;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const inlineConfig = () => ({
|
|
18
|
+
apiUrl: "https://api.example.com/chat",
|
|
19
|
+
launcher: { enabled: false },
|
|
20
|
+
attachments: { enabled: true },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("header stability across unrelated updates", () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
document.body.innerHTML = "";
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps the close button hidden on a non-closeable panel after an unrelated update", () => {
|
|
30
|
+
window.scrollTo = vi.fn();
|
|
31
|
+
const mount = createMount();
|
|
32
|
+
const controller = createAgentExperience(mount, inlineConfig());
|
|
33
|
+
|
|
34
|
+
const closeBtn = () =>
|
|
35
|
+
mount.querySelector<HTMLElement>('button[aria-label="Close chat"]')!;
|
|
36
|
+
expect(closeBtn().style.display).toBe("none");
|
|
37
|
+
|
|
38
|
+
controller.update({ attachments: { enabled: true, maxFiles: 2 } });
|
|
39
|
+
expect(closeBtn().style.display).toBe("none");
|
|
40
|
+
|
|
41
|
+
controller.destroy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("showCloseButton filters on top of toggleability, it cannot force-show", () => {
|
|
45
|
+
window.scrollTo = vi.fn();
|
|
46
|
+
const mount = createMount();
|
|
47
|
+
const controller = createAgentExperience(mount, inlineConfig());
|
|
48
|
+
|
|
49
|
+
const closeBtn = () =>
|
|
50
|
+
mount.querySelector<HTMLElement>('button[aria-label="Close chat"]')!;
|
|
51
|
+
|
|
52
|
+
// Non-closeable panel stays hidden even with an explicit true (which is
|
|
53
|
+
// also the default, so it cannot mean force-show).
|
|
54
|
+
controller.update({ layout: { header: { showCloseButton: true } } });
|
|
55
|
+
expect(closeBtn().style.display).toBe("none");
|
|
56
|
+
|
|
57
|
+
controller.destroy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("still honors showCloseButton on a toggleable (launcher) panel", () => {
|
|
61
|
+
window.scrollTo = vi.fn();
|
|
62
|
+
const mount = createMount();
|
|
63
|
+
const controller = createAgentExperience(mount, {
|
|
64
|
+
apiUrl: "https://api.example.com/chat",
|
|
65
|
+
launcher: { enabled: true, autoExpand: true },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const closeBtn = () =>
|
|
69
|
+
mount.querySelector<HTMLElement>('button[aria-label="Close chat"]')!;
|
|
70
|
+
expect(closeBtn().style.display).toBe("");
|
|
71
|
+
|
|
72
|
+
controller.update({ layout: { header: { showCloseButton: false } } });
|
|
73
|
+
expect(closeBtn().style.display).toBe("none");
|
|
74
|
+
|
|
75
|
+
controller.update({ layout: { header: { showCloseButton: true } } });
|
|
76
|
+
expect(closeBtn().style.display).toBe("");
|
|
77
|
+
|
|
78
|
+
controller.destroy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("keeps the clear-chat icon stroke weight stable across updates", () => {
|
|
82
|
+
window.scrollTo = vi.fn();
|
|
83
|
+
const mount = createMount();
|
|
84
|
+
const controller = createAgentExperience(mount, inlineConfig());
|
|
85
|
+
|
|
86
|
+
const stroke = () =>
|
|
87
|
+
mount
|
|
88
|
+
.querySelector('button[aria-label="Clear chat"] svg')!
|
|
89
|
+
.getAttribute("stroke-width");
|
|
90
|
+
expect(stroke()).toBe("1");
|
|
91
|
+
|
|
92
|
+
controller.update({ attachments: { enabled: true, maxFiles: 2 } });
|
|
93
|
+
expect(stroke()).toBe("1");
|
|
94
|
+
|
|
95
|
+
controller.destroy();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("composer and header icon stability across unrelated updates", () => {
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
document.body.innerHTML = "";
|
|
102
|
+
vi.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps the mic icon stroke and button color stable", () => {
|
|
106
|
+
window.scrollTo = vi.fn();
|
|
107
|
+
// The mic button only renders when speech recognition is available.
|
|
108
|
+
vi.stubGlobal("SpeechRecognition", class {});
|
|
109
|
+
const mount = createMount();
|
|
110
|
+
const controller = createAgentExperience(mount, {
|
|
111
|
+
apiUrl: "https://api.example.com/chat",
|
|
112
|
+
launcher: { enabled: false },
|
|
113
|
+
voiceRecognition: { enabled: true },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const mic = () => mount.querySelector<HTMLElement>("[data-persona-composer-mic]")!;
|
|
117
|
+
expect(mic()).not.toBeNull();
|
|
118
|
+
expect(mic().querySelector("svg")!.getAttribute("stroke-width")).toBe("1.5");
|
|
119
|
+
expect(mic().style.color).toBe("var(--persona-text, #111827)");
|
|
120
|
+
|
|
121
|
+
controller.update({ attachments: { enabled: true } });
|
|
122
|
+
|
|
123
|
+
expect(mic().querySelector("svg")!.getAttribute("stroke-width")).toBe("1.5");
|
|
124
|
+
expect(mic().style.color).toBe("var(--persona-text, #111827)");
|
|
125
|
+
|
|
126
|
+
controller.destroy();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("keeps display:block on close and clear-chat icons", () => {
|
|
130
|
+
window.scrollTo = vi.fn();
|
|
131
|
+
const mount = createMount();
|
|
132
|
+
const controller = createAgentExperience(mount, {
|
|
133
|
+
apiUrl: "https://api.example.com/chat",
|
|
134
|
+
launcher: { enabled: true, autoExpand: true },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const iconDisplay = (label: string) =>
|
|
138
|
+
mount.querySelector<SVGElement>(`button[aria-label="${label}"] svg`)!.style.display;
|
|
139
|
+
expect(iconDisplay("Close chat")).toBe("block");
|
|
140
|
+
expect(iconDisplay("Clear chat")).toBe("block");
|
|
141
|
+
|
|
142
|
+
controller.update({ attachments: { enabled: true } });
|
|
143
|
+
|
|
144
|
+
expect(iconDisplay("Close chat")).toBe("block");
|
|
145
|
+
expect(iconDisplay("Clear chat")).toBe("block");
|
|
146
|
+
|
|
147
|
+
controller.destroy();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
|
|
7
|
+
const createMount = () => {
|
|
8
|
+
const mount = document.createElement("div");
|
|
9
|
+
document.body.appendChild(mount);
|
|
10
|
+
return mount;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("createAgentExperience partial launcher update", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
document.body.innerHTML = "";
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("preserves defaulted launcher styling when update() carries a partial launcher (regression: header buttons fell back to UA buttonface chrome)", () => {
|
|
20
|
+
window.scrollTo = vi.fn();
|
|
21
|
+
const mount = createMount();
|
|
22
|
+
const controller = createAgentExperience(mount, {
|
|
23
|
+
apiUrl: "https://api.example.com/chat",
|
|
24
|
+
launcher: { enabled: false, title: "Before" },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const closeBefore = mount.querySelector<HTMLButtonElement>('button[aria-label="Close chat"]');
|
|
28
|
+
const clearBefore = mount.querySelector<HTMLButtonElement>('button[aria-label="Clear chat"]');
|
|
29
|
+
expect(closeBefore).not.toBeNull();
|
|
30
|
+
expect(clearBefore).not.toBeNull();
|
|
31
|
+
// mergeWithDefaults supplies transparent button chrome at mount time.
|
|
32
|
+
expect(closeBefore!.style.backgroundColor).toBe("transparent");
|
|
33
|
+
expect(clearBefore!.style.backgroundColor).toBe("transparent");
|
|
34
|
+
|
|
35
|
+
// A partial launcher update (e.g. a live settings preview changing only
|
|
36
|
+
// the title) must not wholesale-replace the defaulted launcher config.
|
|
37
|
+
controller.update({ launcher: { enabled: false, title: "After" } });
|
|
38
|
+
|
|
39
|
+
const closeAfter = mount.querySelector<HTMLButtonElement>('button[aria-label="Close chat"]');
|
|
40
|
+
const clearAfter = mount.querySelector<HTMLButtonElement>('button[aria-label="Clear chat"]');
|
|
41
|
+
expect(closeAfter).not.toBeNull();
|
|
42
|
+
expect(clearAfter).not.toBeNull();
|
|
43
|
+
expect(closeAfter!.style.backgroundColor).toBe("transparent");
|
|
44
|
+
expect(clearAfter!.style.backgroundColor).toBe("transparent");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("keeps nested clearChat overrides across unrelated launcher updates", () => {
|
|
48
|
+
window.scrollTo = vi.fn();
|
|
49
|
+
const mount = createMount();
|
|
50
|
+
const controller = createAgentExperience(mount, {
|
|
51
|
+
apiUrl: "https://api.example.com/chat",
|
|
52
|
+
launcher: {
|
|
53
|
+
enabled: false,
|
|
54
|
+
clearChat: { backgroundColor: "#123456" },
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
controller.update({ launcher: { enabled: false, title: "After" } });
|
|
59
|
+
|
|
60
|
+
const clear = mount.querySelector<HTMLButtonElement>('button[aria-label="Clear chat"]');
|
|
61
|
+
expect(clear).not.toBeNull();
|
|
62
|
+
expect(clear!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps a clearChat override when a later update patches only launcher.dock", () => {
|
|
66
|
+
window.scrollTo = vi.fn();
|
|
67
|
+
const mount = createMount();
|
|
68
|
+
const controller = createAgentExperience(mount, {
|
|
69
|
+
apiUrl: "https://api.example.com/chat",
|
|
70
|
+
launcher: {
|
|
71
|
+
enabled: false,
|
|
72
|
+
clearChat: { backgroundColor: "#123456" },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// A partial dock patch must not wholesale-replace the launcher group.
|
|
77
|
+
controller.update({ launcher: { dock: { side: "left" } } });
|
|
78
|
+
|
|
79
|
+
const clear = mount.querySelector<HTMLButtonElement>('button[aria-label="Clear chat"]');
|
|
80
|
+
expect(clear).not.toBeNull();
|
|
81
|
+
expect(clear!.style.backgroundColor).toBe("rgb(18, 52, 86)");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
// An update() that lands during an active stream must not clobber the stop
|
|
4
|
+
// icon back to the send icon: the button holds the stop glyph (owned by
|
|
5
|
+
// setSendButtonMode) and the aria-label still reads "stop", so re-rendering
|
|
6
|
+
// the send icon here produced a mid-stream icon/label mismatch.
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { createAgentExperience } from "./ui";
|
|
11
|
+
import { buildAssistantTurnFrames, createMockSSEStream } from "./testing/mock-stream";
|
|
12
|
+
|
|
13
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
14
|
+
|
|
15
|
+
const createMount = () => {
|
|
16
|
+
const m = document.createElement("div");
|
|
17
|
+
document.body.appendChild(m);
|
|
18
|
+
return m;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("send button icon stability across a mid-stream update", () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
document.body.innerHTML = "";
|
|
24
|
+
vi.restoreAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keeps the stop icon after update() while streaming, and heals to send on completion", async () => {
|
|
28
|
+
window.scrollTo = vi.fn();
|
|
29
|
+
const mount = createMount();
|
|
30
|
+
const controller = createAgentExperience(mount, {
|
|
31
|
+
apiUrl: "https://api.example.com/chat",
|
|
32
|
+
launcher: { enabled: false },
|
|
33
|
+
sendButton: { useIcon: true, iconName: "arrow-up", stopIconName: "square" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The send button element is mutated in place by update(); hold the ref.
|
|
37
|
+
const btn = Array.from(mount.querySelectorAll("button")).find(
|
|
38
|
+
(b) => b.getAttribute("aria-label") === "Send message"
|
|
39
|
+
)!;
|
|
40
|
+
const glyph = () => btn.querySelector("svg")?.querySelector("rect, path")?.tagName.toLowerCase() ?? null;
|
|
41
|
+
|
|
42
|
+
// Send icon (arrow-up) is a <path>; stop icon (square) is a <rect>.
|
|
43
|
+
expect(glyph()).toBe("path");
|
|
44
|
+
|
|
45
|
+
const frames = buildAssistantTurnFrames({
|
|
46
|
+
executionId: "e1",
|
|
47
|
+
text: "a reasonably long streamed reply to keep the stream open",
|
|
48
|
+
chunkSize: 4,
|
|
49
|
+
});
|
|
50
|
+
const done = controller.connectStream(createMockSSEStream(frames, { delayMs: 40 }));
|
|
51
|
+
|
|
52
|
+
await sleep(80); // stop mode engages
|
|
53
|
+
expect(btn.getAttribute("aria-label")).toBe("Stop generating");
|
|
54
|
+
expect(glyph()).toBe("rect");
|
|
55
|
+
|
|
56
|
+
// Unrelated update mid-stream must not revert the stop icon.
|
|
57
|
+
controller.update({ attachments: { enabled: true } });
|
|
58
|
+
expect(btn.getAttribute("aria-label")).toBe("Stop generating");
|
|
59
|
+
expect(glyph()).toBe("rect");
|
|
60
|
+
|
|
61
|
+
await done;
|
|
62
|
+
await sleep(20);
|
|
63
|
+
// Completion returns the button to send mode.
|
|
64
|
+
expect(btn.getAttribute("aria-label")).toBe("Send message");
|
|
65
|
+
expect(glyph()).toBe("path");
|
|
66
|
+
|
|
67
|
+
controller.destroy();
|
|
68
|
+
});
|
|
69
|
+
});
|