@btst/stack 4.2.0 → 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/client/components/chat-layout.cjs +173 -72
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +176 -75
- 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/index.d.cts +2 -2
- package/dist/plugins/ai-chat/client/index.d.mts +2 -2
- package/dist/plugins/ai-chat/client/index.d.ts +2 -2
- 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/cms/api/index.d.cts +1 -1
- package/dist/plugins/cms/api/index.d.mts +1 -1
- package/dist/plugins/cms/api/index.d.ts +1 -1
- package/dist/plugins/cms/query-keys.d.cts +1 -1
- package/dist/plugins/cms/query-keys.d.mts +1 -1
- package/dist/plugins/cms/query-keys.d.ts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/ui-builder/client/index.d.cts +1 -1
- package/dist/plugins/ui-builder/client/index.d.mts +1 -1
- package/dist/plugins/ui-builder/client/index.d.ts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
- package/dist/shared/{stack.C5FZr-jJ.d.mts → stack.CHrtGKLz.d.mts} +9 -3
- package/dist/shared/{stack.CTneIW0u.d.mts → stack.CZ1h_T4k.d.mts} +88 -88
- 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.D5b9l24t.d.ts → stack.DQpyA3Cq.d.ts} +88 -88
- package/dist/shared/{stack.Ce6V7eDj.d.cts → stack.DiYg7Jw1.d.cts} +88 -88
- package/dist/shared/{stack.xAiy2VtO.d.cts → stack.DzJgoNqJ.d.cts} +9 -3
- package/dist/shared/{stack.C-eQN_Jt.d.ts → stack.qMp2VzNw.d.ts} +9 -3
- package/dist/shared/{stack.BTStHVTy.d.cts → stack.rmWRtSjs.d.cts} +5 -5
- package/package.json +2 -2
- package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +22 -2
- package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +103 -1
- package/src/plugins/ai-chat/client/components/chat-layout.tsx +197 -72
- package/src/plugins/ai-chat/client/localization/index.ts +4 -0
- package/dist/shared/{stack.CyalJ0j6.d.ts → stack.BMlTZnZp.d.ts} +16 -16
- package/dist/shared/{stack.D6Pg-ufX.d.cts → stack.BV0uNV4f.d.cts} +16 -16
- 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.B8PG21zX.d.mts → stack.lsbdKRj7.d.mts} +16 -16
|
@@ -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 () => {
|
|
@@ -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(
|
|
@@ -6,7 +6,14 @@ import {
|
|
|
6
6
|
useEffect,
|
|
7
7
|
useRef,
|
|
8
8
|
type CSSProperties,
|
|
9
|
+
type ReactNode,
|
|
9
10
|
} from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
12
|
+
import {
|
|
13
|
+
Dialog,
|
|
14
|
+
DialogContent,
|
|
15
|
+
DialogTitle,
|
|
16
|
+
} from "@workspace/ui/components/dialog";
|
|
10
17
|
import { Button } from "@workspace/ui/components/button";
|
|
11
18
|
import { Badge } from "@workspace/ui/components/badge";
|
|
12
19
|
import {
|
|
@@ -19,6 +26,8 @@ import {
|
|
|
19
26
|
PanelLeftClose,
|
|
20
27
|
PanelLeft,
|
|
21
28
|
Sparkles,
|
|
29
|
+
Maximize2,
|
|
30
|
+
Minimize2,
|
|
22
31
|
Trash2,
|
|
23
32
|
X,
|
|
24
33
|
} from "lucide-react";
|
|
@@ -67,10 +76,12 @@ interface ChatLayoutBaseProps {
|
|
|
67
76
|
interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
|
|
68
77
|
/** Widget mode: compact embeddable panel with a floating trigger button */
|
|
69
78
|
layout: "widget";
|
|
70
|
-
/** Height of the widget panel. Default: `"min(
|
|
79
|
+
/** Height of the widget panel. Default: `"min(640px, calc(100dvh - 112px))"` */
|
|
71
80
|
widgetHeight?: string | number;
|
|
72
|
-
/** Width of the widget panel. Default: `"min(
|
|
81
|
+
/** Width of the widget panel. Default: `"min(440px, calc(100vw - 32px))"` */
|
|
73
82
|
widgetWidth?: string | number;
|
|
83
|
+
/** Show a button to expand the widget to the viewport. Default: false. Escape restores the compact size. */
|
|
84
|
+
expandable?: boolean;
|
|
74
85
|
/** Fix the widget to the bottom right. Default: false (embedded). Override offsets with className or style. */
|
|
75
86
|
floating?: boolean;
|
|
76
87
|
/** Optional dismissible introduction shown while the widget is closed. */
|
|
@@ -91,6 +102,8 @@ interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
|
|
|
91
102
|
* so that the built-in button does not appear alongside your own UI.
|
|
92
103
|
*/
|
|
93
104
|
showTrigger?: boolean;
|
|
105
|
+
/** Icon shown in the closed widget's trigger button. Defaults to Sparkles. */
|
|
106
|
+
triggerIcon?: ReactNode;
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
interface ChatLayoutFullProps extends ChatLayoutBaseProps {
|
|
@@ -139,16 +152,17 @@ function ChatLayoutContent(props: ChatLayoutProps) {
|
|
|
139
152
|
// Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here
|
|
140
153
|
const widgetHeight =
|
|
141
154
|
props.layout === "widget"
|
|
142
|
-
? (props.widgetHeight ?? "min(
|
|
155
|
+
? (props.widgetHeight ?? "min(640px, calc(100dvh - 112px))")
|
|
143
156
|
: undefined;
|
|
144
157
|
const widgetWidth =
|
|
145
158
|
props.layout === "widget"
|
|
146
|
-
? (props.widgetWidth ?? "min(
|
|
159
|
+
? (props.widgetWidth ?? "min(440px, calc(100vw - 32px))")
|
|
147
160
|
: undefined;
|
|
148
161
|
const defaultOpen =
|
|
149
162
|
props.layout === "widget" ? (props.defaultOpen ?? false) : false;
|
|
150
163
|
const showTrigger =
|
|
151
164
|
props.layout === "widget" ? (props.showTrigger ?? true) : true;
|
|
165
|
+
const triggerIcon = props.layout === "widget" ? props.triggerIcon : undefined;
|
|
152
166
|
|
|
153
167
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
154
168
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
@@ -156,6 +170,40 @@ function ChatLayoutContent(props: ChatLayoutProps) {
|
|
|
156
170
|
const [chatResetKey, setChatResetKey] = useState(0);
|
|
157
171
|
// Widget open/closed state — starts with defaultOpen value
|
|
158
172
|
const [widgetOpen, setWidgetOpen] = useState(defaultOpen);
|
|
173
|
+
const [expanded, setExpanded] = useState(false);
|
|
174
|
+
const expandable = props.layout === "widget" && props.expandable === true;
|
|
175
|
+
const widgetExpanded = expandable && expanded && widgetOpen;
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!expandable) setExpanded(false);
|
|
178
|
+
}, [expandable]);
|
|
179
|
+
const compactPanelRef = useRef<HTMLDivElement>(null);
|
|
180
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
181
|
+
const expandRef = useRef<HTMLButtonElement>(null);
|
|
182
|
+
const [panelContainer, setPanelContainer] = useState<HTMLDivElement | null>(
|
|
183
|
+
null,
|
|
184
|
+
);
|
|
185
|
+
const expandLabel = widgetExpanded
|
|
186
|
+
? tr("A11Y_COLLAPSE_CHAT", "aiChat.a11y.collapseChat", "Collapse chat")
|
|
187
|
+
: tr("A11Y_EXPAND_CHAT", "aiChat.a11y.expandChat", "Expand chat");
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (layout !== "widget") return;
|
|
191
|
+
const container = document.createElement("div");
|
|
192
|
+
container.className = "flex h-full min-h-0 flex-col";
|
|
193
|
+
compactPanelRef.current?.appendChild(container);
|
|
194
|
+
setPanelContainer(container);
|
|
195
|
+
return () => container.remove();
|
|
196
|
+
}, [layout]);
|
|
197
|
+
// The portal target stays the same; only its DOM parent changes. This keeps
|
|
198
|
+
// the draft, attachments and active stream mounted while Dialog owns modality.
|
|
199
|
+
const mountExpandedPanel = useCallback(
|
|
200
|
+
(node: HTMLDivElement | null) => {
|
|
201
|
+
if (panelContainer) {
|
|
202
|
+
(node ?? compactPanelRef.current)?.appendChild(panelContainer);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
[panelContainer],
|
|
206
|
+
);
|
|
159
207
|
// Key to force widget ChatInterface remount on clear
|
|
160
208
|
const [widgetResetKey, setWidgetResetKey] = useState(0);
|
|
161
209
|
// Only mount the widget ChatInterface after the widget has been opened at least once.
|
|
@@ -243,90 +291,167 @@ function ChatLayoutContent(props: ChatLayoutProps) {
|
|
|
243
291
|
</Button>
|
|
244
292
|
</div>
|
|
245
293
|
)}
|
|
246
|
-
{/*
|
|
294
|
+
{/* Compact panel remains available when the fullscreen dialog closes. */}
|
|
247
295
|
<div
|
|
296
|
+
ref={compactPanelRef}
|
|
297
|
+
role="dialog"
|
|
298
|
+
tabIndex={-1}
|
|
299
|
+
aria-label={tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
|
|
248
300
|
className={cn(
|
|
249
|
-
"
|
|
250
|
-
widgetOpen
|
|
301
|
+
"overflow-hidden rounded-xl border bg-background shadow-xl",
|
|
302
|
+
(!widgetOpen || widgetExpanded) && "hidden",
|
|
251
303
|
)}
|
|
252
304
|
style={{ height: widgetHeight, width: widgetWidth }}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
{
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
305
|
+
/>
|
|
306
|
+
<Dialog open={widgetExpanded} onOpenChange={setExpanded}>
|
|
307
|
+
<DialogContent
|
|
308
|
+
showCloseButton={false}
|
|
309
|
+
style={{
|
|
310
|
+
...style,
|
|
311
|
+
position: "fixed",
|
|
312
|
+
inset: 0,
|
|
313
|
+
top: 0,
|
|
314
|
+
right: 0,
|
|
315
|
+
bottom: 0,
|
|
316
|
+
left: 0,
|
|
317
|
+
width: "100vw",
|
|
318
|
+
height: "100dvh",
|
|
319
|
+
minWidth: 0,
|
|
320
|
+
minHeight: 0,
|
|
321
|
+
maxWidth: "none",
|
|
322
|
+
maxHeight: "none",
|
|
323
|
+
margin: 0,
|
|
324
|
+
transform: "none",
|
|
325
|
+
translate: "none",
|
|
326
|
+
zIndex: 50,
|
|
327
|
+
animation: "none",
|
|
328
|
+
}}
|
|
329
|
+
aria-describedby={undefined}
|
|
330
|
+
className={cn(
|
|
331
|
+
className,
|
|
332
|
+
classNames?.container,
|
|
333
|
+
"fixed inset-0 z-50 flex h-dvh w-screen max-w-none translate-x-0 translate-y-0 flex-col gap-0 rounded-none border-0 p-0 sm:max-w-none pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] pr-[env(safe-area-inset-right)]",
|
|
269
334
|
)}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
335
|
+
onCloseAutoFocus={(event) => {
|
|
336
|
+
const target = widgetOpen
|
|
337
|
+
? (expandRef.current ?? compactPanelRef.current)
|
|
338
|
+
: triggerRef.current;
|
|
339
|
+
if (target) {
|
|
340
|
+
event.preventDefault();
|
|
341
|
+
target.focus();
|
|
342
|
+
}
|
|
343
|
+
}}
|
|
344
|
+
>
|
|
345
|
+
<DialogTitle className="sr-only">
|
|
346
|
+
{tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
|
|
347
|
+
</DialogTitle>
|
|
348
|
+
<div ref={mountExpandedPanel} className="min-h-0 flex-1" />
|
|
349
|
+
</DialogContent>
|
|
350
|
+
</Dialog>
|
|
351
|
+
{panelContainer &&
|
|
352
|
+
createPortal(
|
|
353
|
+
<>
|
|
354
|
+
{/* Widget header with page context badge and action buttons */}
|
|
355
|
+
<div className="flex shrink-0 items-center gap-1.5 px-3 py-1.5 border-b bg-muted/40">
|
|
356
|
+
<Sparkles className="h-3 w-3 text-muted-foreground" />
|
|
357
|
+
{pageAIContext ? (
|
|
358
|
+
<Badge
|
|
359
|
+
variant="secondary"
|
|
360
|
+
className="text-xs"
|
|
361
|
+
data-testid="page-context-badge"
|
|
362
|
+
>
|
|
363
|
+
{pageAIContext.routeName}
|
|
364
|
+
</Badge>
|
|
365
|
+
) : (
|
|
366
|
+
<span className="text-xs text-muted-foreground font-medium">
|
|
367
|
+
{tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
|
|
368
|
+
</span>
|
|
369
|
+
)}
|
|
370
|
+
<div className="flex-1" />
|
|
371
|
+
{expandable && (
|
|
372
|
+
<Button
|
|
373
|
+
type="button"
|
|
374
|
+
variant="ghost"
|
|
375
|
+
size="icon"
|
|
376
|
+
className="h-7 w-7"
|
|
377
|
+
ref={expandRef}
|
|
378
|
+
onClick={() => setExpanded((prev) => !prev)}
|
|
379
|
+
aria-label={expandLabel}
|
|
380
|
+
title={expandLabel}
|
|
381
|
+
aria-expanded={widgetExpanded}
|
|
382
|
+
>
|
|
383
|
+
{widgetExpanded ? (
|
|
384
|
+
<Minimize2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
385
|
+
) : (
|
|
386
|
+
<Maximize2 className="h-3.5 w-3.5" aria-hidden="true" />
|
|
387
|
+
)}
|
|
388
|
+
</Button>
|
|
389
|
+
)}
|
|
390
|
+
<Button
|
|
391
|
+
variant="ghost"
|
|
392
|
+
size="icon"
|
|
393
|
+
className="h-5 w-5"
|
|
394
|
+
onClick={() => {
|
|
395
|
+
track({ type: "conversation_cleared" });
|
|
396
|
+
onClear?.();
|
|
397
|
+
setWidgetResetKey((prev) => prev + 1);
|
|
398
|
+
}}
|
|
399
|
+
aria-label={tr(
|
|
400
|
+
"A11Y_CLEAR_CHAT",
|
|
401
|
+
"aiChat.a11y.clearChat",
|
|
402
|
+
"Clear chat",
|
|
403
|
+
)}
|
|
404
|
+
title={tr(
|
|
405
|
+
"A11Y_CLEAR_CHAT",
|
|
406
|
+
"aiChat.a11y.clearChat",
|
|
407
|
+
"Clear chat",
|
|
408
|
+
)}
|
|
409
|
+
>
|
|
410
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
411
|
+
</Button>
|
|
412
|
+
<Button
|
|
413
|
+
variant="ghost"
|
|
414
|
+
size="icon"
|
|
415
|
+
className="h-5 w-5"
|
|
416
|
+
onClick={() => {
|
|
417
|
+
setWidgetOpen(false);
|
|
418
|
+
setExpanded(false);
|
|
419
|
+
triggerRef.current?.focus();
|
|
420
|
+
track({ type: "widget_closed" });
|
|
421
|
+
}}
|
|
422
|
+
aria-label={tr(
|
|
423
|
+
"A11Y_CLOSE_CHAT",
|
|
424
|
+
"aiChat.a11y.closeChat",
|
|
425
|
+
"Close chat",
|
|
426
|
+
)}
|
|
427
|
+
>
|
|
428
|
+
<X className="h-3.5 w-3.5" />
|
|
429
|
+
</Button>
|
|
430
|
+
</div>
|
|
431
|
+
{widgetEverOpened && (
|
|
432
|
+
<ChatInterface
|
|
433
|
+
key={`widget-${conversationId ?? "new"}-${widgetResetKey}`}
|
|
434
|
+
id={conversationId}
|
|
435
|
+
variant="widget"
|
|
436
|
+
initialMessages={initialMessages}
|
|
437
|
+
onMessagesChange={onMessagesChange}
|
|
438
|
+
/>
|
|
305
439
|
)}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
</Button>
|
|
309
|
-
</div>
|
|
310
|
-
{widgetEverOpened && (
|
|
311
|
-
<ChatInterface
|
|
312
|
-
key={`widget-${conversationId ?? "new"}-${widgetResetKey}`}
|
|
313
|
-
id={conversationId}
|
|
314
|
-
variant="widget"
|
|
315
|
-
initialMessages={initialMessages}
|
|
316
|
-
onMessagesChange={onMessagesChange}
|
|
317
|
-
/>
|
|
440
|
+
</>,
|
|
441
|
+
panelContainer,
|
|
318
442
|
)}
|
|
319
|
-
</div>
|
|
320
443
|
|
|
321
444
|
{/* Trigger button — rendered only when showTrigger is true */}
|
|
322
445
|
{showTrigger && (
|
|
323
446
|
<Button
|
|
447
|
+
ref={triggerRef}
|
|
324
448
|
size="icon"
|
|
325
449
|
className="h-12 w-12 rounded-full shadow-lg"
|
|
326
450
|
onClick={() => {
|
|
327
451
|
dismissTip("open");
|
|
328
452
|
track({ type: widgetOpen ? "widget_closed" : "widget_opened" });
|
|
329
453
|
setWidgetOpen((prev) => !prev);
|
|
454
|
+
setExpanded(false);
|
|
330
455
|
setWidgetEverOpened(true);
|
|
331
456
|
}}
|
|
332
457
|
aria-label={
|
|
@@ -339,7 +464,7 @@ function ChatLayoutContent(props: ChatLayoutProps) {
|
|
|
339
464
|
{widgetOpen ? (
|
|
340
465
|
<X className="h-5 w-5" />
|
|
341
466
|
) : (
|
|
342
|
-
<Sparkles className="h-5 w-5" />
|
|
467
|
+
(triggerIcon ?? <Sparkles className="h-5 w-5" />)
|
|
343
468
|
)}
|
|
344
469
|
</Button>
|
|
345
470
|
)}
|
|
@@ -86,6 +86,8 @@ export interface AiChatLocalization {
|
|
|
86
86
|
A11Y_CHAT_TITLE: string;
|
|
87
87
|
A11Y_CLEAR_CHAT: string;
|
|
88
88
|
A11Y_CLOSE_CHAT: string;
|
|
89
|
+
A11Y_EXPAND_CHAT: string;
|
|
90
|
+
A11Y_COLLAPSE_CHAT: string;
|
|
89
91
|
A11Y_OPEN_CHAT: string;
|
|
90
92
|
A11Y_DISMISS_CHAT_TIP: string;
|
|
91
93
|
PAGE_CONTENT_READING: string;
|
|
@@ -194,6 +196,8 @@ export const AI_CHAT_LOCALIZATION: AiChatLocalization = {
|
|
|
194
196
|
A11Y_CHAT_TITLE: "AI Chat",
|
|
195
197
|
A11Y_CLEAR_CHAT: "Clear chat",
|
|
196
198
|
A11Y_CLOSE_CHAT: "Close chat",
|
|
199
|
+
A11Y_EXPAND_CHAT: "Expand chat",
|
|
200
|
+
A11Y_COLLAPSE_CHAT: "Collapse chat",
|
|
197
201
|
A11Y_OPEN_CHAT: "Open chat",
|
|
198
202
|
A11Y_DISMISS_CHAT_TIP: "Dismiss chat tip",
|
|
199
203
|
PAGE_CONTENT_READING: "Reading page content…",
|
|
@@ -95,8 +95,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
95
95
|
readonly id: string;
|
|
96
96
|
readonly slug: string;
|
|
97
97
|
readonly contentTypeId: string;
|
|
98
|
-
readonly authorId?: string | undefined;
|
|
99
98
|
readonly data: string;
|
|
99
|
+
readonly authorId?: string | undefined;
|
|
100
100
|
}[];
|
|
101
101
|
readonly total: number;
|
|
102
102
|
}>;
|
|
@@ -128,8 +128,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
128
128
|
readonly id: string;
|
|
129
129
|
readonly slug: string;
|
|
130
130
|
readonly contentTypeId: string;
|
|
131
|
-
readonly authorId?: string | undefined;
|
|
132
131
|
readonly data: string;
|
|
132
|
+
readonly authorId?: string | undefined;
|
|
133
133
|
}>;
|
|
134
134
|
readonly createContentItem: better_call.StrictEndpoint<"/content/:typeSlug", {
|
|
135
135
|
params: z.ZodObject<{
|
|
@@ -165,8 +165,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
165
165
|
readonly id: string;
|
|
166
166
|
readonly slug: string;
|
|
167
167
|
readonly contentTypeId: string;
|
|
168
|
-
readonly authorId?: string | undefined;
|
|
169
168
|
readonly data: string;
|
|
169
|
+
readonly authorId?: string | undefined;
|
|
170
170
|
}>;
|
|
171
171
|
readonly updateContentItem: better_call.StrictEndpoint<"/content/:typeSlug/:id", {
|
|
172
172
|
params: z.ZodObject<{
|
|
@@ -203,8 +203,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
203
203
|
readonly id: string;
|
|
204
204
|
readonly slug: string;
|
|
205
205
|
readonly contentTypeId: string;
|
|
206
|
-
readonly authorId?: string | undefined;
|
|
207
206
|
readonly data: string;
|
|
207
|
+
readonly authorId?: string | undefined;
|
|
208
208
|
}>;
|
|
209
209
|
readonly deleteContentItem: better_call.StrictEndpoint<"/content/:typeSlug/:id", {
|
|
210
210
|
params: z.ZodObject<{
|
|
@@ -248,8 +248,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
248
248
|
readonly id: string;
|
|
249
249
|
readonly slug: string;
|
|
250
250
|
readonly contentTypeId: string;
|
|
251
|
-
readonly authorId?: string | undefined;
|
|
252
251
|
readonly data: string;
|
|
252
|
+
readonly authorId?: string | undefined;
|
|
253
253
|
}[];
|
|
254
254
|
};
|
|
255
255
|
readonly contentType?: {
|
|
@@ -271,8 +271,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
271
271
|
readonly id: string;
|
|
272
272
|
readonly slug: string;
|
|
273
273
|
readonly contentTypeId: string;
|
|
274
|
-
readonly authorId?: string | undefined;
|
|
275
274
|
readonly data: string;
|
|
275
|
+
readonly authorId?: string | undefined;
|
|
276
276
|
}>;
|
|
277
277
|
readonly listContentByRelation: better_call.StrictEndpoint<"/content/:typeSlug/by-relation", {
|
|
278
278
|
params: z.ZodObject<{
|
|
@@ -314,8 +314,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
314
314
|
readonly id: string;
|
|
315
315
|
readonly slug: string;
|
|
316
316
|
readonly contentTypeId: string;
|
|
317
|
-
readonly authorId?: string | undefined;
|
|
318
317
|
readonly data: string;
|
|
318
|
+
readonly authorId?: string | undefined;
|
|
319
319
|
}[];
|
|
320
320
|
readonly total: number;
|
|
321
321
|
readonly limit: number;
|
|
@@ -383,8 +383,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
383
383
|
readonly id: string;
|
|
384
384
|
readonly slug: string;
|
|
385
385
|
readonly contentTypeId: string;
|
|
386
|
-
readonly authorId?: string | undefined;
|
|
387
386
|
readonly data: string;
|
|
387
|
+
readonly authorId?: string | undefined;
|
|
388
388
|
}[];
|
|
389
389
|
readonly total: number;
|
|
390
390
|
readonly limit: number;
|
|
@@ -459,8 +459,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
459
459
|
id: string;
|
|
460
460
|
slug: string;
|
|
461
461
|
contentTypeId: string;
|
|
462
|
-
authorId?: string | undefined;
|
|
463
462
|
data: string;
|
|
463
|
+
authorId?: string | undefined;
|
|
464
464
|
}[];
|
|
465
465
|
total: number;
|
|
466
466
|
}, false>;
|
|
@@ -493,8 +493,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
493
493
|
id: string;
|
|
494
494
|
slug: string;
|
|
495
495
|
contentTypeId: string;
|
|
496
|
-
authorId?: string | undefined;
|
|
497
496
|
data: string;
|
|
497
|
+
authorId?: string | undefined;
|
|
498
498
|
}, false>;
|
|
499
499
|
readonly createContentItem: _btst_stack_plugins_api.Operation<z.ZodObject<{
|
|
500
500
|
typeSlug: z.ZodString;
|
|
@@ -522,8 +522,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
522
522
|
id: string;
|
|
523
523
|
slug: string;
|
|
524
524
|
contentTypeId: string;
|
|
525
|
-
authorId?: string | undefined;
|
|
526
525
|
data: string;
|
|
526
|
+
authorId?: string | undefined;
|
|
527
527
|
}, false>;
|
|
528
528
|
readonly updateContentItem: _btst_stack_plugins_api.Operation<z.ZodObject<{
|
|
529
529
|
typeSlug: z.ZodString;
|
|
@@ -554,8 +554,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
554
554
|
id: string;
|
|
555
555
|
slug: string;
|
|
556
556
|
contentTypeId: string;
|
|
557
|
-
authorId?: string | undefined;
|
|
558
557
|
data: string;
|
|
558
|
+
authorId?: string | undefined;
|
|
559
559
|
}, false>;
|
|
560
560
|
readonly deleteContentItem: _btst_stack_plugins_api.Operation<z.ZodObject<{
|
|
561
561
|
typeSlug: z.ZodString;
|
|
@@ -598,8 +598,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
598
598
|
id: string;
|
|
599
599
|
slug: string;
|
|
600
600
|
contentTypeId: string;
|
|
601
|
-
authorId?: string | undefined;
|
|
602
601
|
data: string;
|
|
602
|
+
authorId?: string | undefined;
|
|
603
603
|
}[];
|
|
604
604
|
};
|
|
605
605
|
contentType?: {
|
|
@@ -619,8 +619,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
619
619
|
id: string;
|
|
620
620
|
slug: string;
|
|
621
621
|
contentTypeId: string;
|
|
622
|
-
authorId?: string | undefined;
|
|
623
622
|
data: string;
|
|
623
|
+
authorId?: string | undefined;
|
|
624
624
|
}, false>;
|
|
625
625
|
readonly listContentByRelation: _btst_stack_plugins_api.Operation<z.ZodObject<{
|
|
626
626
|
typeSlug: z.ZodString;
|
|
@@ -657,8 +657,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
657
657
|
id: string;
|
|
658
658
|
slug: string;
|
|
659
659
|
contentTypeId: string;
|
|
660
|
-
authorId?: string | undefined;
|
|
661
660
|
data: string;
|
|
661
|
+
authorId?: string | undefined;
|
|
662
662
|
}[];
|
|
663
663
|
total: number;
|
|
664
664
|
limit: number;
|
|
@@ -715,8 +715,8 @@ declare const cmsBackendPlugin: (config: CMSBackendConfig) => _btst_stack_plugin
|
|
|
715
715
|
id: string;
|
|
716
716
|
slug: string;
|
|
717
717
|
contentTypeId: string;
|
|
718
|
-
authorId?: string | undefined;
|
|
719
718
|
data: string;
|
|
719
|
+
authorId?: string | undefined;
|
|
720
720
|
}[];
|
|
721
721
|
total: number;
|
|
722
722
|
limit: number;
|