@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.
@@ -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
+ });
@@ -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
+ });
@@ -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: _AgentWidgetConfig) {
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
  },
@@ -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
- controller.update(base);
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
+ });