@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,573 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Collapsed rail as a floating overlay
5
+ * (`features.history.rail.collapsedBehavior: "overlay"`): the trigger that
6
+ * stands in for the column, hover intent, pinning, and every dismissal.
7
+ *
8
+ * Real timers: the intent and grace delays are the behaviour under test.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+
13
+ import { createAgentExperience } from "./ui";
14
+ import { createHistoryView } from "./components/history-view";
15
+ import { setHistoryViewLoader } from "./history-view-loader";
16
+ import { setHistoryProviderFactory } from "./internal/history-provider-registry";
17
+ import {
18
+ createDemoHistoryProvider,
19
+ type DemoHistoryConversationSeed,
20
+ } from "./internal/demo-history-provider";
21
+ import { setMacPlatformOverride } from "./utils/shortcuts";
22
+
23
+ const SEEDS: DemoHistoryConversationSeed[] = [
24
+ {
25
+ id: "conv-a",
26
+ title: "Order status",
27
+ targetId: null,
28
+ messages: [{ id: "a1", role: "user", content: "where is my order" }],
29
+ },
30
+ {
31
+ id: "conv-b",
32
+ title: "Refund request",
33
+ targetId: null,
34
+ messages: [{ id: "b1", role: "user", content: "i need a refund" }],
35
+ },
36
+ ];
37
+
38
+ const mounts: HTMLElement[] = [];
39
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
40
+
41
+ const flush = async (times = 12) => {
42
+ for (let i = 0; i < times; i += 1) {
43
+ // eslint-disable-next-line no-await-in-loop
44
+ await Promise.resolve();
45
+ }
46
+ };
47
+
48
+ const wait = (ms: number) =>
49
+ new Promise((resolve) => {
50
+ setTimeout(resolve, ms);
51
+ });
52
+
53
+ /** Past the 300ms pointer-out grace. */
54
+ const GRACE = 380;
55
+
56
+ const loadHistoryChunk = vi.fn(async () => ({ createHistoryView }));
57
+
58
+ const setup = (
59
+ options: {
60
+ rail?: Record<string, unknown>;
61
+ config?: Record<string, unknown>;
62
+ width?: number;
63
+ } = {}
64
+ ) => {
65
+ setHistoryProviderFactory(() => createDemoHistoryProvider({ conversations: SEEDS }));
66
+ const mount = document.createElement("div");
67
+ document.body.appendChild(mount);
68
+ mounts.push(mount);
69
+ const controller = createAgentExperience(mount, {
70
+ apiUrl: "https://api.example.com/chat",
71
+ launcher: { enabled: false },
72
+ persistState: false,
73
+ suggestionChips: [],
74
+ features: {
75
+ history: {
76
+ enabled: true,
77
+ presentation: "rail",
78
+ rail: { collapsedBehavior: "overlay", ...options.rail },
79
+ },
80
+ },
81
+ ...options.config,
82
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
83
+ controllers.push(controller);
84
+ const container = mount.querySelector<HTMLElement>(".persona-widget-container")!;
85
+ Object.defineProperty(container, "clientWidth", {
86
+ configurable: true,
87
+ get: () => options.width ?? 900,
88
+ });
89
+ // jsdom reports no width at construction, so the width-derived chrome only
90
+ // resolves on the next sync.
91
+ controller.update({});
92
+ return { mount, controller };
93
+ };
94
+
95
+ const trigger = (mount: HTMLElement) =>
96
+ mount.querySelector<HTMLButtonElement>("[data-persona-rail-trigger]");
97
+ const overlayHost = (mount: HTMLElement) =>
98
+ mount.querySelector<HTMLElement>(".persona-history-rail-overlay");
99
+ const railShell = (mount: HTMLElement) =>
100
+ mount.querySelector<HTMLElement>(".persona-history-rail-shell");
101
+ const view = (mount: HTMLElement) =>
102
+ mount.querySelector<HTMLElement>(".persona-history-view");
103
+ const railToggle = (mount: HTMLElement) =>
104
+ mount.querySelector<HTMLButtonElement>('[data-persona-history-focus="collapse"]')!;
105
+ const historyButton = (mount: HTMLElement) =>
106
+ mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]")!;
107
+
108
+ /** The themed margin, as the host carries it: a var with its default inline. */
109
+ const MARGIN = "var(--persona-history-overlay-margin,8px)";
110
+
111
+ /** A laid-out rail: the trigger's row, and the rail hanging below it. */
112
+ const ROW = { top: 12, bottom: 44, left: 8, right: 40 };
113
+ const RAIL = { top: 52, bottom: 500, left: 8, right: 268 };
114
+
115
+ /** jsdom measures nothing, so both rects are stubbed. Safe to re-run at open. */
116
+ const stubRailGeometry = (mount: HTMLElement) => {
117
+ const rect = (box: Partial<DOMRect>) => () => ({ ...box }) as DOMRect;
118
+ mount.querySelector<HTMLElement>(".persona-widget-container")!.getBoundingClientRect =
119
+ rect({ top: 0, left: 0, width: 900 });
120
+ trigger(mount)!.getBoundingClientRect = rect(ROW);
121
+ const host = overlayHost(mount);
122
+ if (host) host.getBoundingClientRect = rect(RAIL);
123
+ };
124
+
125
+ const movePointer = (x: number, y: number) =>
126
+ document.dispatchEvent(
127
+ new MouseEvent("pointermove", { clientX: x, clientY: y })
128
+ );
129
+
130
+ /** Enter and Space synthesize a click with detail 0; a pointer reports 1. */
131
+ const click = (element: HTMLElement, detail: number) =>
132
+ element.dispatchEvent(
133
+ new MouseEvent("click", { bubbles: true, cancelable: true, detail })
134
+ );
135
+
136
+ const hover = (element: HTMLElement) =>
137
+ element.dispatchEvent(new MouseEvent("mouseenter"));
138
+ const unhover = (element: HTMLElement) =>
139
+ element.dispatchEvent(new MouseEvent("mouseleave"));
140
+
141
+ /** Opens the overlay the way a pointer does, and settles the chunk load. */
142
+ const hoverOpen = async (mount: HTMLElement) => {
143
+ hover(trigger(mount)!);
144
+ await flush();
145
+ };
146
+
147
+ describe("collapsed rail overlay", () => {
148
+ beforeEach(() => {
149
+ window.scrollTo = vi.fn();
150
+ setHistoryViewLoader(loadHistoryChunk);
151
+ });
152
+
153
+ afterEach(() => {
154
+ setHistoryProviderFactory(null);
155
+ controllers.splice(0).forEach((controller) => {
156
+ try {
157
+ controller.destroy();
158
+ } catch {
159
+ /* already destroyed */
160
+ }
161
+ });
162
+ mounts.splice(0).forEach((mount) => mount.remove());
163
+ document.body.innerHTML = "";
164
+ window.localStorage.clear();
165
+ loadHistoryChunk.mockClear();
166
+ });
167
+
168
+ // FIRST in this file on purpose: the chunk loader memoizes per module
169
+ // registry, so only the first mount can observe a cold one.
170
+ it("renders the trigger alone and leaves the chunk unloaded until warmed", async () => {
171
+ const { mount, controller } = setup({ rail: { defaultCollapsed: true } });
172
+ // A restore of the collapsed state must not reach for the view either.
173
+ await controller.showHistory();
174
+ await flush();
175
+
176
+ const button = trigger(mount)!;
177
+ expect(button).not.toBeNull();
178
+ expect(button.getAttribute("aria-label")).toBe("Expand conversation list");
179
+ expect(button.getAttribute("aria-expanded")).toBe("false");
180
+ expect(view(mount)).toBeNull();
181
+ expect(railShell(mount)).toBeNull();
182
+ expect(loadHistoryChunk).not.toHaveBeenCalled();
183
+
184
+ // Keyboard focus warms it without opening anything.
185
+ button.dispatchEvent(new FocusEvent("focus"));
186
+ await flush();
187
+ expect(loadHistoryChunk).toHaveBeenCalledTimes(1);
188
+ expect(view(mount)).toBeNull();
189
+ });
190
+
191
+ it("takes the leading edge of the conversation header", () => {
192
+ const { mount } = setup();
193
+ const header = mount.querySelector<HTMLElement>('[data-persona-theme-zone="header"]')!;
194
+ expect(header.firstElementChild!.contains(trigger(mount))).toBe(true);
195
+ });
196
+
197
+ it("stays the plain sidebar glyph even under a rail brand", async () => {
198
+ const { mount } = setup({
199
+ rail: { brand: { iconUrl: "https://cdn.example/mark.png" } },
200
+ });
201
+ const button = trigger(mount)!;
202
+ // The conversation header beside it already carries the agent's identity.
203
+ expect(button.querySelector("img")).toBeNull();
204
+ expect(button.querySelector("svg")).not.toBeNull();
205
+ expect(button.getAttribute("aria-label")).toBe("Expand conversation list");
206
+
207
+ // The brand still dresses the floating rail's own header.
208
+ await hoverOpen(mount);
209
+ expect(
210
+ overlayHost(mount)!.querySelector(".persona-history-brand-mark > img")
211
+ ).not.toBeNull();
212
+ });
213
+
214
+ it("mirrors to the trailing edge for a right-docked rail", () => {
215
+ const { mount } = setup({ rail: { side: "right" } });
216
+ const header = mount.querySelector<HTMLElement>('[data-persona-theme-zone="header"]')!;
217
+ expect(header.lastElementChild!.contains(trigger(mount))).toBe(true);
218
+ });
219
+
220
+ it("floats the expanded rail as soon as the pointer arrives", async () => {
221
+ const { mount } = setup();
222
+ stubRailGeometry(mount);
223
+ // No dwell: the loaded chunk opens it on the enter itself.
224
+ await hoverOpen(mount);
225
+ const host = overlayHost(mount)!;
226
+ expect(host).not.toBeNull();
227
+ expect(host.contains(view(mount))).toBe(true);
228
+ expect(host.style.width).toBe("260px");
229
+ // Hangs from the trigger's row, one margin off every other edge, and
230
+ // rounded on all four corners.
231
+ expect(host.style.top).toBe(`calc(44px + ${MARGIN})`);
232
+ expect(host.style.bottom).toBe(MARGIN);
233
+ expect(host.style.left).toBe(MARGIN);
234
+ expect(host.style.right).toBe("");
235
+ expect(host.style.borderRadius).toBe("var(--persona-history-overlay-radius,16px)");
236
+ expect(host.style.background).toBe(
237
+ "var(--persona-history-overlay-bg,var(--persona-container,#f7f7f8))"
238
+ );
239
+ expect(host.style.boxShadow).toBe(
240
+ "var(--persona-history-overlay-shadow,0 12px 40px rgba(0,0,0,0.25))"
241
+ );
242
+ // The trigger stays above it, visible and clickable.
243
+ expect(trigger(mount)!.parentElement!.style.display).toBe("");
244
+ // Expanded, not the icon column, and floating rather than docked.
245
+ expect(
246
+ view(mount)!.classList.contains("persona-history-view--rail-collapsed")
247
+ ).toBe(false);
248
+ expect(railShell(mount)).toBeNull();
249
+ expect(trigger(mount)!.getAttribute("aria-expanded")).toBe("true");
250
+ expect(mount.querySelectorAll(".persona-history-row").length).toBeGreaterThan(0);
251
+ // The rail is the answer to the hover; a tooltip would race it.
252
+ expect(document.querySelector(".persona-control-tooltip")).toBeNull();
253
+ // The control still carries its name and its combo for assistive tech.
254
+ expect(trigger(mount)!.getAttribute("aria-label")).toBe(
255
+ "Expand conversation list"
256
+ );
257
+ });
258
+
259
+ it("takes a pass of the pointer back off after the grace", async () => {
260
+ const { mount } = setup();
261
+ hover(trigger(mount)!);
262
+ unhover(trigger(mount)!);
263
+ await flush();
264
+ await wait(GRACE);
265
+ await flush(20);
266
+ expect(overlayHost(mount)).toBeNull();
267
+ expect(view(mount)).toBeNull();
268
+ });
269
+
270
+ it("holds the rail while the pointer crosses the header above it", async () => {
271
+ const { mount } = setup();
272
+ stubRailGeometry(mount);
273
+ await hoverOpen(mount);
274
+ stubRailGeometry(mount);
275
+
276
+ // Leaving the trigger arms the grace; the bridge above the rail, which
277
+ // belongs to neither element, takes it back off.
278
+ unhover(trigger(mount)!);
279
+ movePointer(150, 28);
280
+ await wait(GRACE);
281
+ await flush();
282
+ expect(overlayHost(mount)).not.toBeNull();
283
+
284
+ // And inside the rail itself.
285
+ movePointer(150, 200);
286
+ await wait(GRACE);
287
+ await flush();
288
+ expect(overlayHost(mount)).not.toBeNull();
289
+ });
290
+
291
+ it("dismisses once the pointer passes the rail's edges", async () => {
292
+ const { mount } = setup();
293
+ stubRailGeometry(mount);
294
+ await hoverOpen(mount);
295
+ stubRailGeometry(mount);
296
+
297
+ // Same header row, past the rail's right edge.
298
+ movePointer(RAIL.right + 40, 28);
299
+ await wait(GRACE);
300
+ await flush(20);
301
+ expect(overlayHost(mount)).toBeNull();
302
+ expect(view(mount)).toBeNull();
303
+ expect(trigger(mount)!.getAttribute("aria-expanded")).toBe("false");
304
+ });
305
+
306
+ it("dismisses below the rail as well", async () => {
307
+ const { mount } = setup();
308
+ stubRailGeometry(mount);
309
+ await hoverOpen(mount);
310
+ stubRailGeometry(mount);
311
+
312
+ movePointer(150, RAIL.bottom + 40);
313
+ await wait(GRACE);
314
+ await flush(20);
315
+ expect(overlayHost(mount)).toBeNull();
316
+ });
317
+
318
+ it("tracks the pointer only while the rail floats", async () => {
319
+ const added = vi.spyOn(document, "addEventListener");
320
+ const removed = vi.spyOn(document, "removeEventListener");
321
+ const { mount, controller } = setup();
322
+ stubRailGeometry(mount);
323
+ await hoverOpen(mount);
324
+ const listener = added.mock.calls.find(([type]) => type === "pointermove");
325
+ expect(listener).toBeDefined();
326
+
327
+ stubRailGeometry(mount);
328
+ movePointer(RAIL.right + 40, RAIL.bottom + 40);
329
+ await wait(GRACE);
330
+ await flush(20);
331
+ expect(overlayHost(mount)).toBeNull();
332
+ const wasRemoved = ([type, fn]: unknown[]) =>
333
+ type === "pointermove" && fn === listener![1];
334
+ expect(removed.mock.calls.some(wasRemoved)).toBe(true);
335
+
336
+ removed.mockClear();
337
+ controller.destroy();
338
+ expect(removed.mock.calls.some(wasRemoved)).toBe(true);
339
+ });
340
+
341
+ it("pins the same view element into the full-height rail on click", async () => {
342
+ const { mount } = setup({ config: { persistState: { keyPrefix: "persona-test-" } } });
343
+ await hoverOpen(mount);
344
+ const element = view(mount)!;
345
+ element.dataset.mark = "same-view";
346
+
347
+ // A real pointer click, on the trigger the floating rail no longer covers.
348
+ click(trigger(mount)!, 1);
349
+ await flush();
350
+ expect(overlayHost(mount)).toBeNull();
351
+ const shell = railShell(mount)!;
352
+ expect(shell).not.toBeNull();
353
+ expect(shell.contains(element)).toBe(true);
354
+ expect(view(mount)!.dataset.mark).toBe("same-view");
355
+ expect(
356
+ mount.querySelector<HTMLElement>(".persona-history-rail-host")!.style.flex
357
+ ).toBe("0 0 260px");
358
+ expect(window.localStorage.getItem("persona-test-rail-collapsed")).toBe("0");
359
+ // The rail's own toggle owns the control now, so the trigger stands down.
360
+ expect(trigger(mount)!.parentElement!.style.display).toBe("none");
361
+ });
362
+
363
+ it("pins from the keyboard, even right after a dismissal under the pointer", async () => {
364
+ const { mount } = setup();
365
+ await hoverOpen(mount);
366
+ // Dismiss with the pointer still inside: that arms the hover hold-off.
367
+ view(mount)!.dispatchEvent(
368
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
369
+ );
370
+ await flush(20);
371
+ expect(overlayHost(mount)).toBeNull();
372
+
373
+ // The hold-off gates hover, never a commitment.
374
+ click(trigger(mount)!, 0);
375
+ await flush();
376
+ expect(railShell(mount)).not.toBeNull();
377
+ expect(overlayHost(mount)).toBeNull();
378
+ });
379
+
380
+ it("returns to the trigger when the pinned rail collapses", async () => {
381
+ const { mount } = setup({ config: { persistState: { keyPrefix: "persona-test-" } } });
382
+ trigger(mount)!.click();
383
+ await flush();
384
+ expect(railShell(mount)).not.toBeNull();
385
+
386
+ railToggle(mount).click();
387
+ await flush(20);
388
+ expect(railShell(mount)).toBeNull();
389
+ expect(view(mount)).toBeNull();
390
+ expect(window.localStorage.getItem("persona-test-rail-collapsed")).toBe("1");
391
+ const button = trigger(mount)!;
392
+ expect(button.parentElement!.style.display).toBe("");
393
+ expect(document.activeElement).toBe(button);
394
+ });
395
+
396
+ it("pins from the floating rail's own toggle, which sits where the trigger does", async () => {
397
+ const { mount } = setup();
398
+ await hoverOpen(mount);
399
+ // Floating, that control docks the rail, and says so.
400
+ expect(railToggle(mount).getAttribute("aria-label")).toBe(
401
+ "Expand conversation list"
402
+ );
403
+ expect(railToggle(mount).getAttribute("aria-expanded")).toBe("false");
404
+
405
+ railToggle(mount).click();
406
+ await flush();
407
+ expect(overlayHost(mount)).toBeNull();
408
+ expect(railShell(mount)).not.toBeNull();
409
+ expect(railToggle(mount).getAttribute("aria-label")).toBe(
410
+ "Collapse conversation list"
411
+ );
412
+ expect(railToggle(mount).getAttribute("aria-expanded")).toBe("true");
413
+ });
414
+
415
+ it("toggles the pin from the collapse shortcut", async () => {
416
+ setMacPlatformOverride(false);
417
+ const { mount } = setup({ rail: { collapseShortcut: "mod+b" } });
418
+ const press = (target: EventTarget) =>
419
+ target.dispatchEvent(
420
+ new KeyboardEvent("keydown", {
421
+ key: "b",
422
+ ctrlKey: true,
423
+ bubbles: true,
424
+ cancelable: true,
425
+ composed: true,
426
+ })
427
+ );
428
+ // From rest, with nothing mounted, the combo still pins.
429
+ press(trigger(mount)!);
430
+ await flush();
431
+ expect(railShell(mount)).not.toBeNull();
432
+ press(railToggle(mount));
433
+ await flush(20);
434
+ expect(railShell(mount)).toBeNull();
435
+ expect(trigger(mount)).not.toBeNull();
436
+ setMacPlatformOverride(null);
437
+ });
438
+
439
+ it("closes on Escape and hands focus back to the trigger", async () => {
440
+ const { mount } = setup();
441
+ await hoverOpen(mount);
442
+ view(mount)!.dispatchEvent(
443
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
444
+ );
445
+ await flush(20);
446
+ expect(overlayHost(mount)).toBeNull();
447
+ expect(document.activeElement).toBe(trigger(mount));
448
+
449
+ // The trigger the floating rail hangs from is inside its Escape scope.
450
+ await hoverOpen(mount);
451
+ trigger(mount)!.dispatchEvent(
452
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
453
+ );
454
+ await flush(20);
455
+ expect(overlayHost(mount)).toBeNull();
456
+ });
457
+
458
+ it("does not let a re-enter undo a dismissal made under the pointer", async () => {
459
+ const { mount } = setup();
460
+ stubRailGeometry(mount);
461
+ await hoverOpen(mount);
462
+ stubRailGeometry(mount);
463
+ // The pointer is resting inside the rail as it is dismissed.
464
+ unhover(trigger(mount)!);
465
+ movePointer(150, 200);
466
+ view(mount)!.dispatchEvent(
467
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
468
+ );
469
+ await flush(20);
470
+ expect(overlayHost(mount)).toBeNull();
471
+
472
+ // The removal re-enters whatever is under the pointer, unmoved.
473
+ hover(trigger(mount)!);
474
+ await flush();
475
+ expect(overlayHost(mount)).toBeNull();
476
+
477
+ // A real departure ends the hold-off.
478
+ unhover(trigger(mount)!);
479
+ await hoverOpen(mount);
480
+ expect(overlayHost(mount)).not.toBeNull();
481
+ });
482
+
483
+ it("closes on a click outside, but not on one inside", async () => {
484
+ const { mount } = setup();
485
+ await hoverOpen(mount);
486
+ overlayHost(mount)!.dispatchEvent(
487
+ new Event("pointerdown", { bubbles: true })
488
+ );
489
+ await flush();
490
+ expect(overlayHost(mount)).not.toBeNull();
491
+
492
+ document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
493
+ await flush(20);
494
+ expect(overlayHost(mount)).toBeNull();
495
+ });
496
+
497
+ it("closes after a conversation is selected", async () => {
498
+ const { mount } = setup();
499
+ await hoverOpen(mount);
500
+ mount
501
+ .querySelector<HTMLButtonElement>(
502
+ '[data-persona-history-conversation="conv-b"]'
503
+ )!
504
+ .click();
505
+ await flush(30);
506
+ expect(overlayHost(mount)).toBeNull();
507
+ expect(view(mount)).toBeNull();
508
+ });
509
+
510
+ it("opens on the first tap and pins on the second under a coarse pointer", async () => {
511
+ // jsdom ships no matchMedia, which is why the fine-pointer tests above
512
+ // exercise the hover path at all.
513
+ window.matchMedia = ((query: string) =>
514
+ ({ matches: query.includes("coarse") }) as MediaQueryList) as typeof window.matchMedia;
515
+ const { mount } = setup();
516
+ // No hover to open with, so the enter is ignored.
517
+ hover(trigger(mount)!);
518
+ await flush();
519
+ expect(overlayHost(mount)).toBeNull();
520
+
521
+ click(trigger(mount)!, 1);
522
+ await flush();
523
+ expect(overlayHost(mount)).not.toBeNull();
524
+
525
+ click(trigger(mount)!, 1);
526
+ await flush();
527
+ expect(overlayHost(mount)).toBeNull();
528
+ expect(railShell(mount)).not.toBeNull();
529
+ delete (window as { matchMedia?: unknown }).matchMedia;
530
+ });
531
+
532
+ it("keeps the header Messages button hidden behind the trigger", async () => {
533
+ const { mount } = setup();
534
+ const hidden = () => historyButton(mount).parentElement!.style.display;
535
+ // Rest, floating and pinned: the trigger and the rail own every entry.
536
+ expect(hidden()).toBe("none");
537
+ await hoverOpen(mount);
538
+ expect(hidden()).toBe("none");
539
+ railToggle(mount).click();
540
+ await flush();
541
+ expect(railShell(mount)).not.toBeNull();
542
+ expect(hidden()).toBe("none");
543
+ });
544
+
545
+ it("leaves the panel fallback below 720px untouched", async () => {
546
+ const { mount } = setup({ width: 600 });
547
+ expect(trigger(mount)).toBeNull();
548
+ historyButton(mount).click();
549
+ await flush();
550
+ expect(view(mount)).not.toBeNull();
551
+ expect(railShell(mount)).toBeNull();
552
+ expect(overlayHost(mount)).toBeNull();
553
+ expect(
554
+ view(mount)!.getAttribute("data-persona-history-presentation")
555
+ ).toBe("panel");
556
+ });
557
+
558
+ it("takes its listeners and timers down with the widget", async () => {
559
+ const { mount, controller } = setup();
560
+ // An open raced by the teardown must not mount behind it.
561
+ hover(trigger(mount)!);
562
+ controller.destroy();
563
+ await flush(20);
564
+ expect(overlayHost(mount)).toBeNull();
565
+ await wait(GRACE);
566
+ await flush();
567
+ expect(overlayHost(mount)).toBeNull();
568
+ // A pointerdown after teardown must not reach a detached handler.
569
+ expect(() =>
570
+ document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }))
571
+ ).not.toThrow();
572
+ });
573
+ });