@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,904 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public history render hooks (`docs/visitor-history-implementation-plan.md`
|
|
5
|
+
* D7, "Public rendering customization contract"): arbitration order, default
|
|
6
|
+
* delegation, throw containment, `requestRender`, snapshot sanitization, the
|
|
7
|
+
* shared action path, and the ChatGPT-style rail composition.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import { createAgentExperience } from "./ui";
|
|
13
|
+
import { createHistoryView } from "./components/history-view";
|
|
14
|
+
import { setHistoryViewLoader } from "./history-view-loader";
|
|
15
|
+
import { setHistoryProviderFactory } from "./internal/history-provider-registry";
|
|
16
|
+
import {
|
|
17
|
+
createDemoHistoryProvider,
|
|
18
|
+
type DemoHistoryConversationSeed,
|
|
19
|
+
type DemoHistoryProviderOptions,
|
|
20
|
+
} from "./internal/demo-history-provider";
|
|
21
|
+
import type {
|
|
22
|
+
AgentWidgetPlugin,
|
|
23
|
+
AgentWidgetRailSectionContext,
|
|
24
|
+
} from "./plugins/types";
|
|
25
|
+
import type {
|
|
26
|
+
AgentWidgetRenderHistoryViewContext,
|
|
27
|
+
AgentWidgetRenderHistoryConversationContext,
|
|
28
|
+
AgentWidgetRenderHistoryHeaderContext,
|
|
29
|
+
AgentWidgetRenderHistoryStateContext,
|
|
30
|
+
} from "./types";
|
|
31
|
+
|
|
32
|
+
const SEEDS: DemoHistoryConversationSeed[] = [
|
|
33
|
+
{
|
|
34
|
+
id: "conv-a",
|
|
35
|
+
title: "Order status",
|
|
36
|
+
targetId: null,
|
|
37
|
+
messages: [
|
|
38
|
+
{ id: "a1", role: "user", content: "where is my order" },
|
|
39
|
+
{ id: "a2", role: "assistant", content: "it ships tomorrow" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "conv-b",
|
|
44
|
+
title: "Refund request",
|
|
45
|
+
targetId: null,
|
|
46
|
+
messages: [{ id: "b1", role: "user", content: "i need a refund" }],
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const mounts: HTMLElement[] = [];
|
|
51
|
+
const controllers: ReturnType<typeof createAgentExperience>[] = [];
|
|
52
|
+
|
|
53
|
+
const flush = async (times = 12) => {
|
|
54
|
+
for (let i = 0; i < times; i += 1) {
|
|
55
|
+
// eslint-disable-next-line no-await-in-loop
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type SetupOptions = {
|
|
61
|
+
plugins?: AgentWidgetPlugin[];
|
|
62
|
+
provider?: DemoHistoryProviderOptions;
|
|
63
|
+
historyFeature?: Record<string, unknown>;
|
|
64
|
+
config?: Record<string, unknown>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const setup = (options: SetupOptions = {}) => {
|
|
68
|
+
const provider = createDemoHistoryProvider({
|
|
69
|
+
conversations: SEEDS,
|
|
70
|
+
...options.provider,
|
|
71
|
+
});
|
|
72
|
+
setHistoryProviderFactory(() => provider);
|
|
73
|
+
const mount = document.createElement("div");
|
|
74
|
+
document.body.appendChild(mount);
|
|
75
|
+
mounts.push(mount);
|
|
76
|
+
const controller = createAgentExperience(mount, {
|
|
77
|
+
apiUrl: "https://api.example.com/chat",
|
|
78
|
+
launcher: { enabled: false },
|
|
79
|
+
persistState: false,
|
|
80
|
+
suggestionChips: [],
|
|
81
|
+
features: { history: { enabled: true, ...options.historyFeature } },
|
|
82
|
+
...(options.plugins ? { plugins: options.plugins } : {}),
|
|
83
|
+
...options.config,
|
|
84
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
85
|
+
controllers.push(controller);
|
|
86
|
+
return { mount, controller, provider };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const historyButton = (mount: HTMLElement) =>
|
|
90
|
+
mount.querySelector<HTMLButtonElement>("[data-persona-history-toggle]")!;
|
|
91
|
+
const defaultView = (mount: HTMLElement) =>
|
|
92
|
+
mount.querySelector<HTMLElement>(".persona-history-view");
|
|
93
|
+
const rowOf = (mount: HTMLElement, id: string) =>
|
|
94
|
+
mount.querySelector<HTMLButtonElement>(
|
|
95
|
+
`[data-persona-history-conversation="${id}"]`
|
|
96
|
+
);
|
|
97
|
+
const dialogOf = () => document.querySelector<HTMLElement>('[role="alertdialog"]');
|
|
98
|
+
|
|
99
|
+
const openHistoryUI = async (mount: HTMLElement) => {
|
|
100
|
+
historyButton(mount).click();
|
|
101
|
+
await flush(20);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const setContainerWidth = (mount: HTMLElement, width: number) => {
|
|
105
|
+
const container = mount.querySelector<HTMLElement>(".persona-widget-container")!;
|
|
106
|
+
Object.defineProperty(container, "clientWidth", {
|
|
107
|
+
configurable: true,
|
|
108
|
+
get: () => width,
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Minimal full-view plugin returning a tagged element. */
|
|
113
|
+
const fullViewPlugin = (
|
|
114
|
+
id: string,
|
|
115
|
+
tag: string,
|
|
116
|
+
priority?: number
|
|
117
|
+
): AgentWidgetPlugin => ({
|
|
118
|
+
id,
|
|
119
|
+
...(priority === undefined ? {} : { priority }),
|
|
120
|
+
renderHistoryView: () => {
|
|
121
|
+
const element = document.createElement("div");
|
|
122
|
+
element.setAttribute("data-test-view", tag);
|
|
123
|
+
return element;
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("history render hooks", () => {
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
window.scrollTo = vi.fn();
|
|
130
|
+
setHistoryViewLoader(async () => ({ createHistoryView }));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
afterEach(() => {
|
|
134
|
+
setHistoryProviderFactory(null);
|
|
135
|
+
controllers.splice(0).forEach((controller) => {
|
|
136
|
+
try {
|
|
137
|
+
controller.destroy();
|
|
138
|
+
} catch {
|
|
139
|
+
/* already destroyed */
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
mounts.splice(0).forEach((mount) => mount.remove());
|
|
143
|
+
document.body.innerHTML = "";
|
|
144
|
+
window.localStorage.clear();
|
|
145
|
+
vi.restoreAllMocks();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("full view arbitration", () => {
|
|
149
|
+
it("runs plugins by priority and the first non-null view wins", async () => {
|
|
150
|
+
const { mount } = setup({
|
|
151
|
+
plugins: [
|
|
152
|
+
fullViewPlugin("low", "low", 1),
|
|
153
|
+
fullViewPlugin("high", "high", 10),
|
|
154
|
+
],
|
|
155
|
+
});
|
|
156
|
+
await openHistoryUI(mount);
|
|
157
|
+
expect(
|
|
158
|
+
mount.querySelector("[data-test-view]")?.getAttribute("data-test-view")
|
|
159
|
+
).toBe("high");
|
|
160
|
+
expect(defaultView(mount)).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("falls through a null hook to the next plugin, then to the default", async () => {
|
|
164
|
+
const nulled = vi.fn(() => null);
|
|
165
|
+
const { mount } = setup({
|
|
166
|
+
plugins: [{ id: "nulls", priority: 10, renderHistoryView: nulled }],
|
|
167
|
+
});
|
|
168
|
+
await openHistoryUI(mount);
|
|
169
|
+
expect(nulled).toHaveBeenCalled();
|
|
170
|
+
expect(defaultView(mount)).not.toBeNull();
|
|
171
|
+
expect(rowOf(mount, "conv-a")).not.toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("delegates to defaultRenderer without recursion and keeps lower hooks", async () => {
|
|
175
|
+
const calls: string[] = [];
|
|
176
|
+
const { mount } = setup({
|
|
177
|
+
plugins: [
|
|
178
|
+
{
|
|
179
|
+
id: "composer",
|
|
180
|
+
renderHistoryView: (context) => {
|
|
181
|
+
calls.push("view");
|
|
182
|
+
const wrapper = document.createElement("div");
|
|
183
|
+
wrapper.setAttribute("data-test-wrapper", "");
|
|
184
|
+
wrapper.appendChild(context.defaultRenderer());
|
|
185
|
+
return wrapper;
|
|
186
|
+
},
|
|
187
|
+
// Same plugin's lower-level hook still applies.
|
|
188
|
+
renderHistoryConversation: (context) => {
|
|
189
|
+
calls.push(`row:${context.conversation.id}`);
|
|
190
|
+
const row = document.createElement("li");
|
|
191
|
+
row.setAttribute("data-test-row", context.conversation.id);
|
|
192
|
+
return row;
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
await openHistoryUI(mount);
|
|
198
|
+
const wrapper = mount.querySelector("[data-test-wrapper]");
|
|
199
|
+
expect(wrapper).not.toBeNull();
|
|
200
|
+
expect(wrapper!.contains(defaultView(mount))).toBe(true);
|
|
201
|
+
expect(mount.querySelectorAll("[data-test-row]").length).toBe(2);
|
|
202
|
+
// One full-hook invocation: defaultRenderer() cannot re-enter it.
|
|
203
|
+
expect(calls.filter((entry) => entry === "view").length).toBe(1);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("reports a thrown hook and falls back without losing history state", async () => {
|
|
207
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
208
|
+
const { mount } = setup({
|
|
209
|
+
plugins: [
|
|
210
|
+
{
|
|
211
|
+
id: "boom",
|
|
212
|
+
priority: 10,
|
|
213
|
+
renderHistoryView: () => {
|
|
214
|
+
throw new Error("hook exploded");
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
await openHistoryUI(mount);
|
|
220
|
+
expect(warn).toHaveBeenCalledWith(
|
|
221
|
+
"[persona] renderHistoryView threw",
|
|
222
|
+
expect.any(Error)
|
|
223
|
+
);
|
|
224
|
+
expect(defaultView(mount)).not.toBeNull();
|
|
225
|
+
expect(rowOf(mount, "conv-a")).not.toBeNull();
|
|
226
|
+
expect(rowOf(mount, "conv-b")).not.toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("requestRender", () => {
|
|
231
|
+
it("replaces the mounted DOM after running the prior cleanups", async () => {
|
|
232
|
+
const order: string[] = [];
|
|
233
|
+
let context: AgentWidgetRenderHistoryViewContext | null = null;
|
|
234
|
+
let generation = 0;
|
|
235
|
+
const { mount } = setup({
|
|
236
|
+
plugins: [
|
|
237
|
+
{
|
|
238
|
+
id: "stateful",
|
|
239
|
+
renderHistoryView: (ctx) => {
|
|
240
|
+
context = ctx;
|
|
241
|
+
generation += 1;
|
|
242
|
+
order.push(`render:${generation}`);
|
|
243
|
+
ctx.onCleanup(() => order.push(`cleanup:${generation}`));
|
|
244
|
+
const element = document.createElement("div");
|
|
245
|
+
element.setAttribute("data-test-view", String(generation));
|
|
246
|
+
return element;
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
await openHistoryUI(mount);
|
|
252
|
+
const first = mount.querySelector("[data-test-view]")!;
|
|
253
|
+
const settled = generation;
|
|
254
|
+
order.length = 0;
|
|
255
|
+
|
|
256
|
+
context!.requestRender();
|
|
257
|
+
await flush();
|
|
258
|
+
const second = mount.querySelector("[data-test-view]")!;
|
|
259
|
+
expect(second).not.toBe(first);
|
|
260
|
+
expect(second.getAttribute("data-test-view")).toBe(String(settled + 1));
|
|
261
|
+
expect(first.isConnected).toBe(false);
|
|
262
|
+
// The previous render's cleanup runs BEFORE its DOM is replaced.
|
|
263
|
+
expect(order).toEqual([`cleanup:${settled}`, `render:${settled + 1}`]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("is inert after the surface closes", async () => {
|
|
267
|
+
let context: AgentWidgetRenderHistoryViewContext | null = null;
|
|
268
|
+
const renders = vi.fn();
|
|
269
|
+
const { mount, controller } = setup({
|
|
270
|
+
plugins: [
|
|
271
|
+
{
|
|
272
|
+
id: "inert",
|
|
273
|
+
renderHistoryView: (ctx) => {
|
|
274
|
+
context = ctx;
|
|
275
|
+
renders();
|
|
276
|
+
return document.createElement("div");
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
await openHistoryUI(mount);
|
|
282
|
+
const before = renders.mock.calls.length;
|
|
283
|
+
controller.hideHistory();
|
|
284
|
+
await flush();
|
|
285
|
+
context!.requestRender();
|
|
286
|
+
await flush();
|
|
287
|
+
expect(renders.mock.calls.length).toBe(before);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("re-invokes the renderer when the core model changes", async () => {
|
|
291
|
+
const snapshots: number[] = [];
|
|
292
|
+
let context: AgentWidgetRenderHistoryViewContext | null = null;
|
|
293
|
+
const { mount, controller } = setup({
|
|
294
|
+
plugins: [
|
|
295
|
+
{
|
|
296
|
+
id: "counts",
|
|
297
|
+
renderHistoryView: (ctx) => {
|
|
298
|
+
context = ctx;
|
|
299
|
+
snapshots.push(ctx.conversations.length);
|
|
300
|
+
const element = document.createElement("div");
|
|
301
|
+
element.setAttribute("data-test-view", "counts");
|
|
302
|
+
return element;
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
await openHistoryUI(mount);
|
|
308
|
+
expect(snapshots.at(-1)).toBe(2);
|
|
309
|
+
|
|
310
|
+
await controller.deleteConversation("conv-a");
|
|
311
|
+
// No plugin bookkeeping: reloading the list rerenders the custom view.
|
|
312
|
+
await context!.actions.refresh();
|
|
313
|
+
await flush(20);
|
|
314
|
+
expect(snapshots.at(-1)).toBe(1);
|
|
315
|
+
expect(mount.querySelector("[data-test-view]")).not.toBeNull();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("snapshot", () => {
|
|
320
|
+
it("is frozen and carries no provider, credential, or transport data", async () => {
|
|
321
|
+
let context: AgentWidgetRenderHistoryViewContext | null = null;
|
|
322
|
+
const { mount } = setup({
|
|
323
|
+
plugins: [
|
|
324
|
+
{
|
|
325
|
+
id: "capture",
|
|
326
|
+
renderHistoryView: (ctx) => {
|
|
327
|
+
context = ctx;
|
|
328
|
+
return document.createElement("div");
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
});
|
|
333
|
+
await openHistoryUI(mount);
|
|
334
|
+
const captured = context!;
|
|
335
|
+
|
|
336
|
+
expect(Object.isFrozen(captured)).toBe(true);
|
|
337
|
+
expect(Object.isFrozen(captured.conversations)).toBe(true);
|
|
338
|
+
expect(Object.isFrozen(captured.conversations[0])).toBe(true);
|
|
339
|
+
expect(Object.isFrozen(captured.identityStatus)).toBe(true);
|
|
340
|
+
expect(Object.isFrozen(captured.actions)).toBe(true);
|
|
341
|
+
expect(Object.keys(captured.conversations[0]!).sort()).toEqual([
|
|
342
|
+
"createdAt",
|
|
343
|
+
"id",
|
|
344
|
+
"messageCount",
|
|
345
|
+
"preview",
|
|
346
|
+
"targetId",
|
|
347
|
+
"title",
|
|
348
|
+
"updatedAt",
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
// Deep sweep of everything except the host's own config object.
|
|
352
|
+
const forbidden =
|
|
353
|
+
/provider|token|proof|jwt|credential|visitor|enduser|secret|commit|discard|response|headers|cookie|authorization|client/i;
|
|
354
|
+
const seen = new Set<unknown>();
|
|
355
|
+
const walk = (value: unknown, path: string): void => {
|
|
356
|
+
if (value === null || typeof value !== "object") return;
|
|
357
|
+
if (seen.has(value)) return;
|
|
358
|
+
seen.add(value);
|
|
359
|
+
for (const [key, child] of Object.entries(value)) {
|
|
360
|
+
expect(
|
|
361
|
+
forbidden.test(key),
|
|
362
|
+
`${path}.${key} leaks a non-public field`
|
|
363
|
+
).toBe(false);
|
|
364
|
+
walk(child, `${path}.${key}`);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
// `config` is the host's own object and `copy` is a flat string map, so
|
|
368
|
+
// both are asserted directly rather than swept for field names.
|
|
369
|
+
const { config: _config, copy, ...snapshot } = captured;
|
|
370
|
+
walk(snapshot, "context");
|
|
371
|
+
expect(
|
|
372
|
+
Object.values(copy).every((value) => typeof value === "string")
|
|
373
|
+
).toBe(true);
|
|
374
|
+
// The transactional seam is absent, not merely undefined.
|
|
375
|
+
expect("prepareOpen" in captured).toBe(false);
|
|
376
|
+
expect(Object.keys(captured.actions).sort()).toEqual([
|
|
377
|
+
"close",
|
|
378
|
+
"loadMore",
|
|
379
|
+
"openConversation",
|
|
380
|
+
"refresh",
|
|
381
|
+
"requestClearConversationHistory",
|
|
382
|
+
"requestDeleteConversation",
|
|
383
|
+
"requestResetHistoryIdentity",
|
|
384
|
+
"startNewConversation",
|
|
385
|
+
]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("re-invokes the same renderer with the new presentation on a rail collapse", async () => {
|
|
389
|
+
const seen: string[] = [];
|
|
390
|
+
const renderer = vi.fn((context: AgentWidgetRenderHistoryViewContext) => {
|
|
391
|
+
seen.push(context.presentation);
|
|
392
|
+
const element = document.createElement("div");
|
|
393
|
+
element.setAttribute("data-test-view", context.presentation);
|
|
394
|
+
return element;
|
|
395
|
+
});
|
|
396
|
+
const { mount, controller } = setup({
|
|
397
|
+
historyFeature: { presentation: "rail" },
|
|
398
|
+
plugins: [{ id: "responsive", renderHistoryView: renderer }],
|
|
399
|
+
});
|
|
400
|
+
setContainerWidth(mount, 900);
|
|
401
|
+
await openHistoryUI(mount);
|
|
402
|
+
expect(seen.at(-1)).toBe("rail");
|
|
403
|
+
expect(
|
|
404
|
+
mount.querySelector(".persona-history-rail-host")?.querySelector(
|
|
405
|
+
"[data-test-view]"
|
|
406
|
+
)
|
|
407
|
+
).not.toBeNull();
|
|
408
|
+
|
|
409
|
+
setContainerWidth(mount, 500);
|
|
410
|
+
controller.update({});
|
|
411
|
+
await flush(20);
|
|
412
|
+
expect(seen.at(-1)).toBe("panel");
|
|
413
|
+
expect(
|
|
414
|
+
mount.querySelector("[data-test-view]")?.getAttribute("data-test-view")
|
|
415
|
+
).toBe("panel");
|
|
416
|
+
expect(mount.querySelector(".persona-history-rail-host")).toBeNull();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("actions", () => {
|
|
421
|
+
const actionSetup = async () => {
|
|
422
|
+
let context: AgentWidgetRenderHistoryViewContext | null = null;
|
|
423
|
+
const result = setup({
|
|
424
|
+
plugins: [
|
|
425
|
+
{
|
|
426
|
+
id: "actions",
|
|
427
|
+
renderHistoryView: (ctx) => {
|
|
428
|
+
context = ctx;
|
|
429
|
+
const element = document.createElement("div");
|
|
430
|
+
element.setAttribute("data-test-view", "actions");
|
|
431
|
+
return element;
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
});
|
|
436
|
+
await openHistoryUI(result.mount);
|
|
437
|
+
return { ...result, context: () => context! };
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
it("opens a conversation through the shell path and closes the panel", async () => {
|
|
441
|
+
const { mount, provider, context } = await actionSetup();
|
|
442
|
+
await context().actions.openConversation("conv-a");
|
|
443
|
+
await flush(20);
|
|
444
|
+
expect(provider.getActiveConversationId()).toBe("conv-a");
|
|
445
|
+
expect(mount.querySelector("[data-test-view]")).toBeNull();
|
|
446
|
+
const ids = Array.from(
|
|
447
|
+
mount.querySelectorAll("#persona-scroll-container [data-message-id]")
|
|
448
|
+
).map((node) => node.getAttribute("data-message-id"));
|
|
449
|
+
expect(ids).toEqual(["a1", "a2"]);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("routes a delete through Persona's confirmation and resolves the outcome", async () => {
|
|
453
|
+
const { provider, context } = await actionSetup();
|
|
454
|
+
const cancelled = context().actions.requestDeleteConversation("conv-a");
|
|
455
|
+
await flush();
|
|
456
|
+
dialogOf()!
|
|
457
|
+
.querySelector<HTMLButtonElement>(".persona-history-confirm__cancel")!
|
|
458
|
+
.click();
|
|
459
|
+
expect(await cancelled).toBe("cancelled");
|
|
460
|
+
expect(provider.getConversationIds()).toContain("conv-a");
|
|
461
|
+
|
|
462
|
+
const deleted = context().actions.requestDeleteConversation("conv-a");
|
|
463
|
+
await flush();
|
|
464
|
+
dialogOf()!
|
|
465
|
+
.querySelector<HTMLButtonElement>(".persona-history-confirm__confirm")!
|
|
466
|
+
.click();
|
|
467
|
+
expect(await deleted).toBe("deleted");
|
|
468
|
+
expect(provider.getConversationIds()).not.toContain("conv-a");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("ignores a second action while one is pending", async () => {
|
|
472
|
+
let context: AgentWidgetRenderHistoryViewContext | null = null;
|
|
473
|
+
const pendings: string[] = [];
|
|
474
|
+
const { mount, provider } = setup({
|
|
475
|
+
provider: { latencyMs: 10 },
|
|
476
|
+
plugins: [
|
|
477
|
+
{
|
|
478
|
+
id: "busy",
|
|
479
|
+
renderHistoryView: (ctx) => {
|
|
480
|
+
context = ctx;
|
|
481
|
+
pendings.push(ctx.pendingAction?.kind ?? "idle");
|
|
482
|
+
return document.createElement("div");
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
],
|
|
486
|
+
});
|
|
487
|
+
await openHistoryUI(mount);
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
489
|
+
await flush(20);
|
|
490
|
+
|
|
491
|
+
const first = context!.actions.openConversation("conv-a");
|
|
492
|
+
// The busy view rejects the duplicate exactly like an inert default row.
|
|
493
|
+
const second = context!.actions.openConversation("conv-b");
|
|
494
|
+
await second;
|
|
495
|
+
expect(provider.getActiveConversationId()).not.toBe("conv-b");
|
|
496
|
+
await first;
|
|
497
|
+
await flush(20);
|
|
498
|
+
expect(provider.getActiveConversationId()).toBe("conv-a");
|
|
499
|
+
expect(pendings).toContain("open");
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe("slot hooks", () => {
|
|
504
|
+
it("applies the first non-null header, row, and state hook by priority", async () => {
|
|
505
|
+
const header = vi.fn((context: AgentWidgetRenderHistoryHeaderContext) => {
|
|
506
|
+
const element = document.createElement("div");
|
|
507
|
+
element.setAttribute("data-test-header", context.presentation);
|
|
508
|
+
const close = document.createElement("button");
|
|
509
|
+
close.setAttribute("data-test-close", "");
|
|
510
|
+
close.addEventListener("click", () => context.actions.close());
|
|
511
|
+
element.appendChild(close);
|
|
512
|
+
return element;
|
|
513
|
+
});
|
|
514
|
+
const { mount, controller } = setup({
|
|
515
|
+
plugins: [
|
|
516
|
+
{ id: "low", priority: 1, renderHistoryHeader: () => null },
|
|
517
|
+
{
|
|
518
|
+
id: "high",
|
|
519
|
+
priority: 10,
|
|
520
|
+
renderHistoryHeader: header,
|
|
521
|
+
renderHistoryConversation: (
|
|
522
|
+
context: AgentWidgetRenderHistoryConversationContext
|
|
523
|
+
) => {
|
|
524
|
+
const row = document.createElement("li");
|
|
525
|
+
row.setAttribute("data-test-row", context.conversation.id);
|
|
526
|
+
row.textContent = context.conversation.title;
|
|
527
|
+
return row;
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
});
|
|
532
|
+
await openHistoryUI(mount);
|
|
533
|
+
expect(
|
|
534
|
+
mount.querySelector("[data-test-header]")?.getAttribute("data-test-header")
|
|
535
|
+
).toBe("panel");
|
|
536
|
+
expect(mount.querySelector(".persona-history-topbar")).toBeNull();
|
|
537
|
+
expect(mount.querySelectorAll("[data-test-row]").length).toBe(2);
|
|
538
|
+
// The shell keeps ownership of the close path the header invoked.
|
|
539
|
+
mount.querySelector<HTMLButtonElement>("[data-test-close]")!.click();
|
|
540
|
+
await flush();
|
|
541
|
+
expect(controller.isHistoryVisible()).toBe(false);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("replaces only non-ready list states and keeps default DOM on a throw", async () => {
|
|
545
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
546
|
+
const states: string[] = [];
|
|
547
|
+
const { mount } = setup({
|
|
548
|
+
provider: { conversations: [], failures: { list: { code: "unavailable" } } },
|
|
549
|
+
plugins: [
|
|
550
|
+
{
|
|
551
|
+
id: "states",
|
|
552
|
+
renderHistoryState: (context: AgentWidgetRenderHistoryStateContext) => {
|
|
553
|
+
states.push(context.state.kind);
|
|
554
|
+
if (context.state.kind === "error") {
|
|
555
|
+
throw new Error("state hook exploded");
|
|
556
|
+
}
|
|
557
|
+
const element = document.createElement("div");
|
|
558
|
+
element.setAttribute("data-test-state", context.state.kind);
|
|
559
|
+
return element;
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
});
|
|
564
|
+
await openHistoryUI(mount);
|
|
565
|
+
expect(states).toContain("loading");
|
|
566
|
+
expect(states).toContain("error");
|
|
567
|
+
expect(warn).toHaveBeenCalledWith(
|
|
568
|
+
"[persona] renderHistoryState threw",
|
|
569
|
+
expect.any(Error)
|
|
570
|
+
);
|
|
571
|
+
// The throwing state falls back to Persona's block, retry included.
|
|
572
|
+
expect(
|
|
573
|
+
mount.querySelector('[data-persona-history-state="error"]')
|
|
574
|
+
).not.toBeNull();
|
|
575
|
+
expect(states).not.toContain("ready");
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
describe("reference ChatGPT-style rail", () => {
|
|
580
|
+
/**
|
|
581
|
+
* Fixture proving the rail composition is reachable through public hooks
|
|
582
|
+
* only: no provider, no controller, no chunk internals.
|
|
583
|
+
*/
|
|
584
|
+
const railPlugin = (): AgentWidgetPlugin => ({
|
|
585
|
+
id: "chatgpt-rail",
|
|
586
|
+
renderHistoryView: (context) => {
|
|
587
|
+
const rail = document.createElement("nav");
|
|
588
|
+
rail.setAttribute("data-test-rail", context.presentation);
|
|
589
|
+
rail.setAttribute("aria-label", context.copy.viewTitle);
|
|
590
|
+
|
|
591
|
+
const create = document.createElement("button");
|
|
592
|
+
create.setAttribute("data-test-new", "");
|
|
593
|
+
create.textContent = context.copy.newConversationLabel;
|
|
594
|
+
create.addEventListener("click", () => {
|
|
595
|
+
void context.actions.startNewConversation();
|
|
596
|
+
});
|
|
597
|
+
rail.appendChild(create);
|
|
598
|
+
|
|
599
|
+
if (context.state.kind === "loading") {
|
|
600
|
+
const status = document.createElement("p");
|
|
601
|
+
status.setAttribute("data-test-loading", context.state.phase);
|
|
602
|
+
rail.appendChild(status);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
for (const conversation of context.conversations) {
|
|
606
|
+
const row = document.createElement("button");
|
|
607
|
+
row.setAttribute("data-test-rail-row", conversation.id);
|
|
608
|
+
row.textContent = conversation.title;
|
|
609
|
+
if (conversation.id === context.activeConversationId) {
|
|
610
|
+
row.setAttribute("aria-current", "page");
|
|
611
|
+
}
|
|
612
|
+
row.addEventListener("click", () => {
|
|
613
|
+
void context.actions.openConversation(conversation.id);
|
|
614
|
+
});
|
|
615
|
+
rail.appendChild(row);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (context.nextCursor) {
|
|
619
|
+
const more = document.createElement("button");
|
|
620
|
+
more.setAttribute("data-test-more", "");
|
|
621
|
+
more.addEventListener("click", () => void context.actions.loadMore());
|
|
622
|
+
rail.appendChild(more);
|
|
623
|
+
}
|
|
624
|
+
return rail;
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("stays open beside the conversation and drives selection", async () => {
|
|
629
|
+
const { mount, provider } = setup({
|
|
630
|
+
historyFeature: { presentation: "rail" },
|
|
631
|
+
plugins: [railPlugin()],
|
|
632
|
+
});
|
|
633
|
+
setContainerWidth(mount, 900);
|
|
634
|
+
await openHistoryUI(mount);
|
|
635
|
+
|
|
636
|
+
const rail = mount.querySelector<HTMLElement>("[data-test-rail]")!;
|
|
637
|
+
expect(rail.getAttribute("data-test-rail")).toBe("rail");
|
|
638
|
+
expect(
|
|
639
|
+
mount.querySelector(".persona-history-rail-host")!.contains(rail)
|
|
640
|
+
).toBe(true);
|
|
641
|
+
expect(mount.querySelectorAll("[data-test-rail-row]").length).toBe(2);
|
|
642
|
+
// The default view never rendered: only the plugin's DOM is mounted.
|
|
643
|
+
expect(defaultView(mount)).toBeNull();
|
|
644
|
+
|
|
645
|
+
mount
|
|
646
|
+
.querySelector<HTMLButtonElement>('[data-test-rail-row="conv-a"]')!
|
|
647
|
+
.click();
|
|
648
|
+
await flush(30);
|
|
649
|
+
expect(provider.getActiveConversationId()).toBe("conv-a");
|
|
650
|
+
// Rail presentation keeps the navigation open and marks the active row.
|
|
651
|
+
const active = mount.querySelector<HTMLElement>("[data-test-rail]")!;
|
|
652
|
+
expect(active.isConnected).toBe(true);
|
|
653
|
+
expect(
|
|
654
|
+
active
|
|
655
|
+
.querySelector('[data-test-rail-row="conv-a"]')!
|
|
656
|
+
.getAttribute("aria-current")
|
|
657
|
+
).toBe("page");
|
|
658
|
+
const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
|
|
659
|
+
expect(body.style.display).not.toBe("none");
|
|
660
|
+
expect(body.hasAttribute("inert")).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("pages through the public loadMore action", async () => {
|
|
664
|
+
const many: DemoHistoryConversationSeed[] = Array.from(
|
|
665
|
+
{ length: 4 },
|
|
666
|
+
(_value, index) => ({
|
|
667
|
+
id: `conv-${index}`,
|
|
668
|
+
title: `Thread ${index}`,
|
|
669
|
+
targetId: null,
|
|
670
|
+
messages: [{ id: `m${index}`, role: "user" as const, content: "hi" }],
|
|
671
|
+
})
|
|
672
|
+
);
|
|
673
|
+
const { mount } = setup({
|
|
674
|
+
provider: { conversations: many },
|
|
675
|
+
historyFeature: { pageSize: 2 },
|
|
676
|
+
plugins: [railPlugin()],
|
|
677
|
+
});
|
|
678
|
+
await openHistoryUI(mount);
|
|
679
|
+
expect(mount.querySelectorAll("[data-test-rail-row]").length).toBe(2);
|
|
680
|
+
|
|
681
|
+
mount.querySelector<HTMLButtonElement>("[data-test-more]")!.click();
|
|
682
|
+
await flush(30);
|
|
683
|
+
expect(mount.querySelectorAll("[data-test-rail-row]").length).toBe(4);
|
|
684
|
+
expect(mount.querySelector("[data-test-more]")).toBeNull();
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
describe("plugin rail sections", () => {
|
|
688
|
+
/** Section body tagged with the collapsed state it was rendered for. */
|
|
689
|
+
const pinnedBody = (context: AgentWidgetRailSectionContext): HTMLElement => {
|
|
690
|
+
const node = document.createElement("div");
|
|
691
|
+
node.setAttribute("data-test-pinned", String(context.collapsed));
|
|
692
|
+
const open = document.createElement("button");
|
|
693
|
+
open.setAttribute("data-test-pinned-open", "");
|
|
694
|
+
open.addEventListener("click", () => {
|
|
695
|
+
void context.actions.openConversation("conv-b");
|
|
696
|
+
});
|
|
697
|
+
node.appendChild(open);
|
|
698
|
+
return node;
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const pinsPlugin = (
|
|
702
|
+
render: (context: AgentWidgetRailSectionContext) => Element | null
|
|
703
|
+
): AgentWidgetPlugin => ({
|
|
704
|
+
id: "pins",
|
|
705
|
+
railSections: [{ id: "pinned", title: "Pinned", render }],
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
/** One config section, so bucket ordering is observable. */
|
|
709
|
+
const configSection = {
|
|
710
|
+
id: "workspace",
|
|
711
|
+
title: "Workspace",
|
|
712
|
+
items: [{ id: "projects", label: "Projects", onSelect: () => {} }],
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const railSetup = async (options: SetupOptions) => {
|
|
716
|
+
const result = setup(options);
|
|
717
|
+
setContainerWidth(result.mount, 900);
|
|
718
|
+
await openHistoryUI(result.mount);
|
|
719
|
+
return result;
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const sectionAt = (mount: HTMLElement, id: string) =>
|
|
723
|
+
mount.querySelector<HTMLElement>(`[data-persona-rail-section="${id}"]`);
|
|
724
|
+
|
|
725
|
+
it("stacks plugin sections behind the config ones and shares the action path", async () => {
|
|
726
|
+
const { mount, provider } = await railSetup({
|
|
727
|
+
historyFeature: {
|
|
728
|
+
presentation: "rail",
|
|
729
|
+
rail: { sections: [configSection] },
|
|
730
|
+
},
|
|
731
|
+
plugins: [pinsPlugin(pinnedBody)],
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const ids = Array.from(
|
|
735
|
+
mount.querySelectorAll<HTMLElement>("[data-persona-rail-section]")
|
|
736
|
+
).map((node) => node.getAttribute("data-persona-rail-section"));
|
|
737
|
+
expect(ids).toEqual(["workspace", "pinned"]);
|
|
738
|
+
const pinned = sectionAt(mount, "pinned")!;
|
|
739
|
+
expect(
|
|
740
|
+
pinned.querySelector(".persona-history-group-heading")?.textContent
|
|
741
|
+
).toBe("Pinned");
|
|
742
|
+
expect(
|
|
743
|
+
pinned.querySelector("[data-test-pinned]")?.getAttribute("data-test-pinned")
|
|
744
|
+
).toBe("false");
|
|
745
|
+
|
|
746
|
+
pinned.querySelector<HTMLButtonElement>("[data-test-pinned-open]")!.click();
|
|
747
|
+
await flush(30);
|
|
748
|
+
expect(provider.getActiveConversationId()).toBe("conv-b");
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("re-invokes the section with the new collapsed value", async () => {
|
|
752
|
+
const seen: Array<{ collapsed: boolean; presentation: string }> = [];
|
|
753
|
+
const { mount } = await railSetup({
|
|
754
|
+
historyFeature: { presentation: "rail", rail: { collapsible: true } },
|
|
755
|
+
plugins: [
|
|
756
|
+
pinsPlugin((context) => {
|
|
757
|
+
seen.push({
|
|
758
|
+
collapsed: context.collapsed,
|
|
759
|
+
presentation: context.presentation,
|
|
760
|
+
});
|
|
761
|
+
return context.collapsed ? null : pinnedBody(context);
|
|
762
|
+
}),
|
|
763
|
+
],
|
|
764
|
+
});
|
|
765
|
+
expect(seen).toEqual([{ collapsed: false, presentation: "rail" }]);
|
|
766
|
+
|
|
767
|
+
mount
|
|
768
|
+
.querySelector<HTMLButtonElement>('[data-persona-history-focus="collapse"]')!
|
|
769
|
+
.click();
|
|
770
|
+
await flush(20);
|
|
771
|
+
expect(seen).toEqual([
|
|
772
|
+
{ collapsed: false, presentation: "rail" },
|
|
773
|
+
{ collapsed: true, presentation: "rail" },
|
|
774
|
+
]);
|
|
775
|
+
// Null empties the section and takes its heading with it.
|
|
776
|
+
const pinned = sectionAt(mount, "pinned")!;
|
|
777
|
+
expect(pinned.hidden).toBe(true);
|
|
778
|
+
expect(pinned.querySelector("[data-test-pinned]")).toBeNull();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("drops a plugin section whose id a config section already owns", async () => {
|
|
782
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
783
|
+
const render = vi.fn(pinnedBody);
|
|
784
|
+
const { mount } = await railSetup({
|
|
785
|
+
historyFeature: {
|
|
786
|
+
presentation: "rail",
|
|
787
|
+
rail: { sections: [{ ...configSection, id: "pinned" }] },
|
|
788
|
+
},
|
|
789
|
+
plugins: [pinsPlugin(render)],
|
|
790
|
+
});
|
|
791
|
+
expect(render).not.toHaveBeenCalled();
|
|
792
|
+
expect(warn).toHaveBeenCalledWith(
|
|
793
|
+
"[persona] duplicate history rail section id",
|
|
794
|
+
"pinned"
|
|
795
|
+
);
|
|
796
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
797
|
+
// The config section kept the id and its rows.
|
|
798
|
+
expect(
|
|
799
|
+
sectionAt(mount, "pinned")!.querySelector('[data-persona-rail-item="projects"]')
|
|
800
|
+
).not.toBeNull();
|
|
801
|
+
warn.mockRestore();
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
it("renders no sections in the panel presentation", async () => {
|
|
805
|
+
const render = vi.fn(pinnedBody);
|
|
806
|
+
const { mount } = setup({
|
|
807
|
+
historyFeature: { presentation: "panel" },
|
|
808
|
+
plugins: [pinsPlugin(render)],
|
|
809
|
+
});
|
|
810
|
+
await openHistoryUI(mount);
|
|
811
|
+
expect(mount.querySelector("[data-persona-rail-section]")).toBeNull();
|
|
812
|
+
expect(render).not.toHaveBeenCalled();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it("never invokes a section under a full view replacement", async () => {
|
|
816
|
+
const render = vi.fn(pinnedBody);
|
|
817
|
+
const { mount } = await railSetup({
|
|
818
|
+
historyFeature: { presentation: "rail" },
|
|
819
|
+
plugins: [
|
|
820
|
+
{ ...pinsPlugin(render), ...fullViewPlugin("pins", "custom") },
|
|
821
|
+
],
|
|
822
|
+
});
|
|
823
|
+
expect(mount.querySelector("[data-test-view]")).not.toBeNull();
|
|
824
|
+
expect(defaultView(mount)).toBeNull();
|
|
825
|
+
expect(mount.querySelector("[data-persona-rail-section]")).toBeNull();
|
|
826
|
+
expect(render).not.toHaveBeenCalled();
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Panel presentation keeps ONE bar: the shell header hosts the default view's
|
|
832
|
+
* bar. A plugin that owns the whole surface owns the header too, so the shell
|
|
833
|
+
* falls back to hiding it.
|
|
834
|
+
*/
|
|
835
|
+
describe("shell header hosting", () => {
|
|
836
|
+
const headerOf = (mount: HTMLElement) =>
|
|
837
|
+
mount.querySelector<HTMLElement>('[data-persona-theme-zone="header"]')!;
|
|
838
|
+
const headerHostOf = (mount: HTMLElement) =>
|
|
839
|
+
mount.querySelector<HTMLElement>(".persona-history-header-host");
|
|
840
|
+
|
|
841
|
+
it("hides the whole header for a custom full view and re-syncs on re-arbitration", async () => {
|
|
842
|
+
let custom = true;
|
|
843
|
+
let context: AgentWidgetRenderHistoryViewContext | null = null;
|
|
844
|
+
const { mount } = setup({
|
|
845
|
+
plugins: [
|
|
846
|
+
{
|
|
847
|
+
id: "toggling",
|
|
848
|
+
renderHistoryView: (ctx) => {
|
|
849
|
+
context = ctx;
|
|
850
|
+
if (!custom) return null;
|
|
851
|
+
const element = document.createElement("div");
|
|
852
|
+
element.setAttribute("data-test-view", "custom");
|
|
853
|
+
return element;
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
});
|
|
858
|
+
await openHistoryUI(mount);
|
|
859
|
+
const header = headerOf(mount);
|
|
860
|
+
expect(defaultView(mount)).toBeNull();
|
|
861
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
862
|
+
expect(header.style.display).toBe("none");
|
|
863
|
+
expect(header.hasAttribute("inert")).toBe(true);
|
|
864
|
+
expect(header.querySelector("[data-persona-history-suppressed]")).toBeNull();
|
|
865
|
+
|
|
866
|
+
custom = false;
|
|
867
|
+
context!.requestRender();
|
|
868
|
+
await flush(20);
|
|
869
|
+
expect(defaultView(mount)).not.toBeNull();
|
|
870
|
+
expect(header.style.display).not.toBe("none");
|
|
871
|
+
expect(header.hasAttribute("inert")).toBe(false);
|
|
872
|
+
expect(headerHostOf(mount)!.querySelector(".persona-history-topbar")).not.toBeNull();
|
|
873
|
+
expect(header.querySelector("[data-persona-history-suppressed]")).not.toBeNull();
|
|
874
|
+
|
|
875
|
+
custom = true;
|
|
876
|
+
context!.requestRender();
|
|
877
|
+
await flush(20);
|
|
878
|
+
expect(defaultView(mount)).toBeNull();
|
|
879
|
+
expect(headerHostOf(mount)).toBeNull();
|
|
880
|
+
expect(header.style.display).toBe("none");
|
|
881
|
+
expect(header.querySelector("[data-persona-history-suppressed]")).toBeNull();
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it("hosts a header-slot replacement in the shell header", async () => {
|
|
885
|
+
const { mount } = setup({
|
|
886
|
+
plugins: [
|
|
887
|
+
{
|
|
888
|
+
id: "header",
|
|
889
|
+
renderHistoryHeader: (context: AgentWidgetRenderHistoryHeaderContext) => {
|
|
890
|
+
const element = document.createElement("div");
|
|
891
|
+
element.setAttribute("data-test-header", context.presentation);
|
|
892
|
+
return element;
|
|
893
|
+
},
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
});
|
|
897
|
+
await openHistoryUI(mount);
|
|
898
|
+
const slot = mount.querySelector<HTMLElement>("[data-test-header]")!;
|
|
899
|
+
expect(slot.parentElement).toBe(headerHostOf(mount));
|
|
900
|
+
expect(mount.querySelector(".persona-history-topbar")).toBeNull();
|
|
901
|
+
expect(headerOf(mount).hasAttribute("inert")).toBe(false);
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
});
|