@runtypelabs/persona 4.15.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +283 -9
- package/dist/context-mentions.d.ts +283 -9
- package/dist/index.cjs +68 -68
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +299 -10
- package/dist/index.d.ts +299 -10
- package/dist/index.global.js +57 -57
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +69 -69
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +283 -9
- package/dist/smart-dom-reader.d.ts +283 -9
- package/dist/testing.cjs +3 -3
- package/dist/testing.d.cts +7 -0
- package/dist/testing.d.ts +7 -0
- package/dist/testing.js +3 -3
- package/dist/theme-editor-preview.cjs +64 -64
- package/dist/theme-editor-preview.d.cts +283 -9
- package/dist/theme-editor-preview.d.ts +283 -9
- package/dist/theme-editor-preview.js +56 -56
- package/dist/theme-editor.cjs +10 -10
- package/dist/theme-editor.d.cts +283 -10
- package/dist/theme-editor.d.ts +283 -10
- package/dist/theme-editor.js +10 -10
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +66 -3
- package/dist/theme-reference.d.ts +66 -3
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +1 -1
- package/src/components/artifact-pane.test.ts +27 -0
- package/src/components/artifact-pane.ts +7 -3
- package/src/components/header-builder.ts +27 -2
- package/src/components/header-layouts.test.ts +56 -0
- package/src/components/header-layouts.ts +29 -9
- package/src/components/launcher-surface.test.ts +403 -0
- package/src/components/launcher.ts +297 -2
- package/src/components/panel.ts +69 -74
- package/src/components/suggestions.test.ts +40 -0
- package/src/components/suggestions.ts +213 -153
- package/src/components/welcome.test.ts +121 -0
- package/src/components/welcome.ts +305 -0
- package/src/components/widget-view.test.ts +5 -3
- package/src/components/widget-view.ts +14 -7
- package/src/defaults.ts +2 -2
- package/src/index-core.ts +12 -1
- package/src/launcher-global.ts +10 -7
- package/src/plugins/types.ts +66 -1
- package/src/styles/widget-styles.test.ts +220 -0
- package/src/styles/widget.css +353 -9
- package/src/testing/mock-stream.test.ts +19 -0
- package/src/testing/mock-stream.ts +20 -0
- package/src/theme-editor/sections.ts +30 -1
- package/src/theme-reference.ts +26 -4
- package/src/types/theme.ts +40 -3
- package/src/types.ts +164 -6
- package/src/ui.body-top-slot.test.ts +95 -0
- package/src/ui.content-width.test.ts +91 -0
- package/src/ui.docked.test.ts +7 -4
- package/src/ui.launcher-teaser.test.ts +162 -0
- package/src/ui.rebuild-composer.test.ts +491 -0
- package/src/ui.render-welcome.test.ts +505 -0
- package/src/ui.scroll-additive.test.ts +91 -21
- package/src/ui.scroll.test.ts +302 -14
- package/src/ui.suggest-replies.test.ts +46 -0
- package/src/ui.ts +747 -136
- package/src/ui.welcome.test.ts +504 -0
- package/src/utils/artifact-gate.test.ts +27 -0
- package/src/utils/artifact-gate.ts +9 -0
- package/src/utils/attachment-manager.test.ts +37 -0
- package/src/utils/attachment-manager.ts +15 -0
- package/src/utils/auto-follow.test.ts +23 -0
- package/src/utils/auto-follow.ts +11 -1
- package/src/utils/config-merge.ts +2 -0
- package/src/utils/content-width.test.ts +37 -0
- package/src/utils/content-width.ts +17 -0
- package/src/utils/plugin-storage.test.ts +94 -0
- package/src/utils/plugin-storage.ts +98 -0
- package/src/utils/theme.test.ts +137 -0
- package/src/utils/tokens.ts +49 -3
- package/src/welcome.test.ts +213 -0
- package/src/welcome.ts +130 -0
|
@@ -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
|
+
});
|