@btst/stack 4.2.0 → 4.4.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/compaction.cjs +249 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/compaction.mjs +241 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +20 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +20 -0
- 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 +38 -3
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +38 -3
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +173 -72
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +176 -75
- package/dist/packages/stack/src/plugins/ai-chat/client/context-checkpoint.cjs +30 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/context-checkpoint.mjs +27 -0
- package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +2 -0
- package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +2 -0
- package/dist/packages/ui/src/components/markdown-content.cjs +9 -9
- package/dist/packages/ui/src/components/markdown-content.mjs +9 -9
- 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 +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.ts +1 -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 +3 -3
- package/dist/plugins/ai-chat/client/index.d.mts +3 -3
- package/dist/plugins/ai-chat/client/index.d.ts +3 -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/shared/{stack.BTStHVTy.d.cts → stack.BGzgFx3R.d.cts} +5 -5
- package/dist/shared/{stack.DBH1Bjko.d.ts → stack.C4zspX0T.d.ts} +13 -2
- package/dist/shared/{stack.94kN3pEK.d.mts → stack.CjCSDfWy.d.mts} +5 -5
- package/dist/shared/{stack.D5b9l24t.d.ts → stack.Clil75lW.d.ts} +35 -35
- package/dist/shared/{stack.xAiy2VtO.d.cts → stack.DDQlE2Pm.d.cts} +10 -3
- package/dist/shared/{stack.Ce6V7eDj.d.cts → stack.DNw6Hv5o.d.cts} +35 -35
- package/dist/shared/{stack.CTneIW0u.d.mts → stack.DhZtP2JZ.d.mts} +35 -35
- package/dist/shared/{stack.C-eQN_Jt.d.ts → stack.DlehgNZZ.d.ts} +10 -3
- package/dist/shared/{stack.DfrRYOIl.d.ts → stack.Dpg_JjIo.d.ts} +5 -5
- package/dist/shared/{stack.B7Mj74BQ.d.mts → stack.Ps3MDhTC.d.mts} +13 -2
- package/dist/shared/{stack.C5FZr-jJ.d.mts → stack.tAUeRAtd.d.mts} +10 -3
- package/dist/shared/{stack.Dgt3MCtW.d.cts → stack.ymWsP4HZ.d.cts} +13 -2
- package/package.json +2 -2
- package/src/__tests__/markdown-link-navigation.test.tsx +29 -0
- package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +22 -2
- package/src/plugins/ai-chat/__tests__/compaction.test.ts +272 -0
- package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +103 -1
- package/src/plugins/ai-chat/api/compaction.ts +310 -0
- package/src/plugins/ai-chat/api/operations.ts +25 -0
- package/src/plugins/ai-chat/api/plugin.ts +14 -0
- package/src/plugins/ai-chat/client/components/chat-interface.tsx +58 -3
- package/src/plugins/ai-chat/client/components/chat-layout.tsx +197 -72
- package/src/plugins/ai-chat/client/context-checkpoint.ts +47 -0
- package/src/plugins/ai-chat/client/localization/index.ts +5 -0
- package/src/plugins/ai-chat/schemas.ts +2 -0
- package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
- package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
- package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
|
@@ -219,13 +219,33 @@ describe("ai-chat classNames overrides (issue #36)", () => {
|
|
|
219
219
|
expect(list.className).toContain("flex-col");
|
|
220
220
|
});
|
|
221
221
|
|
|
222
|
-
it("
|
|
223
|
-
await render(
|
|
222
|
+
it("preserves container customizations when the widget expands", async () => {
|
|
223
|
+
await render(
|
|
224
|
+
<ChatLayout
|
|
225
|
+
layout="widget"
|
|
226
|
+
defaultOpen
|
|
227
|
+
expandable
|
|
228
|
+
className="chat-theme right-10 bottom-20 z-40"
|
|
229
|
+
style={{ fontFamily: "monospace", right: 24, zIndex: 40 }}
|
|
230
|
+
/>,
|
|
231
|
+
);
|
|
224
232
|
|
|
225
233
|
const trigger = query('[data-testid="widget-trigger"]');
|
|
226
234
|
const widgetRoot = trigger.parentElement!;
|
|
227
235
|
expect(widgetRoot.className).toContain(CLASS_NAMES.container);
|
|
228
236
|
expect(widgetRoot.className).toContain("items-end");
|
|
237
|
+
await act(async () => query('[aria-label="Expand chat"]').click());
|
|
238
|
+
const expanded = document.querySelector<HTMLElement>(
|
|
239
|
+
'[data-slot="dialog-content"]',
|
|
240
|
+
)!;
|
|
241
|
+
expect(expanded.className).toContain("chat-theme");
|
|
242
|
+
expect(expanded.className).toContain(CLASS_NAMES.container);
|
|
243
|
+
expect(expanded.className).not.toContain("right-10");
|
|
244
|
+
expect(expanded.className).not.toContain("bottom-20");
|
|
245
|
+
expect(expanded.className).not.toContain("z-40");
|
|
246
|
+
expect(expanded.style.fontFamily).toBe("monospace");
|
|
247
|
+
expect(expanded.style.right).toBe("0px");
|
|
248
|
+
expect(expanded.style.zIndex).toBe("50");
|
|
229
249
|
});
|
|
230
250
|
|
|
231
251
|
it("leaves base classes untouched when no classNames override is supplied", async () => {
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createMemoryAdapter } from "@btst/adapter-memory";
|
|
3
|
+
import { MockLanguageModelV2 } from "ai/test";
|
|
4
|
+
import { simulateReadableStream, type ModelMessage, type UIMessage } from "ai";
|
|
5
|
+
import { createBackendStack } from "../../../api";
|
|
6
|
+
import { aiChatBackendPlugin } from "../api";
|
|
7
|
+
import {
|
|
8
|
+
compactConversation,
|
|
9
|
+
createStepCompactor,
|
|
10
|
+
createSummarizer,
|
|
11
|
+
estimateContextTokens,
|
|
12
|
+
} from "../api/compaction";
|
|
13
|
+
import {
|
|
14
|
+
captureContextCheckpoint,
|
|
15
|
+
contextRequest,
|
|
16
|
+
} from "../client/context-checkpoint";
|
|
17
|
+
|
|
18
|
+
const message = (
|
|
19
|
+
id: string,
|
|
20
|
+
role: "user" | "assistant",
|
|
21
|
+
text: string,
|
|
22
|
+
): UIMessage => ({ id, role, parts: [{ type: "text", text }] });
|
|
23
|
+
const history = [
|
|
24
|
+
message("u1", "user", "Remember my goal. " + "research ".repeat(2000)),
|
|
25
|
+
message("a1", "assistant", "https://example.org/source; evidence uncertain"),
|
|
26
|
+
message("u2", "user", "Compare the alternatives"),
|
|
27
|
+
message("a2", "assistant", "A comparison"),
|
|
28
|
+
message("u3", "user", "What was the uncertainty?"),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
describe("public context compaction", () => {
|
|
32
|
+
it("uses size rather than message count and leaves the transcript untouched", async () => {
|
|
33
|
+
const summarize = vi
|
|
34
|
+
.fn()
|
|
35
|
+
.mockResolvedValue("Goal, sources, and uncertainty");
|
|
36
|
+
const original = structuredClone(history);
|
|
37
|
+
const result = await compactConversation({
|
|
38
|
+
messages: history,
|
|
39
|
+
budget: 3200,
|
|
40
|
+
overhead: "rules",
|
|
41
|
+
summarize,
|
|
42
|
+
});
|
|
43
|
+
expect(result.throughMessageId).toBe("a1");
|
|
44
|
+
expect(result.messages.map((m) => m.id)).toEqual(["u2", "a2", "u3"]);
|
|
45
|
+
expect(summarize.mock.calls[0]?.[0].messages).toEqual(history.slice(0, 2));
|
|
46
|
+
expect(history).toEqual(original);
|
|
47
|
+
const short = Array.from({ length: 100 }, (_, i) =>
|
|
48
|
+
message(String(i), i % 2 ? "assistant" : "user", "Hi"),
|
|
49
|
+
);
|
|
50
|
+
await compactConversation({
|
|
51
|
+
messages: short,
|
|
52
|
+
budget: 10000,
|
|
53
|
+
overhead: "",
|
|
54
|
+
summarize,
|
|
55
|
+
});
|
|
56
|
+
expect(summarize).toHaveBeenCalledOnce();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("reuses a checkpoint only while all summarized messages are unchanged", () => {
|
|
60
|
+
const checkpoint = captureContextCheckpoint(history, "summary", "a1");
|
|
61
|
+
expect(contextRequest(history, checkpoint)).toEqual({
|
|
62
|
+
messages: history.slice(2),
|
|
63
|
+
contextSummary: "summary",
|
|
64
|
+
});
|
|
65
|
+
const edited = [
|
|
66
|
+
message("u1", "user", "Different goal"),
|
|
67
|
+
...history.slice(1),
|
|
68
|
+
];
|
|
69
|
+
expect(contextRequest(edited, checkpoint)).toEqual({ messages: edited });
|
|
70
|
+
expect(contextRequest(history.slice(0, 1), checkpoint)).toEqual({
|
|
71
|
+
messages: history.slice(0, 1),
|
|
72
|
+
});
|
|
73
|
+
expect(contextRequest([], checkpoint)).toEqual({ messages: [] });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("retains tool pairs, recent questions, and system instructions between steps", async () => {
|
|
77
|
+
const messages: ModelMessage[] = [
|
|
78
|
+
{ role: "system", content: "Keep original instructions" },
|
|
79
|
+
{ role: "user", content: "Read this profile" },
|
|
80
|
+
{
|
|
81
|
+
role: "assistant",
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: "tool-call",
|
|
85
|
+
toolName: "readPage",
|
|
86
|
+
toolCallId: "read-1",
|
|
87
|
+
input: { path: "/one" },
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
role: "tool",
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: "tool-result",
|
|
96
|
+
toolName: "readPage",
|
|
97
|
+
toolCallId: "read-1",
|
|
98
|
+
output: { type: "text", value: "evidence ".repeat(10000) },
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
const summarize = vi
|
|
104
|
+
.fn()
|
|
105
|
+
.mockResolvedValue(
|
|
106
|
+
"Source /one; preclinical evidence; uncertainty remains.",
|
|
107
|
+
);
|
|
108
|
+
const status = vi.fn();
|
|
109
|
+
const prepare = createStepCompactor({
|
|
110
|
+
budget: 3000,
|
|
111
|
+
overhead: "",
|
|
112
|
+
summarize,
|
|
113
|
+
onCompacting: status,
|
|
114
|
+
});
|
|
115
|
+
const result = await prepare({ messages });
|
|
116
|
+
expect(result.messages[0]).toEqual(messages[0]);
|
|
117
|
+
expect(result.messages[1]).toEqual(messages[1]);
|
|
118
|
+
expect(result.messages[2]).toEqual(messages[2]);
|
|
119
|
+
expect(JSON.stringify(result.messages[3])).toContain(
|
|
120
|
+
'"toolCallId":"read-1"',
|
|
121
|
+
);
|
|
122
|
+
expect(JSON.stringify(result.messages[3])).toContain(
|
|
123
|
+
"preclinical evidence",
|
|
124
|
+
);
|
|
125
|
+
expect(estimateContextTokens(result.messages)).toBeLessThan(3000);
|
|
126
|
+
await prepare({
|
|
127
|
+
messages: [...messages, { role: "assistant", content: "Answer" }],
|
|
128
|
+
});
|
|
129
|
+
expect(summarize).toHaveBeenCalledOnce();
|
|
130
|
+
expect(status.mock.calls).toEqual([[true], [false]]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not discard context when summarization fails", async () => {
|
|
134
|
+
const original = structuredClone(history);
|
|
135
|
+
await expect(
|
|
136
|
+
compactConversation({
|
|
137
|
+
messages: history,
|
|
138
|
+
budget: 3000,
|
|
139
|
+
overhead: "",
|
|
140
|
+
summarize: async () => {
|
|
141
|
+
throw new Error("provider unavailable");
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
).rejects.toThrow("provider unavailable");
|
|
145
|
+
expect(history).toEqual(original);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("chunks oversized Unicode histories and includes earlier summaries", async () => {
|
|
149
|
+
const model = new MockLanguageModelV2({
|
|
150
|
+
doGenerate: async () => ({
|
|
151
|
+
content: [{ type: "text", text: "Retained facts" }],
|
|
152
|
+
finishReason: "stop",
|
|
153
|
+
usage: { inputTokens: 100, outputTokens: 10, totalTokens: 110 },
|
|
154
|
+
warnings: [],
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
await createSummarizer(model, 4096)({ text: "研究".repeat(4000) });
|
|
158
|
+
expect(model.doGenerateCalls.length).toBeGreaterThan(1);
|
|
159
|
+
for (const call of model.doGenerateCalls)
|
|
160
|
+
expect(estimateContextTokens(call.prompt)).toBeLessThan(4096);
|
|
161
|
+
expect(JSON.stringify(model.doGenerateCalls[1]?.prompt)).toContain(
|
|
162
|
+
"Retained facts",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("streams status/checkpoints and continues with a compact model prompt", async () => {
|
|
167
|
+
const model = new MockLanguageModelV2({
|
|
168
|
+
doGenerate: async () => ({
|
|
169
|
+
content: [
|
|
170
|
+
{
|
|
171
|
+
type: "text",
|
|
172
|
+
text: "Earlier goal and uncertain evidence: https://example.org/source",
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
finishReason: "stop",
|
|
176
|
+
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
177
|
+
warnings: [],
|
|
178
|
+
}),
|
|
179
|
+
doStream: async () => ({
|
|
180
|
+
stream: simulateReadableStream({
|
|
181
|
+
chunks: [
|
|
182
|
+
{ type: "text-start" as const, id: "t" },
|
|
183
|
+
{
|
|
184
|
+
type: "text-delta" as const,
|
|
185
|
+
id: "t",
|
|
186
|
+
delta: "Continuing the conversation.",
|
|
187
|
+
},
|
|
188
|
+
{ type: "text-end" as const, id: "t" },
|
|
189
|
+
{
|
|
190
|
+
type: "finish" as const,
|
|
191
|
+
finishReason: "stop" as const,
|
|
192
|
+
usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
initialDelayInMs: null,
|
|
196
|
+
chunkDelayInMs: null,
|
|
197
|
+
}),
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
const before = vi.fn();
|
|
201
|
+
const app = createBackendStack({
|
|
202
|
+
basePath: "/api",
|
|
203
|
+
plugins: {
|
|
204
|
+
aiChat: aiChatBackendPlugin({
|
|
205
|
+
model,
|
|
206
|
+
access: "public",
|
|
207
|
+
systemPrompt: "Original rules",
|
|
208
|
+
compaction: { contextWindowTokens: 4096 },
|
|
209
|
+
hooks: { onBeforeChat: before },
|
|
210
|
+
}),
|
|
211
|
+
},
|
|
212
|
+
adapter: (db) => createMemoryAdapter(db)({}),
|
|
213
|
+
});
|
|
214
|
+
const send = (body: unknown) =>
|
|
215
|
+
app.handler(
|
|
216
|
+
new Request("http://localhost/api/chat", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "content-type": "application/json" },
|
|
219
|
+
body: JSON.stringify(body),
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const attachment = await send({
|
|
223
|
+
messages: [
|
|
224
|
+
{
|
|
225
|
+
id: "file",
|
|
226
|
+
role: "user",
|
|
227
|
+
parts: [
|
|
228
|
+
{
|
|
229
|
+
type: "file",
|
|
230
|
+
mediaType: "image/png",
|
|
231
|
+
url: "https://example.org/image.png",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
});
|
|
237
|
+
expect(attachment.status).toBe(400);
|
|
238
|
+
expect(await attachment.text()).toContain("text-only");
|
|
239
|
+
expect(model.doGenerateCalls).toHaveLength(0);
|
|
240
|
+
expect(model.doStreamCalls).toHaveLength(0);
|
|
241
|
+
const response = await send({ messages: history });
|
|
242
|
+
expect(response.status).toBe(200);
|
|
243
|
+
const text = await response.text();
|
|
244
|
+
expect(text).toContain('"data-context-status"');
|
|
245
|
+
expect(text).toContain('"throughMessageId":"a1"');
|
|
246
|
+
expect(text).toContain("Continuing the conversation.");
|
|
247
|
+
expect(before).toHaveBeenCalledOnce();
|
|
248
|
+
expect(JSON.stringify(model.doStreamCalls[0]?.prompt)).toContain(
|
|
249
|
+
"Original rules",
|
|
250
|
+
);
|
|
251
|
+
expect(JSON.stringify(model.doStreamCalls[0]?.prompt)).not.toContain(
|
|
252
|
+
"research ".repeat(100),
|
|
253
|
+
);
|
|
254
|
+
const checkpointLine = text
|
|
255
|
+
.split("\n")
|
|
256
|
+
.find((line) => line.includes('"data-context-checkpoint"'))!;
|
|
257
|
+
const checkpoint = JSON.parse(checkpointLine.slice(6)).data;
|
|
258
|
+
const next = await send({
|
|
259
|
+
messages: history.slice(2),
|
|
260
|
+
contextSummary: checkpoint.summary,
|
|
261
|
+
});
|
|
262
|
+
await next.text();
|
|
263
|
+
expect(JSON.stringify(model.doStreamCalls.at(-1)?.prompt)).toContain(
|
|
264
|
+
"Earlier goal",
|
|
265
|
+
);
|
|
266
|
+
const oversized = await send({
|
|
267
|
+
messages: [message("large", "user", "research ".repeat(4000))],
|
|
268
|
+
});
|
|
269
|
+
expect(await oversized.text()).toContain("Shorten it and retry");
|
|
270
|
+
expect(model.doStreamCalls).toHaveLength(2);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -124,7 +124,7 @@ async function render(props: ChatLayoutProps, strict = false) {
|
|
|
124
124
|
);
|
|
125
125
|
}
|
|
126
126
|
async function click(label: string) {
|
|
127
|
-
const button =
|
|
127
|
+
const button = document.querySelector<HTMLButtonElement>(
|
|
128
128
|
`button[aria-label="${label}"]`,
|
|
129
129
|
);
|
|
130
130
|
expect(button).not.toBeNull();
|
|
@@ -139,6 +139,29 @@ const page = {
|
|
|
139
139
|
};
|
|
140
140
|
|
|
141
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
|
+
});
|
|
142
165
|
it.each([false, true])(
|
|
143
166
|
"checks readPage authorization for send, edit and retry when enabled: %s",
|
|
144
167
|
async (pageContent) => {
|
|
@@ -374,6 +397,85 @@ describe("page widget", () => {
|
|
|
374
397
|
expect(panel.style.height).toBe("400px");
|
|
375
398
|
expect(trigger.parentElement?.className).not.toContain("fixed");
|
|
376
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
|
+
|
|
377
479
|
it("emits content-free lifecycle analytics once under StrictMode", async () => {
|
|
378
480
|
vi.useFakeTimers();
|
|
379
481
|
await render(
|