@runtypelabs/persona 4.15.0 → 4.17.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 (91) hide show
  1. package/dist/codegen.cjs +1 -1
  2. package/dist/codegen.js +1 -1
  3. package/dist/context-mentions.d.cts +283 -9
  4. package/dist/context-mentions.d.ts +283 -9
  5. package/dist/index.cjs +68 -68
  6. package/dist/index.cjs.map +1 -1
  7. package/dist/index.d.cts +299 -10
  8. package/dist/index.d.ts +299 -10
  9. package/dist/index.global.js +57 -57
  10. package/dist/index.global.js.map +1 -1
  11. package/dist/index.js +69 -69
  12. package/dist/index.js.map +1 -1
  13. package/dist/install.global.js +1 -1
  14. package/dist/install.global.js.map +1 -1
  15. package/dist/launcher.global.js +2 -2
  16. package/dist/launcher.global.js.map +1 -1
  17. package/dist/markdown-parsers.js +36 -36
  18. package/dist/smart-dom-reader.d.cts +283 -9
  19. package/dist/smart-dom-reader.d.ts +283 -9
  20. package/dist/testing.cjs +3 -3
  21. package/dist/testing.d.cts +7 -0
  22. package/dist/testing.d.ts +7 -0
  23. package/dist/testing.js +3 -3
  24. package/dist/theme-editor-preview.cjs +64 -64
  25. package/dist/theme-editor-preview.d.cts +283 -9
  26. package/dist/theme-editor-preview.d.ts +283 -9
  27. package/dist/theme-editor-preview.js +56 -56
  28. package/dist/theme-editor.cjs +10 -10
  29. package/dist/theme-editor.d.cts +283 -10
  30. package/dist/theme-editor.d.ts +283 -10
  31. package/dist/theme-editor.js +10 -10
  32. package/dist/theme-reference.cjs +1 -1
  33. package/dist/theme-reference.d.cts +66 -3
  34. package/dist/theme-reference.d.ts +66 -3
  35. package/dist/theme-reference.js +1 -1
  36. package/dist/widget.css +1 -1
  37. package/package.json +2 -2
  38. package/src/components/artifact-pane.test.ts +27 -0
  39. package/src/components/artifact-pane.ts +7 -3
  40. package/src/components/header-builder.ts +27 -2
  41. package/src/components/header-layouts.test.ts +56 -0
  42. package/src/components/header-layouts.ts +29 -9
  43. package/src/components/launcher-surface.test.ts +403 -0
  44. package/src/components/launcher.ts +297 -2
  45. package/src/components/panel.ts +69 -74
  46. package/src/components/suggestions.test.ts +40 -0
  47. package/src/components/suggestions.ts +213 -153
  48. package/src/components/welcome.test.ts +121 -0
  49. package/src/components/welcome.ts +305 -0
  50. package/src/components/widget-view.test.ts +5 -3
  51. package/src/components/widget-view.ts +14 -7
  52. package/src/defaults.ts +2 -2
  53. package/src/index-core.ts +12 -1
  54. package/src/install.test.ts +35 -1
  55. package/src/install.ts +47 -11
  56. package/src/launcher-global.ts +10 -7
  57. package/src/plugins/types.ts +66 -1
  58. package/src/styles/widget-styles.test.ts +220 -0
  59. package/src/styles/widget.css +353 -9
  60. package/src/testing/mock-stream.test.ts +19 -0
  61. package/src/testing/mock-stream.ts +20 -0
  62. package/src/theme-editor/sections.ts +30 -1
  63. package/src/theme-reference.ts +26 -4
  64. package/src/types/theme.ts +40 -3
  65. package/src/types.ts +164 -6
  66. package/src/ui.body-top-slot.test.ts +95 -0
  67. package/src/ui.content-width.test.ts +91 -0
  68. package/src/ui.docked.test.ts +7 -4
  69. package/src/ui.launcher-teaser.test.ts +162 -0
  70. package/src/ui.rebuild-composer.test.ts +491 -0
  71. package/src/ui.render-welcome.test.ts +505 -0
  72. package/src/ui.scroll-additive.test.ts +91 -21
  73. package/src/ui.scroll.test.ts +302 -14
  74. package/src/ui.suggest-replies.test.ts +46 -0
  75. package/src/ui.ts +747 -136
  76. package/src/ui.welcome.test.ts +504 -0
  77. package/src/utils/artifact-gate.test.ts +27 -0
  78. package/src/utils/artifact-gate.ts +9 -0
  79. package/src/utils/attachment-manager.test.ts +37 -0
  80. package/src/utils/attachment-manager.ts +15 -0
  81. package/src/utils/auto-follow.test.ts +23 -0
  82. package/src/utils/auto-follow.ts +11 -1
  83. package/src/utils/config-merge.ts +2 -0
  84. package/src/utils/content-width.test.ts +37 -0
  85. package/src/utils/content-width.ts +17 -0
  86. package/src/utils/plugin-storage.test.ts +94 -0
  87. package/src/utils/plugin-storage.ts +98 -0
  88. package/src/utils/theme.test.ts +137 -0
  89. package/src/utils/tokens.ts +49 -3
  90. package/src/welcome.test.ts +213 -0
  91. package/src/welcome.ts +130 -0
@@ -0,0 +1,162 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import { mount as mountCriticalLauncher } from "./launcher-global";
7
+
8
+ const TEASER = "[data-persona-launcher-teaser]";
9
+
10
+ const createMount = () => {
11
+ const mount = document.createElement("div");
12
+ document.body.appendChild(mount);
13
+ return mount;
14
+ };
15
+
16
+ let keyCounter = 0;
17
+ const nextPrefix = () => `ui-teaser-test-${++keyCounter}-`;
18
+
19
+ describe("launcher teaser", () => {
20
+ beforeEach(() => {
21
+ window.scrollTo = vi.fn();
22
+ window.localStorage.clear();
23
+ window.sessionStorage.clear();
24
+ });
25
+
26
+ afterEach(() => {
27
+ document.body.innerHTML = "";
28
+ vi.restoreAllMocks();
29
+ vi.useRealTimers();
30
+ });
31
+
32
+ it("renders the same teaser from the full widget and the critical bundle", () => {
33
+ const prefix = nextPrefix();
34
+ const config = {
35
+ apiUrl: "https://api.example.com/chat",
36
+ persistState: { keyPrefix: prefix },
37
+ launcher: { teaser: { text: "Questions about pricing?" } },
38
+ };
39
+
40
+ const host = createMount();
41
+ const controller = createAgentExperience(host, config);
42
+ const eager = host.querySelector<HTMLElement>(TEASER);
43
+
44
+ const criticalTarget = createMount();
45
+ const critical = mountCriticalLauncher({
46
+ target: criticalTarget,
47
+ config,
48
+ onOpen: () => {},
49
+ });
50
+ const deferred = criticalTarget.querySelector<HTMLElement>(TEASER);
51
+
52
+ expect(eager).not.toBeNull();
53
+ expect(deferred).not.toBeNull();
54
+ expect(deferred!.outerHTML).toBe(eager!.outerHTML);
55
+ expect(deferred!.parentElement!.getAttribute("data-teaser")).toBe(
56
+ eager!.parentElement!.getAttribute("data-teaser")
57
+ );
58
+
59
+ critical.destroy();
60
+ controller.destroy();
61
+ });
62
+
63
+ it("live-updates the teaser through controller.update()", () => {
64
+ const prefix = nextPrefix();
65
+ const host = createMount();
66
+ const controller = createAgentExperience(host, {
67
+ apiUrl: "https://api.example.com/chat",
68
+ persistState: { keyPrefix: prefix },
69
+ });
70
+
71
+ expect(host.querySelector(TEASER)).toBeNull();
72
+
73
+ controller.update({ launcher: { teaser: { text: "Need a hand?" } } });
74
+ const teaser = host.querySelector<HTMLElement>(TEASER);
75
+ expect(teaser).not.toBeNull();
76
+ expect(teaser!.querySelector("button")!.textContent).toBe("Need a hand?");
77
+
78
+ controller.update({ launcher: { teaser: { text: "Still here to help" } } });
79
+ expect(host.querySelector<HTMLElement>(TEASER)!.querySelector("button")!.textContent).toBe(
80
+ "Still here to help"
81
+ );
82
+
83
+ controller.update({ launcher: { teaser: undefined } });
84
+ expect(host.querySelector(TEASER)).toBeNull();
85
+
86
+ controller.destroy();
87
+ });
88
+
89
+ it("suppresses the teaser when the panel starts open", () => {
90
+ vi.useFakeTimers();
91
+ const prefix = nextPrefix();
92
+ const host = createMount();
93
+ const controller = createAgentExperience(host, {
94
+ apiUrl: "https://api.example.com/chat",
95
+ persistState: { keyPrefix: prefix },
96
+ launcher: { autoExpand: true, teaser: { text: "Need a hand?" } },
97
+ });
98
+
99
+ vi.advanceTimersByTime(1000);
100
+ expect(host.querySelector<HTMLElement>(TEASER)!.hidden).toBe(true);
101
+
102
+ controller.destroy();
103
+ });
104
+
105
+ it("suppresses the teaser when restored open state opens the panel", () => {
106
+ vi.useFakeTimers();
107
+ const prefix = nextPrefix();
108
+ window.sessionStorage.setItem(`${prefix}widget-open`, "true");
109
+ const host = createMount();
110
+ const controller = createAgentExperience(host, {
111
+ apiUrl: "https://api.example.com/chat",
112
+ persistState: { keyPrefix: prefix, persist: { openState: true } },
113
+ launcher: { teaser: { text: "Need a hand?" } },
114
+ });
115
+
116
+ vi.advanceTimersByTime(1000);
117
+
118
+ expect(host.querySelector<HTMLElement>(TEASER)!.hidden).toBe(true);
119
+ expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
120
+
121
+ controller.destroy();
122
+ });
123
+
124
+ it("suppresses the teaser when onStateLoaded requests an open", () => {
125
+ vi.useFakeTimers();
126
+ const prefix = nextPrefix();
127
+ const host = createMount();
128
+ const controller = createAgentExperience(host, {
129
+ apiUrl: "https://api.example.com/chat",
130
+ persistState: { keyPrefix: prefix },
131
+ launcher: { teaser: { text: "Need a hand?" } },
132
+ onStateLoaded: (state) => ({ state, open: true }),
133
+ });
134
+
135
+ vi.advanceTimersByTime(1000);
136
+
137
+ expect(host.querySelector<HTMLElement>(TEASER)!.hidden).toBe(true);
138
+ expect(window.localStorage.getItem(`${prefix}teaser-dismissed`)).toBeNull();
139
+
140
+ controller.destroy();
141
+ });
142
+
143
+ it("clears the pending teaser timer when the widget is destroyed", () => {
144
+ vi.useFakeTimers();
145
+ const prefix = nextPrefix();
146
+ const host = createMount();
147
+ const controller = createAgentExperience(host, {
148
+ apiUrl: "https://api.example.com/chat",
149
+ persistState: { keyPrefix: prefix },
150
+ launcher: { teaser: { text: "Need a hand?", delayMs: 5000 } },
151
+ });
152
+
153
+ const teaser = host.querySelector<HTMLElement>(TEASER);
154
+ expect(teaser!.hidden).toBe(true);
155
+
156
+ controller.destroy();
157
+ vi.advanceTimersByTime(10000);
158
+
159
+ expect(teaser!.hidden).toBe(true);
160
+ expect(host.querySelector(TEASER)).toBeNull();
161
+ });
162
+ });
@@ -0,0 +1,491 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+ import type { AgentWidgetPlugin } from "./plugins/types";
7
+
8
+ type ComposerCtx = Parameters<NonNullable<AgentWidgetPlugin["renderComposer"]>>[0];
9
+
10
+ const mounts: HTMLElement[] = [];
11
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
12
+
13
+ const makeController = (config: Record<string, unknown> = {}) => {
14
+ const mount = document.createElement("div");
15
+ document.body.appendChild(mount);
16
+ mounts.push(mount);
17
+ const controller = createAgentExperience(mount, {
18
+ apiUrl: "https://api.example.com/chat",
19
+ launcher: { enabled: false },
20
+ persistState: false,
21
+ suggestionChips: [],
22
+ ...config,
23
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
24
+ controllers.push(controller);
25
+ return { mount, controller };
26
+ };
27
+
28
+ /**
29
+ * Composer plugin that gates the composer while `gated` is true and falls
30
+ * through to the built-in composer once it flips: blueprint B's shape.
31
+ */
32
+ const createGatePlugin = () => {
33
+ const state = {
34
+ gated: true,
35
+ renders: 0,
36
+ ctx: null as ComposerCtx | null,
37
+ };
38
+ const plugin: AgentWidgetPlugin = {
39
+ id: "gate",
40
+ renderComposer: (ctx) => {
41
+ state.renders += 1;
42
+ state.ctx = ctx;
43
+ if (!state.gated) return null;
44
+ const footer = document.createElement("div");
45
+ footer.setAttribute("data-test-gate", "");
46
+ return footer;
47
+ },
48
+ };
49
+ return { plugin, state };
50
+ };
51
+
52
+ const flush = async (times = 4) => {
53
+ for (let i = 0; i < times; i += 1) {
54
+ // eslint-disable-next-line no-await-in-loop
55
+ await Promise.resolve();
56
+ }
57
+ };
58
+
59
+ const textareaOf = (mount: HTMLElement) =>
60
+ mount.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]");
61
+
62
+ describe("rebuildComposer (composer ctx requestRender)", () => {
63
+ beforeEach(() => {
64
+ window.scrollTo = vi.fn();
65
+ });
66
+
67
+ afterEach(() => {
68
+ controllers.splice(0).forEach((controller) => {
69
+ try {
70
+ controller.destroy();
71
+ } catch {
72
+ /* already destroyed in the test */
73
+ }
74
+ });
75
+ mounts.splice(0).forEach((mount) => mount.remove());
76
+ document.body.innerHTML = "";
77
+ window.localStorage.clear();
78
+ vi.unstubAllGlobals();
79
+ vi.restoreAllMocks();
80
+ });
81
+
82
+ it("swaps a gated plugin composer for the default composer in place", () => {
83
+ const { plugin, state } = createGatePlugin();
84
+ const { mount } = makeController({ plugins: [plugin] });
85
+
86
+ expect(mount.querySelector("[data-test-gate]")).not.toBeNull();
87
+ expect(textareaOf(mount)).toBeNull();
88
+
89
+ const container = mount.querySelector<HTMLElement>(".persona-widget-container")!;
90
+ const gateIndex = Array.from(container.children).findIndex((child) =>
91
+ child.hasAttribute("data-test-gate")
92
+ );
93
+
94
+ state.gated = false;
95
+ state.ctx!.requestRender();
96
+
97
+ expect(mount.querySelector("[data-test-gate]")).toBeNull();
98
+ expect(textareaOf(mount)).not.toBeNull();
99
+ // Swapped in place: the footer keeps its slot in the panel.
100
+ expect(
101
+ Array.from(container.children).findIndex((child) =>
102
+ child.classList.contains("persona-widget-footer")
103
+ )
104
+ ).toBe(gateIndex);
105
+ expect(state.renders).toBe(2);
106
+ });
107
+
108
+ it("re-attaches the composer listener registry and the form submit handler", async () => {
109
+ const { plugin, state } = createGatePlugin();
110
+ const { mount, controller } = makeController({ plugins: [plugin] });
111
+
112
+ state.gated = false;
113
+ state.ctx!.requestRender();
114
+
115
+ const textarea = textareaOf(mount)!;
116
+ textarea.value = "typed after rebuild";
117
+ textarea.dispatchEvent(
118
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
119
+ );
120
+ await flush();
121
+
122
+ expect(
123
+ controller.getMessages().some((m) => m.content === "typed after rebuild")
124
+ ).toBe(true);
125
+ });
126
+
127
+ it("re-attaches the form submit handler", async () => {
128
+ const { plugin, state } = createGatePlugin();
129
+ const { mount, controller } = makeController({ plugins: [plugin] });
130
+
131
+ state.gated = false;
132
+ state.ctx!.requestRender();
133
+
134
+ textareaOf(mount)!.value = "submitted after rebuild";
135
+ mount
136
+ .querySelector<HTMLFormElement>("[data-persona-composer-form]")!
137
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
138
+ await flush();
139
+
140
+ expect(
141
+ controller.getMessages().some((m) => m.content === "submitted after rebuild")
142
+ ).toBe(true);
143
+ });
144
+
145
+ it("detaches the outgoing composer so its listeners cannot fire again", async () => {
146
+ const { plugin, state } = createGatePlugin();
147
+ const { mount, controller } = makeController({ plugins: [plugin] });
148
+
149
+ state.gated = false;
150
+ state.ctx!.requestRender();
151
+ const first = textareaOf(mount)!;
152
+ const firstForm = mount.querySelector<HTMLFormElement>(
153
+ "[data-persona-composer-form]"
154
+ )!;
155
+
156
+ // Rebuild again: the first default composer is now detached.
157
+ state.ctx!.requestRender();
158
+
159
+ first.value = "stale listener";
160
+ first.dispatchEvent(
161
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
162
+ );
163
+ firstForm.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
164
+ await flush();
165
+
166
+ expect(controller.getMessages()).toHaveLength(0);
167
+ expect(textareaOf(mount)).not.toBe(first);
168
+ });
169
+
170
+ it("carries the composer text across the rebuild", () => {
171
+ const { plugin, state } = createGatePlugin();
172
+ const { mount } = makeController({ plugins: [plugin] });
173
+
174
+ state.gated = false;
175
+ state.ctx!.requestRender();
176
+ textareaOf(mount)!.value = "half-written question";
177
+
178
+ state.ctx!.requestRender();
179
+
180
+ expect(textareaOf(mount)!.value).toBe("half-written question");
181
+ });
182
+
183
+ it("keeps pending attachments and rebinds the file input", async () => {
184
+ const { plugin, state } = createGatePlugin();
185
+ const { mount } = makeController({
186
+ plugins: [plugin],
187
+ attachments: { enabled: true },
188
+ });
189
+
190
+ state.gated = false;
191
+ state.ctx!.requestRender();
192
+
193
+ const input = mount.querySelector<HTMLInputElement>(
194
+ "[data-persona-composer-attachment-input]"
195
+ )!;
196
+ const file = new File(["notes"], "notes.txt", { type: "text/plain" });
197
+ Object.defineProperty(input, "files", {
198
+ value: { 0: file, length: 1, item: () => file },
199
+ configurable: true,
200
+ });
201
+ input.dispatchEvent(new Event("change"));
202
+
203
+ await vi.waitFor(() => {
204
+ const previews = mount.querySelector<HTMLElement>(
205
+ "[data-persona-composer-attachment-previews]"
206
+ )!;
207
+ expect(previews.children.length).toBe(1);
208
+ });
209
+
210
+ const previewsBefore = mount.querySelector<HTMLElement>(
211
+ "[data-persona-composer-attachment-previews]"
212
+ )!;
213
+
214
+ state.ctx!.requestRender();
215
+
216
+ const previewsAfter = mount.querySelector<HTMLElement>(
217
+ "[data-persona-composer-attachment-previews]"
218
+ )!;
219
+ expect(previewsAfter).not.toBe(previewsBefore);
220
+ // The manager survives the swap and repaints its pending previews.
221
+ expect(previewsAfter.children.length).toBe(1);
222
+ expect(previewsAfter.querySelector("[data-attachment-id]")).not.toBeNull();
223
+ expect(
224
+ mount.querySelector("[data-persona-composer-attachment-input]")
225
+ ).not.toBe(input);
226
+ });
227
+
228
+ it("re-attaches the mention context row and affordance buttons", () => {
229
+ const { plugin, state } = createGatePlugin();
230
+ const { mount } = makeController({
231
+ plugins: [plugin],
232
+ contextMentions: {
233
+ enabled: true,
234
+ sources: [
235
+ {
236
+ id: "docs",
237
+ label: "Docs",
238
+ items: [{ id: "a", label: "Getting started" }],
239
+ },
240
+ ],
241
+ },
242
+ });
243
+
244
+ state.gated = false;
245
+ state.ctx!.requestRender();
246
+
247
+ const footer = mount.querySelector<HTMLElement>(".persona-widget-footer")!;
248
+ expect(footer.querySelector(".persona-mention-button")).not.toBeNull();
249
+ const textarea = textareaOf(mount)!;
250
+ const contextRow = textarea.parentElement!.querySelector(
251
+ "[data-persona-mention-context-row]"
252
+ );
253
+ expect(contextRow).not.toBeNull();
254
+
255
+ // Rebuild again: exactly one context row and one affordance button remain.
256
+ state.ctx!.requestRender();
257
+ const rebuilt = mount.querySelector<HTMLElement>(".persona-widget-footer")!;
258
+ expect(rebuilt.querySelectorAll(".persona-mention-button").length).toBe(1);
259
+ expect(
260
+ rebuilt.querySelectorAll("[data-persona-mention-context-row]").length
261
+ ).toBe(1);
262
+ });
263
+
264
+ it("rewires the mic button after the rebuild", () => {
265
+ const start = vi.fn();
266
+ class FakeSpeechRecognition {
267
+ continuous = false;
268
+ interimResults = false;
269
+ lang = "";
270
+ onresult: unknown = null;
271
+ onerror: unknown = null;
272
+ onend: unknown = null;
273
+ start = start;
274
+ stop = vi.fn();
275
+ abort = vi.fn();
276
+ }
277
+ vi.stubGlobal("SpeechRecognition", FakeSpeechRecognition);
278
+
279
+ const { plugin, state } = createGatePlugin();
280
+ const { mount } = makeController({
281
+ plugins: [plugin],
282
+ voiceRecognition: { enabled: true },
283
+ });
284
+
285
+ state.gated = false;
286
+ state.ctx!.requestRender();
287
+
288
+ const mic = mount.querySelector<HTMLButtonElement>(
289
+ "[data-persona-composer-mic]"
290
+ )!;
291
+ mic.click();
292
+
293
+ expect(start).toHaveBeenCalledTimes(1);
294
+ });
295
+
296
+ it("rebuilds mid-stream with the streaming state applied", async () => {
297
+ const originalFetch = global.fetch;
298
+ global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
299
+ const signal = options.signal as AbortSignal;
300
+ return new Promise((_resolve, reject) => {
301
+ signal.addEventListener("abort", () => {
302
+ const error = new Error("aborted");
303
+ error.name = "AbortError";
304
+ reject(error);
305
+ });
306
+ });
307
+ }) as unknown as typeof fetch;
308
+
309
+ try {
310
+ const { plugin, state } = createGatePlugin();
311
+ const { mount, controller } = makeController({ plugins: [plugin] });
312
+
313
+ state.gated = false;
314
+ state.ctx!.requestRender();
315
+
316
+ const textarea = textareaOf(mount)!;
317
+ textarea.value = "stream please";
318
+ mount
319
+ .querySelector<HTMLFormElement>("[data-persona-composer-form]")!
320
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
321
+ await flush();
322
+ expect(controller.getState().streaming).toBe(true);
323
+
324
+ state.ctx!.requestRender();
325
+
326
+ expect(state.ctx!.streaming).toBe(true);
327
+ const footer = mount.querySelector<HTMLElement>(".persona-widget-footer")!;
328
+ expect(footer.dataset.personaComposerStreaming).toBe("true");
329
+ } finally {
330
+ global.fetch = originalFetch;
331
+ }
332
+ });
333
+
334
+ it("keeps theme variables on the mount across a rebuild", () => {
335
+ // applyFullHeightStyles resets mount.style.cssText; a rebuild must go
336
+ // through syncPanelChrome or every theme var silently disappears
337
+ // (white header, borderless suggestion cards).
338
+ const { plugin, state } = createGatePlugin();
339
+ const { mount } = makeController({ plugins: [plugin] });
340
+
341
+ expect(mount.style.getPropertyValue("--persona-border")).not.toBe("");
342
+
343
+ state.ctx!.requestRender();
344
+
345
+ expect(mount.style.getPropertyValue("--persona-border")).not.toBe("");
346
+ });
347
+
348
+ it("keeps the floating panel's fixed height across a rebuild", () => {
349
+ // applyFullHeightStyles wipes panel.style.cssText and only restores width;
350
+ // the fixed height belongs to recalcPanelHeight. Without a re-stamp in
351
+ // syncPanelChrome, a rebuild leaves the floating panel height-less and it
352
+ // grows to fit its content (pre-chat submit stretched to the viewport).
353
+ const { plugin, state } = createGatePlugin();
354
+ const { mount } = makeController({
355
+ plugins: [plugin],
356
+ launcher: { enabled: true },
357
+ });
358
+
359
+ const panel = mount.querySelector<HTMLElement>(".persona-widget-panel")!;
360
+ expect(panel.style.height).toBe("640px");
361
+
362
+ state.ctx!.requestRender();
363
+
364
+ expect(panel.style.height).toBe("640px");
365
+ });
366
+
367
+ it("does not stamp copy onto a plugin-owned composer's input", () => {
368
+ const plugin: AgentWidgetPlugin = {
369
+ id: "gate",
370
+ renderComposer: () => {
371
+ const footer = document.createElement("div");
372
+ const input = document.createElement("textarea");
373
+ input.setAttribute("data-persona-composer-input", "");
374
+ input.placeholder = "Submit the form above to start.";
375
+ footer.appendChild(input);
376
+ return footer;
377
+ },
378
+ };
379
+ const { mount, controller } = makeController({
380
+ plugins: [plugin],
381
+ copy: { inputPlaceholder: "Ask a question" },
382
+ });
383
+
384
+ const input = textareaOf(mount)!;
385
+ expect(input.placeholder).toBe("Submit the form above to start.");
386
+
387
+ controller.update({ copy: { inputPlaceholder: "Changed" } });
388
+ expect(input.placeholder).toBe("Submit the form above to start.");
389
+ });
390
+
391
+ it("stamps copy onto the default composer once a plugin falls through", () => {
392
+ const { plugin, state } = createGatePlugin();
393
+ const { mount, controller } = makeController({ plugins: [plugin] });
394
+
395
+ state.gated = false;
396
+ state.ctx!.requestRender();
397
+
398
+ controller.update({ copy: { inputPlaceholder: "Changed" } });
399
+ expect(textareaOf(mount)!.placeholder).toBe("Changed");
400
+ });
401
+
402
+ it("rebuilds the pill composer in composer-bar mode", () => {
403
+ const { plugin, state } = createGatePlugin();
404
+ const { mount } = makeController({
405
+ plugins: [plugin],
406
+ launcher: { mountMode: "composer-bar" },
407
+ });
408
+
409
+ state.gated = false;
410
+ state.ctx!.requestRender();
411
+
412
+ const pillRoot = mount.querySelector<HTMLElement>(".persona-widget-pill-root")!;
413
+ const footer = pillRoot.querySelector<HTMLElement>(
414
+ "[data-persona-composer-form]"
415
+ );
416
+ expect(footer).not.toBeNull();
417
+ // The pill stays a child of the viewport-fixed pill root, not the panel.
418
+ expect(mount.querySelector(".persona-widget-container")!.contains(footer!)).toBe(
419
+ false
420
+ );
421
+ });
422
+
423
+ it("carries the model selection into the next render", () => {
424
+ const { plugin, state } = createGatePlugin();
425
+ makeController({
426
+ plugins: [plugin],
427
+ composer: {
428
+ models: [
429
+ { id: "fast", label: "Fast" },
430
+ { id: "smart", label: "Smart" },
431
+ ],
432
+ selectedModelId: "fast",
433
+ },
434
+ });
435
+
436
+ expect(state.ctx!.selectedModelId).toBe("fast");
437
+ state.ctx!.onModelChange?.("smart");
438
+ state.ctx!.requestRender();
439
+
440
+ expect(state.ctx!.selectedModelId).toBe("smart");
441
+ expect(state.ctx!.models?.map((model) => model.id)).toEqual(["fast", "smart"]);
442
+ });
443
+
444
+ it("exposes the same storage facade as renderWelcome, memory-backed when blocked", () => {
445
+ const setItem = vi
446
+ .spyOn(Storage.prototype, "setItem")
447
+ .mockImplementation(() => {
448
+ throw new Error("blocked");
449
+ });
450
+ const getItem = vi
451
+ .spyOn(Storage.prototype, "getItem")
452
+ .mockImplementation(() => {
453
+ throw new Error("blocked");
454
+ });
455
+
456
+ const { plugin, state } = createGatePlugin();
457
+ makeController({ plugins: [plugin], persistState: true });
458
+
459
+ state.ctx!.storage.set("identity", "ada");
460
+ expect(state.ctx!.storage.get("identity")).toBe("ada");
461
+
462
+ state.ctx!.requestRender();
463
+ expect(state.ctx!.storage.get("identity")).toBe("ada");
464
+
465
+ setItem.mockRestore();
466
+ getItem.mockRestore();
467
+ });
468
+
469
+ it("keeps the composer suggestion row live after a rebuild", () => {
470
+ const { plugin, state } = createGatePlugin();
471
+ const { mount } = makeController({
472
+ plugins: [plugin],
473
+ suggestions: {
474
+ starters: {
475
+ items: ["Track my order"],
476
+ placement: "composer",
477
+ variant: "chip",
478
+ },
479
+ },
480
+ });
481
+
482
+ state.gated = false;
483
+ state.ctx!.requestRender();
484
+
485
+ const row = mount.querySelector<HTMLElement>(
486
+ "[data-persona-composer-suggestions]"
487
+ )!;
488
+ expect(row.hidden).toBe(false);
489
+ expect(row.querySelectorAll("button.persona-suggestion").length).toBe(1);
490
+ });
491
+ });