@runtypelabs/persona 4.8.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.
Files changed (130) hide show
  1. package/README.md +14 -1
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-BsZtXPKK.d.cts → types-4ROVJ1gA.d.cts} +42 -0
  5. package/dist/animations/{types-BsZtXPKK.d.ts → types-4ROVJ1gA.d.ts} +42 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-IO5VVUKP.js +3 -0
  9. package/dist/chunk-IPVK3KOM.js +1 -0
  10. package/dist/chunk-UPO4GUFC.js +1 -0
  11. package/dist/codegen.cjs +6 -6
  12. package/dist/codegen.js +8 -8
  13. package/dist/context-mentions-7S5KVUTG.js +169 -0
  14. package/dist/context-mentions-inline-TTCN7ZM2.js +4 -0
  15. package/dist/context-mentions-inline.cjs +4 -0
  16. package/dist/context-mentions-inline.d.cts +203 -0
  17. package/dist/context-mentions-inline.d.ts +203 -0
  18. package/dist/context-mentions-inline.js +4 -0
  19. package/dist/context-mentions.cjs +295 -0
  20. package/dist/context-mentions.d.cts +7025 -0
  21. package/dist/context-mentions.d.ts +7025 -0
  22. package/dist/context-mentions.js +295 -0
  23. package/dist/index.cjs +72 -64
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +634 -3
  26. package/dist/index.d.ts +634 -3
  27. package/dist/index.global.js +59 -51
  28. package/dist/index.global.js.map +1 -1
  29. package/dist/index.js +64 -56
  30. package/dist/index.js.map +1 -1
  31. package/dist/launcher.global.js +2 -2
  32. package/dist/launcher.global.js.map +1 -1
  33. package/dist/plugin-kit.cjs +1 -1
  34. package/dist/plugin-kit.d.cts +17 -0
  35. package/dist/plugin-kit.d.ts +17 -0
  36. package/dist/plugin-kit.js +1 -1
  37. package/dist/smart-dom-reader.cjs +17 -16
  38. package/dist/smart-dom-reader.d.cts +507 -1
  39. package/dist/smart-dom-reader.d.ts +507 -1
  40. package/dist/smart-dom-reader.js +17 -16
  41. package/dist/theme-editor-preview.cjs +236 -57
  42. package/dist/theme-editor-preview.d.cts +485 -1
  43. package/dist/theme-editor-preview.d.ts +485 -1
  44. package/dist/theme-editor-preview.js +53 -47
  45. package/dist/theme-editor.cjs +7 -7
  46. package/dist/theme-editor.d.cts +473 -0
  47. package/dist/theme-editor.d.ts +473 -0
  48. package/dist/theme-editor.js +5 -5
  49. package/dist/theme-reference.cjs +1 -1
  50. package/dist/theme-reference.d.cts +2 -0
  51. package/dist/theme-reference.d.ts +2 -0
  52. package/dist/widget.css +1 -1
  53. package/package.json +15 -3
  54. package/src/client.test.ts +69 -0
  55. package/src/client.ts +65 -51
  56. package/src/components/artifact-pane.test.ts +47 -0
  57. package/src/components/artifact-pane.ts +25 -2
  58. package/src/components/composer-parts.ts +3 -12
  59. package/src/components/context-mention-button.test.ts +70 -0
  60. package/src/components/context-mention-button.ts +82 -0
  61. package/src/components/context-mention-chip.ts +134 -0
  62. package/src/components/context-mention-menu.test.ts +508 -0
  63. package/src/components/context-mention-menu.ts +0 -0
  64. package/src/components/message-bubble.test.ts +175 -0
  65. package/src/components/message-bubble.ts +177 -19
  66. package/src/components/panel.ts +7 -10
  67. package/src/context-mentions-bundle.test.ts +163 -0
  68. package/src/context-mentions-entry.ts +185 -0
  69. package/src/context-mentions-inline-entry.test.ts +136 -0
  70. package/src/context-mentions-inline-entry.ts +226 -0
  71. package/src/context-mentions-inline-loader.test.ts +30 -0
  72. package/src/context-mentions-inline-loader.ts +36 -0
  73. package/src/context-mentions-inline.ts +15 -0
  74. package/src/context-mentions-loader.ts +32 -0
  75. package/src/context-mentions.ts +16 -0
  76. package/src/index-core.ts +27 -0
  77. package/src/index-global.ts +51 -0
  78. package/src/markdown-parsers-loader.ts +35 -44
  79. package/src/plugin-kit.test.ts +40 -0
  80. package/src/plugin-kit.ts +39 -5
  81. package/src/runtime/init-update-reset.test.ts +81 -0
  82. package/src/runtime/init.test.ts +62 -0
  83. package/src/runtime/init.ts +7 -14
  84. package/src/session.mentions.test.ts +175 -0
  85. package/src/session.test.ts +52 -4
  86. package/src/session.ts +121 -5
  87. package/src/smart-dom-reader.test.ts +129 -2
  88. package/src/smart-dom-reader.ts +127 -1
  89. package/src/styles/context-mention-menu-css.ts +176 -0
  90. package/src/styles/widget.css +243 -126
  91. package/src/theme-editor/sections.ts +3 -3
  92. package/src/types/theme.ts +2 -0
  93. package/src/types.ts +542 -0
  94. package/src/ui.artifact-pane-gating.test.ts +11 -1
  95. package/src/ui.attachments-drop.test.ts +90 -0
  96. package/src/ui.header-update-stability.test.ts +149 -0
  97. package/src/ui.launcher-update-merge.test.ts +83 -0
  98. package/src/ui.mention-submit.test.ts +235 -0
  99. package/src/ui.send-button-stream-update.test.ts +69 -0
  100. package/src/ui.ts +379 -84
  101. package/src/utils/chunk-loader.test.ts +97 -0
  102. package/src/utils/chunk-loader.ts +88 -0
  103. package/src/utils/composer-contenteditable.test.ts +507 -0
  104. package/src/utils/composer-contenteditable.ts +626 -0
  105. package/src/utils/composer-document.test.ts +280 -0
  106. package/src/utils/composer-document.ts +293 -0
  107. package/src/utils/composer-history.test.ts +35 -7
  108. package/src/utils/composer-history.ts +30 -20
  109. package/src/utils/composer-input.ts +159 -0
  110. package/src/utils/config-merge.test.ts +131 -0
  111. package/src/utils/config-merge.ts +61 -0
  112. package/src/utils/context-mention-controller.test.ts +1215 -0
  113. package/src/utils/context-mention-controller.ts +1186 -0
  114. package/src/utils/context-mention-manager.test.ts +422 -0
  115. package/src/utils/context-mention-manager.ts +410 -0
  116. package/src/utils/context-mention-orchestrator.test.ts +538 -0
  117. package/src/utils/context-mention-orchestrator.ts +348 -0
  118. package/src/utils/live-region.test.ts +108 -0
  119. package/src/utils/live-region.ts +94 -0
  120. package/src/utils/mention-channels.ts +63 -0
  121. package/src/utils/mention-llm-format.test.ts +91 -0
  122. package/src/utils/mention-llm-format.ts +79 -0
  123. package/src/utils/mention-matcher.test.ts +86 -0
  124. package/src/utils/mention-matcher.ts +221 -0
  125. package/src/utils/mention-token.ts +72 -0
  126. package/src/utils/mention-trigger.test.ts +155 -0
  127. package/src/utils/mention-trigger.ts +156 -0
  128. package/src/utils/theme.test.ts +54 -4
  129. package/src/utils/theme.ts +6 -3
  130. package/src/utils/tokens.ts +27 -11
@@ -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,235 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import {
6
+ createAgentExperience,
7
+ mergeMentionContext,
8
+ mergeFinalizedMentions,
9
+ } from "./ui";
10
+ import { createStaticMentionSource } from "./utils/mention-matcher";
11
+ import { loadContextMentions } from "./context-mentions-loader";
12
+ import type { MentionSubmitBundle } from "./utils/context-mention-manager";
13
+
14
+ const createMount = () => {
15
+ const mount = document.createElement("div");
16
+ document.body.appendChild(mount);
17
+ return mount;
18
+ };
19
+
20
+ const flush = async (times = 8) => {
21
+ for (let i = 0; i < times; i += 1) {
22
+ // eslint-disable-next-line no-await-in-loop
23
+ await Promise.resolve();
24
+ }
25
+ };
26
+
27
+ // Deterministically flush the lazy mention chunk load → mount → open chain by
28
+ // awaiting the same memoized loader promise the orchestrator uses, then pumping
29
+ // a few macrotasks for the debounced menu search.
30
+ const flushMentions = async () => {
31
+ await loadContextMentions().catch(() => {});
32
+ for (let i = 0; i < 8; i += 1) {
33
+ // eslint-disable-next-line no-await-in-loop
34
+ await new Promise((r) => setTimeout(r, 0));
35
+ }
36
+ };
37
+
38
+ const getTextarea = (mount: HTMLElement) =>
39
+ mount.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]")!;
40
+
41
+ const getSubmit = (mount: HTMLElement) =>
42
+ mount.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!;
43
+
44
+ const press = (el: Element, key: string) =>
45
+ el.dispatchEvent(
46
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
47
+ );
48
+
49
+ // --- Bug 3: deep cross-bundle context merge (pure helpers) -----------------
50
+ describe("mergeMentionContext / mergeFinalizedMentions", () => {
51
+ it("deep-merges per-item maps for a source present in both bundles", () => {
52
+ const a = { files: { app: "a-body" }, docs: { r: 1 } };
53
+ const b = { files: { readme: "b-body" }, links: { x: 2 } };
54
+ const merged = mergeMentionContext(a, b);
55
+ // The shared `files` source keeps BOTH items (shallow spread would drop `app`).
56
+ expect(merged.files).toEqual({ app: "a-body", readme: "b-body" });
57
+ expect(merged.docs).toEqual({ r: 1 });
58
+ expect(merged.links).toEqual({ x: 2 });
59
+ });
60
+
61
+ it("b wins on a genuine per-item collision", () => {
62
+ const merged = mergeMentionContext(
63
+ { files: { app: "old" } },
64
+ { files: { app: "new" } }
65
+ );
66
+ expect(merged.files).toEqual({ app: "new" });
67
+ });
68
+
69
+ it("concatenates blocks/contentParts and deep-merges context", () => {
70
+ const ra: MentionSubmitBundle = {
71
+ blocks: ["A"],
72
+ contentParts: [],
73
+ context: { files: { app: "a" } },
74
+ };
75
+ const rb: MentionSubmitBundle = {
76
+ blocks: ["B"],
77
+ contentParts: [],
78
+ context: { files: { readme: "b" } },
79
+ };
80
+ const out = mergeFinalizedMentions([ra, rb]);
81
+ expect(out.blocks).toEqual(["A", "B"]);
82
+ expect(out.context.files).toEqual({ app: "a", readme: "b" });
83
+ });
84
+
85
+ it("returns an empty bundle when nothing is fulfilled", () => {
86
+ expect(mergeFinalizedMentions([])).toEqual({
87
+ blocks: [],
88
+ contentParts: [],
89
+ context: {},
90
+ });
91
+ });
92
+ });
93
+
94
+ // --- Bug 1: double-submit re-entrancy guard --------------------------------
95
+ describe("createAgentExperience: double-submit guard", () => {
96
+ const originalFetch = global.fetch;
97
+ let fetchCalls = 0;
98
+
99
+ beforeEach(() => {
100
+ fetchCalls = 0;
101
+ window.scrollTo = vi.fn();
102
+ vi.stubGlobal("requestAnimationFrame", (cb: (t: number) => void) => {
103
+ cb(0);
104
+ return 1;
105
+ });
106
+ vi.stubGlobal("cancelAnimationFrame", () => {});
107
+ // Fetch hangs (models an in-flight SSE stream) and counts invocations.
108
+ global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
109
+ fetchCalls += 1;
110
+ const signal = options.signal as AbortSignal;
111
+ return new Promise((_resolve, reject) => {
112
+ signal.addEventListener("abort", () => {
113
+ const err = new Error("aborted");
114
+ err.name = "AbortError";
115
+ reject(err);
116
+ });
117
+ });
118
+ }) as unknown as typeof fetch;
119
+ });
120
+
121
+ afterEach(() => {
122
+ document.body.innerHTML = "";
123
+ global.fetch = originalFetch;
124
+ vi.restoreAllMocks();
125
+ });
126
+
127
+ it("two synchronous submits dispatch the message only once", async () => {
128
+ const mount = createMount();
129
+ const controller = createAgentExperience(mount, {
130
+ apiUrl: "https://api.example.com/chat",
131
+ launcher: { enabled: false },
132
+ });
133
+
134
+ const textarea = getTextarea(mount);
135
+ textarea.value = "hello";
136
+
137
+ // Both clicks land inside the async pre-send window (isStreaming() is still
138
+ // false); the guard must swallow the second.
139
+ getSubmit(mount).click();
140
+ getSubmit(mount).click();
141
+ await flush();
142
+
143
+ expect(fetchCalls).toBe(1);
144
+ controller.destroy();
145
+ });
146
+ });
147
+
148
+ // --- Bug 4: history recall must not open the mention menu ------------------
149
+ describe("createAgentExperience: history recall does not open the mention menu", () => {
150
+ beforeEach(() => {
151
+ window.scrollTo = vi.fn();
152
+ try {
153
+ window.localStorage.clear();
154
+ } catch {
155
+ /* jsdom edge cases */
156
+ }
157
+ });
158
+
159
+ afterEach(() => {
160
+ document.body.innerHTML = "";
161
+ vi.restoreAllMocks();
162
+ });
163
+
164
+ const menuOpen = () =>
165
+ document.querySelector('[role="listbox"]') !== null ||
166
+ !!document
167
+ .querySelector("[aria-haspopup='listbox']")
168
+ ?.getAttribute("aria-expanded")
169
+ ?.includes("true");
170
+
171
+ it("recalling a message ending in '@word' keeps the menu closed and ArrowUp still navigates", async () => {
172
+ const mount = createMount();
173
+ const controller = createAgentExperience(mount, {
174
+ apiUrl: "https://api.example.com/chat",
175
+ launcher: { enabled: false },
176
+ contextMentions: {
177
+ enabled: true,
178
+ sources: [
179
+ createStaticMentionSource({
180
+ id: "files",
181
+ label: "Files",
182
+ items: [{ id: "app", label: "app" }],
183
+ resolve: (i: { label: string }) => ({ llmAppend: `body of ${i.label}` }),
184
+ }),
185
+ ],
186
+ },
187
+ initialMessages: [
188
+ {
189
+ id: "u1",
190
+ role: "user",
191
+ content: "first note",
192
+ createdAt: "2026-01-01T00:00:00.000Z",
193
+ },
194
+ {
195
+ id: "u2",
196
+ role: "user",
197
+ content: "second @app",
198
+ createdAt: "2026-01-01T00:00:02.000Z",
199
+ },
200
+ ],
201
+ });
202
+
203
+ const textarea = getTextarea(mount);
204
+
205
+ // Warm the lazy mention engine by opening the picker from its affordance
206
+ // button, so `engine` is live for the recall step below (a loaded engine is
207
+ // exactly the state in which the bug reopened the menu on synthetic input).
208
+ mount.querySelector<HTMLButtonElement>("[aria-haspopup='listbox']")!.click();
209
+ await flushMentions();
210
+ // If the harness can't drive the async menu open, this scenario can't be
211
+ // expressed; bail loudly rather than asserting a vacuous pass.
212
+ expect(menuOpen()).toBe(true);
213
+
214
+ // Close the menu, back to an empty composer.
215
+ press(textarea, "Escape");
216
+ await flush();
217
+ expect(menuOpen()).toBe(false);
218
+
219
+ // Recall the newest user message (ends in '@app'): its synthetic input must
220
+ // NOT reopen the menu (engine.handleInput would otherwise fire synchronously).
221
+ textarea.setSelectionRange(0, 0);
222
+ press(textarea, "ArrowUp");
223
+ await flush();
224
+ expect(textarea.value).toBe("second @app");
225
+ expect(menuOpen()).toBe(false);
226
+
227
+ // Because the menu stayed closed, ArrowUp is still owned by history nav.
228
+ textarea.setSelectionRange(0, 0);
229
+ press(textarea, "ArrowUp");
230
+ await flush();
231
+ expect(textarea.value).toBe("first note");
232
+
233
+ controller.destroy();
234
+ });
235
+ });
@@ -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
+ });