@runtypelabs/persona 4.17.0 → 4.18.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 (165) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-BpWGm7_6.d.cts → types-DsmTD8TO.d.cts} +12 -0
  4. package/dist/animations/{types-BpWGm7_6.d.ts → types-DsmTD8TO.d.ts} +12 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/chunk-3I2S3I5P.js +1 -0
  8. package/dist/chunk-76IELU7F.js +1 -0
  9. package/dist/chunk-EFJBUH5F.js +1 -0
  10. package/dist/chunk-PDPVORUF.js +1 -0
  11. package/dist/chunk-Q735PSZL.js +1 -0
  12. package/dist/chunk-VK6JOIYJ.js +1 -0
  13. package/dist/codegen.cjs +4 -4
  14. package/dist/codegen.js +4 -4
  15. package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
  16. package/dist/context-mentions.cjs +9 -8
  17. package/dist/context-mentions.d.cts +1224 -14
  18. package/dist/context-mentions.d.ts +1224 -14
  19. package/dist/context-mentions.js +9 -8
  20. package/dist/event-stream-view-4EZ7BGFK.js +1 -0
  21. package/dist/event-stream-view.cjs +129 -0
  22. package/dist/event-stream-view.d.cts +8384 -0
  23. package/dist/event-stream-view.d.ts +8384 -0
  24. package/dist/event-stream-view.js +129 -0
  25. package/dist/history-view-BD6OXLNA.js +898 -0
  26. package/dist/history-view.cjs +898 -0
  27. package/dist/history-view.d.cts +1461 -0
  28. package/dist/history-view.d.ts +1461 -0
  29. package/dist/history-view.js +898 -0
  30. package/dist/index.cjs +77 -77
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +2040 -21
  33. package/dist/index.d.ts +2040 -21
  34. package/dist/index.global.js +67 -66
  35. package/dist/index.global.js.map +1 -1
  36. package/dist/index.js +77 -77
  37. package/dist/index.js.map +1 -1
  38. package/dist/launcher.global.js +5 -4
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/plugin-kit.cjs +1 -1
  41. package/dist/plugin-kit.d.cts +0 -8
  42. package/dist/plugin-kit.d.ts +0 -8
  43. package/dist/plugin-kit.js +1 -1
  44. package/dist/smart-dom-reader.d.cts +1224 -14
  45. package/dist/smart-dom-reader.d.ts +1224 -14
  46. package/dist/theme-editor-preview.cjs +970 -73
  47. package/dist/theme-editor-preview.d.cts +1343 -15
  48. package/dist/theme-editor-preview.d.ts +1343 -15
  49. package/dist/theme-editor-preview.js +81 -81
  50. package/dist/theme-editor.cjs +7 -7
  51. package/dist/theme-editor.d.cts +1227 -15
  52. package/dist/theme-editor.d.ts +1227 -15
  53. package/dist/theme-editor.js +9 -9
  54. package/dist/theme-reference.cjs +1 -1
  55. package/dist/theme-reference.d.cts +226 -7
  56. package/dist/theme-reference.d.ts +226 -7
  57. package/dist/theme-reference.js +1 -1
  58. package/dist/webmcp-polyfill.js +2 -2
  59. package/dist/widget.css +1 -1
  60. package/package.json +18 -6
  61. package/src/client-history-rest.test.ts +1034 -0
  62. package/src/client-visitor-history.test.ts +596 -0
  63. package/src/client.ts +1040 -24
  64. package/src/components/artifact-inline.ts +8 -1
  65. package/src/components/artifact-pane.test.ts +278 -1
  66. package/src/components/artifact-pane.ts +135 -67
  67. package/src/components/artifact-preview.test.ts +14 -1
  68. package/src/components/artifact-preview.ts +9 -0
  69. package/src/components/header-builder.ts +3 -0
  70. package/src/components/header-layouts.test.ts +171 -7
  71. package/src/components/header-layouts.ts +83 -53
  72. package/src/components/header-parts.test.ts +154 -1
  73. package/src/components/header-parts.ts +266 -138
  74. package/src/components/history-confirm-dialog.ts +177 -0
  75. package/src/components/history-shell-copy.ts +99 -0
  76. package/src/components/history-view/announcer.ts +40 -0
  77. package/src/components/history-view/copy.ts +105 -0
  78. package/src/components/history-view/css.ts +1078 -0
  79. package/src/components/history-view/grouping.test.ts +142 -0
  80. package/src/components/history-view/grouping.ts +142 -0
  81. package/src/components/history-view/icons.ts +73 -0
  82. package/src/components/history-view/rows.ts +341 -0
  83. package/src/components/history-view/state.ts +309 -0
  84. package/src/components/history-view.test.ts +2660 -0
  85. package/src/components/history-view.ts +1891 -0
  86. package/src/components/launcher-surface.test.ts +133 -0
  87. package/src/components/launcher.test.ts +18 -0
  88. package/src/components/launcher.ts +7 -7
  89. package/src/components/panel.ts +17 -7
  90. package/src/components/welcome.ts +72 -2
  91. package/src/defaults.ts +36 -3
  92. package/src/event-stream-view-bundle.test.ts +69 -0
  93. package/src/event-stream-view-entry.ts +19 -0
  94. package/src/event-stream-view-loader.ts +31 -0
  95. package/src/event-stream-view.ts +19 -0
  96. package/src/generated/runtype-openapi-contract.ts +54 -4
  97. package/src/history-render.ts +325 -0
  98. package/src/history-view-bundle.test.ts +78 -0
  99. package/src/history-view-entry.ts +41 -0
  100. package/src/history-view-loader.test.ts +30 -0
  101. package/src/history-view-loader.ts +32 -0
  102. package/src/history-view.ts +28 -0
  103. package/src/index-core.ts +50 -2
  104. package/src/index-global.ts +55 -0
  105. package/src/internal/demo-history-provider.test.ts +402 -0
  106. package/src/internal/demo-history-provider.ts +689 -0
  107. package/src/internal/history-provider-registry.ts +25 -0
  108. package/src/internal/history-provider.ts +150 -0
  109. package/src/internal/runtype-history-provider.test.ts +313 -0
  110. package/src/internal/runtype-history-provider.ts +240 -0
  111. package/src/plugin-kit.test.ts +16 -0
  112. package/src/plugin-kit.ts +13 -5
  113. package/src/plugins/types.ts +177 -1
  114. package/src/session-history.test.ts +1397 -0
  115. package/src/session.ts +1200 -26
  116. package/src/styles/widget-styles.test.ts +133 -0
  117. package/src/styles/widget.css +227 -8
  118. package/src/theme-editor/preview-utils.test.ts +19 -0
  119. package/src/theme-editor/preview-utils.ts +15 -1
  120. package/src/theme-editor/preview.ts +7 -1
  121. package/src/theme-editor/role-mappings.ts +4 -1
  122. package/src/theme-editor/sections.test.ts +168 -1
  123. package/src/theme-editor/sections.ts +136 -7
  124. package/src/theme-editor/webmcp/summary.ts +16 -2
  125. package/src/theme-reference.ts +20 -6
  126. package/src/types/theme.ts +213 -7
  127. package/src/types.ts +927 -7
  128. package/src/ui.artifact-rail-resize.test.ts +235 -0
  129. package/src/ui.attachments-drop.test.ts +1 -0
  130. package/src/ui.header-update-stability.test.ts +2 -2
  131. package/src/ui.history-chunk.test.ts +72 -0
  132. package/src/ui.history-config-provider.test.ts +318 -0
  133. package/src/ui.history-controller.test.ts +711 -0
  134. package/src/ui.history-lifecycle.test.ts +456 -0
  135. package/src/ui.history-render-hooks.test.ts +904 -0
  136. package/src/ui.history-shell.test.ts +1887 -0
  137. package/src/ui.rail-overlay.test.ts +573 -0
  138. package/src/ui.rail-resize.test.ts +284 -0
  139. package/src/ui.rebuild-composer.test.ts +21 -0
  140. package/src/ui.render-welcome.test.ts +42 -0
  141. package/src/ui.shortcuts.test.ts +166 -0
  142. package/src/ui.ts +3071 -240
  143. package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
  144. package/src/utils/artifact-file.ts +15 -0
  145. package/src/utils/artifact-gate.test.ts +4 -0
  146. package/src/utils/artifact-gate.ts +13 -17
  147. package/src/utils/artifact-resize.test.ts +28 -0
  148. package/src/utils/artifact-resize.ts +9 -2
  149. package/src/utils/buttons.ts +188 -1
  150. package/src/utils/config-merge.test.ts +91 -0
  151. package/src/utils/dropdown.ts +10 -1
  152. package/src/utils/history-messages.test.ts +284 -0
  153. package/src/utils/history-messages.ts +284 -0
  154. package/src/utils/icons.ts +3 -0
  155. package/src/utils/shortcuts.test.ts +288 -0
  156. package/src/utils/shortcuts.ts +221 -0
  157. package/src/utils/theme.test.ts +501 -4
  158. package/src/utils/theme.ts +42 -3
  159. package/src/utils/tokens.ts +153 -15
  160. package/src/utils/tooltip-timing.ts +4 -0
  161. package/src/utils/tooltip.test.ts +249 -7
  162. package/src/utils/tooltip.ts +146 -4
  163. package/src/utils/visitor-store.test.ts +425 -0
  164. package/src/utils/visitor-store.ts +337 -0
  165. package/dist/chunk-QJ4LV2KW.js +0 -1
@@ -0,0 +1,284 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Drag-resize of the docked Messages rail
5
+ * (`features.history.rail.resizable`): where the handle exists, what a drag
6
+ * and the keyboard do to the width, and how the remembered width is resolved.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import { createAgentExperience } from "./ui";
12
+ import { createHistoryView } from "./components/history-view";
13
+ import { setHistoryViewLoader } from "./history-view-loader";
14
+ import { setHistoryProviderFactory } from "./internal/history-provider-registry";
15
+ import {
16
+ createDemoHistoryProvider,
17
+ type DemoHistoryConversationSeed,
18
+ } from "./internal/demo-history-provider";
19
+
20
+ const SEEDS: DemoHistoryConversationSeed[] = [
21
+ {
22
+ id: "conv-a",
23
+ title: "Order status",
24
+ targetId: null,
25
+ messages: [{ id: "a1", role: "user", content: "where is my order" }],
26
+ },
27
+ ];
28
+
29
+ const mounts: HTMLElement[] = [];
30
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
31
+
32
+ const flush = async (times = 12) => {
33
+ for (let i = 0; i < times; i += 1) {
34
+ // eslint-disable-next-line no-await-in-loop
35
+ await Promise.resolve();
36
+ }
37
+ };
38
+
39
+ const setup = (
40
+ options: { rail?: Record<string, unknown>; config?: Record<string, unknown> } = {}
41
+ ) => {
42
+ setHistoryProviderFactory(() => createDemoHistoryProvider({ conversations: SEEDS }));
43
+ const mount = document.createElement("div");
44
+ document.body.appendChild(mount);
45
+ mounts.push(mount);
46
+ const controller = createAgentExperience(mount, {
47
+ apiUrl: "https://api.example.com/chat",
48
+ launcher: { enabled: false },
49
+ persistState: { keyPrefix: "persona-test-" },
50
+ suggestionChips: [],
51
+ features: {
52
+ history: {
53
+ enabled: true,
54
+ presentation: "rail",
55
+ rail: { resizable: true, ...options.rail },
56
+ },
57
+ },
58
+ ...options.config,
59
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
60
+ controllers.push(controller);
61
+ const container = mount.querySelector<HTMLElement>(".persona-widget-container")!;
62
+ Object.defineProperty(container, "clientWidth", { configurable: true, get: () => 900 });
63
+ controller.update({});
64
+ return { mount, controller };
65
+ };
66
+
67
+ const handleOf = (mount: HTMLElement) =>
68
+ mount.querySelector<HTMLElement>(".persona-rail-resizer");
69
+ const hostOf = (mount: HTMLElement) =>
70
+ mount.querySelector<HTMLElement>(".persona-history-rail-host")!;
71
+ const overlayOf = (mount: HTMLElement) =>
72
+ mount.querySelector<HTMLElement>(".persona-history-rail-overlay");
73
+ const historyButton = (mount: HTMLElement) =>
74
+ mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]")!;
75
+ const railToggle = (mount: HTMLElement) =>
76
+ mount.querySelector<HTMLButtonElement>('[data-persona-history-focus="collapse"]')!;
77
+ const trigger = (mount: HTMLElement) =>
78
+ mount.querySelector<HTMLButtonElement>("[data-persona-rail-trigger]")!;
79
+
80
+ /** Opens Messages the way a pointer does. */
81
+ const openRail = async (mount: HTMLElement) => {
82
+ historyButton(mount).dispatchEvent(
83
+ new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })
84
+ );
85
+ await flush();
86
+ };
87
+
88
+ const press = (element: HTMLElement, key: string) =>
89
+ element.dispatchEvent(
90
+ new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
91
+ );
92
+
93
+ /** A whole drag: press on the handle, travel `dx`, release. */
94
+ const drag = (handle: HTMLElement, dx: number, opts?: { hold?: boolean }) => {
95
+ handle.dispatchEvent(
96
+ new MouseEvent("pointerdown", { bubbles: true, cancelable: true, button: 0, clientX: 0 })
97
+ );
98
+ document.dispatchEvent(new MouseEvent("pointermove", { clientX: dx }));
99
+ if (opts?.hold) return;
100
+ document.dispatchEvent(new MouseEvent("pointerup", { clientX: dx }));
101
+ };
102
+
103
+ describe("rail resize handle", () => {
104
+ beforeEach(() => {
105
+ window.scrollTo = vi.fn();
106
+ setHistoryViewLoader(async () => ({ createHistoryView }));
107
+ });
108
+
109
+ afterEach(() => {
110
+ setHistoryProviderFactory(null);
111
+ controllers.splice(0).forEach((controller) => {
112
+ try {
113
+ controller.destroy();
114
+ } catch {
115
+ /* already destroyed */
116
+ }
117
+ });
118
+ mounts.splice(0).forEach((mount) => mount.remove());
119
+ document.body.innerHTML = "";
120
+ window.localStorage.clear();
121
+ vi.restoreAllMocks();
122
+ });
123
+
124
+ it("puts a labelled separator on the edge facing the conversation", async () => {
125
+ const { mount } = setup();
126
+ await openRail(mount);
127
+ const handle = handleOf(mount)!;
128
+ expect(handle).not.toBeNull();
129
+ expect(handle.getAttribute("role")).toBe("separator");
130
+ expect(handle.getAttribute("aria-orientation")).toBe("vertical");
131
+ expect(handle.getAttribute("aria-label")).toBe("Resize conversation list");
132
+ expect(handle.getAttribute("aria-valuemin")).toBe("200");
133
+ expect(handle.getAttribute("aria-valuemax")).toBe("400");
134
+ expect(handle.getAttribute("aria-valuenow")).toBe("260");
135
+ expect(handle.tabIndex).toBe(0);
136
+ // Between the rail and the conversation, on the divider's edge.
137
+ expect(handle.previousElementSibling).toBe(hostOf(mount));
138
+ expect(handle.nextElementSibling!.className).toContain(
139
+ "persona-history-rail-conversation"
140
+ );
141
+ });
142
+
143
+ it("mirrors to the other side of the rail when it docks right", async () => {
144
+ const { mount } = setup({ rail: { side: "right" } });
145
+ await openRail(mount);
146
+ const handle = handleOf(mount)!;
147
+ expect(handle.nextElementSibling).toBe(hostOf(mount));
148
+ });
149
+
150
+ it("stays out of a rail that was never made resizable", async () => {
151
+ const { mount } = setup({ rail: { resizable: false } });
152
+ await openRail(mount);
153
+ expect(handleOf(mount)).toBeNull();
154
+ });
155
+
156
+ it("leaves the collapsed icon column alone and comes back on expand", async () => {
157
+ const { mount } = setup();
158
+ await openRail(mount);
159
+ railToggle(mount).click();
160
+ await flush();
161
+ expect(hostOf(mount).style.flex).toBe("0 0 52px");
162
+ expect(handleOf(mount)).toBeNull();
163
+
164
+ railToggle(mount).click();
165
+ await flush();
166
+ expect(handleOf(mount)).not.toBeNull();
167
+ expect(hostOf(mount).style.flex).toBe("0 0 260px");
168
+ });
169
+
170
+ it("resizes live on drag and commits the width on release", async () => {
171
+ const { mount } = setup();
172
+ await openRail(mount);
173
+ const host = hostOf(mount);
174
+ const handle = handleOf(mount)!;
175
+
176
+ drag(handle, 60, { hold: true });
177
+ expect(host.style.flex).toBe("0 0 320px");
178
+ expect(handle.getAttribute("aria-valuenow")).toBe("320");
179
+ // The collapse transition would trail the pointer for the whole drag.
180
+ expect(host.style.transition).toBe("none");
181
+
182
+ document.dispatchEvent(new MouseEvent("pointerup", { clientX: 60 }));
183
+ expect(host.style.transition).toBe("");
184
+ expect(host.style.flex).toBe("0 0 320px");
185
+ expect(window.localStorage.getItem("persona-test-rail-width")).toBe("320");
186
+ });
187
+
188
+ it("clamps a drag to the rail's own bounds", async () => {
189
+ const { mount } = setup();
190
+ await openRail(mount);
191
+ drag(handleOf(mount)!, 600);
192
+ expect(hostOf(mount).style.flex).toBe("0 0 400px");
193
+ expect(window.localStorage.getItem("persona-test-rail-width")).toBe("400");
194
+
195
+ drag(handleOf(mount)!, -600);
196
+ expect(hostOf(mount).style.flex).toBe("0 0 200px");
197
+ expect(window.localStorage.getItem("persona-test-rail-width")).toBe("200");
198
+ });
199
+
200
+ it("takes arrow steps and jumps to the bounds from the keyboard", async () => {
201
+ const { mount } = setup();
202
+ await openRail(mount);
203
+ const handle = handleOf(mount)!;
204
+
205
+ press(handle, "ArrowRight");
206
+ expect(hostOf(mount).style.flex).toBe("0 0 276px");
207
+ expect(handle.getAttribute("aria-valuenow")).toBe("276");
208
+ press(handle, "ArrowLeft");
209
+ press(handle, "ArrowLeft");
210
+ expect(hostOf(mount).style.flex).toBe("0 0 244px");
211
+
212
+ press(handle, "End");
213
+ expect(hostOf(mount).style.flex).toBe("0 0 400px");
214
+ press(handle, "Home");
215
+ expect(hostOf(mount).style.flex).toBe("0 0 200px");
216
+ expect(handle.getAttribute("aria-valuenow")).toBe("200");
217
+ expect(window.localStorage.getItem("persona-test-rail-width")).toBe("200");
218
+ });
219
+
220
+ it("inverts the arrows for a rail docked on the right", async () => {
221
+ const { mount } = setup({ rail: { side: "right" } });
222
+ await openRail(mount);
223
+ // The right rail's edge grows leftward, so the arrows track the pixels.
224
+ press(handleOf(mount)!, "ArrowLeft");
225
+ expect(hostOf(mount).style.flex).toBe("0 0 276px");
226
+ });
227
+
228
+ it("keeps a chosen width over config and across a reopen", async () => {
229
+ const { mount, controller } = setup({ rail: { width: 300 } });
230
+ await openRail(mount);
231
+ expect(hostOf(mount).style.flex).toBe("0 0 300px");
232
+
233
+ drag(handleOf(mount)!, 40);
234
+ expect(hostOf(mount).style.flex).toBe("0 0 340px");
235
+
236
+ // A live config update must not undo what the visitor chose.
237
+ controller.update({
238
+ features: { history: { rail: { width: 220 } } },
239
+ } as never);
240
+ await flush();
241
+ expect(hostOf(mount).style.flex).toBe("0 0 340px");
242
+
243
+ historyButton(mount).click();
244
+ await flush(20);
245
+ await openRail(mount);
246
+ expect(hostOf(mount).style.flex).toBe("0 0 340px");
247
+ });
248
+
249
+ it("hands the remembered width to the floating rail, which has no handle", async () => {
250
+ window.localStorage.setItem("persona-test-rail-width", "340");
251
+ const { mount } = setup({ rail: { collapsedBehavior: "overlay", defaultCollapsed: true } });
252
+ trigger(mount).dispatchEvent(new MouseEvent("mouseenter"));
253
+ await flush();
254
+ const overlay = overlayOf(mount)!;
255
+ expect(overlay).not.toBeNull();
256
+ expect(overlay.style.width).toBe("340px");
257
+ expect(handleOf(mount)).toBeNull();
258
+
259
+ // Pinned, the same width docks, and the handle comes with it.
260
+ trigger(mount).dispatchEvent(
261
+ new MouseEvent("click", { bubbles: true, cancelable: true, detail: 1 })
262
+ );
263
+ await flush();
264
+ expect(hostOf(mount).style.flex).toBe("0 0 340px");
265
+ expect(handleOf(mount)).not.toBeNull();
266
+ });
267
+
268
+ it("stays in memory when state persistence is off", async () => {
269
+ const setItem = vi.spyOn(Storage.prototype, "setItem");
270
+ const { mount } = setup({ config: { persistState: false } });
271
+ await openRail(mount);
272
+ drag(handleOf(mount)!, 40);
273
+ expect(hostOf(mount).style.flex).toBe("0 0 300px");
274
+ expect(
275
+ setItem.mock.calls.filter(([key]) => String(key).endsWith("rail-width"))
276
+ ).toEqual([]);
277
+
278
+ // The in-memory value still survives a close and reopen.
279
+ historyButton(mount).click();
280
+ await flush(20);
281
+ await openRail(mount);
282
+ expect(hostOf(mount).style.flex).toBe("0 0 300px");
283
+ });
284
+ });
@@ -345,6 +345,27 @@ describe("rebuildComposer (composer ctx requestRender)", () => {
345
345
  expect(mount.style.getPropertyValue("--persona-border")).not.toBe("");
346
346
  });
347
347
 
348
+ it("keeps a plugin footer's own inline styles across a rebuild", () => {
349
+ // applyFullHeightStyles resets footer.style.cssText for the built-in
350
+ // composer's per-mode geometry. A plugin footer is the plugin's DOM: the
351
+ // home screen's hidden placeholder relies on inline `display: none`, and
352
+ // the reset turned it into an empty visible composer band.
353
+ const plugin: AgentWidgetPlugin = {
354
+ id: "gate",
355
+ renderComposer: () => {
356
+ const footer = document.createElement("div");
357
+ footer.className = "persona-widget-footer";
358
+ footer.style.display = "none";
359
+ footer.setAttribute("data-test-gate", "");
360
+ return footer;
361
+ },
362
+ };
363
+ const { mount } = makeController({ plugins: [plugin] });
364
+
365
+ const footer = mount.querySelector<HTMLElement>("[data-test-gate]")!;
366
+ expect(footer.style.display).toBe("none");
367
+ });
368
+
348
369
  it("keeps the floating panel's fixed height across a rebuild", () => {
349
370
  // applyFullHeightStyles wipes panel.style.cssText and only restores width;
350
371
  // the fixed height belongs to recalcPanelHeight. Without a re-stamp in
@@ -1,5 +1,9 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
3
7
  import { afterEach, describe, expect, it, vi } from "vitest";
4
8
 
5
9
  import { createAgentExperience } from "./ui";
@@ -256,6 +260,44 @@ describe("renderWelcome plugin hook", () => {
256
260
  expect(body.scrollTop).toBe(240);
257
261
  });
258
262
 
263
+ it("takes the transcript out of layout while the overlay is mounted", () => {
264
+ // Zeroing the scroll once at mount does not hold the overlay in place: the
265
+ // body keeps its own `overflow-y: auto` (inline in fill/fullscreen
266
+ // layouts, where the overlay state class cannot outrank it), and every
267
+ // later transcript scroll - autoscroll, a repin, a wheel - drags the
268
+ // containing block of an `inset: 0` box out from under the viewport. The
269
+ // plugin stack and the transcript then render as one scrolling column,
270
+ // with the composer still hidden. Out of layout, nothing scrolls under it.
271
+ const style = document.createElement("style");
272
+ style.textContent = readFileSync(
273
+ resolve(dirname(fileURLToPath(import.meta.url)), "styles/widget.css"),
274
+ "utf8"
275
+ );
276
+ document.head.appendChild(style);
277
+ let showHome = true;
278
+ let request: () => void = () => {};
279
+ const plugin: AgentWidgetPlugin = {
280
+ id: "home",
281
+ renderWelcome: (ctx) => {
282
+ request = ctx.requestRender;
283
+ if (!showHome) return null;
284
+ const root = document.createElement("div");
285
+ root.setAttribute("data-test-home", "");
286
+ return root;
287
+ },
288
+ };
289
+ const { mount, controller } = makeController({ plugins: [plugin] });
290
+ controller.injectUserMessage({ content: "hello" });
291
+
292
+ const messages = mount.querySelector<HTMLElement>(".persona-widget-messages")!;
293
+ expect(getComputedStyle(messages).display).toBe("none");
294
+
295
+ showHome = false;
296
+ request();
297
+ expect(getComputedStyle(messages).display).not.toBe("none");
298
+ style.remove();
299
+ });
300
+
259
301
  it("re-enables renderStarter output when a stream ends", async () => {
260
302
  const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
261
303
  const originalFetch = global.fetch;
@@ -0,0 +1,166 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Instance-scoped keyboard shortcuts: the header trailing-action surface and
5
+ * the plugin capability, both bound into the one registry the controller owns.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+
10
+ import { createAgentExperience } from "./ui";
11
+ import { setMacPlatformOverride } from "./utils/shortcuts";
12
+ import type { AgentWidgetPlugin } from "./plugins/types";
13
+
14
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
15
+
16
+ const setup = (config: Record<string, unknown>) => {
17
+ const mount = document.createElement("div");
18
+ document.body.appendChild(mount);
19
+ const controller = createAgentExperience(mount, {
20
+ apiUrl: "https://api.example.com/chat",
21
+ launcher: { enabled: false },
22
+ persistState: false,
23
+ ...config,
24
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
25
+ controllers.push(controller);
26
+ return { mount, controller };
27
+ };
28
+
29
+ const press = (target: EventTarget, key: string) =>
30
+ target.dispatchEvent(
31
+ new KeyboardEvent("keydown", {
32
+ key,
33
+ ctrlKey: true,
34
+ bubbles: true,
35
+ cancelable: true,
36
+ composed: true,
37
+ })
38
+ );
39
+
40
+ describe("widget keyboard shortcuts", () => {
41
+ beforeEach(() => {
42
+ window.scrollTo = vi.fn();
43
+ setMacPlatformOverride(false);
44
+ });
45
+
46
+ afterEach(() => {
47
+ setMacPlatformOverride(null);
48
+ controllers.splice(0).forEach((controller) => {
49
+ try {
50
+ controller.destroy();
51
+ } catch {
52
+ /* already destroyed */
53
+ }
54
+ });
55
+ document.body.innerHTML = "";
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ describe("header trailing actions", () => {
60
+ const withAction = (shortcut?: string) => {
61
+ const onAction = vi.fn();
62
+ const { mount, controller } = setup({
63
+ layout: {
64
+ header: {
65
+ layout: "minimal",
66
+ trailingActions: [
67
+ {
68
+ id: "home",
69
+ icon: "house",
70
+ ariaLabel: "Back to home",
71
+ ...(shortcut ? { shortcut } : {}),
72
+ },
73
+ ],
74
+ onAction,
75
+ },
76
+ },
77
+ });
78
+ return { mount, controller, onAction };
79
+ };
80
+
81
+ it("fires onAction from the declared combo", () => {
82
+ const { mount, onAction } = withAction("mod+shift+h");
83
+ const event = new KeyboardEvent("keydown", {
84
+ key: "h",
85
+ ctrlKey: true,
86
+ shiftKey: true,
87
+ bubbles: true,
88
+ cancelable: true,
89
+ composed: true,
90
+ });
91
+ mount.dispatchEvent(event);
92
+ expect(onAction).toHaveBeenCalledWith("home");
93
+ expect(event.defaultPrevented).toBe(true);
94
+ });
95
+
96
+ it("binds nothing when the action declares no shortcut", () => {
97
+ const { mount, onAction } = withAction();
98
+ press(mount, "h");
99
+ expect(onAction).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it("re-derives the binding from a live update", () => {
103
+ const { mount, controller, onAction } = withAction();
104
+ controller.update({
105
+ layout: {
106
+ header: {
107
+ layout: "minimal",
108
+ trailingActions: [
109
+ {
110
+ id: "home",
111
+ icon: "house",
112
+ ariaLabel: "Back to home",
113
+ shortcut: "mod+j",
114
+ },
115
+ ],
116
+ },
117
+ },
118
+ } as never);
119
+ press(mount, "j");
120
+ expect(onAction).toHaveBeenCalledWith("home");
121
+ });
122
+ });
123
+
124
+ describe("plugin shortcuts", () => {
125
+ const plugin = (run: () => void): AgentWidgetPlugin => ({
126
+ id: "demo",
127
+ shortcuts: [{ id: "open", combo: "mod+j", run }],
128
+ });
129
+
130
+ it("binds on init and drops on destroy", () => {
131
+ const run = vi.fn();
132
+ const { mount, controller } = setup({ plugins: [plugin(run)] });
133
+ press(mount, "j");
134
+ expect(run).toHaveBeenCalledTimes(1);
135
+
136
+ controller.destroy();
137
+ press(mount, "j");
138
+ expect(run).toHaveBeenCalledTimes(1);
139
+ });
140
+
141
+ it("keeps a widget-scoped plugin binding out of the rest of the page", () => {
142
+ const run = vi.fn();
143
+ setup({ plugins: [plugin(run)] });
144
+ const outside = document.createElement("button");
145
+ document.body.appendChild(outside);
146
+ press(outside, "j");
147
+ expect(run).not.toHaveBeenCalled();
148
+ });
149
+
150
+ it("honors an opt-in page scope", () => {
151
+ const run = vi.fn();
152
+ setup({
153
+ plugins: [
154
+ {
155
+ id: "demo",
156
+ shortcuts: [{ id: "open", combo: "mod+j", scope: "page", run }],
157
+ } as AgentWidgetPlugin,
158
+ ],
159
+ });
160
+ const outside = document.createElement("button");
161
+ document.body.appendChild(outside);
162
+ press(outside, "j");
163
+ expect(run).toHaveBeenCalledTimes(1);
164
+ });
165
+ });
166
+ });