@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.
- 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/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/markdown-parsers.js +36 -36
- 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 +2 -2
- 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/install.test.ts +35 -1
- package/src/install.ts +47 -11
- 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,504 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import { DEFAULT_WELCOME_SUBTITLE, DEFAULT_WELCOME_TITLE } from "./welcome";
|
|
7
|
+
import type { AgentWidgetMessage } from "./types";
|
|
8
|
+
|
|
9
|
+
const mounts: HTMLElement[] = [];
|
|
10
|
+
const controllers: ReturnType<typeof createAgentExperience>[] = [];
|
|
11
|
+
|
|
12
|
+
const makeController = (config?: Record<string, unknown>) => {
|
|
13
|
+
const mount = document.createElement("div");
|
|
14
|
+
document.body.appendChild(mount);
|
|
15
|
+
mounts.push(mount);
|
|
16
|
+
const controller = createAgentExperience(mount, {
|
|
17
|
+
apiUrl: "https://api.example.com/chat",
|
|
18
|
+
launcher: { enabled: false },
|
|
19
|
+
persistState: false,
|
|
20
|
+
suggestionChips: [],
|
|
21
|
+
...config,
|
|
22
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
23
|
+
controllers.push(controller);
|
|
24
|
+
return { mount, controller };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const welcomeHost = (mount: HTMLElement) =>
|
|
28
|
+
mount.querySelector<HTMLElement>("[data-persona-welcome]")!;
|
|
29
|
+
|
|
30
|
+
const greetingHost = (mount: HTMLElement) =>
|
|
31
|
+
mount.querySelector<HTMLElement>("[data-persona-welcome-greeting]")!;
|
|
32
|
+
|
|
33
|
+
const isVisible = (element: HTMLElement) =>
|
|
34
|
+
!element.hidden && element.style.display !== "none";
|
|
35
|
+
|
|
36
|
+
const userMessage = (id = "u1"): AgentWidgetMessage => ({
|
|
37
|
+
id,
|
|
38
|
+
role: "user",
|
|
39
|
+
content: "hello",
|
|
40
|
+
createdAt: "2026-08-01T00:00:00.000Z",
|
|
41
|
+
streaming: false,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const assistantMessage = (id = "a1"): AgentWidgetMessage => ({
|
|
45
|
+
id,
|
|
46
|
+
role: "assistant",
|
|
47
|
+
content: "hi there",
|
|
48
|
+
createdAt: "2026-08-01T00:00:01.000Z",
|
|
49
|
+
streaming: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** Stubs WAAPI with an animation that never finishes until `finish()` is called. */
|
|
53
|
+
const stubAnimate = () => {
|
|
54
|
+
let resolveFinished: () => void = () => {};
|
|
55
|
+
const finished = new Promise<void>((resolve) => {
|
|
56
|
+
resolveFinished = resolve;
|
|
57
|
+
});
|
|
58
|
+
const cancel = vi.fn();
|
|
59
|
+
const animate = vi.fn(
|
|
60
|
+
(_keyframes: unknown, _options?: { fill?: string }) =>
|
|
61
|
+
({ finished, cancel }) as unknown as Animation
|
|
62
|
+
);
|
|
63
|
+
const original = (Element.prototype as { animate?: unknown }).animate;
|
|
64
|
+
(Element.prototype as { animate?: unknown }).animate = animate;
|
|
65
|
+
return {
|
|
66
|
+
animate,
|
|
67
|
+
cancel,
|
|
68
|
+
finished,
|
|
69
|
+
finish: () => resolveFinished(),
|
|
70
|
+
restore: () => {
|
|
71
|
+
if (original === undefined) {
|
|
72
|
+
delete (Element.prototype as { animate?: unknown }).animate;
|
|
73
|
+
} else {
|
|
74
|
+
(Element.prototype as { animate?: unknown }).animate = original;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const sendUserMessage = (
|
|
81
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
82
|
+
id = "u1"
|
|
83
|
+
) => {
|
|
84
|
+
controller.injectTestMessage({ type: "message", message: userMessage(id) });
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
controllers.splice(0).forEach((controller) => controller.destroy());
|
|
89
|
+
mounts.splice(0).forEach((mount) => mount.remove());
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("welcome surface", () => {
|
|
93
|
+
it("renders resolver defaults into a permanent host", () => {
|
|
94
|
+
const { mount } = makeController();
|
|
95
|
+
const host = welcomeHost(mount);
|
|
96
|
+
expect(host).toBeTruthy();
|
|
97
|
+
expect(host.getAttribute("data-persona-welcome-variant")).toBe("card");
|
|
98
|
+
expect(host.querySelector("h2")?.textContent).toBe(DEFAULT_WELCOME_TITLE);
|
|
99
|
+
expect(host.querySelector("p")?.textContent).toBe(DEFAULT_WELCOME_SUBTITLE);
|
|
100
|
+
expect(isVisible(host)).toBe(true);
|
|
101
|
+
// Back-compatible hook for layout.slots["body-top"].
|
|
102
|
+
expect(host.hasAttribute("data-persona-intro-card")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("hides the host for variant none", () => {
|
|
106
|
+
const { mount } = makeController({ welcome: { variant: "none" } });
|
|
107
|
+
expect(isVisible(welcomeHost(mount))).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("omits the subtitle paragraph when the subtitle is empty", () => {
|
|
111
|
+
const { mount } = makeController({ welcome: { subtitle: "" } });
|
|
112
|
+
expect(welcomeHost(mount).querySelector("p")!.hidden).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("welcome visibility state machine", () => {
|
|
117
|
+
it("keeps a card visible after the first user message (dismiss never)", () => {
|
|
118
|
+
const { mount, controller } = makeController();
|
|
119
|
+
sendUserMessage(controller);
|
|
120
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("dismisses a card configured with on-first-message", () => {
|
|
124
|
+
const { mount, controller } = makeController({
|
|
125
|
+
welcome: { dismiss: "on-first-message" },
|
|
126
|
+
});
|
|
127
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
128
|
+
sendUserMessage(controller);
|
|
129
|
+
expect(isVisible(welcomeHost(mount))).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("shows the hero on a fresh empty session and dismisses it on send", () => {
|
|
133
|
+
const { mount, controller } = makeController({
|
|
134
|
+
welcome: { variant: "hero" },
|
|
135
|
+
});
|
|
136
|
+
const host = welcomeHost(mount);
|
|
137
|
+
expect(host.getAttribute("data-persona-welcome-variant")).toBe("hero");
|
|
138
|
+
expect(host.getAttribute("data-persona-welcome-dismiss")).toBe(
|
|
139
|
+
"on-first-message"
|
|
140
|
+
);
|
|
141
|
+
expect(isVisible(host)).toBe(true);
|
|
142
|
+
sendUserMessage(controller);
|
|
143
|
+
expect(isVisible(host)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("never shows the hero for restored history containing user messages", () => {
|
|
147
|
+
const { mount } = makeController({
|
|
148
|
+
welcome: { variant: "hero" },
|
|
149
|
+
initialMessages: [userMessage("restored")],
|
|
150
|
+
});
|
|
151
|
+
expect(isVisible(welcomeHost(mount))).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("shows a never-dismissing card for restored history", () => {
|
|
155
|
+
const { mount } = makeController({
|
|
156
|
+
initialMessages: [userMessage("restored")],
|
|
157
|
+
});
|
|
158
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns the hero after clearChat, without stored state", () => {
|
|
162
|
+
const { mount, controller } = makeController({
|
|
163
|
+
welcome: { variant: "hero" },
|
|
164
|
+
});
|
|
165
|
+
sendUserMessage(controller);
|
|
166
|
+
expect(isVisible(welcomeHost(mount))).toBe(false);
|
|
167
|
+
controller.clearChat();
|
|
168
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("derives visibility per load with persistState false", () => {
|
|
172
|
+
const first = makeController({
|
|
173
|
+
welcome: { variant: "hero" },
|
|
174
|
+
persistState: false,
|
|
175
|
+
});
|
|
176
|
+
sendUserMessage(first.controller);
|
|
177
|
+
expect(isVisible(welcomeHost(first.mount))).toBe(false);
|
|
178
|
+
|
|
179
|
+
const second = makeController({
|
|
180
|
+
welcome: { variant: "hero" },
|
|
181
|
+
persistState: false,
|
|
182
|
+
});
|
|
183
|
+
expect(isVisible(welcomeHost(second.mount))).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("animates the hero out through WAAPI before hiding it", async () => {
|
|
187
|
+
// WAAPI, not a CSS transition: morph re-renders cancel transitions.
|
|
188
|
+
const waapi = stubAnimate();
|
|
189
|
+
try {
|
|
190
|
+
const { mount, controller } = makeController({
|
|
191
|
+
welcome: { variant: "hero" },
|
|
192
|
+
});
|
|
193
|
+
const host = welcomeHost(mount);
|
|
194
|
+
sendUserMessage(controller);
|
|
195
|
+
expect(waapi.animate).toHaveBeenCalled();
|
|
196
|
+
// `forwards`: `backwards` reverts to opacity 1 after the keyframes end.
|
|
197
|
+
expect(waapi.animate.mock.calls[0]![1]?.fill).toBe("forwards");
|
|
198
|
+
expect(isVisible(host)).toBe(true);
|
|
199
|
+
waapi.finish();
|
|
200
|
+
await waapi.finished;
|
|
201
|
+
await Promise.resolve();
|
|
202
|
+
expect(isVisible(host)).toBe(false);
|
|
203
|
+
} finally {
|
|
204
|
+
waapi.restore();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("animates a dismissing card out through WAAPI, same as the hero", async () => {
|
|
209
|
+
// The spec's state machine keys the animation on the dismiss rule, not
|
|
210
|
+
// the variant: { variant: "card", dismiss: "on-first-message" } fades too.
|
|
211
|
+
const waapi = stubAnimate();
|
|
212
|
+
try {
|
|
213
|
+
const { mount, controller } = makeController({
|
|
214
|
+
welcome: { dismiss: "on-first-message" },
|
|
215
|
+
});
|
|
216
|
+
const host = welcomeHost(mount);
|
|
217
|
+
sendUserMessage(controller);
|
|
218
|
+
expect(waapi.animate).toHaveBeenCalled();
|
|
219
|
+
expect(waapi.animate.mock.calls[0]![1]?.fill).toBe("forwards");
|
|
220
|
+
expect(isVisible(host)).toBe(true);
|
|
221
|
+
waapi.finish();
|
|
222
|
+
await waapi.finished;
|
|
223
|
+
await Promise.resolve();
|
|
224
|
+
expect(isVisible(host)).toBe(false);
|
|
225
|
+
} finally {
|
|
226
|
+
waapi.restore();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("keeps the hero visible across later renders while the dismiss runs", async () => {
|
|
231
|
+
// The assistant placeholder and every streaming chunk re-render within
|
|
232
|
+
// milliseconds of the send; none of them may truncate the animation.
|
|
233
|
+
const waapi = stubAnimate();
|
|
234
|
+
try {
|
|
235
|
+
const { mount, controller } = makeController({
|
|
236
|
+
welcome: { variant: "hero" },
|
|
237
|
+
});
|
|
238
|
+
const host = welcomeHost(mount);
|
|
239
|
+
sendUserMessage(controller);
|
|
240
|
+
expect(isVisible(host)).toBe(true);
|
|
241
|
+
|
|
242
|
+
controller.injectTestMessage({
|
|
243
|
+
type: "message",
|
|
244
|
+
message: assistantMessage(),
|
|
245
|
+
});
|
|
246
|
+
expect(isVisible(host)).toBe(true);
|
|
247
|
+
controller.injectTestMessage({
|
|
248
|
+
type: "message",
|
|
249
|
+
message: assistantMessage("a2"),
|
|
250
|
+
});
|
|
251
|
+
expect(isVisible(host)).toBe(true);
|
|
252
|
+
expect(waapi.animate).toHaveBeenCalledTimes(1);
|
|
253
|
+
|
|
254
|
+
waapi.finish();
|
|
255
|
+
await waapi.finished;
|
|
256
|
+
await Promise.resolve();
|
|
257
|
+
expect(isVisible(host)).toBe(false);
|
|
258
|
+
} finally {
|
|
259
|
+
waapi.restore();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("cancels an in-flight dismiss when clearChat re-shows the hero", async () => {
|
|
264
|
+
const waapi = stubAnimate();
|
|
265
|
+
try {
|
|
266
|
+
const { mount, controller } = makeController({
|
|
267
|
+
welcome: { variant: "hero" },
|
|
268
|
+
});
|
|
269
|
+
const host = welcomeHost(mount);
|
|
270
|
+
sendUserMessage(controller);
|
|
271
|
+
controller.clearChat();
|
|
272
|
+
expect(waapi.cancel).toHaveBeenCalled();
|
|
273
|
+
expect(isVisible(host)).toBe(true);
|
|
274
|
+
|
|
275
|
+
waapi.finish();
|
|
276
|
+
await waapi.finished;
|
|
277
|
+
await Promise.resolve();
|
|
278
|
+
// The settled animation must not hide a host that was re-shown.
|
|
279
|
+
expect(isVisible(host)).toBe(true);
|
|
280
|
+
} finally {
|
|
281
|
+
waapi.restore();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("hides the hero without animating when hydration restores user messages", async () => {
|
|
286
|
+
const waapi = stubAnimate();
|
|
287
|
+
try {
|
|
288
|
+
const { mount } = makeController({
|
|
289
|
+
welcome: { variant: "hero" },
|
|
290
|
+
persistState: true,
|
|
291
|
+
storageAdapter: {
|
|
292
|
+
load: () =>
|
|
293
|
+
Promise.resolve({
|
|
294
|
+
messages: [userMessage("restored")],
|
|
295
|
+
metadata: {},
|
|
296
|
+
}),
|
|
297
|
+
save: () => {},
|
|
298
|
+
clear: () => {},
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
const host = welcomeHost(mount);
|
|
302
|
+
expect(isVisible(host)).toBe(true);
|
|
303
|
+
await Promise.resolve();
|
|
304
|
+
await Promise.resolve();
|
|
305
|
+
await Promise.resolve();
|
|
306
|
+
expect(isVisible(host)).toBe(false);
|
|
307
|
+
expect(waapi.animate).not.toHaveBeenCalled();
|
|
308
|
+
} finally {
|
|
309
|
+
waapi.restore();
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("re-derives visibility when update() changes the variant", () => {
|
|
314
|
+
const { mount, controller } = makeController();
|
|
315
|
+
const host = welcomeHost(mount);
|
|
316
|
+
sendUserMessage(controller);
|
|
317
|
+
controller.update({ welcome: { variant: "hero" } });
|
|
318
|
+
// Same host element: the welcome host is permanent, content swaps in place.
|
|
319
|
+
expect(welcomeHost(mount)).toBe(host);
|
|
320
|
+
expect(isVisible(host)).toBe(false);
|
|
321
|
+
controller.clearChat();
|
|
322
|
+
expect(isVisible(host)).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("welcome greeting bubble", () => {
|
|
327
|
+
it("renders an assistant-styled bubble above the transcript", () => {
|
|
328
|
+
const { mount } = makeController({ welcome: { message: "Hi, I'm Ada." } });
|
|
329
|
+
const greeting = greetingHost(mount);
|
|
330
|
+
expect(isVisible(greeting)).toBe(true);
|
|
331
|
+
expect(greeting.textContent).toBe("Hi, I'm Ada.");
|
|
332
|
+
const bubble = greeting.querySelector<HTMLElement>(
|
|
333
|
+
"[data-persona-theme-zone='assistant-message']"
|
|
334
|
+
);
|
|
335
|
+
expect(bubble).toBeTruthy();
|
|
336
|
+
expect(bubble!.hasAttribute("data-message-id")).toBe(false);
|
|
337
|
+
|
|
338
|
+
const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
339
|
+
const children = Array.from(body.children);
|
|
340
|
+
const transcript = body.querySelector<HTMLElement>(
|
|
341
|
+
":scope > .persona-gap-3"
|
|
342
|
+
)!;
|
|
343
|
+
expect(children.indexOf(greeting)).toBeLessThan(
|
|
344
|
+
children.indexOf(transcript)
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("never enters session state and survives clearChat", () => {
|
|
349
|
+
const { mount, controller } = makeController({
|
|
350
|
+
welcome: { message: "Hi, I'm Ada." },
|
|
351
|
+
});
|
|
352
|
+
expect(controller.getMessages()).toHaveLength(0);
|
|
353
|
+
sendUserMessage(controller);
|
|
354
|
+
expect(
|
|
355
|
+
controller.getMessages().some((message) => message.content.includes("Ada"))
|
|
356
|
+
).toBe(false);
|
|
357
|
+
expect(isVisible(greetingHost(mount))).toBe(true);
|
|
358
|
+
controller.clearChat();
|
|
359
|
+
expect(controller.getMessages()).toHaveLength(0);
|
|
360
|
+
expect(isVisible(greetingHost(mount))).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("is suppressed entirely under the hero variant", () => {
|
|
364
|
+
const { mount } = makeController({
|
|
365
|
+
welcome: { variant: "hero", message: "Hi, I'm Ada." },
|
|
366
|
+
});
|
|
367
|
+
expect(isVisible(greetingHost(mount))).toBe(false);
|
|
368
|
+
expect(greetingHost(mount).textContent).toBe("");
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("welcome icon", () => {
|
|
373
|
+
it("renders a lucide icon", () => {
|
|
374
|
+
const { mount } = makeController({
|
|
375
|
+
welcome: { icon: { type: "lucide", name: "sparkles" } },
|
|
376
|
+
});
|
|
377
|
+
const holder = mount.querySelector<HTMLElement>(
|
|
378
|
+
"[data-persona-welcome-icon]"
|
|
379
|
+
)!;
|
|
380
|
+
expect(holder.hidden).toBe(false);
|
|
381
|
+
expect(holder.querySelector("svg")).toBeTruthy();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("renders an image with its required alt text", () => {
|
|
385
|
+
const { mount } = makeController({
|
|
386
|
+
welcome: { icon: { type: "image", url: "/logo.png", alt: "Acme" } },
|
|
387
|
+
});
|
|
388
|
+
const image = mount.querySelector<HTMLImageElement>(
|
|
389
|
+
"[data-persona-welcome-icon] img"
|
|
390
|
+
)!;
|
|
391
|
+
expect(image.getAttribute("src")).toBe("/logo.png");
|
|
392
|
+
expect(image.getAttribute("alt")).toBe("Acme");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("renders a text glyph", () => {
|
|
396
|
+
const { mount } = makeController({
|
|
397
|
+
welcome: { icon: { type: "text", text: "🎈" } },
|
|
398
|
+
});
|
|
399
|
+
expect(
|
|
400
|
+
mount.querySelector("[data-persona-welcome-icon]")!.textContent
|
|
401
|
+
).toBe("🎈");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("renders a function-provided element", () => {
|
|
405
|
+
const { mount } = makeController({
|
|
406
|
+
welcome: {
|
|
407
|
+
icon: () => {
|
|
408
|
+
const custom = document.createElement("span");
|
|
409
|
+
custom.id = "custom-welcome-icon";
|
|
410
|
+
return custom;
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
expect(mount.querySelector("#custom-welcome-icon")).toBeTruthy();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("hides the holder when no icon is configured", () => {
|
|
418
|
+
const { mount } = makeController();
|
|
419
|
+
expect(
|
|
420
|
+
mount.querySelector<HTMLElement>("[data-persona-welcome-icon]")!.hidden
|
|
421
|
+
).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("welcome live updates", () => {
|
|
426
|
+
it("updates the title", () => {
|
|
427
|
+
const { mount, controller } = makeController();
|
|
428
|
+
controller.update({ welcome: { title: "Updated title" } });
|
|
429
|
+
expect(welcomeHost(mount).querySelector("h2")?.textContent).toBe(
|
|
430
|
+
"Updated title"
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("updates the subtitle", () => {
|
|
435
|
+
const { mount, controller } = makeController();
|
|
436
|
+
controller.update({ welcome: { subtitle: "Updated subtitle" } });
|
|
437
|
+
expect(welcomeHost(mount).querySelector("p")?.textContent).toBe(
|
|
438
|
+
"Updated subtitle"
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("updates the icon", () => {
|
|
443
|
+
const { mount, controller } = makeController();
|
|
444
|
+
controller.update({
|
|
445
|
+
welcome: { icon: { type: "text", text: "★" } },
|
|
446
|
+
});
|
|
447
|
+
const holder = mount.querySelector<HTMLElement>(
|
|
448
|
+
"[data-persona-welcome-icon]"
|
|
449
|
+
)!;
|
|
450
|
+
expect(holder.textContent).toBe("★");
|
|
451
|
+
controller.update({ welcome: { icon: undefined } });
|
|
452
|
+
expect(holder.hidden).toBe(true);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("updates the variant", () => {
|
|
456
|
+
const { mount, controller } = makeController();
|
|
457
|
+
controller.update({ welcome: { variant: "hero" } });
|
|
458
|
+
expect(
|
|
459
|
+
welcomeHost(mount).getAttribute("data-persona-welcome-variant")
|
|
460
|
+
).toBe("hero");
|
|
461
|
+
controller.update({ welcome: { variant: "none" } });
|
|
462
|
+
expect(isVisible(welcomeHost(mount))).toBe(false);
|
|
463
|
+
controller.update({ welcome: { variant: undefined } });
|
|
464
|
+
expect(
|
|
465
|
+
welcomeHost(mount).getAttribute("data-persona-welcome-variant")
|
|
466
|
+
).toBe("card");
|
|
467
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("updates the dismiss rule against the current transcript", () => {
|
|
471
|
+
const { mount, controller } = makeController();
|
|
472
|
+
sendUserMessage(controller);
|
|
473
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
474
|
+
controller.update({ welcome: { dismiss: "on-first-message" } });
|
|
475
|
+
expect(isVisible(welcomeHost(mount))).toBe(false);
|
|
476
|
+
controller.update({ welcome: { dismiss: "never" } });
|
|
477
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("updates the greeting message in place", () => {
|
|
481
|
+
const { mount, controller } = makeController({
|
|
482
|
+
welcome: { message: "First greeting" },
|
|
483
|
+
});
|
|
484
|
+
const greeting = greetingHost(mount);
|
|
485
|
+
controller.update({ welcome: { message: "Second greeting" } });
|
|
486
|
+
expect(greetingHost(mount)).toBe(greeting);
|
|
487
|
+
expect(greeting.textContent).toBe("Second greeting");
|
|
488
|
+
controller.update({ welcome: { message: undefined } });
|
|
489
|
+
expect(isVisible(greeting)).toBe(false);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("still honors the legacy copy aliases through update()", () => {
|
|
493
|
+
const { mount, controller } = makeController();
|
|
494
|
+
controller.update({
|
|
495
|
+
copy: { welcomeTitle: "Legacy title", showWelcomeCard: false },
|
|
496
|
+
});
|
|
497
|
+
expect(welcomeHost(mount).querySelector("h2")?.textContent).toBe(
|
|
498
|
+
"Legacy title"
|
|
499
|
+
);
|
|
500
|
+
expect(isVisible(welcomeHost(mount))).toBe(false);
|
|
501
|
+
controller.update({ copy: { showWelcomeCard: undefined } });
|
|
502
|
+
expect(isVisible(welcomeHost(mount))).toBe(true);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -288,6 +288,33 @@ describe("applyArtifactLayoutCssVars", () => {
|
|
|
288
288
|
expect(mount.style.getPropertyValue("--persona-artifact-pane-padding").trim()).toBe("24px");
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
it("sets the drawer width var only when drawerWidth is configured", () => {
|
|
292
|
+
const configured = createFakeMount();
|
|
293
|
+
applyArtifactLayoutCssVars(
|
|
294
|
+
configured,
|
|
295
|
+
baseConfig({
|
|
296
|
+
features: {
|
|
297
|
+
artifacts: { enabled: true, layout: { drawerWidth: "100%" } },
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
);
|
|
301
|
+
expect(
|
|
302
|
+
configured.style.getPropertyValue("--persona-artifact-drawer-width").trim()
|
|
303
|
+
).toBe("100%");
|
|
304
|
+
|
|
305
|
+
// Unset must remove the var: the drawer CSS owns the default, and a
|
|
306
|
+
// lingering var would also resize hosts that only sized the split.
|
|
307
|
+
const unset = createFakeMount();
|
|
308
|
+
unset.style.setProperty("--persona-artifact-drawer-width", "100%");
|
|
309
|
+
applyArtifactLayoutCssVars(
|
|
310
|
+
unset,
|
|
311
|
+
baseConfig({ features: { artifacts: { enabled: true } } })
|
|
312
|
+
);
|
|
313
|
+
expect(
|
|
314
|
+
unset.style.getPropertyValue("--persona-artifact-drawer-width").trim()
|
|
315
|
+
).toBe("");
|
|
316
|
+
});
|
|
317
|
+
|
|
291
318
|
it("defaults split gap to panel inset when detached, explicit splitGap wins", () => {
|
|
292
319
|
const detached = createFakeMount();
|
|
293
320
|
applyArtifactLayoutCssVars(
|
|
@@ -65,6 +65,7 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
|
|
|
65
65
|
mount.style.removeProperty("--persona-artifact-pane-width");
|
|
66
66
|
mount.style.removeProperty("--persona-artifact-pane-max-width");
|
|
67
67
|
mount.style.removeProperty("--persona-artifact-pane-min-width");
|
|
68
|
+
mount.style.removeProperty("--persona-artifact-drawer-width");
|
|
68
69
|
mount.style.removeProperty("--persona-artifact-pane-bg");
|
|
69
70
|
mount.style.removeProperty("--persona-artifact-pane-padding");
|
|
70
71
|
clearDocumentToolbarLayoutVars(mount);
|
|
@@ -80,6 +81,14 @@ export function applyArtifactLayoutCssVars(mount: HTMLElement, config: AgentWidg
|
|
|
80
81
|
mount.style.setProperty("--persona-artifact-split-gap", l?.splitGap ?? gapDefault);
|
|
81
82
|
mount.style.setProperty("--persona-artifact-pane-width", l?.paneWidth ?? "40%");
|
|
82
83
|
mount.style.setProperty("--persona-artifact-pane-max-width", l?.paneMaxWidth ?? "28rem");
|
|
84
|
+
// Set only when configured: the drawer CSS carries its own default and the
|
|
85
|
+
// var must not exist otherwise, or it would also widen hosts that only
|
|
86
|
+
// sized the desktop split.
|
|
87
|
+
if (l?.drawerWidth) {
|
|
88
|
+
mount.style.setProperty("--persona-artifact-drawer-width", l.drawerWidth);
|
|
89
|
+
} else {
|
|
90
|
+
mount.style.removeProperty("--persona-artifact-drawer-width");
|
|
91
|
+
}
|
|
83
92
|
if (l?.paneMinWidth) {
|
|
84
93
|
mount.style.setProperty("--persona-artifact-pane-min-width", l.paneMinWidth);
|
|
85
94
|
} else {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { AttachmentManager } from "./attachment-manager";
|
|
6
|
+
|
|
7
|
+
describe("AttachmentManager.remountPreviews", () => {
|
|
8
|
+
it("repaints pending previews into a new container (composer rebuild)", async () => {
|
|
9
|
+
const manager = new AttachmentManager({ allowedTypes: ["text/plain"] });
|
|
10
|
+
const first = document.createElement("div");
|
|
11
|
+
manager.setPreviewsContainer(first);
|
|
12
|
+
|
|
13
|
+
await manager.handleFiles([
|
|
14
|
+
new File(["notes"], "notes.txt", { type: "text/plain" }),
|
|
15
|
+
]);
|
|
16
|
+
expect(first.children.length).toBe(1);
|
|
17
|
+
|
|
18
|
+
const second = document.createElement("div");
|
|
19
|
+
manager.remountPreviews(second);
|
|
20
|
+
|
|
21
|
+
expect(second.children.length).toBe(1);
|
|
22
|
+
expect(second.querySelector("[data-attachment-id]")).not.toBeNull();
|
|
23
|
+
expect(second.style.display).not.toBe("none");
|
|
24
|
+
expect(manager.count()).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("hides an empty container and keeps working with no container", () => {
|
|
28
|
+
const manager = new AttachmentManager();
|
|
29
|
+
const container = document.createElement("div");
|
|
30
|
+
|
|
31
|
+
manager.remountPreviews(container);
|
|
32
|
+
expect(container.children.length).toBe(0);
|
|
33
|
+
expect(container.style.display).toBe("none");
|
|
34
|
+
|
|
35
|
+
expect(() => manager.remountPreviews(null)).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -98,6 +98,21 @@ export class AttachmentManager {
|
|
|
98
98
|
this.previewsContainer = container;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Re-point previews at a new container and repaint from state. A composer
|
|
103
|
+
* rebuild swaps the footer, so pending attachments must follow it.
|
|
104
|
+
*/
|
|
105
|
+
remountPreviews(container: HTMLElement | null): void {
|
|
106
|
+
this.previewsContainer = container;
|
|
107
|
+
if (container) {
|
|
108
|
+
container.innerHTML = "";
|
|
109
|
+
for (const attachment of this.attachments) {
|
|
110
|
+
this.renderPreview(attachment);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.updatePreviewsVisibility();
|
|
114
|
+
}
|
|
115
|
+
|
|
101
116
|
/**
|
|
102
117
|
* Update the configuration (e.g., when allowed types change)
|
|
103
118
|
*/
|
|
@@ -160,6 +160,29 @@ describe("auto-follow utilities", () => {
|
|
|
160
160
|
contentHeight: 2000
|
|
161
161
|
}).spacerHeight
|
|
162
162
|
).toBe(0);
|
|
163
|
+
|
|
164
|
+
// Slack pads the reserve so the held position never sits exactly at
|
|
165
|
+
// maxScrollTop, where a transient content dip would clamp and bounce it.
|
|
166
|
+
expect(
|
|
167
|
+
computeAnchorScrollState({
|
|
168
|
+
anchorOffsetTop: 700,
|
|
169
|
+
topOffset: 16,
|
|
170
|
+
viewportHeight: 400,
|
|
171
|
+
contentHeight: 1000,
|
|
172
|
+
slack: 24
|
|
173
|
+
}).spacerHeight
|
|
174
|
+
).toBe(108);
|
|
175
|
+
|
|
176
|
+
// Plenty of natural room already: slack never forces a spacer.
|
|
177
|
+
expect(
|
|
178
|
+
computeAnchorScrollState({
|
|
179
|
+
anchorOffsetTop: 500,
|
|
180
|
+
topOffset: 16,
|
|
181
|
+
viewportHeight: 400,
|
|
182
|
+
contentHeight: 2000,
|
|
183
|
+
slack: 24
|
|
184
|
+
}).spacerHeight
|
|
185
|
+
).toBe(0);
|
|
163
186
|
});
|
|
164
187
|
|
|
165
188
|
it("shrinks the anchor spacer as content grows, never below zero or above initial", () => {
|
package/src/utils/auto-follow.ts
CHANGED
|
@@ -177,6 +177,13 @@ export type AnchorScrollInput = {
|
|
|
177
177
|
viewportHeight: number;
|
|
178
178
|
/** scrollHeight of the scroll container, excluding any anchor spacer. */
|
|
179
179
|
contentHeight: number;
|
|
180
|
+
/**
|
|
181
|
+
* Minimum scrollable room kept past the held position. Without it the
|
|
182
|
+
* anchor sits precisely at maxScrollTop, so any transient content shrink
|
|
183
|
+
* during streaming (a typing-indicator swap, a collapsing skeleton) clamps
|
|
184
|
+
* scrollTop and the viewport visibly bounces by that amount.
|
|
185
|
+
*/
|
|
186
|
+
slack?: number;
|
|
180
187
|
};
|
|
181
188
|
|
|
182
189
|
/**
|
|
@@ -192,7 +199,10 @@ export function computeAnchorScrollState(input: AnchorScrollInput): {
|
|
|
192
199
|
const targetScrollTop = Math.max(0, input.anchorOffsetTop - input.topOffset);
|
|
193
200
|
const spacerHeight = Math.max(
|
|
194
201
|
0,
|
|
195
|
-
targetScrollTop +
|
|
202
|
+
targetScrollTop +
|
|
203
|
+
input.viewportHeight -
|
|
204
|
+
input.contentHeight +
|
|
205
|
+
(input.slack ?? 0)
|
|
196
206
|
);
|
|
197
207
|
return { targetScrollTop, spacerHeight };
|
|
198
208
|
}
|
|
@@ -25,6 +25,8 @@ const REPLACE_LEAF_PATHS = new Set<string>([
|
|
|
25
25
|
"features.artifacts.display",
|
|
26
26
|
"features.streamAnimation.plugins",
|
|
27
27
|
"preferences",
|
|
28
|
+
// Discriminated union: recursive merge would splice two icon variants.
|
|
29
|
+
"welcome.icon",
|
|
28
30
|
]);
|
|
29
31
|
|
|
30
32
|
// Recursive patch merge. A key merges recursively iff both previous and patch
|