@runtypelabs/persona 4.14.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.
Files changed (119) hide show
  1. package/README.md +4 -4
  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-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +520 -21
  14. package/dist/context-mentions.d.ts +520 -21
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +69 -68
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +595 -46
  19. package/dist/index.d.ts +595 -46
  20. package/dist/index.global.js +60 -57
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +69 -68
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +520 -21
  27. package/dist/smart-dom-reader.d.ts +520 -21
  28. package/dist/testing.cjs +3 -3
  29. package/dist/testing.d.cts +7 -0
  30. package/dist/testing.d.ts +7 -0
  31. package/dist/testing.js +3 -3
  32. package/dist/theme-editor-preview.cjs +67 -67
  33. package/dist/theme-editor-preview.d.cts +534 -22
  34. package/dist/theme-editor-preview.d.ts +534 -22
  35. package/dist/theme-editor-preview.js +57 -57
  36. package/dist/theme-editor.cjs +10 -10
  37. package/dist/theme-editor.d.cts +521 -23
  38. package/dist/theme-editor.d.ts +521 -23
  39. package/dist/theme-editor.js +10 -10
  40. package/dist/theme-reference.cjs +1 -1
  41. package/dist/theme-reference.d.cts +104 -3
  42. package/dist/theme-reference.d.ts +104 -3
  43. package/dist/theme-reference.js +1 -1
  44. package/dist/widget.css +1 -1
  45. package/package.json +1 -1
  46. package/src/ask-user-question-tool.ts +10 -7
  47. package/src/components/artifact-pane.test.ts +27 -0
  48. package/src/components/artifact-pane.ts +7 -3
  49. package/src/components/artifact-preview.ts +1 -1
  50. package/src/components/composer-parts.test.ts +103 -4
  51. package/src/components/composer-parts.ts +53 -15
  52. package/src/components/header-builder.ts +27 -2
  53. package/src/components/header-layouts.test.ts +56 -0
  54. package/src/components/header-layouts.ts +29 -9
  55. package/src/components/launcher-surface.test.ts +403 -0
  56. package/src/components/launcher.ts +297 -2
  57. package/src/components/messages.ts +5 -2
  58. package/src/components/panel.ts +92 -71
  59. package/src/components/pill-composer-builder.test.ts +18 -0
  60. package/src/components/suggestions.test.ts +273 -0
  61. package/src/components/suggestions.ts +528 -86
  62. package/src/components/welcome.test.ts +121 -0
  63. package/src/components/welcome.ts +305 -0
  64. package/src/components/widget-view.test.ts +5 -3
  65. package/src/components/widget-view.ts +18 -7
  66. package/src/defaults.ts +7 -5
  67. package/src/generated/runtype-openapi-contract.ts +17 -0
  68. package/src/index-core.ts +29 -2
  69. package/src/launcher-global.ts +11 -8
  70. package/src/plugins/types.ts +136 -3
  71. package/src/presets.ts +3 -3
  72. package/src/session.ts +10 -8
  73. package/src/styles/widget-styles.test.ts +382 -0
  74. package/src/styles/widget.css +778 -4
  75. package/src/suggest-replies-tool.test.ts +233 -2
  76. package/src/suggest-replies-tool.ts +115 -39
  77. package/src/testing/mock-stream.test.ts +19 -0
  78. package/src/testing/mock-stream.ts +20 -0
  79. package/src/theme-editor/preview-utils.test.ts +34 -0
  80. package/src/theme-editor/preview-utils.ts +77 -2
  81. package/src/theme-editor/sections.test.ts +33 -0
  82. package/src/theme-editor/sections.ts +83 -6
  83. package/src/theme-reference.ts +34 -5
  84. package/src/types/theme.ts +72 -3
  85. package/src/types.ts +312 -18
  86. package/src/ui.body-top-slot.test.ts +95 -0
  87. package/src/ui.content-width.test.ts +91 -0
  88. package/src/ui.detached-panel.test.ts +1 -1
  89. package/src/ui.docked.test.ts +7 -4
  90. package/src/ui.launcher-teaser.test.ts +162 -0
  91. package/src/ui.rebuild-composer.test.ts +491 -0
  92. package/src/ui.render-welcome.test.ts +505 -0
  93. package/src/ui.scroll-additive.test.ts +91 -21
  94. package/src/ui.scroll.test.ts +302 -14
  95. package/src/ui.suggest-replies.test.ts +1112 -1
  96. package/src/ui.ts +1008 -149
  97. package/src/ui.welcome.test.ts +504 -0
  98. package/src/utils/artifact-file.test.ts +1 -1
  99. package/src/utils/artifact-gate.test.ts +27 -0
  100. package/src/utils/artifact-gate.ts +9 -0
  101. package/src/utils/attachment-manager.test.ts +37 -0
  102. package/src/utils/attachment-manager.ts +15 -0
  103. package/src/utils/auto-follow.test.ts +23 -0
  104. package/src/utils/auto-follow.ts +11 -1
  105. package/src/utils/code-generators.test.ts +40 -0
  106. package/src/utils/code-generators.ts +22 -0
  107. package/src/utils/config-merge.test.ts +24 -0
  108. package/src/utils/config-merge.ts +2 -0
  109. package/src/utils/content-width.test.ts +37 -0
  110. package/src/utils/content-width.ts +17 -0
  111. package/src/utils/icons.test.ts +26 -0
  112. package/src/utils/icons.ts +8 -0
  113. package/src/utils/plugin-storage.test.ts +94 -0
  114. package/src/utils/plugin-storage.ts +98 -0
  115. package/src/utils/theme.test.ts +216 -0
  116. package/src/utils/tokens.ts +113 -3
  117. package/src/welcome.test.ts +213 -0
  118. package/src/welcome.ts +130 -0
  119. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -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
+ });
@@ -10,7 +10,7 @@ import {
10
10
  // Canonical cross-repo fixture. The zero-width space is written as a
11
11
  // backslash-u200b escape sequence (not a literal invisible char) so the bytes
12
12
  // are unambiguous in source.
13
- // Mirrors core/packages/shared/src/file-artifact.ts escapeFenceTerminators fixture — keep byte-identical.
13
+ // Mirrors core/packages/shared/src/file-artifact.ts escapeFenceTerminators fixture — keep in sync.
14
14
  const ZWSP = "\u200b";
15
15
  const FIXTURE_RAW = "# Title\n\n```js\nconsole.log('hi')\n```\n";
16
16
  const FIXTURE_ESCAPED =
@@ -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", () => {
@@ -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 + input.viewportHeight - input.contentHeight
202
+ targetScrollTop +
203
+ input.viewportHeight -
204
+ input.contentHeight +
205
+ (input.slack ?? 0)
196
206
  );
197
207
  return { targetScrollTop, spacerHeight };
198
208
  }