@runtypelabs/persona 4.17.1 → 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.
- package/dist/chunk-3I2S3I5P.js +1 -0
- package/dist/chunk-76IELU7F.js +1 -0
- package/dist/chunk-EFJBUH5F.js +1 -0
- package/dist/chunk-PDPVORUF.js +1 -0
- package/dist/chunk-Q735PSZL.js +1 -0
- package/dist/chunk-VK6JOIYJ.js +1 -0
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +4 -4
- package/dist/{context-mentions-BTSTIBFC.js → context-mentions-MJL6S6P2.js} +1 -1
- package/dist/context-mentions.cjs +9 -8
- package/dist/context-mentions.d.cts +1212 -14
- package/dist/context-mentions.d.ts +1212 -14
- package/dist/context-mentions.js +9 -8
- package/dist/event-stream-view-4EZ7BGFK.js +1 -0
- package/dist/event-stream-view.cjs +129 -0
- package/dist/event-stream-view.d.cts +8384 -0
- package/dist/event-stream-view.d.ts +8384 -0
- package/dist/event-stream-view.js +129 -0
- package/dist/history-view-BD6OXLNA.js +898 -0
- package/dist/history-view.cjs +898 -0
- package/dist/history-view.d.cts +1461 -0
- package/dist/history-view.d.ts +1461 -0
- package/dist/history-view.js +898 -0
- package/dist/index.cjs +77 -77
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1987 -17
- package/dist/index.d.ts +1987 -17
- package/dist/index.global.js +67 -66
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +77 -77
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +5 -4
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -1
- package/dist/plugin-kit.d.cts +0 -8
- package/dist/plugin-kit.d.ts +0 -8
- package/dist/plugin-kit.js +1 -1
- package/dist/smart-dom-reader.d.cts +1212 -14
- package/dist/smart-dom-reader.d.ts +1212 -14
- package/dist/theme-editor-preview.cjs +970 -73
- package/dist/theme-editor-preview.d.cts +1331 -15
- package/dist/theme-editor-preview.d.ts +1331 -15
- package/dist/theme-editor-preview.js +81 -81
- package/dist/theme-editor.cjs +7 -7
- package/dist/theme-editor.d.cts +1215 -15
- package/dist/theme-editor.d.ts +1215 -15
- package/dist/theme-editor.js +9 -9
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +226 -7
- package/dist/theme-reference.d.ts +226 -7
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +1 -1
- package/package.json +16 -4
- package/src/client-history-rest.test.ts +1034 -0
- package/src/client-visitor-history.test.ts +596 -0
- package/src/client.ts +1040 -24
- package/src/components/artifact-inline.ts +8 -1
- package/src/components/artifact-pane.test.ts +278 -1
- package/src/components/artifact-pane.ts +135 -67
- package/src/components/artifact-preview.test.ts +14 -1
- package/src/components/artifact-preview.ts +9 -0
- package/src/components/header-builder.ts +3 -0
- package/src/components/header-layouts.test.ts +171 -7
- package/src/components/header-layouts.ts +83 -53
- package/src/components/header-parts.test.ts +154 -1
- package/src/components/header-parts.ts +266 -138
- package/src/components/history-confirm-dialog.ts +177 -0
- package/src/components/history-shell-copy.ts +99 -0
- package/src/components/history-view/announcer.ts +40 -0
- package/src/components/history-view/copy.ts +105 -0
- package/src/components/history-view/css.ts +1078 -0
- package/src/components/history-view/grouping.test.ts +142 -0
- package/src/components/history-view/grouping.ts +142 -0
- package/src/components/history-view/icons.ts +73 -0
- package/src/components/history-view/rows.ts +341 -0
- package/src/components/history-view/state.ts +309 -0
- package/src/components/history-view.test.ts +2660 -0
- package/src/components/history-view.ts +1891 -0
- package/src/components/launcher-surface.test.ts +133 -0
- package/src/components/launcher.test.ts +18 -0
- package/src/components/launcher.ts +7 -7
- package/src/components/panel.ts +17 -7
- package/src/components/welcome.ts +72 -2
- package/src/defaults.ts +36 -3
- package/src/event-stream-view-bundle.test.ts +69 -0
- package/src/event-stream-view-entry.ts +19 -0
- package/src/event-stream-view-loader.ts +31 -0
- package/src/event-stream-view.ts +19 -0
- package/src/generated/runtype-openapi-contract.ts +1 -0
- package/src/history-render.ts +325 -0
- package/src/history-view-bundle.test.ts +78 -0
- package/src/history-view-entry.ts +41 -0
- package/src/history-view-loader.test.ts +30 -0
- package/src/history-view-loader.ts +32 -0
- package/src/history-view.ts +28 -0
- package/src/index-core.ts +50 -2
- package/src/index-global.ts +55 -0
- package/src/internal/demo-history-provider.test.ts +402 -0
- package/src/internal/demo-history-provider.ts +689 -0
- package/src/internal/history-provider-registry.ts +25 -0
- package/src/internal/history-provider.ts +150 -0
- package/src/internal/runtype-history-provider.test.ts +313 -0
- package/src/internal/runtype-history-provider.ts +240 -0
- package/src/plugin-kit.test.ts +16 -0
- package/src/plugin-kit.ts +13 -5
- package/src/plugins/types.ts +177 -1
- package/src/session-history.test.ts +1397 -0
- package/src/session.ts +1200 -26
- package/src/styles/widget-styles.test.ts +133 -0
- package/src/styles/widget.css +227 -8
- package/src/theme-editor/preview-utils.test.ts +19 -0
- package/src/theme-editor/preview-utils.ts +15 -1
- package/src/theme-editor/preview.ts +7 -1
- package/src/theme-editor/role-mappings.ts +4 -1
- package/src/theme-editor/sections.test.ts +168 -1
- package/src/theme-editor/sections.ts +136 -7
- package/src/theme-editor/webmcp/summary.ts +16 -2
- package/src/theme-reference.ts +20 -6
- package/src/types/theme.ts +213 -7
- package/src/types.ts +927 -7
- package/src/ui.artifact-rail-resize.test.ts +235 -0
- package/src/ui.attachments-drop.test.ts +1 -0
- package/src/ui.header-update-stability.test.ts +2 -2
- package/src/ui.history-chunk.test.ts +72 -0
- package/src/ui.history-config-provider.test.ts +318 -0
- package/src/ui.history-controller.test.ts +711 -0
- package/src/ui.history-lifecycle.test.ts +456 -0
- package/src/ui.history-render-hooks.test.ts +904 -0
- package/src/ui.history-shell.test.ts +1887 -0
- package/src/ui.rail-overlay.test.ts +573 -0
- package/src/ui.rail-resize.test.ts +284 -0
- package/src/ui.rebuild-composer.test.ts +21 -0
- package/src/ui.render-welcome.test.ts +42 -0
- package/src/ui.shortcuts.test.ts +166 -0
- package/src/ui.ts +3071 -240
- package/src/utils/__fixtures__/client-conversation-history-wire.json +108 -0
- package/src/utils/artifact-file.ts +15 -0
- package/src/utils/artifact-gate.test.ts +4 -0
- package/src/utils/artifact-gate.ts +13 -17
- package/src/utils/artifact-resize.test.ts +28 -0
- package/src/utils/artifact-resize.ts +9 -2
- package/src/utils/buttons.ts +188 -1
- package/src/utils/config-merge.test.ts +91 -0
- package/src/utils/dropdown.ts +10 -1
- package/src/utils/history-messages.test.ts +284 -0
- package/src/utils/history-messages.ts +284 -0
- package/src/utils/icons.ts +3 -0
- package/src/utils/shortcuts.test.ts +288 -0
- package/src/utils/shortcuts.ts +221 -0
- package/src/utils/theme.test.ts +501 -4
- package/src/utils/theme.ts +42 -3
- package/src/utils/tokens.ts +153 -15
- package/src/utils/tooltip-timing.ts +4 -0
- package/src/utils/tooltip.test.ts +249 -7
- package/src/utils/tooltip.ts +146 -4
- package/src/utils/visitor-store.test.ts +425 -0
- package/src/utils/visitor-store.ts +337 -0
- package/dist/chunk-QJ4LV2KW.js +0 -1
|
@@ -0,0 +1,1887 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shell wiring for the visitor conversation history surface
|
|
5
|
+
* (`docs/visitor-history-implementation-plan.md` D7): header button, panel/rail
|
|
6
|
+
* hosts, navigation/transition table, open flow, prepend, and confirmations.
|
|
7
|
+
*
|
|
8
|
+
* Runs against the in-memory demo provider through the internal registry, so no
|
|
9
|
+
* transport or credential plumbing is involved.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
import { createAgentExperience } from "./ui";
|
|
15
|
+
import { createHistoryView } from "./components/history-view";
|
|
16
|
+
import { setHistoryViewLoader } from "./history-view-loader";
|
|
17
|
+
import { setHistoryProviderFactory } from "./internal/history-provider-registry";
|
|
18
|
+
import {
|
|
19
|
+
createDemoHistoryProvider,
|
|
20
|
+
type DemoHistoryConversationSeed,
|
|
21
|
+
type DemoHistoryProvider,
|
|
22
|
+
type DemoHistoryProviderOptions,
|
|
23
|
+
} from "./internal/demo-history-provider";
|
|
24
|
+
import type { AgentWidgetPlugin } from "./plugins/types";
|
|
25
|
+
import type { LoadingIndicatorRenderContext } from "./types";
|
|
26
|
+
import { setMacPlatformOverride } from "./utils/shortcuts";
|
|
27
|
+
import { resetTooltipTiming } from "./utils/tooltip";
|
|
28
|
+
|
|
29
|
+
const SEEDS: DemoHistoryConversationSeed[] = [
|
|
30
|
+
{
|
|
31
|
+
id: "conv-a",
|
|
32
|
+
title: "Order status",
|
|
33
|
+
targetId: null,
|
|
34
|
+
messages: [
|
|
35
|
+
{ id: "a1", role: "user", content: "where is my order" },
|
|
36
|
+
{ id: "a2", role: "assistant", content: "it ships tomorrow" },
|
|
37
|
+
{ id: "a3", role: "user", content: "thanks" },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "conv-b",
|
|
42
|
+
title: "Refund request",
|
|
43
|
+
targetId: null,
|
|
44
|
+
messages: [
|
|
45
|
+
{ id: "b1", role: "user", content: "i need a refund" },
|
|
46
|
+
{ id: "b2", role: "assistant", content: "started your refund" },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const mounts: HTMLElement[] = [];
|
|
52
|
+
const controllers: ReturnType<typeof createAgentExperience>[] = [];
|
|
53
|
+
|
|
54
|
+
const flush = async (times = 12) => {
|
|
55
|
+
for (let i = 0; i < times; i += 1) {
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop
|
|
57
|
+
await Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Real timers, so a slow open is polled rather than timed to the millisecond. */
|
|
62
|
+
const waitUntil = async (predicate: () => boolean, timeoutMs = 3000) => {
|
|
63
|
+
const started = Date.now();
|
|
64
|
+
while (!predicate()) {
|
|
65
|
+
if (Date.now() - started > timeoutMs) throw new Error("waitUntil timed out");
|
|
66
|
+
// eslint-disable-next-line no-await-in-loop
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
68
|
+
// eslint-disable-next-line no-await-in-loop
|
|
69
|
+
await flush(8);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type SetupOptions = {
|
|
74
|
+
config?: Record<string, unknown>;
|
|
75
|
+
provider?: DemoHistoryProviderOptions;
|
|
76
|
+
/** Replaces the demo provider entirely (e.g. to add `resetDevice`). */
|
|
77
|
+
factory?: () => DemoHistoryProvider;
|
|
78
|
+
historyFeature?: Record<string, unknown> | null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const setup = (options: SetupOptions = {}) => {
|
|
82
|
+
const provider =
|
|
83
|
+
options.factory?.() ??
|
|
84
|
+
createDemoHistoryProvider({ conversations: SEEDS, ...options.provider });
|
|
85
|
+
setHistoryProviderFactory(() => provider);
|
|
86
|
+
const mount = document.createElement("div");
|
|
87
|
+
document.body.appendChild(mount);
|
|
88
|
+
mounts.push(mount);
|
|
89
|
+
const historyFeature =
|
|
90
|
+
options.historyFeature === undefined ? { enabled: true } : options.historyFeature;
|
|
91
|
+
const controller = createAgentExperience(mount, {
|
|
92
|
+
apiUrl: "https://api.example.com/chat",
|
|
93
|
+
launcher: { enabled: false },
|
|
94
|
+
persistState: false,
|
|
95
|
+
suggestionChips: [],
|
|
96
|
+
...(historyFeature ? { features: { history: historyFeature } } : {}),
|
|
97
|
+
...options.config,
|
|
98
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
99
|
+
controllers.push(controller);
|
|
100
|
+
return { mount, controller, provider };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const historyButton = (mount: HTMLElement) =>
|
|
104
|
+
mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]");
|
|
105
|
+
const historyRoot = (mount: HTMLElement) =>
|
|
106
|
+
mount.querySelector<HTMLElement>(".persona-history-view");
|
|
107
|
+
const bodyOf = (mount: HTMLElement) =>
|
|
108
|
+
mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
109
|
+
/** Transcript wrapper the conversation-open stand-in hides once it takes over. */
|
|
110
|
+
const messagesOf = (mount: HTMLElement) =>
|
|
111
|
+
mount.querySelector<HTMLElement>(".persona-widget-messages")!;
|
|
112
|
+
const footerOf = (mount: HTMLElement) =>
|
|
113
|
+
mount.querySelector<HTMLElement>(".persona-widget-footer") ??
|
|
114
|
+
mount.querySelector<HTMLElement>("[data-test-gate]")!;
|
|
115
|
+
/** Every header builder (default, minimal, composer-bar placeholder) marks it. */
|
|
116
|
+
const headerOf = (mount: HTMLElement) =>
|
|
117
|
+
mount.querySelector<HTMLElement>('[data-persona-theme-zone="header"]')!;
|
|
118
|
+
/** Shell-owned wrapper holding the Messages bar inside the widget header. */
|
|
119
|
+
const headerHostOf = (mount: HTMLElement) =>
|
|
120
|
+
mount.querySelector<HTMLElement>(".persona-history-header-host");
|
|
121
|
+
const suppressedIn = (header: HTMLElement) =>
|
|
122
|
+
Array.from(header.querySelectorAll("[data-persona-history-suppressed]"));
|
|
123
|
+
/** Header children the visitor can still see while Messages owns the bar. */
|
|
124
|
+
const visibleHeaderChildren = (header: HTMLElement) =>
|
|
125
|
+
Array.from(header.children).filter(
|
|
126
|
+
(child) =>
|
|
127
|
+
!child.hasAttribute("data-persona-history-suppressed") &&
|
|
128
|
+
!child.classList.contains("persona-history-header-host")
|
|
129
|
+
);
|
|
130
|
+
/** Permanent welcome host; `applyWelcomeVisibility` toggles its hidden state. */
|
|
131
|
+
const welcomeOf = (mount: HTMLElement) =>
|
|
132
|
+
mount.querySelector<HTMLElement>("[data-persona-welcome]")!;
|
|
133
|
+
const rowOf = (mount: HTMLElement, id: string) =>
|
|
134
|
+
mount.querySelector<HTMLButtonElement>(`[data-persona-history-conversation="${id}"]`);
|
|
135
|
+
const dialogOf = () => document.querySelector<HTMLElement>('[role="alertdialog"]');
|
|
136
|
+
|
|
137
|
+
/** Give the container a rail-capable width; jsdom reports 0 for everything. */
|
|
138
|
+
const setContainerWidth = (mount: HTMLElement, width: number) => {
|
|
139
|
+
const container = mount.querySelector<HTMLElement>(".persona-widget-container")!;
|
|
140
|
+
Object.defineProperty(container, "clientWidth", {
|
|
141
|
+
configurable: true,
|
|
142
|
+
get: () => width,
|
|
143
|
+
});
|
|
144
|
+
return container;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Enter/Space synthesize a click with detail 0; a pointer press reports 1. */
|
|
148
|
+
const openHistoryUI = async (
|
|
149
|
+
mount: HTMLElement,
|
|
150
|
+
opts?: { keyboard?: boolean }
|
|
151
|
+
) => {
|
|
152
|
+
historyButton(mount)!.dispatchEvent(
|
|
153
|
+
new MouseEvent("click", {
|
|
154
|
+
bubbles: true,
|
|
155
|
+
cancelable: true,
|
|
156
|
+
detail: opts?.keyboard === true ? 0 : 1,
|
|
157
|
+
})
|
|
158
|
+
);
|
|
159
|
+
await flush();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Railed, the header toggle is gone, so the close comes from the controller. */
|
|
163
|
+
const closeHistoryUI = async (controller: { hideHistory: () => void }) => {
|
|
164
|
+
controller.hideHistory();
|
|
165
|
+
await flush(20);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
describe("history shell", () => {
|
|
169
|
+
beforeEach(() => {
|
|
170
|
+
window.scrollTo = vi.fn();
|
|
171
|
+
// The loader's production path is a sibling-URL / external-subpath import.
|
|
172
|
+
setHistoryViewLoader(async () => ({ createHistoryView }));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
afterEach(() => {
|
|
176
|
+
setHistoryProviderFactory(null);
|
|
177
|
+
controllers.splice(0).forEach((controller) => {
|
|
178
|
+
try {
|
|
179
|
+
controller.destroy();
|
|
180
|
+
} catch {
|
|
181
|
+
/* already destroyed */
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
mounts.splice(0).forEach((mount) => mount.remove());
|
|
185
|
+
document.body.innerHTML = "";
|
|
186
|
+
window.localStorage.clear();
|
|
187
|
+
vi.restoreAllMocks();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("header button", () => {
|
|
191
|
+
it("renders a token-sized Messages control before the close affordance", () => {
|
|
192
|
+
const { mount } = setup();
|
|
193
|
+
const button = historyButton(mount);
|
|
194
|
+
expect(button).not.toBeNull();
|
|
195
|
+
expect(button!.getAttribute("aria-label")).toBe("Messages");
|
|
196
|
+
// Box and glyph come from the header control tokens, not an inline size.
|
|
197
|
+
expect(button!.classList.contains("persona-header-control")).toBe(true);
|
|
198
|
+
expect(button!.classList.contains("persona-header-control--glyph")).toBe(true);
|
|
199
|
+
expect(button!.style.width).toBe("");
|
|
200
|
+
expect(button!.style.minWidth).toBe("");
|
|
201
|
+
expect(button!.querySelector("svg")).not.toBeNull();
|
|
202
|
+
// The styled tooltip reads the live aria-label; a title would double it.
|
|
203
|
+
expect(button!.hasAttribute("title")).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("joins the minimal layout's trailing cluster before the close control", () => {
|
|
207
|
+
const { mount } = setup({
|
|
208
|
+
config: { layout: { header: { layout: "minimal" } } },
|
|
209
|
+
});
|
|
210
|
+
const button = historyButton(mount)!;
|
|
211
|
+
const closeWrapper = mount.querySelector<HTMLButtonElement>(
|
|
212
|
+
'button[aria-label="Close chat"]'
|
|
213
|
+
)!.parentElement!;
|
|
214
|
+
// Same cluster, close outermost.
|
|
215
|
+
expect(button.parentElement!.parentElement).toBe(closeWrapper.parentElement);
|
|
216
|
+
expect(
|
|
217
|
+
button.compareDocumentPosition(closeWrapper) &
|
|
218
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
219
|
+
).toBeTruthy();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("does not render when the feature is disabled", () => {
|
|
223
|
+
const { mount } = setup({ historyFeature: { enabled: false } });
|
|
224
|
+
expect(historyButton(mount)).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("does not render without a provider", () => {
|
|
228
|
+
setHistoryProviderFactory(null);
|
|
229
|
+
const mount = document.createElement("div");
|
|
230
|
+
document.body.appendChild(mount);
|
|
231
|
+
mounts.push(mount);
|
|
232
|
+
const controller = createAgentExperience(mount, {
|
|
233
|
+
apiUrl: "https://api.example.com/chat",
|
|
234
|
+
launcher: { enabled: false },
|
|
235
|
+
persistState: false,
|
|
236
|
+
features: { history: { enabled: true } },
|
|
237
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
238
|
+
controllers.push(controller);
|
|
239
|
+
expect(historyButton(mount)).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("relabels the visible start-over affordance to New conversation", () => {
|
|
243
|
+
const { mount } = setup({
|
|
244
|
+
config: { launcher: { enabled: false, clearChat: { enabled: true } } },
|
|
245
|
+
});
|
|
246
|
+
const clear = mount.querySelector<HTMLButtonElement>(
|
|
247
|
+
'button[aria-label="New conversation"]'
|
|
248
|
+
);
|
|
249
|
+
expect(clear).not.toBeNull();
|
|
250
|
+
expect(clear).not.toBe(historyButton(mount));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("mounts and unmounts through controller.update()", () => {
|
|
254
|
+
const { mount, controller } = setup({ historyFeature: { enabled: false } });
|
|
255
|
+
expect(historyButton(mount)).toBeNull();
|
|
256
|
+
controller.update({ features: { history: { enabled: true } } });
|
|
257
|
+
expect(historyButton(mount)).not.toBeNull();
|
|
258
|
+
controller.update({ features: { history: { enabled: false } } });
|
|
259
|
+
expect(historyButton(mount)).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("removes an already-rendered button when availability degrades", () => {
|
|
263
|
+
let notify: ((available: boolean) => void) | null = null;
|
|
264
|
+
const base = createDemoHistoryProvider({ conversations: SEEDS });
|
|
265
|
+
const { mount } = setup({
|
|
266
|
+
factory: () =>
|
|
267
|
+
Object.assign(Object.create(Object.getPrototypeOf(base)), base, {
|
|
268
|
+
subscribeAvailability: (callback: (available: boolean) => void) => {
|
|
269
|
+
notify = callback;
|
|
270
|
+
return () => {
|
|
271
|
+
notify = null;
|
|
272
|
+
};
|
|
273
|
+
},
|
|
274
|
+
}) as DemoHistoryProvider,
|
|
275
|
+
});
|
|
276
|
+
expect(historyButton(mount)).not.toBeNull();
|
|
277
|
+
notify!(false);
|
|
278
|
+
expect(historyButton(mount)).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("panel host", () => {
|
|
283
|
+
it("swaps the header's contents, hides transcript and composer, then restores them", async () => {
|
|
284
|
+
const { mount } = setup();
|
|
285
|
+
const body = bodyOf(mount);
|
|
286
|
+
const footer = footerOf(mount);
|
|
287
|
+
const header = headerOf(mount);
|
|
288
|
+
const originals = Array.from(header.children);
|
|
289
|
+
|
|
290
|
+
await openHistoryUI(mount);
|
|
291
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
292
|
+
expect(body.style.display).toBe("none");
|
|
293
|
+
expect(body.getAttribute("aria-hidden")).toBe("true");
|
|
294
|
+
expect(body.hasAttribute("inert")).toBe(true);
|
|
295
|
+
expect(footer.hidden).toBe(true);
|
|
296
|
+
expect(footer.getAttribute("aria-hidden")).toBe("true");
|
|
297
|
+
expect(footer.hasAttribute("inert")).toBe(true);
|
|
298
|
+
// One persistent bar: the header stays, only its contents change.
|
|
299
|
+
expect(header.style.display).not.toBe("none");
|
|
300
|
+
expect(header.hasAttribute("aria-hidden")).toBe(false);
|
|
301
|
+
expect(header.hasAttribute("inert")).toBe(false);
|
|
302
|
+
const host = headerHostOf(mount)!;
|
|
303
|
+
expect(host.parentElement).toBe(header);
|
|
304
|
+
expect(host.querySelector(".persona-history-topbar")).not.toBeNull();
|
|
305
|
+
expect(historyRoot(mount)!.querySelector(".persona-history-topbar")).toBeNull();
|
|
306
|
+
expect(suppressedIn(header)).toEqual(originals);
|
|
307
|
+
expect(visibleHeaderChildren(header)).toEqual([]);
|
|
308
|
+
|
|
309
|
+
mount
|
|
310
|
+
.querySelector<HTMLButtonElement>('[data-persona-history-focus="close"]')!
|
|
311
|
+
.click();
|
|
312
|
+
await flush();
|
|
313
|
+
|
|
314
|
+
expect(historyRoot(mount)).toBeNull();
|
|
315
|
+
expect(body.style.display).not.toBe("none");
|
|
316
|
+
expect(body.hasAttribute("inert")).toBe(false);
|
|
317
|
+
expect(body.hasAttribute("aria-hidden")).toBe(false);
|
|
318
|
+
expect(footer.hidden).toBe(false);
|
|
319
|
+
expect(footer.hasAttribute("inert")).toBe(false);
|
|
320
|
+
expect(header.style.display).not.toBe("none");
|
|
321
|
+
expect(header.hasAttribute("inert")).toBe(false);
|
|
322
|
+
expect(header.hasAttribute("aria-hidden")).toBe(false);
|
|
323
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
324
|
+
expect(suppressedIn(header)).toEqual([]);
|
|
325
|
+
expect(Array.from(header.children)).toEqual(originals);
|
|
326
|
+
// The invoker is suppressed until this restores it, so chrome comes first.
|
|
327
|
+
expect(document.activeElement).toBe(historyButton(mount));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("pins the header's pre-swap height while Messages owns the bar", async () => {
|
|
331
|
+
const { mount, controller } = setup();
|
|
332
|
+
const header = headerOf(mount);
|
|
333
|
+
// jsdom reports 0 for layout; stand in for a real header measurement.
|
|
334
|
+
Object.defineProperty(header, "offsetHeight", {
|
|
335
|
+
configurable: true,
|
|
336
|
+
get: () => 81,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await openHistoryUI(mount);
|
|
340
|
+
// Constant-height chrome: the hosted bar swap must not move the header.
|
|
341
|
+
expect(header.style.minHeight).toBe("81px");
|
|
342
|
+
|
|
343
|
+
// A re-entry on the already-hosted header keeps the standing pin rather
|
|
344
|
+
// than re-measuring the (now shorter) hosted contents.
|
|
345
|
+
controller.update({});
|
|
346
|
+
await flush();
|
|
347
|
+
expect(headerOf(mount).style.minHeight).toBe("81px");
|
|
348
|
+
|
|
349
|
+
await closeHistoryUI(controller);
|
|
350
|
+
expect(header.style.minHeight).toBe("");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("re-homes the bar into a shell header rebuilt by update() while Messages is open", async () => {
|
|
354
|
+
const { mount, controller } = setup({
|
|
355
|
+
config: { layout: { header: { layout: "default" } } },
|
|
356
|
+
});
|
|
357
|
+
await openHistoryUI(mount);
|
|
358
|
+
const first = headerOf(mount);
|
|
359
|
+
const host = headerHostOf(mount)!;
|
|
360
|
+
expect(host.parentElement).toBe(first);
|
|
361
|
+
|
|
362
|
+
controller.update({ layout: { header: { layout: "minimal" } } });
|
|
363
|
+
await flush();
|
|
364
|
+
|
|
365
|
+
const replacement = headerOf(mount);
|
|
366
|
+
expect(replacement).not.toBe(first);
|
|
367
|
+
expect(replacement.style.display).not.toBe("none");
|
|
368
|
+
expect(replacement.hasAttribute("inert")).toBe(false);
|
|
369
|
+
// Same wrapper instance: focus inside the bar survives the rebuild.
|
|
370
|
+
expect(headerHostOf(mount)).toBe(host);
|
|
371
|
+
expect(host.parentElement).toBe(replacement);
|
|
372
|
+
expect(host.querySelector(".persona-history-topbar")).not.toBeNull();
|
|
373
|
+
expect(visibleHeaderChildren(replacement)).toEqual([]);
|
|
374
|
+
// Including the Messages control the rebuild re-created: suppression
|
|
375
|
+
// lands on its wrapper, which is the header's direct child.
|
|
376
|
+
expect(
|
|
377
|
+
historyButton(mount)!.parentElement!.hasAttribute(
|
|
378
|
+
"data-persona-history-suppressed"
|
|
379
|
+
)
|
|
380
|
+
).toBe(true);
|
|
381
|
+
|
|
382
|
+
mount
|
|
383
|
+
.querySelector<HTMLButtonElement>('[data-persona-history-focus="close"]')!
|
|
384
|
+
.click();
|
|
385
|
+
await flush();
|
|
386
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
387
|
+
expect(suppressedIn(replacement)).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("falls back to hiding the header entirely when there is none to host in", async () => {
|
|
391
|
+
const { mount } = setup({ config: { layout: { showHeader: false } } });
|
|
392
|
+
await openHistoryUI(mount);
|
|
393
|
+
const header = headerOf(mount);
|
|
394
|
+
|
|
395
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
396
|
+
expect(header.style.display).toBe("none");
|
|
397
|
+
expect(header.getAttribute("aria-hidden")).toBe("true");
|
|
398
|
+
expect(header.hasAttribute("inert")).toBe(true);
|
|
399
|
+
// The bar rides inside the view element instead.
|
|
400
|
+
expect(historyRoot(mount)!.querySelector(".persona-history-topbar")).not.toBeNull();
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("re-applies hidden/inert to a replacement composer footer", async () => {
|
|
404
|
+
let requestRender: (() => void) | null = null;
|
|
405
|
+
let gated = true;
|
|
406
|
+
const plugin: AgentWidgetPlugin = {
|
|
407
|
+
id: "gate",
|
|
408
|
+
renderComposer: (ctx) => {
|
|
409
|
+
requestRender = ctx.requestRender;
|
|
410
|
+
if (!gated) return null;
|
|
411
|
+
const footer = document.createElement("div");
|
|
412
|
+
footer.setAttribute("data-test-gate", "");
|
|
413
|
+
return footer;
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
const { mount } = setup({ config: { plugins: [plugin] } });
|
|
417
|
+
await openHistoryUI(mount);
|
|
418
|
+
const first = mount.querySelector<HTMLElement>("[data-test-gate]")!;
|
|
419
|
+
expect(first.hidden).toBe(true);
|
|
420
|
+
|
|
421
|
+
gated = false;
|
|
422
|
+
requestRender!();
|
|
423
|
+
await flush();
|
|
424
|
+
|
|
425
|
+
const replacement = footerOf(mount);
|
|
426
|
+
expect(replacement).not.toBe(first);
|
|
427
|
+
expect(replacement.hidden).toBe(true);
|
|
428
|
+
expect(replacement.getAttribute("aria-hidden")).toBe("true");
|
|
429
|
+
expect(replacement.hasAttribute("inert")).toBe(true);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("suppresses the scroll-to-bottom affordance while the panel is open", async () => {
|
|
433
|
+
const { mount } = setup();
|
|
434
|
+
await openHistoryUI(mount);
|
|
435
|
+
const jump = mount.querySelector<HTMLElement>("[data-persona-scroll-to-bottom]");
|
|
436
|
+
expect(jump === null || jump.style.display === "none").toBe(true);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("leaves focus untouched for focus: false opens and restoreFocus: false closes", async () => {
|
|
440
|
+
const { mount, controller } = setup();
|
|
441
|
+
// A focus owner outside the widget (the theme editor's input in the
|
|
442
|
+
// preview-replay case): neither half may move it.
|
|
443
|
+
const outside = document.createElement("input");
|
|
444
|
+
document.body.appendChild(outside);
|
|
445
|
+
outside.focus();
|
|
446
|
+
|
|
447
|
+
await controller.showHistory({ focus: false });
|
|
448
|
+
await flush();
|
|
449
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
450
|
+
expect(document.activeElement).toBe(outside);
|
|
451
|
+
|
|
452
|
+
controller.hideHistory({ restoreFocus: false });
|
|
453
|
+
await flush(20);
|
|
454
|
+
expect(historyRoot(mount)).toBeNull();
|
|
455
|
+
expect(document.activeElement).toBe(outside);
|
|
456
|
+
outside.remove();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
describe("rail host", () => {
|
|
461
|
+
const railSetup = async (rail?: Record<string, unknown>) => {
|
|
462
|
+
const result = setup({
|
|
463
|
+
historyFeature: { enabled: true, presentation: "rail", ...(rail ? { rail } : {}) },
|
|
464
|
+
});
|
|
465
|
+
setContainerWidth(result.mount, 900);
|
|
466
|
+
await openHistoryUI(result.mount);
|
|
467
|
+
return result;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
it("docks the navigation ahead of the conversation at 260px by default", async () => {
|
|
471
|
+
const { mount } = await railSetup();
|
|
472
|
+
const shell = mount.querySelector<HTMLElement>(".persona-history-rail-shell")!;
|
|
473
|
+
const host = shell.querySelector<HTMLElement>(".persona-history-rail-host")!;
|
|
474
|
+
expect(shell.firstElementChild).toBe(host);
|
|
475
|
+
expect(host.style.flex).toBe("0 0 260px");
|
|
476
|
+
// The divider faces the conversation, so a leading rail draws it right.
|
|
477
|
+
expect(host.style.borderRight).not.toBe("");
|
|
478
|
+
expect(host.style.borderLeft).toBe("");
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("flips to the trailing edge and takes a clamped width from config", async () => {
|
|
482
|
+
const { mount } = await railSetup({ side: "right", width: 900 });
|
|
483
|
+
const shell = mount.querySelector<HTMLElement>(".persona-history-rail-shell")!;
|
|
484
|
+
const host = shell.querySelector<HTMLElement>(".persona-history-rail-host")!;
|
|
485
|
+
expect(shell.lastElementChild).toBe(host);
|
|
486
|
+
expect(host.style.flex).toBe("0 0 400px");
|
|
487
|
+
expect(host.style.borderLeft).not.toBe("");
|
|
488
|
+
expect(host.style.borderRight).toBe("");
|
|
489
|
+
// The conversation column still gives the transcript and composer back.
|
|
490
|
+
expect(bodyOf(mount).closest(".persona-history-rail-conversation")).not.toBeNull();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("mirrors the bar order for a rail docked on the right", async () => {
|
|
494
|
+
const { mount, controller } = await railSetup();
|
|
495
|
+
const bar = () =>
|
|
496
|
+
mount.querySelector<HTMLElement>(".persona-history-topbar")!;
|
|
497
|
+
const focusKeys = () =>
|
|
498
|
+
Array.from(bar().children).map((child) =>
|
|
499
|
+
child.getAttribute("data-persona-history-focus")
|
|
500
|
+
);
|
|
501
|
+
// Left rail: identity leads, the toggle takes the inner edge.
|
|
502
|
+
expect(focusKeys()).toEqual([null, "collapse"]);
|
|
503
|
+
|
|
504
|
+
controller.update({
|
|
505
|
+
features: { history: { rail: { side: "right" } } },
|
|
506
|
+
} as never);
|
|
507
|
+
await flush();
|
|
508
|
+
expect(focusKeys()).toEqual(["collapse", null]);
|
|
509
|
+
expect(
|
|
510
|
+
historyRoot(mount)!.classList.contains("persona-history-view--rail-right")
|
|
511
|
+
).toBe(true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("renders the configured rail header brand and re-renders it on collapse", async () => {
|
|
515
|
+
const calls: boolean[] = [];
|
|
516
|
+
const { mount } = await railSetup({
|
|
517
|
+
renderHeader: ({ collapsed }: { collapsed: boolean }) => {
|
|
518
|
+
calls.push(collapsed);
|
|
519
|
+
if (collapsed) return null;
|
|
520
|
+
const brand = document.createElement("span");
|
|
521
|
+
brand.dataset.brand = "";
|
|
522
|
+
brand.textContent = "Acme";
|
|
523
|
+
return brand;
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
const group = () =>
|
|
527
|
+
mount.querySelector<HTMLElement>(".persona-history-heading-group")!;
|
|
528
|
+
expect(calls).toEqual([false]);
|
|
529
|
+
expect(group().querySelector("[data-brand]")?.textContent).toBe("Acme");
|
|
530
|
+
// The heading stays for the region's accessible name.
|
|
531
|
+
const title = mount.querySelector<HTMLElement>(".persona-history-title")!;
|
|
532
|
+
expect(title.classList.contains("persona-history-sr-only")).toBe(true);
|
|
533
|
+
expect(title.textContent).toBe("Messages");
|
|
534
|
+
|
|
535
|
+
mount
|
|
536
|
+
.querySelector<HTMLButtonElement>(
|
|
537
|
+
'[data-persona-history-focus="collapse"]'
|
|
538
|
+
)!
|
|
539
|
+
.click();
|
|
540
|
+
await flush();
|
|
541
|
+
expect(calls).toEqual([false, true]);
|
|
542
|
+
expect(group().querySelector("[data-brand]")).toBeNull();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("resolves one brand declaration into both rail faces", async () => {
|
|
546
|
+
const { mount } = await railSetup({
|
|
547
|
+
brand: { iconUrl: "https://cdn.example/mark.png" },
|
|
548
|
+
});
|
|
549
|
+
const marks = Array.from(
|
|
550
|
+
mount.querySelectorAll<HTMLImageElement>(".persona-history-brand-mark > img")
|
|
551
|
+
);
|
|
552
|
+
// The heading mark and the collapsed toggle face are separate copies.
|
|
553
|
+
expect(marks).toHaveLength(2);
|
|
554
|
+
for (const mark of marks) {
|
|
555
|
+
expect(mark.src).toBe("https://cdn.example/mark.png");
|
|
556
|
+
expect(mark.alt).toBe("");
|
|
557
|
+
expect(mark.getAttribute("aria-hidden")).toBe("true");
|
|
558
|
+
}
|
|
559
|
+
// The wordmark beside the heading mark is the view title.
|
|
560
|
+
expect(
|
|
561
|
+
mount.querySelector<HTMLElement>(".persona-history-wordmark")?.textContent
|
|
562
|
+
).toBe("Messages");
|
|
563
|
+
expect(
|
|
564
|
+
mount
|
|
565
|
+
.querySelector<HTMLElement>('[data-persona-history-focus="collapse"]')
|
|
566
|
+
?.querySelector(".persona-history-toggle-brand > img")
|
|
567
|
+
).not.toBeNull();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("drops the brand and warns once for an unknown lucide name", async () => {
|
|
571
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
572
|
+
const { mount } = await railSetup({ brand: { icon: "not-a-real-icon" } });
|
|
573
|
+
expect(mount.querySelector(".persona-history-brand-mark")).toBeNull();
|
|
574
|
+
expect(mount.querySelector(".persona-history-toggle-brand")).toBeNull();
|
|
575
|
+
// The plain title comes back, visible, as if no brand were declared.
|
|
576
|
+
const title = mount.querySelector<HTMLElement>(".persona-history-title")!;
|
|
577
|
+
expect(title.classList.contains("persona-history-sr-only")).toBe(false);
|
|
578
|
+
// Resolved once for both faces, so the registry warns once.
|
|
579
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
580
|
+
warn.mockRestore();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe("collapse shortcut", () => {
|
|
584
|
+
beforeEach(() => setMacPlatformOverride(false));
|
|
585
|
+
afterEach(() => setMacPlatformOverride(null));
|
|
586
|
+
|
|
587
|
+
const collapseToggle = (mount: HTMLElement) =>
|
|
588
|
+
mount.querySelector<HTMLButtonElement>(
|
|
589
|
+
'[data-persona-history-focus="collapse"]'
|
|
590
|
+
)!;
|
|
591
|
+
const railWidth = (mount: HTMLElement) =>
|
|
592
|
+
mount.querySelector<HTMLElement>(".persona-history-rail-host")!.style.flex;
|
|
593
|
+
const pressModB = (target: EventTarget) =>
|
|
594
|
+
target.dispatchEvent(
|
|
595
|
+
new KeyboardEvent("keydown", {
|
|
596
|
+
key: "b",
|
|
597
|
+
ctrlKey: true,
|
|
598
|
+
bubbles: true,
|
|
599
|
+
cancelable: true,
|
|
600
|
+
composed: true,
|
|
601
|
+
})
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
it("binds nothing by default, so an embed never claims the host's keys", async () => {
|
|
605
|
+
const { mount } = await railSetup();
|
|
606
|
+
expect(railWidth(mount)).toBe("0 0 260px");
|
|
607
|
+
pressModB(collapseToggle(mount));
|
|
608
|
+
await flush();
|
|
609
|
+
expect(railWidth(mount)).toBe("0 0 260px");
|
|
610
|
+
expect(collapseToggle(mount).hasAttribute("aria-keyshortcuts")).toBe(false);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("collapses and expands from the configured combo", async () => {
|
|
614
|
+
const { mount } = await railSetup({ collapseShortcut: "mod+b" });
|
|
615
|
+
pressModB(collapseToggle(mount));
|
|
616
|
+
await flush();
|
|
617
|
+
expect(railWidth(mount)).toBe("0 0 52px");
|
|
618
|
+
pressModB(collapseToggle(mount));
|
|
619
|
+
await flush();
|
|
620
|
+
expect(railWidth(mount)).toBe("0 0 260px");
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("keeps a widget-scoped binding out of the rest of the page", async () => {
|
|
624
|
+
const { mount } = await railSetup({ collapseShortcut: "mod+b" });
|
|
625
|
+
const outside = document.createElement("button");
|
|
626
|
+
document.body.appendChild(outside);
|
|
627
|
+
pressModB(outside);
|
|
628
|
+
await flush();
|
|
629
|
+
expect(railWidth(mount)).toBe("0 0 260px");
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it("answers a page-scoped binding from outside the widget", async () => {
|
|
633
|
+
const { mount } = await railSetup({
|
|
634
|
+
collapseShortcut: "mod+b",
|
|
635
|
+
collapseShortcutScope: "page",
|
|
636
|
+
});
|
|
637
|
+
const outside = document.createElement("button");
|
|
638
|
+
document.body.appendChild(outside);
|
|
639
|
+
pressModB(outside);
|
|
640
|
+
await flush();
|
|
641
|
+
expect(railWidth(mount)).toBe("0 0 52px");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("stamps the toggle with aria-keyshortcuts and hints it in the tooltip", async () => {
|
|
645
|
+
const { mount } = await railSetup({ collapseShortcut: "mod+b" });
|
|
646
|
+
const toggle = collapseToggle(mount);
|
|
647
|
+
expect(toggle.getAttribute("aria-keyshortcuts")).toBe("Control+B");
|
|
648
|
+
|
|
649
|
+
resetTooltipTiming({ delayMs: 0, skipDelayMs: 0 });
|
|
650
|
+
toggle.dispatchEvent(new MouseEvent("mouseenter"));
|
|
651
|
+
const tooltip = document.querySelector(".persona-control-tooltip")!;
|
|
652
|
+
expect(
|
|
653
|
+
tooltip.querySelector(".persona-control-tooltip__label")!.textContent
|
|
654
|
+
).toBe("Collapse conversation list");
|
|
655
|
+
expect(
|
|
656
|
+
tooltip.querySelector(".persona-control-tooltip__hint")!.textContent
|
|
657
|
+
).toBe("Ctrl+B");
|
|
658
|
+
});
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("resolves declarative section icons and runs their callbacks", async () => {
|
|
662
|
+
const onSelect = vi.fn();
|
|
663
|
+
const { mount } = await railSetup({
|
|
664
|
+
sections: [
|
|
665
|
+
{
|
|
666
|
+
id: "workspace",
|
|
667
|
+
title: "Workspace",
|
|
668
|
+
items: [
|
|
669
|
+
{ id: "projects", label: "Projects", icon: "folder", onSelect },
|
|
670
|
+
{
|
|
671
|
+
id: "library",
|
|
672
|
+
label: "Library",
|
|
673
|
+
iconUrl: "https://cdn.example.com/library.png",
|
|
674
|
+
badge: "12",
|
|
675
|
+
onSelect: () => {},
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
},
|
|
679
|
+
],
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const section = mount.querySelector<HTMLElement>(
|
|
683
|
+
'[data-persona-rail-section="workspace"]'
|
|
684
|
+
)!;
|
|
685
|
+
// Default placement is above the list, after the new-conversation row.
|
|
686
|
+
expect(section.previousElementSibling?.className).toContain(
|
|
687
|
+
"persona-history-new"
|
|
688
|
+
);
|
|
689
|
+
const projects = section.querySelector<HTMLButtonElement>(
|
|
690
|
+
'[data-persona-rail-item="projects"]'
|
|
691
|
+
)!;
|
|
692
|
+
expect(projects.querySelector("svg")).not.toBeNull();
|
|
693
|
+
|
|
694
|
+
const image = section.querySelector<HTMLImageElement>(
|
|
695
|
+
'[data-persona-rail-item="library"] img'
|
|
696
|
+
)!;
|
|
697
|
+
expect(image.getAttribute("src")).toBe(
|
|
698
|
+
"https://cdn.example.com/library.png"
|
|
699
|
+
);
|
|
700
|
+
expect(image.getAttribute("alt")).toBe("");
|
|
701
|
+
expect(image.getAttribute("aria-hidden")).toBe("true");
|
|
702
|
+
expect(
|
|
703
|
+
section.querySelector('[data-persona-rail-item="library"] .persona-history-nav-badge')
|
|
704
|
+
?.textContent
|
|
705
|
+
).toBe("12");
|
|
706
|
+
|
|
707
|
+
projects.click();
|
|
708
|
+
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("warns once for an unknown lucide name and renders the row label-only", async () => {
|
|
712
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
713
|
+
const { mount, controller } = await railSetup({
|
|
714
|
+
sections: [
|
|
715
|
+
{
|
|
716
|
+
id: "workspace",
|
|
717
|
+
items: [
|
|
718
|
+
{ id: "projects", label: "Projects", icon: "not-an-icon", onSelect: () => {} },
|
|
719
|
+
],
|
|
720
|
+
},
|
|
721
|
+
],
|
|
722
|
+
});
|
|
723
|
+
const projects = () =>
|
|
724
|
+
mount.querySelector<HTMLButtonElement>(
|
|
725
|
+
'[data-persona-rail-item="projects"]'
|
|
726
|
+
)!;
|
|
727
|
+
expect(projects().querySelector("svg")).toBeNull();
|
|
728
|
+
expect(projects().textContent).toBe("Projects");
|
|
729
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
730
|
+
|
|
731
|
+
// A presentation flip re-attaches the rows it already built: no re-warn.
|
|
732
|
+
controller.update({
|
|
733
|
+
features: { history: { presentation: "panel" } },
|
|
734
|
+
} as never);
|
|
735
|
+
await flush();
|
|
736
|
+
controller.update({
|
|
737
|
+
features: { history: { presentation: "rail" } },
|
|
738
|
+
} as never);
|
|
739
|
+
await flush();
|
|
740
|
+
expect(projects().textContent).toBe("Projects");
|
|
741
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
742
|
+
warn.mockRestore();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("keeps the transcript and composer operable beside the navigation", async () => {
|
|
746
|
+
const { mount } = await railSetup();
|
|
747
|
+
const rail = mount.querySelector<HTMLElement>(".persona-history-rail-host");
|
|
748
|
+
expect(rail).not.toBeNull();
|
|
749
|
+
expect(rail!.contains(historyRoot(mount))).toBe(true);
|
|
750
|
+
const body = bodyOf(mount);
|
|
751
|
+
expect(body.style.display).not.toBe("none");
|
|
752
|
+
expect(body.hasAttribute("inert")).toBe(false);
|
|
753
|
+
expect(footerOf(mount).hidden).toBe(false);
|
|
754
|
+
// The rail runs the full widget height, so the shell header rides inside
|
|
755
|
+
// the conversation column: visible, never inert, never suppressed.
|
|
756
|
+
const header = headerOf(mount);
|
|
757
|
+
expect(header.closest(".persona-history-rail-conversation")).not.toBeNull();
|
|
758
|
+
expect(header.style.display).not.toBe("none");
|
|
759
|
+
expect(header.hasAttribute("inert")).toBe(false);
|
|
760
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
761
|
+
expect(suppressedIn(header)).toEqual([]);
|
|
762
|
+
// The rail host is a sibling of the column, not below the header.
|
|
763
|
+
expect(rail!.parentElement).toBe(
|
|
764
|
+
header.closest(".persona-history-rail-conversation")!.parentElement
|
|
765
|
+
);
|
|
766
|
+
// Its own bar rides inside the rail, led by the collapse toggle.
|
|
767
|
+
const bar = historyRoot(mount)!.querySelector<HTMLElement>(
|
|
768
|
+
".persona-history-topbar"
|
|
769
|
+
)!;
|
|
770
|
+
expect(bar.classList.contains("persona-history-topbar--shell")).toBe(false);
|
|
771
|
+
expect(
|
|
772
|
+
bar
|
|
773
|
+
.querySelector('[data-persona-history-focus="collapse"]')
|
|
774
|
+
?.getAttribute("aria-label")
|
|
775
|
+
).toBe("Collapse conversation list");
|
|
776
|
+
// The rail owns the toggle, so the header keeps none beside it.
|
|
777
|
+
expect(historyButton(mount)!.parentElement!.style.display).toBe("none");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it("hands the header toggle back once the rail is fully closed", async () => {
|
|
781
|
+
const { mount, controller } = await railSetup();
|
|
782
|
+
expect(historyButton(mount)!.parentElement!.style.display).toBe("none");
|
|
783
|
+
await closeHistoryUI(controller);
|
|
784
|
+
// With no rail on screen it is the only way back in.
|
|
785
|
+
expect(historyButton(mount)!.parentElement!.style.display).toBe("");
|
|
786
|
+
await openHistoryUI(mount);
|
|
787
|
+
expect(mount.querySelector(".persona-history-rail-shell")).not.toBeNull();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("keeps the header toggle hidden while an overlay trigger stands in", async () => {
|
|
791
|
+
const { mount, controller } = setup({
|
|
792
|
+
historyFeature: {
|
|
793
|
+
enabled: true,
|
|
794
|
+
presentation: "rail",
|
|
795
|
+
rail: { collapsedBehavior: "overlay" },
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
setContainerWidth(mount, 900);
|
|
799
|
+
controller.update({});
|
|
800
|
+
// Rest state: no rail, but the trigger owns opening and pinning.
|
|
801
|
+
expect(mount.querySelector("[data-persona-rail-trigger]")).not.toBeNull();
|
|
802
|
+
expect(historyButton(mount)!.parentElement!.style.display).toBe("none");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("stays open on selection and marks the active row", async () => {
|
|
806
|
+
const { mount } = await railSetup();
|
|
807
|
+
rowOf(mount, "conv-a")!.click();
|
|
808
|
+
await flush(20);
|
|
809
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
810
|
+
expect(rowOf(mount, "conv-a")!.getAttribute("aria-current")).toBe("page");
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("closes on Escape from inside the rail but not from the transcript", async () => {
|
|
814
|
+
const { mount } = await railSetup();
|
|
815
|
+
const escape = () =>
|
|
816
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
|
817
|
+
bodyOf(mount).dispatchEvent(escape());
|
|
818
|
+
await flush();
|
|
819
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
820
|
+
|
|
821
|
+
historyRoot(mount)!.dispatchEvent(escape());
|
|
822
|
+
await flush();
|
|
823
|
+
expect(historyRoot(mount)).toBeNull();
|
|
824
|
+
expect(document.activeElement).toBe(historyButton(mount));
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
it("collapses to panel below 720px while keeping the same view element", async () => {
|
|
828
|
+
const { mount, controller } = await railSetup();
|
|
829
|
+
const view = historyRoot(mount)!;
|
|
830
|
+
const bar = view.querySelector<HTMLElement>(".persona-history-topbar")!;
|
|
831
|
+
setContainerWidth(mount, 500);
|
|
832
|
+
controller.update({});
|
|
833
|
+
await flush();
|
|
834
|
+
expect(historyRoot(mount)).toBe(view);
|
|
835
|
+
expect(mount.querySelector(".persona-history-rail-host")).toBeNull();
|
|
836
|
+
expect(view.getAttribute("data-persona-history-presentation")).toBe("panel");
|
|
837
|
+
expect(bodyOf(mount).style.display).toBe("none");
|
|
838
|
+
// Panel hosting needs the header back as a direct container child.
|
|
839
|
+
expect(headerOf(mount).parentElement).toBe(
|
|
840
|
+
mount.querySelector(".persona-widget-container")
|
|
841
|
+
);
|
|
842
|
+
// The bar moves into the shell header, one instance, no entrance replay.
|
|
843
|
+
expect(headerOf(mount).style.display).not.toBe("none");
|
|
844
|
+
expect(headerOf(mount).hasAttribute("inert")).toBe(false);
|
|
845
|
+
expect(headerHostOf(mount)!.contains(bar)).toBe(true);
|
|
846
|
+
expect(mount.querySelectorAll(".persona-history-topbar")).toHaveLength(1);
|
|
847
|
+
expect(view.classList.contains("persona-history-view--enter")).toBe(false);
|
|
848
|
+
expect(document.activeElement).toBe(
|
|
849
|
+
bar.querySelector('[data-persona-history-focus="close"]')
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
// Widening back to rail must hand the header back.
|
|
853
|
+
setContainerWidth(mount, 900);
|
|
854
|
+
controller.update({});
|
|
855
|
+
await flush();
|
|
856
|
+
expect(mount.querySelector(".persona-history-rail-host")).not.toBeNull();
|
|
857
|
+
// And the header travels back into the conversation column.
|
|
858
|
+
expect(
|
|
859
|
+
headerOf(mount).closest(".persona-history-rail-conversation")
|
|
860
|
+
).not.toBeNull();
|
|
861
|
+
expect(headerOf(mount).style.display).not.toBe("none");
|
|
862
|
+
expect(headerOf(mount).hasAttribute("inert")).toBe(false);
|
|
863
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
864
|
+
expect(suppressedIn(headerOf(mount))).toEqual([]);
|
|
865
|
+
expect(view.contains(bar)).toBe(true);
|
|
866
|
+
expect(mount.querySelectorAll(".persona-history-topbar")).toHaveLength(1);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("keeps a rebuilt header inside the conversation column", async () => {
|
|
870
|
+
const { mount, controller } = await railSetup();
|
|
871
|
+
controller.update({ layout: { header: { layout: "minimal" } } });
|
|
872
|
+
await flush();
|
|
873
|
+
const header = headerOf(mount);
|
|
874
|
+
expect(header.closest(".persona-history-rail-conversation")).not.toBeNull();
|
|
875
|
+
expect(mount.querySelectorAll('[data-persona-theme-zone="header"]')).toHaveLength(1);
|
|
876
|
+
expect(mount.querySelector(".persona-history-rail-host")).not.toBeNull();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
it("re-derives the rail width from a live config update", async () => {
|
|
880
|
+
const { mount, controller } = await railSetup();
|
|
881
|
+
const host = mount.querySelector<HTMLElement>(".persona-history-rail-host")!;
|
|
882
|
+
expect(host.style.flex).toBe("0 0 260px");
|
|
883
|
+
controller.update({
|
|
884
|
+
features: { history: { rail: { width: 320 } } },
|
|
885
|
+
} as never);
|
|
886
|
+
await flush();
|
|
887
|
+
expect(host.style.flex).toBe("0 0 320px");
|
|
888
|
+
// A side flip moves the host past the conversation and swaps the divider.
|
|
889
|
+
controller.update({
|
|
890
|
+
features: { history: { rail: { side: "right" } } },
|
|
891
|
+
} as never);
|
|
892
|
+
await flush();
|
|
893
|
+
const shell = mount.querySelector<HTMLElement>(".persona-history-rail-shell")!;
|
|
894
|
+
expect(shell.lastElementChild).toBe(host);
|
|
895
|
+
expect(host.style.borderLeft).not.toBe("");
|
|
896
|
+
expect(host.style.borderRight).toBe("");
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("ignores Escape raised by the header now that it sits in the column", async () => {
|
|
900
|
+
const { mount } = await railSetup();
|
|
901
|
+
headerOf(mount).dispatchEvent(
|
|
902
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
|
|
903
|
+
);
|
|
904
|
+
await flush();
|
|
905
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("anchors a top-right close button to the column, not the container", async () => {
|
|
909
|
+
const { mount } = setup({
|
|
910
|
+
historyFeature: { enabled: true, presentation: "rail" },
|
|
911
|
+
config: {
|
|
912
|
+
launcher: { enabled: false, closeButtonPlacement: "top-right" },
|
|
913
|
+
},
|
|
914
|
+
});
|
|
915
|
+
const container = setContainerWidth(mount, 900);
|
|
916
|
+
await openHistoryUI(mount);
|
|
917
|
+
const column = mount.querySelector<HTMLElement>(
|
|
918
|
+
".persona-history-rail-conversation"
|
|
919
|
+
)!;
|
|
920
|
+
const wrapper = mount.querySelector<HTMLElement>(
|
|
921
|
+
'button[aria-label="Close chat"]'
|
|
922
|
+
)!.parentElement!;
|
|
923
|
+
// Absolutely positioned: anchored to the container it would float over
|
|
924
|
+
// the rail instead of over the conversation.
|
|
925
|
+
expect(wrapper.classList.contains("persona-absolute")).toBe(true);
|
|
926
|
+
expect(wrapper.parentElement).toBe(column);
|
|
927
|
+
expect(wrapper.parentElement).not.toBe(container);
|
|
928
|
+
expect(column.style.position).toBe("relative");
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it("hands the container back in its original child order", async () => {
|
|
932
|
+
const { mount, controller } = setup({
|
|
933
|
+
historyFeature: { enabled: true, presentation: "rail" },
|
|
934
|
+
config: {
|
|
935
|
+
launcher: { enabled: false, closeButtonPlacement: "top-right" },
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
const container = setContainerWidth(mount, 900);
|
|
939
|
+
const before = Array.from(container.children);
|
|
940
|
+
await openHistoryUI(mount);
|
|
941
|
+
const column = mount.querySelector<HTMLElement>(
|
|
942
|
+
".persona-history-rail-conversation"
|
|
943
|
+
)!;
|
|
944
|
+
expect(column.contains(headerOf(mount))).toBe(true);
|
|
945
|
+
expect(column.contains(bodyOf(mount))).toBe(true);
|
|
946
|
+
await closeHistoryUI(controller);
|
|
947
|
+
expect(mount.querySelector(".persona-history-rail-shell")).toBeNull();
|
|
948
|
+
const after = Array.from(container.children);
|
|
949
|
+
expect(after).toHaveLength(before.length);
|
|
950
|
+
after.forEach((node, index) => expect(node).toBe(before[index]));
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it("shrinks the host to the icon rail and back from the toggle", async () => {
|
|
954
|
+
const { mount } = await railSetup();
|
|
955
|
+
const host = mount.querySelector<HTMLElement>(".persona-history-rail-host")!;
|
|
956
|
+
const toggle = () =>
|
|
957
|
+
mount.querySelector<HTMLButtonElement>(
|
|
958
|
+
'[data-persona-history-focus="collapse"]'
|
|
959
|
+
)!;
|
|
960
|
+
expect(host.style.flex).toBe("0 0 260px");
|
|
961
|
+
|
|
962
|
+
toggle().click();
|
|
963
|
+
await flush();
|
|
964
|
+
expect(host.style.flex).toBe("0 0 52px");
|
|
965
|
+
expect(
|
|
966
|
+
historyRoot(mount)!.classList.contains("persona-history-view--rail-collapsed")
|
|
967
|
+
).toBe(true);
|
|
968
|
+
// Same button, so focus never leaves the control the visitor pressed.
|
|
969
|
+
expect(toggle().getAttribute("aria-label")).toBe("Expand conversation list");
|
|
970
|
+
|
|
971
|
+
toggle().click();
|
|
972
|
+
await flush();
|
|
973
|
+
expect(host.style.flex).toBe("0 0 260px");
|
|
974
|
+
expect(
|
|
975
|
+
historyRoot(mount)!.classList.contains("persona-history-view--rail-collapsed")
|
|
976
|
+
).toBe(false);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it("moves focus into the rail only for a keyboard-initiated open", async () => {
|
|
980
|
+
const { mount, controller } = setup({
|
|
981
|
+
historyFeature: { enabled: true, presentation: "rail" },
|
|
982
|
+
});
|
|
983
|
+
setContainerWidth(mount, 900);
|
|
984
|
+
const host = () =>
|
|
985
|
+
mount.querySelector<HTMLElement>(".persona-history-rail-host")!;
|
|
986
|
+
const toggle = () =>
|
|
987
|
+
mount.querySelector<HTMLButtonElement>(
|
|
988
|
+
'[data-persona-history-focus="collapse"]'
|
|
989
|
+
)!;
|
|
990
|
+
const close = () => closeHistoryUI(controller);
|
|
991
|
+
|
|
992
|
+
// Pointer: the conversation stays operable beside the rail, so a focus
|
|
993
|
+
// move would only leave a keyboard ring on the toggle.
|
|
994
|
+
await openHistoryUI(mount);
|
|
995
|
+
expect(host().contains(document.activeElement)).toBe(false);
|
|
996
|
+
await close();
|
|
997
|
+
|
|
998
|
+
// Programmatic: no user event behind it at all.
|
|
999
|
+
await controller.showHistory();
|
|
1000
|
+
await flush();
|
|
1001
|
+
expect(host().contains(document.activeElement)).toBe(false);
|
|
1002
|
+
await close();
|
|
1003
|
+
|
|
1004
|
+
await openHistoryUI(mount, { keyboard: true });
|
|
1005
|
+
expect(document.activeElement).toBe(toggle());
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
it("renders no toggle and stays expanded when collapse is turned off", async () => {
|
|
1009
|
+
const { mount } = await railSetup({ collapsible: false });
|
|
1010
|
+
expect(
|
|
1011
|
+
mount.querySelector('[data-persona-history-focus="collapse"]')
|
|
1012
|
+
).toBeNull();
|
|
1013
|
+
expect(
|
|
1014
|
+
mount.querySelector<HTMLElement>(".persona-history-rail-host")!.style.flex
|
|
1015
|
+
).toBe("0 0 260px");
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("starts collapsed when the host configures it", async () => {
|
|
1019
|
+
const { mount } = await railSetup({ defaultCollapsed: true });
|
|
1020
|
+
expect(
|
|
1021
|
+
mount.querySelector<HTMLElement>(".persona-history-rail-host")!.style.flex
|
|
1022
|
+
).toBe("0 0 52px");
|
|
1023
|
+
expect(
|
|
1024
|
+
historyRoot(mount)!.classList.contains("persona-history-view--rail-collapsed")
|
|
1025
|
+
).toBe(true);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("closes fully on Escape from a collapsed rail", async () => {
|
|
1029
|
+
const { mount } = await railSetup();
|
|
1030
|
+
mount
|
|
1031
|
+
.querySelector<HTMLButtonElement>('[data-persona-history-focus="collapse"]')!
|
|
1032
|
+
.click();
|
|
1033
|
+
await flush();
|
|
1034
|
+
historyRoot(mount)!.dispatchEvent(
|
|
1035
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
|
|
1036
|
+
);
|
|
1037
|
+
await flush(20);
|
|
1038
|
+
expect(historyRoot(mount)).toBeNull();
|
|
1039
|
+
expect(mount.querySelector(".persona-history-rail-shell")).toBeNull();
|
|
1040
|
+
expect(document.activeElement).toBe(historyButton(mount));
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it("keeps the collapsed state across a close, a reopen, and a width flip", async () => {
|
|
1044
|
+
const { mount, controller } = setup({
|
|
1045
|
+
historyFeature: { enabled: true, presentation: "rail" },
|
|
1046
|
+
config: { persistState: { keyPrefix: "persona-test-" } },
|
|
1047
|
+
});
|
|
1048
|
+
setContainerWidth(mount, 900);
|
|
1049
|
+
await openHistoryUI(mount);
|
|
1050
|
+
mount
|
|
1051
|
+
.querySelector<HTMLButtonElement>('[data-persona-history-focus="collapse"]')!
|
|
1052
|
+
.click();
|
|
1053
|
+
await flush();
|
|
1054
|
+
expect(window.localStorage.getItem("persona-test-rail-collapsed")).toBe("1");
|
|
1055
|
+
|
|
1056
|
+
await closeHistoryUI(controller);
|
|
1057
|
+
await openHistoryUI(mount);
|
|
1058
|
+
expect(
|
|
1059
|
+
mount.querySelector<HTMLElement>(".persona-history-rail-host")!.style.flex
|
|
1060
|
+
).toBe("0 0 52px");
|
|
1061
|
+
|
|
1062
|
+
// Panel always shows the whole list; the rail restores the state.
|
|
1063
|
+
setContainerWidth(mount, 500);
|
|
1064
|
+
controller.update({});
|
|
1065
|
+
await flush();
|
|
1066
|
+
expect(
|
|
1067
|
+
historyRoot(mount)!.classList.contains("persona-history-view--rail-collapsed")
|
|
1068
|
+
).toBe(false);
|
|
1069
|
+
setContainerWidth(mount, 900);
|
|
1070
|
+
controller.update({});
|
|
1071
|
+
await flush();
|
|
1072
|
+
expect(
|
|
1073
|
+
historyRoot(mount)!.classList.contains("persona-history-view--rail-collapsed")
|
|
1074
|
+
).toBe(true);
|
|
1075
|
+
expect(
|
|
1076
|
+
mount.querySelector<HTMLElement>(".persona-history-rail-host")!.style.flex
|
|
1077
|
+
).toBe("0 0 52px");
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
it("falls back to memory when state persistence is off", async () => {
|
|
1081
|
+
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
|
1082
|
+
const { mount, controller } = await railSetup();
|
|
1083
|
+
mount
|
|
1084
|
+
.querySelector<HTMLButtonElement>('[data-persona-history-focus="collapse"]')!
|
|
1085
|
+
.click();
|
|
1086
|
+
await flush();
|
|
1087
|
+
expect(
|
|
1088
|
+
setItem.mock.calls.filter(([key]) =>
|
|
1089
|
+
String(key).endsWith("rail-collapsed")
|
|
1090
|
+
)
|
|
1091
|
+
).toEqual([]);
|
|
1092
|
+
expect(window.localStorage.getItem("persona-rail-collapsed")).toBeNull();
|
|
1093
|
+
// The in-memory value still survives a close and reopen.
|
|
1094
|
+
await closeHistoryUI(controller);
|
|
1095
|
+
await openHistoryUI(mount);
|
|
1096
|
+
expect(
|
|
1097
|
+
mount.querySelector<HTMLElement>(".persona-history-rail-host")!.style.flex
|
|
1098
|
+
).toBe("0 0 52px");
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
it("auto keeps a floating launcher on panel at any width", async () => {
|
|
1102
|
+
const { mount } = setup({
|
|
1103
|
+
historyFeature: { enabled: true, presentation: "auto" },
|
|
1104
|
+
config: { launcher: { enabled: true, autoExpand: true } },
|
|
1105
|
+
});
|
|
1106
|
+
setContainerWidth(mount, 1200);
|
|
1107
|
+
await openHistoryUI(mount);
|
|
1108
|
+
expect(mount.querySelector(".persona-history-rail-host")).toBeNull();
|
|
1109
|
+
expect(historyRoot(mount)!.getAttribute("data-persona-history-presentation")).toBe(
|
|
1110
|
+
"panel"
|
|
1111
|
+
);
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
describe("navigation and transitions", () => {
|
|
1116
|
+
it("records the conversation return surface and restores invoker focus", async () => {
|
|
1117
|
+
const { mount, controller } = setup();
|
|
1118
|
+
const closed: unknown[] = [];
|
|
1119
|
+
controller.on("history:closed", (payload) => closed.push(payload));
|
|
1120
|
+
await openHistoryUI(mount);
|
|
1121
|
+
mount
|
|
1122
|
+
.querySelector<HTMLButtonElement>('[data-persona-history-focus="close"]')!
|
|
1123
|
+
.click();
|
|
1124
|
+
await flush();
|
|
1125
|
+
expect(closed).toEqual([
|
|
1126
|
+
expect.objectContaining({ returnSurface: "conversation" }),
|
|
1127
|
+
]);
|
|
1128
|
+
expect(document.activeElement).toBe(historyButton(mount));
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it("labels the panel back control for the no-Home fallback", async () => {
|
|
1132
|
+
const { mount } = setup();
|
|
1133
|
+
await openHistoryUI(mount);
|
|
1134
|
+
const back = mount.querySelector<HTMLButtonElement>(
|
|
1135
|
+
'[data-persona-history-focus="close"]'
|
|
1136
|
+
)!;
|
|
1137
|
+
expect(back.getAttribute("aria-label")).toBe("Back to conversation");
|
|
1138
|
+
expect(document.activeElement).toBe(back);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
it("shows a retryable error surface when an open fails, with a way back", async () => {
|
|
1142
|
+
const { mount, provider } = setup();
|
|
1143
|
+
provider.setFailure("getPage", { code: "unavailable" });
|
|
1144
|
+
await openHistoryUI(mount);
|
|
1145
|
+
rowOf(mount, "conv-a")!.click();
|
|
1146
|
+
await flush(20);
|
|
1147
|
+
// Optimistic navigation: the list is gone, the error surface owns it.
|
|
1148
|
+
expect(historyRoot(mount)).toBeNull();
|
|
1149
|
+
const error = mount.querySelector<HTMLElement>(
|
|
1150
|
+
".persona-conversation-loading-error"
|
|
1151
|
+
)!;
|
|
1152
|
+
expect(error.textContent).toContain("Could not open the conversation.");
|
|
1153
|
+
// The composer must not send into the conversation that failed to open.
|
|
1154
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(true);
|
|
1155
|
+
|
|
1156
|
+
// Retry succeeds once the provider recovers and hydrates the transcript.
|
|
1157
|
+
provider.setFailure("getPage", null);
|
|
1158
|
+
const retry = Array.from(error.querySelectorAll("button")).find(
|
|
1159
|
+
(button) => button.textContent === "Try again"
|
|
1160
|
+
)!;
|
|
1161
|
+
retry.click();
|
|
1162
|
+
await flush(20);
|
|
1163
|
+
expect(
|
|
1164
|
+
mount.querySelector(".persona-conversation-loading-error")
|
|
1165
|
+
).toBeNull();
|
|
1166
|
+
expect(
|
|
1167
|
+
bodyOf(mount).querySelectorAll("[data-message-id]").length
|
|
1168
|
+
).toBeGreaterThan(0);
|
|
1169
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(false);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
it("returns to Messages from the failed-open surface", async () => {
|
|
1173
|
+
const { mount, provider } = setup();
|
|
1174
|
+
provider.setFailure("getPage", { code: "unavailable" });
|
|
1175
|
+
await openHistoryUI(mount);
|
|
1176
|
+
rowOf(mount, "conv-a")!.click();
|
|
1177
|
+
await flush(20);
|
|
1178
|
+
const back = Array.from(
|
|
1179
|
+
mount.querySelectorAll<HTMLButtonElement>(
|
|
1180
|
+
".persona-conversation-loading-error button"
|
|
1181
|
+
)
|
|
1182
|
+
).find((button) => button.textContent === "Back to messages")!;
|
|
1183
|
+
back.click();
|
|
1184
|
+
await flush(20);
|
|
1185
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
1186
|
+
expect(
|
|
1187
|
+
mount.querySelector(".persona-conversation-loading-error")
|
|
1188
|
+
).toBeNull();
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it("disables the button with an accessible explanation while a turn streams", async () => {
|
|
1192
|
+
const hanging = vi.fn().mockImplementation((_url: string, init: any) => {
|
|
1193
|
+
const signal = init.signal as AbortSignal;
|
|
1194
|
+
return new Promise((_resolve, reject) => {
|
|
1195
|
+
signal.addEventListener("abort", () => reject(new Error("aborted")));
|
|
1196
|
+
});
|
|
1197
|
+
});
|
|
1198
|
+
const originalFetch = global.fetch;
|
|
1199
|
+
global.fetch = hanging as unknown as typeof fetch;
|
|
1200
|
+
try {
|
|
1201
|
+
const { mount, controller } = setup();
|
|
1202
|
+
controller.submitMessage("hello");
|
|
1203
|
+
await flush(20);
|
|
1204
|
+
const button = historyButton(mount)!;
|
|
1205
|
+
expect(button.disabled).toBe(true);
|
|
1206
|
+
expect(button.getAttribute("aria-disabled")).toBe("true");
|
|
1207
|
+
expect(button.getAttribute("aria-label")).toBe(
|
|
1208
|
+
"Messages, available once the reply finishes"
|
|
1209
|
+
);
|
|
1210
|
+
} finally {
|
|
1211
|
+
global.fetch = originalFetch;
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
describe("open flow", () => {
|
|
1217
|
+
it("navigates on the click, gates the composer, and hydrates without flashing a skeleton", async () => {
|
|
1218
|
+
const { mount, provider } = setup({ provider: { latencyMs: 20 } });
|
|
1219
|
+
historyButton(mount)!.click();
|
|
1220
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1221
|
+
await flush(20);
|
|
1222
|
+
rowOf(mount, "conv-a")!.click();
|
|
1223
|
+
await flush();
|
|
1224
|
+
// Optimistic navigation: the panel leaves immediately (jsdom has no WAAPI,
|
|
1225
|
+
// so the exit settles synchronously) and the composer is gated, but the
|
|
1226
|
+
// stand-in waits out its show delay before it takes the surface.
|
|
1227
|
+
expect(historyRoot(mount)).toBeNull();
|
|
1228
|
+
const standIn = mount.querySelector<HTMLElement>(
|
|
1229
|
+
".persona-conversation-loading"
|
|
1230
|
+
)!;
|
|
1231
|
+
expect(standIn.getAttribute("role")).toBe("status");
|
|
1232
|
+
expect(standIn.getAttribute("aria-label")).toBe("Loading conversation");
|
|
1233
|
+
expect(
|
|
1234
|
+
standIn.querySelectorAll(".persona-conversation-loading-bubble").length
|
|
1235
|
+
).toBe(3);
|
|
1236
|
+
expect(standIn.hidden).toBe(true);
|
|
1237
|
+
expect(messagesOf(mount).style.display).toBe("");
|
|
1238
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(true);
|
|
1239
|
+
expect(bodyOf(mount).querySelectorAll("[data-message-id]").length).toBe(0);
|
|
1240
|
+
|
|
1241
|
+
// The provider applies latency per internal operation, so the whole open
|
|
1242
|
+
// is a multiple of it and still lands inside the delay: one swap, and the
|
|
1243
|
+
// skeleton the visitor never saw is simply removed.
|
|
1244
|
+
await waitUntil(
|
|
1245
|
+
() => mount.querySelector(".persona-conversation-loading") === null
|
|
1246
|
+
);
|
|
1247
|
+
expect(historyRoot(mount)).toBeNull();
|
|
1248
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(false);
|
|
1249
|
+
expect(provider.getActiveConversationId()).toBe("conv-a");
|
|
1250
|
+
const ids = Array.from(
|
|
1251
|
+
bodyOf(mount).querySelectorAll("[data-message-id]")
|
|
1252
|
+
).map((node) => node.getAttribute("data-message-id"));
|
|
1253
|
+
expect(ids).toEqual(["a1", "a2", "a3"]);
|
|
1254
|
+
expect(messagesOf(mount).style.display).toBe("");
|
|
1255
|
+
|
|
1256
|
+
// The commit cancelled the takeover, so nothing regresses once the delay
|
|
1257
|
+
// it was scheduled for has passed.
|
|
1258
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1259
|
+
await flush(20);
|
|
1260
|
+
expect(mount.querySelector(".persona-conversation-loading")).toBeNull();
|
|
1261
|
+
expect(messagesOf(mount).style.display).toBe("");
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it("holds the welcome surface through a fast open and hides it at hydration", async () => {
|
|
1265
|
+
const { mount } = setup({
|
|
1266
|
+
provider: { latencyMs: 20 },
|
|
1267
|
+
config: { welcome: { variant: "hero", title: "Welcome home" } },
|
|
1268
|
+
});
|
|
1269
|
+
// Empty transcript: the hero owns the conversation surface.
|
|
1270
|
+
expect(welcomeOf(mount).hidden).toBe(false);
|
|
1271
|
+
historyButton(mount)!.click();
|
|
1272
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1273
|
+
await flush(20);
|
|
1274
|
+
rowOf(mount, "conv-a")!.click();
|
|
1275
|
+
await flush();
|
|
1276
|
+
// Mid-flight the stand-in is still waiting, so the hero holds: a fast open
|
|
1277
|
+
// must not parade welcome, skeleton, transcript.
|
|
1278
|
+
expect(mount.querySelector<HTMLElement>(".persona-conversation-loading")!.hidden).toBe(
|
|
1279
|
+
true
|
|
1280
|
+
);
|
|
1281
|
+
expect(welcomeOf(mount).hidden).toBe(false);
|
|
1282
|
+
|
|
1283
|
+
await waitUntil(
|
|
1284
|
+
() => mount.querySelector(".persona-conversation-loading") === null
|
|
1285
|
+
);
|
|
1286
|
+
// Derived visibility hides the hero once the restored user messages land.
|
|
1287
|
+
expect(welcomeOf(mount).hidden).toBe(true);
|
|
1288
|
+
expect(
|
|
1289
|
+
bodyOf(mount).querySelectorAll("[data-message-id]").length
|
|
1290
|
+
).toBeGreaterThan(0);
|
|
1291
|
+
// The cancelled takeover cannot hide the hero again after the fact.
|
|
1292
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
1293
|
+
await flush(20);
|
|
1294
|
+
expect(messagesOf(mount).style.display).toBe("");
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
it("takes the surface with a skeleton once an open outlasts the show delay", async () => {
|
|
1298
|
+
const { mount, provider } = setup({
|
|
1299
|
+
config: { welcome: { variant: "hero", title: "Welcome home" } },
|
|
1300
|
+
});
|
|
1301
|
+
await openHistoryUI(mount);
|
|
1302
|
+
// Far past the 250ms delay, so the takeover is observable mid-flight.
|
|
1303
|
+
provider.setLatency(600);
|
|
1304
|
+
rowOf(mount, "conv-a")!.click();
|
|
1305
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
1306
|
+
await flush(20);
|
|
1307
|
+
const skeleton = mount.querySelector<HTMLElement>(
|
|
1308
|
+
".persona-conversation-loading"
|
|
1309
|
+
)!;
|
|
1310
|
+
expect(skeleton.hidden).toBe(false);
|
|
1311
|
+
expect(messagesOf(mount).style.display).toBe("none");
|
|
1312
|
+
expect(welcomeOf(mount).hidden).toBe(true);
|
|
1313
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(true);
|
|
1314
|
+
|
|
1315
|
+
// Let the rest of the open run at speed; the transcript replaces the
|
|
1316
|
+
// skeleton and restores the wrapper.
|
|
1317
|
+
provider.setLatency(0);
|
|
1318
|
+
await waitUntil(
|
|
1319
|
+
() => mount.querySelector(".persona-conversation-loading") === null
|
|
1320
|
+
);
|
|
1321
|
+
expect(messagesOf(mount).style.display).toBe("");
|
|
1322
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(false);
|
|
1323
|
+
const ids = Array.from(
|
|
1324
|
+
bodyOf(mount).querySelectorAll("[data-message-id]")
|
|
1325
|
+
).map((node) => node.getAttribute("data-message-id"));
|
|
1326
|
+
expect(ids).toEqual(["a1", "a2", "a3"]);
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it("keeps the welcome hidden behind the failed-open surface and restores it on the way back", async () => {
|
|
1330
|
+
const { mount, provider } = setup({
|
|
1331
|
+
config: { welcome: { variant: "hero", title: "Welcome home" } },
|
|
1332
|
+
});
|
|
1333
|
+
provider.setFailure("getPage", { code: "unavailable" });
|
|
1334
|
+
await openHistoryUI(mount);
|
|
1335
|
+
rowOf(mount, "conv-a")!.click();
|
|
1336
|
+
await flush(20);
|
|
1337
|
+
const error = mount.querySelector<HTMLElement>(
|
|
1338
|
+
".persona-conversation-loading-error"
|
|
1339
|
+
)!;
|
|
1340
|
+
expect(welcomeOf(mount).hidden).toBe(true);
|
|
1341
|
+
|
|
1342
|
+
const back = Array.from(error.querySelectorAll("button")).find(
|
|
1343
|
+
(button) => button.textContent === "Back to messages"
|
|
1344
|
+
)!;
|
|
1345
|
+
back.click();
|
|
1346
|
+
await flush(20);
|
|
1347
|
+
// The transcript never changed, so the welcome comes back with it.
|
|
1348
|
+
expect(bodyOf(mount).querySelectorAll("[data-message-id]").length).toBe(0);
|
|
1349
|
+
expect(welcomeOf(mount).hidden).toBe(false);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it("routes the transcript stand-in through loadingIndicator.render", async () => {
|
|
1353
|
+
const seen: Array<{ location: string; streaming: boolean }> = [];
|
|
1354
|
+
const { mount } = setup({
|
|
1355
|
+
provider: { latencyMs: 30 },
|
|
1356
|
+
config: {
|
|
1357
|
+
loadingIndicator: {
|
|
1358
|
+
render: (context: LoadingIndicatorRenderContext) => {
|
|
1359
|
+
seen.push({
|
|
1360
|
+
location: context.location,
|
|
1361
|
+
streaming: context.streaming,
|
|
1362
|
+
});
|
|
1363
|
+
const el = document.createElement("div");
|
|
1364
|
+
el.className = "custom-open-loading";
|
|
1365
|
+
el.textContent = "Fetching that conversation";
|
|
1366
|
+
return el;
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
historyButton(mount)!.click();
|
|
1372
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1373
|
+
await flush(20);
|
|
1374
|
+
rowOf(mount, "conv-a")!.click();
|
|
1375
|
+
await flush();
|
|
1376
|
+
const standIn = mount.querySelector<HTMLElement>(
|
|
1377
|
+
".persona-conversation-loading"
|
|
1378
|
+
)!;
|
|
1379
|
+
expect(standIn.querySelector(".custom-open-loading")).not.toBeNull();
|
|
1380
|
+
expect(
|
|
1381
|
+
standIn.querySelector(".persona-conversation-loading-bubble")
|
|
1382
|
+
).toBeNull();
|
|
1383
|
+
expect(seen).toEqual([
|
|
1384
|
+
{ location: "conversation-open", streaming: false },
|
|
1385
|
+
]);
|
|
1386
|
+
// Persona keeps the orchestration around the custom node.
|
|
1387
|
+
expect(standIn.getAttribute("role")).toBe("status");
|
|
1388
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(true);
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
it("lets a plugin own the stand-in ahead of the config renderer", async () => {
|
|
1392
|
+
const plugin: AgentWidgetPlugin = {
|
|
1393
|
+
id: "test-open-loading",
|
|
1394
|
+
renderLoadingIndicator: ({ location, defaultRenderer }) => {
|
|
1395
|
+
if (location !== "conversation-open") return defaultRenderer();
|
|
1396
|
+
const el = document.createElement("div");
|
|
1397
|
+
el.className = "plugin-open-loading";
|
|
1398
|
+
return el;
|
|
1399
|
+
},
|
|
1400
|
+
};
|
|
1401
|
+
const { mount } = setup({
|
|
1402
|
+
provider: { latencyMs: 30 },
|
|
1403
|
+
config: {
|
|
1404
|
+
plugins: [plugin],
|
|
1405
|
+
loadingIndicator: {
|
|
1406
|
+
render: () => {
|
|
1407
|
+
const el = document.createElement("div");
|
|
1408
|
+
el.className = "config-open-loading";
|
|
1409
|
+
return el;
|
|
1410
|
+
},
|
|
1411
|
+
},
|
|
1412
|
+
},
|
|
1413
|
+
});
|
|
1414
|
+
historyButton(mount)!.click();
|
|
1415
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1416
|
+
await flush(20);
|
|
1417
|
+
rowOf(mount, "conv-a")!.click();
|
|
1418
|
+
await flush();
|
|
1419
|
+
const standIn = mount.querySelector<HTMLElement>(
|
|
1420
|
+
".persona-conversation-loading"
|
|
1421
|
+
)!;
|
|
1422
|
+
expect(standIn.querySelector(".plugin-open-loading")).not.toBeNull();
|
|
1423
|
+
expect(standIn.querySelector(".config-open-loading")).toBeNull();
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
it("routes the failed-open surface through renderHistoryOpenError", async () => {
|
|
1427
|
+
const seen: Array<{ conversationId: string; hasBack: boolean; title: string }> =
|
|
1428
|
+
[];
|
|
1429
|
+
const plugin: AgentWidgetPlugin = {
|
|
1430
|
+
id: "test-open-error",
|
|
1431
|
+
renderHistoryOpenError: (context) => {
|
|
1432
|
+
seen.push({
|
|
1433
|
+
conversationId: context.conversationId,
|
|
1434
|
+
hasBack: typeof context.back === "function",
|
|
1435
|
+
title: context.copy.title,
|
|
1436
|
+
});
|
|
1437
|
+
const el = document.createElement("div");
|
|
1438
|
+
el.className = "custom-open-error";
|
|
1439
|
+
const retryButton = document.createElement("button");
|
|
1440
|
+
retryButton.textContent = "custom retry";
|
|
1441
|
+
retryButton.addEventListener("click", context.retry);
|
|
1442
|
+
el.append(retryButton);
|
|
1443
|
+
return el;
|
|
1444
|
+
},
|
|
1445
|
+
};
|
|
1446
|
+
const { mount, provider } = setup({ config: { plugins: [plugin] } });
|
|
1447
|
+
provider.setFailure("getPage", { code: "unavailable" });
|
|
1448
|
+
await openHistoryUI(mount);
|
|
1449
|
+
rowOf(mount, "conv-a")!.click();
|
|
1450
|
+
await flush(20);
|
|
1451
|
+
const container = mount.querySelector<HTMLElement>(
|
|
1452
|
+
".persona-conversation-loading-error"
|
|
1453
|
+
)!;
|
|
1454
|
+
// Persona keeps the orchestration around the custom node.
|
|
1455
|
+
expect(container.getAttribute("role")).toBe("alert");
|
|
1456
|
+
expect(container.querySelector(".custom-open-error")).not.toBeNull();
|
|
1457
|
+
expect(
|
|
1458
|
+
container.querySelector(".persona-conversation-loading-error-body")
|
|
1459
|
+
).toBeNull();
|
|
1460
|
+
expect(seen).toEqual([
|
|
1461
|
+
{
|
|
1462
|
+
conversationId: "conv-a",
|
|
1463
|
+
hasBack: true,
|
|
1464
|
+
title: "Could not open the conversation.",
|
|
1465
|
+
},
|
|
1466
|
+
]);
|
|
1467
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(true);
|
|
1468
|
+
|
|
1469
|
+
// The provided retry routes through the token-guarded open flow.
|
|
1470
|
+
provider.setFailure("getPage", null);
|
|
1471
|
+
container.querySelector("button")!.click();
|
|
1472
|
+
await flush(20);
|
|
1473
|
+
expect(
|
|
1474
|
+
mount.querySelector(".persona-conversation-loading-error")
|
|
1475
|
+
).toBeNull();
|
|
1476
|
+
expect(
|
|
1477
|
+
bodyOf(mount).querySelectorAll("[data-message-id]").length
|
|
1478
|
+
).toBeGreaterThan(0);
|
|
1479
|
+
expect(footerOf(mount).hasAttribute("inert")).toBe(false);
|
|
1480
|
+
});
|
|
1481
|
+
|
|
1482
|
+
it("falls back to the default error block when the hook returns null", async () => {
|
|
1483
|
+
const plugin: AgentWidgetPlugin = {
|
|
1484
|
+
id: "test-open-error-null",
|
|
1485
|
+
renderHistoryOpenError: () => null,
|
|
1486
|
+
};
|
|
1487
|
+
const { mount, provider } = setup({ config: { plugins: [plugin] } });
|
|
1488
|
+
provider.setFailure("getPage", { code: "unavailable" });
|
|
1489
|
+
await openHistoryUI(mount);
|
|
1490
|
+
rowOf(mount, "conv-a")!.click();
|
|
1491
|
+
await flush(20);
|
|
1492
|
+
const body = mount.querySelector<HTMLElement>(
|
|
1493
|
+
".persona-conversation-loading-error-body"
|
|
1494
|
+
)!;
|
|
1495
|
+
expect(body.textContent).toContain("Could not open the conversation.");
|
|
1496
|
+
expect(
|
|
1497
|
+
Array.from(body.querySelectorAll("button")).map(
|
|
1498
|
+
(button) => button.textContent
|
|
1499
|
+
)
|
|
1500
|
+
).toEqual(["Try again", "Back to messages"]);
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it("falls back to the default skeleton when hooks return null", async () => {
|
|
1504
|
+
const plugin: AgentWidgetPlugin = {
|
|
1505
|
+
id: "test-open-loading-null",
|
|
1506
|
+
renderLoadingIndicator: () => null,
|
|
1507
|
+
};
|
|
1508
|
+
const { mount } = setup({
|
|
1509
|
+
provider: { latencyMs: 30 },
|
|
1510
|
+
config: { plugins: [plugin] },
|
|
1511
|
+
});
|
|
1512
|
+
historyButton(mount)!.click();
|
|
1513
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
1514
|
+
await flush(20);
|
|
1515
|
+
rowOf(mount, "conv-a")!.click();
|
|
1516
|
+
await flush();
|
|
1517
|
+
expect(
|
|
1518
|
+
mount.querySelectorAll(".persona-conversation-loading-bubble").length
|
|
1519
|
+
).toBe(3);
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
|
|
1523
|
+
describe("show earlier messages", () => {
|
|
1524
|
+
it("appears with a cursor and corrects scroll after the prepend", async () => {
|
|
1525
|
+
const long: DemoHistoryConversationSeed = {
|
|
1526
|
+
id: "conv-long",
|
|
1527
|
+
title: "Long thread",
|
|
1528
|
+
targetId: null,
|
|
1529
|
+
messages: Array.from({ length: 8 }, (_value, index) => ({
|
|
1530
|
+
id: `m${index}`,
|
|
1531
|
+
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
|
|
1532
|
+
content: `message ${index}`,
|
|
1533
|
+
})),
|
|
1534
|
+
};
|
|
1535
|
+
const { mount, controller } = setup({
|
|
1536
|
+
provider: { conversations: [long], pageSize: 3 },
|
|
1537
|
+
});
|
|
1538
|
+
await controller.openConversation("conv-long");
|
|
1539
|
+
await flush(20);
|
|
1540
|
+
|
|
1541
|
+
const pill = mount.querySelector<HTMLButtonElement>(
|
|
1542
|
+
"[data-persona-history-earlier]"
|
|
1543
|
+
);
|
|
1544
|
+
expect(pill).not.toBeNull();
|
|
1545
|
+
expect(pill!.textContent).toBe("Show earlier messages");
|
|
1546
|
+
|
|
1547
|
+
const body = bodyOf(mount);
|
|
1548
|
+
let height = 300;
|
|
1549
|
+
Object.defineProperty(body, "scrollHeight", {
|
|
1550
|
+
configurable: true,
|
|
1551
|
+
get: () => height,
|
|
1552
|
+
});
|
|
1553
|
+
body.scrollTop = 0;
|
|
1554
|
+
// The prepend grows the transcript above the reader's position, so the
|
|
1555
|
+
// correction must add exactly the height delta back to scrollTop.
|
|
1556
|
+
pill!.click();
|
|
1557
|
+
height = 500;
|
|
1558
|
+
await flush(30);
|
|
1559
|
+
|
|
1560
|
+
expect(body.scrollTop).toBe(200);
|
|
1561
|
+
const ids = Array.from(body.querySelectorAll("[data-message-id]")).map((node) =>
|
|
1562
|
+
node.getAttribute("data-message-id")
|
|
1563
|
+
);
|
|
1564
|
+
expect(ids.slice(0, 3)).toEqual(["m2", "m3", "m4"]);
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1567
|
+
it("disappears once the transcript start is reached", async () => {
|
|
1568
|
+
const { mount, controller } = setup();
|
|
1569
|
+
await controller.openConversation("conv-a");
|
|
1570
|
+
await flush(20);
|
|
1571
|
+
expect(
|
|
1572
|
+
mount.querySelector("[data-persona-history-earlier]")
|
|
1573
|
+
).toBeNull();
|
|
1574
|
+
});
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
/**
|
|
1578
|
+
* Close sequencing: exit animation -> unmount and restore chrome -> focus.
|
|
1579
|
+
* jsdom has no Web Animations API, so every other test in this file exercises
|
|
1580
|
+
* the synchronous fallback; these install a controllable fake.
|
|
1581
|
+
*/
|
|
1582
|
+
describe("exit animation", () => {
|
|
1583
|
+
type FakeAnimation = { settle(): void; cancelled: boolean; cancel(): void };
|
|
1584
|
+
|
|
1585
|
+
const installWaapi = () => {
|
|
1586
|
+
const animations: FakeAnimation[] = [];
|
|
1587
|
+
Element.prototype.animate = function (): Animation {
|
|
1588
|
+
let resolve!: () => void;
|
|
1589
|
+
let reject!: () => void;
|
|
1590
|
+
const finished = new Promise<Animation>((onDone, onFail) => {
|
|
1591
|
+
resolve = () => onDone(animation as unknown as Animation);
|
|
1592
|
+
reject = () => onFail(new Error("cancelled"));
|
|
1593
|
+
});
|
|
1594
|
+
finished.catch(() => undefined);
|
|
1595
|
+
const animation: FakeAnimation & { finished: Promise<Animation> } = {
|
|
1596
|
+
finished,
|
|
1597
|
+
cancelled: false,
|
|
1598
|
+
cancel: () => {
|
|
1599
|
+
animation.cancelled = true;
|
|
1600
|
+
reject();
|
|
1601
|
+
},
|
|
1602
|
+
settle: resolve,
|
|
1603
|
+
};
|
|
1604
|
+
animations.push(animation);
|
|
1605
|
+
return animation as unknown as Animation;
|
|
1606
|
+
} as unknown as Element["animate"];
|
|
1607
|
+
return {
|
|
1608
|
+
animations,
|
|
1609
|
+
settleAll: () => animations.forEach((animation) => animation.settle()),
|
|
1610
|
+
restore: () => {
|
|
1611
|
+
delete (Element.prototype as Partial<Element>).animate;
|
|
1612
|
+
},
|
|
1613
|
+
};
|
|
1614
|
+
};
|
|
1615
|
+
|
|
1616
|
+
const back = (mount: HTMLElement) =>
|
|
1617
|
+
mount.querySelector<HTMLButtonElement>(
|
|
1618
|
+
'[data-persona-history-focus="close"]'
|
|
1619
|
+
)!;
|
|
1620
|
+
|
|
1621
|
+
afterEach(() => {
|
|
1622
|
+
delete (Element.prototype as Partial<Element>).animate;
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
it("keeps the chrome hidden until the exit finishes, then restores it and focus", async () => {
|
|
1626
|
+
const waapi = installWaapi();
|
|
1627
|
+
try {
|
|
1628
|
+
const { mount } = setup();
|
|
1629
|
+
const header = headerOf(mount);
|
|
1630
|
+
const body = bodyOf(mount);
|
|
1631
|
+
await openHistoryUI(mount);
|
|
1632
|
+
const view = historyRoot(mount)!;
|
|
1633
|
+
|
|
1634
|
+
back(mount).click();
|
|
1635
|
+
await flush();
|
|
1636
|
+
// Still leaving: the surface is mounted and the conversation is hidden.
|
|
1637
|
+
expect(historyRoot(mount)).toBe(view);
|
|
1638
|
+
expect(headerHostOf(mount)).not.toBeNull();
|
|
1639
|
+
expect(visibleHeaderChildren(header)).toEqual([]);
|
|
1640
|
+
expect(body.style.display).toBe("none");
|
|
1641
|
+
expect(view.style.pointerEvents).toBe("none");
|
|
1642
|
+
expect(document.activeElement).not.toBe(historyButton(mount));
|
|
1643
|
+
|
|
1644
|
+
waapi.settleAll();
|
|
1645
|
+
await flush();
|
|
1646
|
+
expect(historyRoot(mount)).toBeNull();
|
|
1647
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
1648
|
+
expect(suppressedIn(header)).toEqual([]);
|
|
1649
|
+
expect(header.style.display).not.toBe("none");
|
|
1650
|
+
expect(header.hasAttribute("inert")).toBe(false);
|
|
1651
|
+
expect(body.style.display).not.toBe("none");
|
|
1652
|
+
// Chrome first, focus second: the invoker lives in that header.
|
|
1653
|
+
expect(document.activeElement).toBe(historyButton(mount));
|
|
1654
|
+
} finally {
|
|
1655
|
+
waapi.restore();
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
it("cancels an unfinished entrance instead of replaying it on the way out", async () => {
|
|
1660
|
+
const waapi = installWaapi();
|
|
1661
|
+
try {
|
|
1662
|
+
const { mount } = setup();
|
|
1663
|
+
await openHistoryUI(mount);
|
|
1664
|
+
const view = historyRoot(mount)!;
|
|
1665
|
+
expect(view.classList.contains("persona-history-view--enter")).toBe(true);
|
|
1666
|
+
|
|
1667
|
+
back(mount).click();
|
|
1668
|
+
await flush();
|
|
1669
|
+
expect(view.classList.contains("persona-history-view--enter")).toBe(false);
|
|
1670
|
+
expect(waapi.animations).toHaveLength(1);
|
|
1671
|
+
|
|
1672
|
+
waapi.settleAll();
|
|
1673
|
+
await flush();
|
|
1674
|
+
expect(historyRoot(mount)).toBeNull();
|
|
1675
|
+
} finally {
|
|
1676
|
+
waapi.restore();
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
it("closes on the timeout fallback when the exit never settles", async () => {
|
|
1681
|
+
const waapi = installWaapi();
|
|
1682
|
+
try {
|
|
1683
|
+
const { mount } = setup();
|
|
1684
|
+
await openHistoryUI(mount);
|
|
1685
|
+
back(mount).click();
|
|
1686
|
+
await flush();
|
|
1687
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
1688
|
+
|
|
1689
|
+
// The 250ms ceiling is wall-clock, so wait for the effect, not the time.
|
|
1690
|
+
await vi.waitFor(() => expect(historyRoot(mount)).toBeNull(), {
|
|
1691
|
+
timeout: 3_000,
|
|
1692
|
+
});
|
|
1693
|
+
await flush();
|
|
1694
|
+
expect(bodyOf(mount).style.display).not.toBe("none");
|
|
1695
|
+
expect(document.activeElement).toBe(historyButton(mount));
|
|
1696
|
+
} finally {
|
|
1697
|
+
waapi.restore();
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
it("mounts exactly one fresh surface when a reopen preempts the exit", async () => {
|
|
1702
|
+
const waapi = installWaapi();
|
|
1703
|
+
try {
|
|
1704
|
+
const { mount } = setup();
|
|
1705
|
+
await openHistoryUI(mount);
|
|
1706
|
+
const first = historyRoot(mount)!;
|
|
1707
|
+
|
|
1708
|
+
back(mount).click();
|
|
1709
|
+
await flush();
|
|
1710
|
+
expect(historyRoot(mount)).toBe(first);
|
|
1711
|
+
|
|
1712
|
+
historyButton(mount)!.click();
|
|
1713
|
+
await flush();
|
|
1714
|
+
const views = mount.querySelectorAll(".persona-history-view");
|
|
1715
|
+
expect(views).toHaveLength(1);
|
|
1716
|
+
expect(views[0]).not.toBe(first);
|
|
1717
|
+
expect(first.isConnected).toBe(false);
|
|
1718
|
+
|
|
1719
|
+
// The preempted exit settling late must not tear the new surface down.
|
|
1720
|
+
waapi.settleAll();
|
|
1721
|
+
await flush(20);
|
|
1722
|
+
expect(historyRoot(mount)).toBe(views[0]);
|
|
1723
|
+
expect(bodyOf(mount).style.display).toBe("none");
|
|
1724
|
+
expect(mount.querySelectorAll(".persona-history-rail-shell")).toHaveLength(0);
|
|
1725
|
+
} finally {
|
|
1726
|
+
waapi.restore();
|
|
1727
|
+
}
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
it("survives toggle spam with one close per open and no double restore", async () => {
|
|
1731
|
+
const waapi = installWaapi();
|
|
1732
|
+
try {
|
|
1733
|
+
const { mount, controller } = setup();
|
|
1734
|
+
const closed: unknown[] = [];
|
|
1735
|
+
controller.on("history:closed", (payload) => closed.push(payload));
|
|
1736
|
+
const body = bodyOf(mount);
|
|
1737
|
+
await openHistoryUI(mount);
|
|
1738
|
+
|
|
1739
|
+
const button = historyButton(mount)!;
|
|
1740
|
+
back(mount).click();
|
|
1741
|
+
back(mount).click();
|
|
1742
|
+
controller.hideHistory();
|
|
1743
|
+
button.click();
|
|
1744
|
+
button.click();
|
|
1745
|
+
await flush(20);
|
|
1746
|
+
|
|
1747
|
+
expect(mount.querySelectorAll(".persona-history-view")).toHaveLength(1);
|
|
1748
|
+
expect(closed).toHaveLength(1);
|
|
1749
|
+
expect(body.style.display).toBe("none");
|
|
1750
|
+
|
|
1751
|
+
waapi.settleAll();
|
|
1752
|
+
await flush(20);
|
|
1753
|
+
// The surviving surface is the reopened one, still open.
|
|
1754
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
1755
|
+
expect(closed).toHaveLength(1);
|
|
1756
|
+
|
|
1757
|
+
back(mount).click();
|
|
1758
|
+
waapi.settleAll();
|
|
1759
|
+
await flush(20);
|
|
1760
|
+
expect(historyRoot(mount)).toBeNull();
|
|
1761
|
+
expect(closed).toHaveLength(2);
|
|
1762
|
+
expect(body.style.display).not.toBe("none");
|
|
1763
|
+
expect(body.hasAttribute("inert")).toBe(false);
|
|
1764
|
+
} finally {
|
|
1765
|
+
waapi.restore();
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
it("tears a leaving surface down when the widget is destroyed mid-exit", async () => {
|
|
1770
|
+
const waapi = installWaapi();
|
|
1771
|
+
try {
|
|
1772
|
+
const { mount, controller } = setup();
|
|
1773
|
+
await openHistoryUI(mount);
|
|
1774
|
+
back(mount).click();
|
|
1775
|
+
await flush();
|
|
1776
|
+
expect(historyRoot(mount)).not.toBeNull();
|
|
1777
|
+
|
|
1778
|
+
controller.destroy();
|
|
1779
|
+
expect(mount.querySelectorAll(".persona-history-view")).toHaveLength(0);
|
|
1780
|
+
waapi.settleAll();
|
|
1781
|
+
await flush();
|
|
1782
|
+
expect(mount.querySelectorAll(".persona-history-view")).toHaveLength(0);
|
|
1783
|
+
} finally {
|
|
1784
|
+
waapi.restore();
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
describe("destructive confirmations", () => {
|
|
1790
|
+
const openRowMenu = async (mount: HTMLElement, id: string) => {
|
|
1791
|
+
mount
|
|
1792
|
+
.querySelector<HTMLButtonElement>(
|
|
1793
|
+
`[data-persona-history-focus="menu:${id}"]`
|
|
1794
|
+
)!
|
|
1795
|
+
.click();
|
|
1796
|
+
await flush();
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
it("confirms a delete with a focus-trapped alert dialog", async () => {
|
|
1800
|
+
const { mount, provider } = setup();
|
|
1801
|
+
await openHistoryUI(mount);
|
|
1802
|
+
await openRowMenu(mount, "conv-a");
|
|
1803
|
+
mount
|
|
1804
|
+
.querySelector<HTMLButtonElement>(
|
|
1805
|
+
'[data-persona-history-focus="menu-item:conv-a"]'
|
|
1806
|
+
)!
|
|
1807
|
+
.click();
|
|
1808
|
+
await flush();
|
|
1809
|
+
|
|
1810
|
+
const dialog = dialogOf();
|
|
1811
|
+
expect(dialog).not.toBeNull();
|
|
1812
|
+
expect(dialog!.getAttribute("aria-modal")).toBe("true");
|
|
1813
|
+
expect(dialog!.hasAttribute("aria-labelledby")).toBe(true);
|
|
1814
|
+
expect(dialog!.hasAttribute("aria-describedby")).toBe(true);
|
|
1815
|
+
// The theme token, never `inherit`: the split-layout host (the panel)
|
|
1816
|
+
// cascades the page's color, not the widget's.
|
|
1817
|
+
expect(dialog!.style.color).toBe("var(--persona-text, #111827)");
|
|
1818
|
+
// Least destructive action holds focus first.
|
|
1819
|
+
expect(document.activeElement).toBe(
|
|
1820
|
+
dialog!.querySelector(".persona-history-confirm__cancel")
|
|
1821
|
+
);
|
|
1822
|
+
|
|
1823
|
+
dialog!
|
|
1824
|
+
.querySelector<HTMLButtonElement>(".persona-history-confirm__confirm")!
|
|
1825
|
+
.click();
|
|
1826
|
+
await flush(20);
|
|
1827
|
+
expect(dialogOf()).toBeNull();
|
|
1828
|
+
expect(provider.getConversationIds()).not.toContain("conv-a");
|
|
1829
|
+
expect(rowOf(mount, "conv-a")).toBeNull();
|
|
1830
|
+
});
|
|
1831
|
+
|
|
1832
|
+
it("cancels on Escape and restores focus without deleting", async () => {
|
|
1833
|
+
const { mount, provider } = setup();
|
|
1834
|
+
await openHistoryUI(mount);
|
|
1835
|
+
await openRowMenu(mount, "conv-b");
|
|
1836
|
+
const trigger = mount.querySelector<HTMLElement>(
|
|
1837
|
+
'[data-persona-history-focus="menu-item:conv-b"]'
|
|
1838
|
+
)!;
|
|
1839
|
+
trigger.focus();
|
|
1840
|
+
trigger.click();
|
|
1841
|
+
await flush();
|
|
1842
|
+
|
|
1843
|
+
const dialog = dialogOf()!;
|
|
1844
|
+
dialog.dispatchEvent(
|
|
1845
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true })
|
|
1846
|
+
);
|
|
1847
|
+
await flush(20);
|
|
1848
|
+
expect(dialogOf()).toBeNull();
|
|
1849
|
+
expect(provider.getConversationIds()).toContain("conv-b");
|
|
1850
|
+
expect(rowOf(mount, "conv-b")).not.toBeNull();
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
it("uses browser-scope copy for delete all", async () => {
|
|
1854
|
+
const { mount } = setup();
|
|
1855
|
+
await openHistoryUI(mount);
|
|
1856
|
+
// Delete-all lives in the list overflow menu, not in standing chrome.
|
|
1857
|
+
mount.querySelector<HTMLButtonElement>(".persona-history-list-options")!.click();
|
|
1858
|
+
await flush();
|
|
1859
|
+
mount
|
|
1860
|
+
.querySelector<HTMLButtonElement>(
|
|
1861
|
+
'.persona-history-caption [role="menuitem"]'
|
|
1862
|
+
)!
|
|
1863
|
+
.click();
|
|
1864
|
+
await flush();
|
|
1865
|
+
const dialog = dialogOf()!;
|
|
1866
|
+
expect(
|
|
1867
|
+
dialog.querySelector(".persona-history-confirm__description")!.textContent
|
|
1868
|
+
).toContain("on this browser");
|
|
1869
|
+
dialog
|
|
1870
|
+
.querySelector<HTMLButtonElement>(".persona-history-confirm__cancel")!
|
|
1871
|
+
.click();
|
|
1872
|
+
await flush();
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it("omits forget-this-device when the provider cannot reset", async () => {
|
|
1876
|
+
const { mount } = setup();
|
|
1877
|
+
await openHistoryUI(mount);
|
|
1878
|
+
mount.querySelector<HTMLButtonElement>(".persona-history-list-options")!.click();
|
|
1879
|
+
await flush();
|
|
1880
|
+
expect(
|
|
1881
|
+
Array.from(
|
|
1882
|
+
mount.querySelectorAll('.persona-history-caption [role="menuitem"]')
|
|
1883
|
+
).map((item) => item.textContent)
|
|
1884
|
+
).toEqual(["Delete all conversations"]);
|
|
1885
|
+
});
|
|
1886
|
+
});
|
|
1887
|
+
});
|