@btst/stack 4.1.1 → 4.3.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/README.md +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +15 -9
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +16 -10
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +28 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +29 -2
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +5 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +5 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +91 -8
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +91 -8
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +242 -68
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +246 -72
- package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.cjs +17 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.mjs +18 -1
- package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.cjs +42 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.mjs +39 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/plugin.cjs +2 -1
- package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +2 -1
- package/dist/plugins/ai-chat/api/index.d.cts +2 -2
- package/dist/plugins/ai-chat/api/index.d.mts +2 -2
- package/dist/plugins/ai-chat/api/index.d.ts +2 -2
- package/dist/plugins/ai-chat/client/components/index.d.cts +2 -1
- package/dist/plugins/ai-chat/client/components/index.d.mts +2 -1
- package/dist/plugins/ai-chat/client/components/index.d.ts +2 -1
- package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +4 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +6 -2
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +6 -2
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +6 -2
- package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +4 -1
- package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
- package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
- package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
- package/dist/plugins/ai-chat/client/index.d.cts +7 -3
- package/dist/plugins/ai-chat/client/index.d.mts +7 -3
- package/dist/plugins/ai-chat/client/index.d.ts +7 -3
- package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
- package/dist/plugins/blog/api/index.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +2 -2
- package/dist/plugins/blog/client/index.d.mts +2 -2
- package/dist/plugins/blog/client/index.d.ts +2 -2
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/form-builder/api/index.d.cts +1 -1
- package/dist/plugins/form-builder/api/index.d.mts +1 -1
- package/dist/plugins/form-builder/api/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/form-builder/query-keys.d.cts +1 -1
- package/dist/plugins/form-builder/query-keys.d.mts +1 -1
- package/dist/plugins/form-builder/query-keys.d.ts +1 -1
- package/dist/shared/{stack.B5yPgHsp.d.mts → stack.B7Mj74BQ.d.mts} +18 -2
- package/dist/shared/{stack.rVF0f509.d.ts → stack.CHrtGKLz.d.mts} +66 -4
- package/dist/shared/{stack.BggTHKlo.d.mts → stack.CZ1h_T4k.d.mts} +90 -90
- package/dist/shared/{stack.DfrRYOIl.d.ts → stack.CnDMBj4z.d.ts} +5 -5
- package/dist/shared/{stack.94kN3pEK.d.mts → stack.D2y4gl03.d.mts} +5 -5
- package/dist/shared/{stack.3Sr6qRtT.d.ts → stack.DBH1Bjko.d.ts} +18 -2
- package/dist/shared/{stack.lPKwd_v0.d.ts → stack.DQpyA3Cq.d.ts} +90 -90
- package/dist/shared/{stack.C4oVW2qK.d.cts → stack.Dgt3MCtW.d.cts} +18 -2
- package/dist/shared/{stack.CtrnoQgH.d.cts → stack.DiYg7Jw1.d.cts} +90 -90
- package/dist/shared/{stack.rVF0f509.d.cts → stack.DzJgoNqJ.d.cts} +66 -4
- package/dist/shared/{stack.rVF0f509.d.mts → stack.qMp2VzNw.d.ts} +66 -4
- package/dist/shared/{stack.BTStHVTy.d.cts → stack.rmWRtSjs.d.cts} +5 -5
- package/package.json +2 -2
- package/src/plugins/ai-chat/__tests__/authorization.test.ts +106 -1
- package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +26 -3
- package/src/plugins/ai-chat/__tests__/page-content.test.ts +177 -0
- package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +675 -0
- package/src/plugins/ai-chat/api/operations.ts +29 -13
- package/src/plugins/ai-chat/api/page-tools.ts +56 -2
- package/src/plugins/ai-chat/api/plugin.ts +13 -0
- package/src/plugins/ai-chat/client/analytics.ts +35 -0
- package/src/plugins/ai-chat/client/components/chat-interface.tsx +113 -10
- package/src/plugins/ai-chat/client/components/chat-layout.tsx +304 -68
- package/src/plugins/ai-chat/client/components/tool-call-display.tsx +29 -1
- package/src/plugins/ai-chat/client/context/chat-analytics.tsx +46 -0
- package/src/plugins/ai-chat/client/context/page-ai-context.tsx +9 -1
- package/src/plugins/ai-chat/client/index.ts +2 -0
- package/src/plugins/ai-chat/client/localization/index.ts +12 -0
- package/src/plugins/ai-chat/client/overrides.ts +5 -0
- package/src/plugins/ai-chat/client/plugin.tsx +3 -0
- package/dist/shared/{stack.BuxUQqL8.d.ts → stack.CGmh-Lpe.d.ts} +12 -12
- package/dist/shared/{stack.Egr3RWhA.d.mts → stack.Doy2_wgl.d.mts} +12 -12
- package/dist/shared/{stack.CKc_Bais.d.cts → stack.DtZgEX84.d.cts} +6 -6
- package/dist/shared/{stack.CKc_Bais.d.mts → stack.DtZgEX84.d.mts} +6 -6
- package/dist/shared/{stack.CKc_Bais.d.ts → stack.DtZgEX84.d.ts} +6 -6
- package/dist/shared/{stack.CX83YNuS.d.cts → stack.zch70Loj.d.cts} +12 -12
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { act, Fragment, StrictMode, useMemo, useState } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { StackProvider } from "@btst/stack/context";
|
|
7
|
+
import type { UIMessage } from "ai";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { defineAuthorization } from "@btst/stack/authorization";
|
|
10
|
+
import { createClientAuth } from "@btst/stack/authorization/client";
|
|
11
|
+
import { aiChatPermissions } from "../permissions";
|
|
12
|
+
import {
|
|
13
|
+
ChatLayout,
|
|
14
|
+
type ChatLayoutProps,
|
|
15
|
+
} from "../client/components/chat-layout";
|
|
16
|
+
import { ToolCallDisplay } from "../client/components/tool-call-display";
|
|
17
|
+
import {
|
|
18
|
+
PageAIContextProvider,
|
|
19
|
+
useRegisterPageAIContext,
|
|
20
|
+
usePageAIContext,
|
|
21
|
+
} from "../client/context/page-ai-context";
|
|
22
|
+
import { aiChatClientPlugin } from "../client/plugin";
|
|
23
|
+
import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";
|
|
24
|
+
|
|
25
|
+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
26
|
+
(globalThis as any).ResizeObserver ??= class {
|
|
27
|
+
observe() {}
|
|
28
|
+
unobserve() {}
|
|
29
|
+
disconnect() {}
|
|
30
|
+
};
|
|
31
|
+
Element.prototype.scrollIntoView ??= () => {};
|
|
32
|
+
const mocks = vi.hoisted(() => ({
|
|
33
|
+
useChat: vi.fn(),
|
|
34
|
+
stop: vi.fn(),
|
|
35
|
+
addToolOutput: vi.fn(),
|
|
36
|
+
onAnalyticsEvent: vi.fn(),
|
|
37
|
+
}));
|
|
38
|
+
vi.mock("@ai-sdk/react", () => ({ useChat: mocks.useChat }));
|
|
39
|
+
vi.mock("../client/hooks/chat-hooks", () => ({
|
|
40
|
+
useAiChatIdentityPartition: () => "anonymous",
|
|
41
|
+
useConversation: () => ({ conversation: null, isLoading: false }),
|
|
42
|
+
useConversations: () => ({ conversations: [], isLoading: false }),
|
|
43
|
+
}));
|
|
44
|
+
vi.mock("@workspace/ui/components/markdown-content", () => ({
|
|
45
|
+
MarkdownContent: ({ markdown }: { markdown: string }) => (
|
|
46
|
+
<div>{markdown}</div>
|
|
47
|
+
),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
let container: HTMLDivElement;
|
|
51
|
+
let root: Root;
|
|
52
|
+
let queryClient: QueryClient;
|
|
53
|
+
let stack: ReturnType<typeof createStack>;
|
|
54
|
+
const createStack = () =>
|
|
55
|
+
createTestClientStack(
|
|
56
|
+
{ aiChat: aiChatClientPlugin({ mode: "public", pageContent: true }) },
|
|
57
|
+
queryClient,
|
|
58
|
+
);
|
|
59
|
+
const inherited = {
|
|
60
|
+
routeName: "Global blog",
|
|
61
|
+
pageDescription: "truncated global context",
|
|
62
|
+
};
|
|
63
|
+
function GlobalPage() {
|
|
64
|
+
useRegisterPageAIContext(inherited);
|
|
65
|
+
return (
|
|
66
|
+
<span data-testid="global-context">
|
|
67
|
+
{usePageAIContext()?.pageDescription}
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
container = document.createElement("div");
|
|
73
|
+
document.body.appendChild(container);
|
|
74
|
+
root = createRoot(container);
|
|
75
|
+
queryClient = new QueryClient({
|
|
76
|
+
defaultOptions: { queries: { retry: false } },
|
|
77
|
+
});
|
|
78
|
+
stack = createStack();
|
|
79
|
+
mocks.useChat.mockImplementation(function useTestChat() {
|
|
80
|
+
const [messages, setMessages] = useState<UIMessage[]>([]);
|
|
81
|
+
const controls = useMemo(
|
|
82
|
+
() => ({
|
|
83
|
+
sendMessage: vi.fn(),
|
|
84
|
+
regenerate: vi.fn(),
|
|
85
|
+
stop: mocks.stop,
|
|
86
|
+
addToolOutput: mocks.addToolOutput,
|
|
87
|
+
}),
|
|
88
|
+
[],
|
|
89
|
+
);
|
|
90
|
+
return { messages, setMessages, status: "ready", error: null, ...controls };
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
afterEach(async () => {
|
|
94
|
+
await act(async () => root.unmount());
|
|
95
|
+
container.remove();
|
|
96
|
+
queryClient.clear();
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
mocks.onAnalyticsEvent.mockReset();
|
|
99
|
+
vi.useRealTimers();
|
|
100
|
+
});
|
|
101
|
+
async function render(props: ChatLayoutProps, strict = false) {
|
|
102
|
+
const Wrapper = strict ? StrictMode : Fragment;
|
|
103
|
+
await act(async () =>
|
|
104
|
+
root.render(
|
|
105
|
+
<Wrapper>
|
|
106
|
+
<QueryClientProvider client={queryClient}>
|
|
107
|
+
<StackProvider
|
|
108
|
+
stack={stack}
|
|
109
|
+
overrides={{
|
|
110
|
+
aiChat: {
|
|
111
|
+
showAttribution: false,
|
|
112
|
+
onAnalyticsEvent: mocks.onAnalyticsEvent,
|
|
113
|
+
},
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<PageAIContextProvider>
|
|
117
|
+
<GlobalPage />
|
|
118
|
+
<ChatLayout {...props} />
|
|
119
|
+
</PageAIContextProvider>
|
|
120
|
+
</StackProvider>
|
|
121
|
+
</QueryClientProvider>
|
|
122
|
+
</Wrapper>,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
async function click(label: string) {
|
|
127
|
+
const button = document.querySelector<HTMLButtonElement>(
|
|
128
|
+
`button[aria-label="${label}"]`,
|
|
129
|
+
);
|
|
130
|
+
expect(button).not.toBeNull();
|
|
131
|
+
await act(async () => button!.click());
|
|
132
|
+
}
|
|
133
|
+
const tip = () => container.querySelector('[role="status"]');
|
|
134
|
+
const options = () => mocks.useChat.mock.calls.at(-1)![0];
|
|
135
|
+
const page = {
|
|
136
|
+
routeName: "Article",
|
|
137
|
+
pageDescription: "/articles/one",
|
|
138
|
+
suggestions: ["Summarize article"],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
describe("page widget", () => {
|
|
142
|
+
it("shows a custom launcher icon while closed and keeps the close control", async () => {
|
|
143
|
+
await render({
|
|
144
|
+
layout: "widget",
|
|
145
|
+
triggerIcon: (
|
|
146
|
+
<span data-testid="custom-trigger-icon" aria-hidden="true" />
|
|
147
|
+
),
|
|
148
|
+
});
|
|
149
|
+
const trigger = () =>
|
|
150
|
+
container.querySelector('[data-testid="widget-trigger"]');
|
|
151
|
+
expect(
|
|
152
|
+
trigger()?.querySelector('[data-testid="custom-trigger-icon"]'),
|
|
153
|
+
).not.toBeNull();
|
|
154
|
+
await click("Open chat");
|
|
155
|
+
expect(trigger()?.getAttribute("aria-label")).toBe("Close chat");
|
|
156
|
+
expect(
|
|
157
|
+
trigger()?.querySelector('[data-testid="custom-trigger-icon"]'),
|
|
158
|
+
).toBeNull();
|
|
159
|
+
expect(trigger()?.querySelector("svg")).not.toBeNull();
|
|
160
|
+
await click("Close chat");
|
|
161
|
+
expect(
|
|
162
|
+
trigger()?.querySelector('[data-testid="custom-trigger-icon"]'),
|
|
163
|
+
).not.toBeNull();
|
|
164
|
+
});
|
|
165
|
+
it.each([false, true])(
|
|
166
|
+
"checks readPage authorization for send, edit and retry when enabled: %s",
|
|
167
|
+
async (pageContent) => {
|
|
168
|
+
const observedTools: string[][] = [];
|
|
169
|
+
const observedRoutes: Array<string | undefined> = [];
|
|
170
|
+
const authorization = defineAuthorization({
|
|
171
|
+
identity: z.object({ id: z.string() }),
|
|
172
|
+
permissions: [aiChatPermissions] as const,
|
|
173
|
+
rules: ({ aiChat }) => [
|
|
174
|
+
aiChat.stream.start.allow(),
|
|
175
|
+
aiChat.message.send.allow(),
|
|
176
|
+
aiChat.message.edit.allow(),
|
|
177
|
+
aiChat.message.retry.allow(),
|
|
178
|
+
aiChat.conversation.create.allow(),
|
|
179
|
+
aiChat.tool.activate.when(({ facts }) => {
|
|
180
|
+
observedTools.push([...facts.toolNames]);
|
|
181
|
+
observedRoutes.push(facts.routeName);
|
|
182
|
+
return !facts.toolNames.includes("readPage");
|
|
183
|
+
}),
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
const identity = { id: "reader" };
|
|
187
|
+
const auth = createClientAuth({
|
|
188
|
+
authorization,
|
|
189
|
+
getIdentity: () => identity,
|
|
190
|
+
});
|
|
191
|
+
const authorizedStack = createTestClientStack(
|
|
192
|
+
{
|
|
193
|
+
aiChat: aiChatClientPlugin({ pageContent }),
|
|
194
|
+
},
|
|
195
|
+
queryClient,
|
|
196
|
+
);
|
|
197
|
+
await act(async () =>
|
|
198
|
+
root.render(
|
|
199
|
+
<QueryClientProvider client={queryClient}>
|
|
200
|
+
<StackProvider
|
|
201
|
+
stack={authorizedStack}
|
|
202
|
+
auth={auth}
|
|
203
|
+
initialIdentity={identity}
|
|
204
|
+
>
|
|
205
|
+
<ChatLayout
|
|
206
|
+
layout="widget"
|
|
207
|
+
defaultOpen
|
|
208
|
+
pageContext={page}
|
|
209
|
+
conversationId="conversation"
|
|
210
|
+
initialMessages={[
|
|
211
|
+
{
|
|
212
|
+
id: "question",
|
|
213
|
+
role: "user",
|
|
214
|
+
parts: [{ type: "text", text: "Question" }],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
id: "answer",
|
|
218
|
+
role: "assistant",
|
|
219
|
+
parts: [{ type: "text", text: "Answer" }],
|
|
220
|
+
},
|
|
221
|
+
]}
|
|
222
|
+
/>
|
|
223
|
+
</StackProvider>
|
|
224
|
+
</QueryClientProvider>,
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
if (pageContent) {
|
|
228
|
+
expect(observedTools).toContainEqual(["readPage"]);
|
|
229
|
+
expect(observedRoutes.every((route) => route === page.routeName)).toBe(
|
|
230
|
+
true,
|
|
231
|
+
);
|
|
232
|
+
expect(container.querySelector("textarea")).toBeNull();
|
|
233
|
+
expect(
|
|
234
|
+
container.querySelector('[aria-label="Edit message"]'),
|
|
235
|
+
).toBeNull();
|
|
236
|
+
expect(container.querySelector('[aria-label="Retry"]')).toBeNull();
|
|
237
|
+
} else {
|
|
238
|
+
expect(observedTools).toHaveLength(0);
|
|
239
|
+
expect(container.querySelector("textarea")).not.toBeNull();
|
|
240
|
+
expect(
|
|
241
|
+
container.querySelector('[aria-label="Edit message"]'),
|
|
242
|
+
).not.toBeNull();
|
|
243
|
+
expect(container.querySelector('[aria-label="Retry"]')).not.toBeNull();
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
);
|
|
247
|
+
it("expires the accessible tip, allows dismissal, and does not reshow after opening", async () => {
|
|
248
|
+
vi.useFakeTimers();
|
|
249
|
+
await render({ layout: "widget", introTip: "Ask about this page" });
|
|
250
|
+
expect(tip()?.textContent).toContain("Ask about this page");
|
|
251
|
+
await act(async () => vi.advanceTimersByTime(5999));
|
|
252
|
+
expect(tip()).not.toBeNull();
|
|
253
|
+
await act(async () => vi.advanceTimersByTime(1));
|
|
254
|
+
expect(tip()).toBeNull();
|
|
255
|
+
await render({
|
|
256
|
+
layout: "widget",
|
|
257
|
+
pageKey: "two",
|
|
258
|
+
introTip: "Ask about this page",
|
|
259
|
+
});
|
|
260
|
+
await click("Dismiss chat tip");
|
|
261
|
+
expect(tip()).toBeNull();
|
|
262
|
+
await render({
|
|
263
|
+
layout: "widget",
|
|
264
|
+
pageKey: "three",
|
|
265
|
+
introTip: "Ask about this page",
|
|
266
|
+
});
|
|
267
|
+
await click("Open chat");
|
|
268
|
+
expect(tip()).toBeNull();
|
|
269
|
+
await click("Close chat");
|
|
270
|
+
expect(tip()).toBeNull();
|
|
271
|
+
});
|
|
272
|
+
it("supports custom tip duration and suppresses tips for initially open or triggerless widgets", async () => {
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
await render({ layout: "widget", introTip: "Tip", introTipDuration: 50 });
|
|
275
|
+
await act(async () => vi.advanceTimersByTime(50));
|
|
276
|
+
expect(tip()).toBeNull();
|
|
277
|
+
await render({
|
|
278
|
+
layout: "widget",
|
|
279
|
+
pageKey: "open",
|
|
280
|
+
introTip: "Tip",
|
|
281
|
+
defaultOpen: true,
|
|
282
|
+
});
|
|
283
|
+
expect(tip()).toBeNull();
|
|
284
|
+
await render({
|
|
285
|
+
layout: "widget",
|
|
286
|
+
pageKey: "hidden",
|
|
287
|
+
introTip: "Tip",
|
|
288
|
+
showTrigger: false,
|
|
289
|
+
});
|
|
290
|
+
expect(tip()).toBeNull();
|
|
291
|
+
});
|
|
292
|
+
it("keeps context instance-scoped and resets history on page identity changes", async () => {
|
|
293
|
+
const initialMessages: UIMessage[] = [
|
|
294
|
+
{
|
|
295
|
+
id: "old",
|
|
296
|
+
role: "user",
|
|
297
|
+
parts: [{ type: "text", text: "Old page question" }],
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
await render({
|
|
301
|
+
layout: "widget",
|
|
302
|
+
defaultOpen: true,
|
|
303
|
+
pageKey: "one",
|
|
304
|
+
pageContext: page,
|
|
305
|
+
initialMessages,
|
|
306
|
+
});
|
|
307
|
+
expect(container.textContent).toContain("Old page question");
|
|
308
|
+
expect(
|
|
309
|
+
container.querySelector('[data-testid="page-context-badge"]')
|
|
310
|
+
?.textContent,
|
|
311
|
+
).toBe("Article");
|
|
312
|
+
expect(
|
|
313
|
+
container.querySelector('[data-testid="global-context"]')?.textContent,
|
|
314
|
+
).toBe(inherited.pageDescription);
|
|
315
|
+
const oldOptions = options();
|
|
316
|
+
expect(
|
|
317
|
+
oldOptions.transport.prepareSendMessagesRequest({ messages: [] }).body
|
|
318
|
+
.pageContext,
|
|
319
|
+
).toBe("/articles/one");
|
|
320
|
+
await render({
|
|
321
|
+
layout: "widget",
|
|
322
|
+
defaultOpen: true,
|
|
323
|
+
pageKey: "two",
|
|
324
|
+
pageContext: { ...page, pageDescription: "/articles/two" },
|
|
325
|
+
});
|
|
326
|
+
expect(container.textContent).not.toContain("Old page question");
|
|
327
|
+
expect(options().id).not.toBe(oldOptions.id);
|
|
328
|
+
expect(mocks.stop).toHaveBeenCalledTimes(1);
|
|
329
|
+
expect(
|
|
330
|
+
options().transport.prepareSendMessagesRequest({ messages: [] }).body
|
|
331
|
+
.pageContext,
|
|
332
|
+
).toBe("/articles/two");
|
|
333
|
+
await render({
|
|
334
|
+
layout: "widget",
|
|
335
|
+
defaultOpen: true,
|
|
336
|
+
pageKey: "two",
|
|
337
|
+
pageContext: null,
|
|
338
|
+
});
|
|
339
|
+
expect(
|
|
340
|
+
container.querySelector('[data-testid="page-context-badge"]'),
|
|
341
|
+
).toBeNull();
|
|
342
|
+
await render({ layout: "widget", defaultOpen: true, pageKey: "two" });
|
|
343
|
+
expect(
|
|
344
|
+
container.querySelector('[data-testid="page-context-badge"]')
|
|
345
|
+
?.textContent,
|
|
346
|
+
).toBe(inherited.routeName);
|
|
347
|
+
});
|
|
348
|
+
it("cancels the old stream and ignores late tool output after changing pages", async () => {
|
|
349
|
+
let finish!: (value: { success: boolean }) => void;
|
|
350
|
+
const handler = vi.fn(
|
|
351
|
+
() =>
|
|
352
|
+
new Promise<{ success: boolean }>((resolve) => {
|
|
353
|
+
finish = resolve;
|
|
354
|
+
}),
|
|
355
|
+
);
|
|
356
|
+
await render({
|
|
357
|
+
layout: "widget",
|
|
358
|
+
defaultOpen: true,
|
|
359
|
+
pageKey: "one",
|
|
360
|
+
pageContext: { ...page, clientTools: { editPage: handler } },
|
|
361
|
+
});
|
|
362
|
+
const oldOptions = options();
|
|
363
|
+
const pending = oldOptions.onToolCall({
|
|
364
|
+
toolCall: { toolName: "editPage", toolCallId: "old-tool", input: {} },
|
|
365
|
+
});
|
|
366
|
+
await render({
|
|
367
|
+
layout: "widget",
|
|
368
|
+
defaultOpen: true,
|
|
369
|
+
pageKey: "two",
|
|
370
|
+
pageContext: page,
|
|
371
|
+
});
|
|
372
|
+
expect(mocks.stop).toHaveBeenCalledTimes(1);
|
|
373
|
+
finish({ success: true });
|
|
374
|
+
await pending;
|
|
375
|
+
expect(mocks.addToolOutput).not.toHaveBeenCalled();
|
|
376
|
+
expect(oldOptions.sendAutomaticallyWhen({ messages: [] })).toBe(false);
|
|
377
|
+
await oldOptions.onToolCall({
|
|
378
|
+
toolCall: { toolName: "editPage", toolCallId: "late-tool", input: {} },
|
|
379
|
+
});
|
|
380
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
381
|
+
});
|
|
382
|
+
it("accepts floating offsets and explicit dimensions", async () => {
|
|
383
|
+
await render({
|
|
384
|
+
layout: "widget",
|
|
385
|
+
floating: true,
|
|
386
|
+
defaultOpen: true,
|
|
387
|
+
className: "bottom-20",
|
|
388
|
+
style: { right: 24 },
|
|
389
|
+
});
|
|
390
|
+
const trigger = container.querySelector('[data-testid="widget-trigger"]')!;
|
|
391
|
+
expect(trigger.parentElement?.className).toContain("fixed");
|
|
392
|
+
expect(trigger.parentElement?.className).toContain("bottom-20");
|
|
393
|
+
expect(trigger.parentElement?.style.right).toBe("24px");
|
|
394
|
+
const panel = trigger.previousElementSibling as HTMLElement;
|
|
395
|
+
await render({ layout: "widget", widgetWidth: 320, widgetHeight: 400 });
|
|
396
|
+
expect(panel.style.width).toBe("320px");
|
|
397
|
+
expect(panel.style.height).toBe("400px");
|
|
398
|
+
expect(trigger.parentElement?.className).not.toContain("fixed");
|
|
399
|
+
});
|
|
400
|
+
it("makes expansion opt-in and preserves the chat when expanding, collapsing and closing", async () => {
|
|
401
|
+
const props = {
|
|
402
|
+
layout: "widget" as const,
|
|
403
|
+
defaultOpen: true,
|
|
404
|
+
widgetWidth: 320,
|
|
405
|
+
widgetHeight: 400,
|
|
406
|
+
initialMessages: [
|
|
407
|
+
{
|
|
408
|
+
id: "one",
|
|
409
|
+
role: "user" as const,
|
|
410
|
+
parts: [{ type: "text" as const, text: "Keep this question" }],
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
};
|
|
414
|
+
await render(props);
|
|
415
|
+
expect(container.querySelector('[aria-label="Expand chat"]')).toBeNull();
|
|
416
|
+
await render({ ...props, expandable: true });
|
|
417
|
+
const panel = container.querySelector<HTMLElement>('[role="dialog"]')!;
|
|
418
|
+
const input = container.querySelector("textarea")!;
|
|
419
|
+
const id = options().id;
|
|
420
|
+
await click("Expand chat");
|
|
421
|
+
const dialog = document.querySelector('[data-slot="dialog-content"]')!;
|
|
422
|
+
expect(dialog.querySelector("textarea")).toBe(input);
|
|
423
|
+
expect(dialog.getAttribute("role")).toBe("dialog");
|
|
424
|
+
expect(document.body.hasAttribute("data-scroll-locked")).toBe(true);
|
|
425
|
+
await click("Collapse chat");
|
|
426
|
+
expect(panel.style.width).toBe("320px");
|
|
427
|
+
expect(panel.style.height).toBe("400px");
|
|
428
|
+
expect(panel.querySelector("textarea")).toBe(input);
|
|
429
|
+
expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
|
|
430
|
+
await click("Expand chat");
|
|
431
|
+
await act(async () => {
|
|
432
|
+
document.dispatchEvent(
|
|
433
|
+
new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
expect(panel.querySelector("textarea")).toBe(input);
|
|
437
|
+
await click("Expand chat");
|
|
438
|
+
await click("Close chat");
|
|
439
|
+
expect(panel.classList.contains("hidden")).toBe(true);
|
|
440
|
+
expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
|
|
441
|
+
await click("Open chat");
|
|
442
|
+
expect(panel.querySelector("textarea")).toBe(input);
|
|
443
|
+
expect(container.textContent).toContain("Keep this question");
|
|
444
|
+
expect(options().id).toBe(id);
|
|
445
|
+
expect(mocks.stop).not.toHaveBeenCalled();
|
|
446
|
+
});
|
|
447
|
+
it("restores scrolling when expansion is disabled, the page changes, or the widget unmounts", async () => {
|
|
448
|
+
const props = {
|
|
449
|
+
layout: "widget" as const,
|
|
450
|
+
defaultOpen: true,
|
|
451
|
+
expandable: true,
|
|
452
|
+
showTrigger: false,
|
|
453
|
+
pageKey: "one",
|
|
454
|
+
};
|
|
455
|
+
await render(props, true);
|
|
456
|
+
const input = container.querySelector("textarea");
|
|
457
|
+
await click("Expand chat");
|
|
458
|
+
await render({ ...props, expandable: false }, true);
|
|
459
|
+
expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
|
|
460
|
+
await vi.waitFor(() =>
|
|
461
|
+
expect(document.activeElement).toBe(
|
|
462
|
+
container.querySelector('[role="dialog"]'),
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
await render(props, true);
|
|
466
|
+
expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
|
|
467
|
+
expect(container.querySelector("textarea")).toBe(input);
|
|
468
|
+
expect(
|
|
469
|
+
container.querySelector('[aria-label="Expand chat"]'),
|
|
470
|
+
).not.toBeNull();
|
|
471
|
+
await click("Expand chat");
|
|
472
|
+
await render({ ...props, pageKey: "two" }, true);
|
|
473
|
+
expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
|
|
474
|
+
await click("Expand chat");
|
|
475
|
+
await act(async () => root.render(null));
|
|
476
|
+
expect(document.body.hasAttribute("data-scroll-locked")).toBe(false);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("emits content-free lifecycle analytics once under StrictMode", async () => {
|
|
480
|
+
vi.useFakeTimers();
|
|
481
|
+
await render(
|
|
482
|
+
{
|
|
483
|
+
layout: "widget",
|
|
484
|
+
pageKey: "one",
|
|
485
|
+
pageContext: page,
|
|
486
|
+
introTip: "PRIVATE TIP",
|
|
487
|
+
},
|
|
488
|
+
true,
|
|
489
|
+
);
|
|
490
|
+
const events = () =>
|
|
491
|
+
mocks.onAnalyticsEvent.mock.calls.map(([event]) => event);
|
|
492
|
+
expect(
|
|
493
|
+
events().filter((event) => event.type === "intro_tip_shown"),
|
|
494
|
+
).toHaveLength(1);
|
|
495
|
+
await click("Open chat");
|
|
496
|
+
expect(
|
|
497
|
+
events().filter((event) => event.type === "intro_tip_dismissed"),
|
|
498
|
+
).toEqual([
|
|
499
|
+
{
|
|
500
|
+
type: "intro_tip_dismissed",
|
|
501
|
+
reason: "open",
|
|
502
|
+
pageKey: "one",
|
|
503
|
+
routeName: "Article",
|
|
504
|
+
mode: "public",
|
|
505
|
+
},
|
|
506
|
+
]);
|
|
507
|
+
const suggestion = Array.from(container.querySelectorAll("button")).find(
|
|
508
|
+
(button) => button.textContent === "Summarize article",
|
|
509
|
+
)!;
|
|
510
|
+
await act(async () => suggestion.click());
|
|
511
|
+
await act(async () =>
|
|
512
|
+
container
|
|
513
|
+
.querySelector("form")!
|
|
514
|
+
.dispatchEvent(
|
|
515
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
expect(
|
|
519
|
+
events().filter((event) => event.type === "message_submitted"),
|
|
520
|
+
).toEqual([
|
|
521
|
+
{
|
|
522
|
+
type: "message_submitted",
|
|
523
|
+
inputKind: "suggestion",
|
|
524
|
+
inputLength: 17,
|
|
525
|
+
messageCount: 1,
|
|
526
|
+
attachmentCount: 0,
|
|
527
|
+
pageKey: "one",
|
|
528
|
+
routeName: "Article",
|
|
529
|
+
mode: "public",
|
|
530
|
+
},
|
|
531
|
+
]);
|
|
532
|
+
const completion = {
|
|
533
|
+
message: {
|
|
534
|
+
id: "answer",
|
|
535
|
+
role: "assistant",
|
|
536
|
+
parts: [
|
|
537
|
+
{
|
|
538
|
+
type: "tool-readPage",
|
|
539
|
+
state: "output-available",
|
|
540
|
+
toolCallId: "tool",
|
|
541
|
+
output: "PRIVATE TOOL",
|
|
542
|
+
},
|
|
543
|
+
{ type: "text", text: "PRIVATE ANSWER" },
|
|
544
|
+
],
|
|
545
|
+
},
|
|
546
|
+
messages: [],
|
|
547
|
+
isAbort: false,
|
|
548
|
+
isError: false,
|
|
549
|
+
isDisconnect: false,
|
|
550
|
+
};
|
|
551
|
+
await act(async () => {
|
|
552
|
+
vi.advanceTimersByTime(250);
|
|
553
|
+
await options().onFinish(completion);
|
|
554
|
+
await options().onFinish(completion);
|
|
555
|
+
});
|
|
556
|
+
expect(
|
|
557
|
+
events().filter((event) => event.type === "response_completed"),
|
|
558
|
+
).toEqual([
|
|
559
|
+
{
|
|
560
|
+
type: "response_completed",
|
|
561
|
+
durationMs: 250,
|
|
562
|
+
outputLength: 14,
|
|
563
|
+
toolCallCount: 1,
|
|
564
|
+
pageKey: "one",
|
|
565
|
+
routeName: "Article",
|
|
566
|
+
mode: "public",
|
|
567
|
+
},
|
|
568
|
+
]);
|
|
569
|
+
await click("Clear chat");
|
|
570
|
+
expect(
|
|
571
|
+
events().filter((event) => event.type === "conversation_cleared"),
|
|
572
|
+
).toHaveLength(1);
|
|
573
|
+
expect(JSON.stringify(events())).not.toMatch(
|
|
574
|
+
/PRIVATE|Summarize article|articles\/one/,
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
it("deduplicates failure callbacks and isolates instrumentation errors", async () => {
|
|
578
|
+
await render({ layout: "widget", defaultOpen: true, pageContext: page });
|
|
579
|
+
const suggestion = Array.from(container.querySelectorAll("button")).find(
|
|
580
|
+
(button) => button.textContent === "Summarize article",
|
|
581
|
+
)!;
|
|
582
|
+
await act(async () => suggestion.click());
|
|
583
|
+
await act(async () =>
|
|
584
|
+
container
|
|
585
|
+
.querySelector("form")!
|
|
586
|
+
.dispatchEvent(
|
|
587
|
+
new Event("submit", { bubbles: true, cancelable: true }),
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
591
|
+
await act(async () => {
|
|
592
|
+
options().onError(new Error("PRIVATE ERROR"));
|
|
593
|
+
await options().onFinish({ isError: true });
|
|
594
|
+
});
|
|
595
|
+
errorLog.mockRestore();
|
|
596
|
+
expect(
|
|
597
|
+
mocks.onAnalyticsEvent.mock.calls.filter(
|
|
598
|
+
([event]) => event.type === "response_failed",
|
|
599
|
+
),
|
|
600
|
+
).toHaveLength(1);
|
|
601
|
+
expect(JSON.stringify(mocks.onAnalyticsEvent.mock.calls)).not.toContain(
|
|
602
|
+
"PRIVATE ERROR",
|
|
603
|
+
);
|
|
604
|
+
mocks.onAnalyticsEvent.mockImplementation(() => {
|
|
605
|
+
throw new Error("analytics unavailable");
|
|
606
|
+
});
|
|
607
|
+
await render({ layout: "widget", pageKey: "error", introTip: "Tip" });
|
|
608
|
+
await click("Open chat");
|
|
609
|
+
expect(
|
|
610
|
+
container.querySelector('[data-testid="chat-interface"]'),
|
|
611
|
+
).not.toBeNull();
|
|
612
|
+
mocks.onAnalyticsEvent.mockImplementation(() =>
|
|
613
|
+
Promise.reject(new Error("analytics unavailable")),
|
|
614
|
+
);
|
|
615
|
+
await click("Close chat");
|
|
616
|
+
});
|
|
617
|
+
it("preserves the detailed renderer for a custom readPage tool", async () => {
|
|
618
|
+
const customStack = createTestClientStack(
|
|
619
|
+
{ aiChat: aiChatClientPlugin({ mode: "public" }) },
|
|
620
|
+
queryClient,
|
|
621
|
+
);
|
|
622
|
+
await act(async () =>
|
|
623
|
+
root.render(
|
|
624
|
+
<StackProvider stack={customStack}>
|
|
625
|
+
<ToolCallDisplay
|
|
626
|
+
toolCallId="custom-1"
|
|
627
|
+
toolName="readPage"
|
|
628
|
+
state="output-available"
|
|
629
|
+
input={{ path: "/custom" }}
|
|
630
|
+
output={{ error: "a legitimate record field" }}
|
|
631
|
+
isLoading={false}
|
|
632
|
+
/>
|
|
633
|
+
</StackProvider>,
|
|
634
|
+
),
|
|
635
|
+
);
|
|
636
|
+
expect(tip()).toBeNull();
|
|
637
|
+
expect(container.textContent).toContain("Read Page");
|
|
638
|
+
expect(container.textContent).not.toContain("Unable to read this page.");
|
|
639
|
+
const toggle = container.querySelector<HTMLButtonElement>(
|
|
640
|
+
'button[data-state="closed"]',
|
|
641
|
+
)!;
|
|
642
|
+
await act(async () => toggle.click());
|
|
643
|
+
expect(container.textContent).toContain("a legitimate record field");
|
|
644
|
+
});
|
|
645
|
+
it.each([
|
|
646
|
+
["input-available", undefined, "Reading page content…"],
|
|
647
|
+
["output-available", { content: "large document" }, "Read page content"],
|
|
648
|
+
[
|
|
649
|
+
"output-available",
|
|
650
|
+
{ error: "a legitimate content field" },
|
|
651
|
+
"Read page content",
|
|
652
|
+
],
|
|
653
|
+
["output-error", undefined, "Unable to read this page."],
|
|
654
|
+
] as const)(
|
|
655
|
+
"renders the readPage status for %s",
|
|
656
|
+
async (state, output, expected) => {
|
|
657
|
+
await act(async () =>
|
|
658
|
+
root.render(
|
|
659
|
+
<StackProvider stack={stack}>
|
|
660
|
+
<ToolCallDisplay
|
|
661
|
+
toolCallId="read-1"
|
|
662
|
+
toolName="readPage"
|
|
663
|
+
input={undefined}
|
|
664
|
+
isLoading={false}
|
|
665
|
+
state={state}
|
|
666
|
+
output={output}
|
|
667
|
+
/>
|
|
668
|
+
</StackProvider>,
|
|
669
|
+
),
|
|
670
|
+
);
|
|
671
|
+
expect(tip()?.textContent).toBe(expected);
|
|
672
|
+
expect(container.textContent).not.toContain("large document");
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
});
|