@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.
Files changed (56) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +173 -72
  3. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +176 -75
  4. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  5. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  6. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  7. package/dist/plugins/ai-chat/client/index.d.cts +2 -2
  8. package/dist/plugins/ai-chat/client/index.d.mts +2 -2
  9. package/dist/plugins/ai-chat/client/index.d.ts +2 -2
  10. package/dist/plugins/blog/api/index.d.cts +2 -2
  11. package/dist/plugins/blog/api/index.d.mts +2 -2
  12. package/dist/plugins/blog/api/index.d.ts +2 -2
  13. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  14. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  15. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  16. package/dist/plugins/blog/client/index.d.cts +2 -2
  17. package/dist/plugins/blog/client/index.d.mts +2 -2
  18. package/dist/plugins/blog/client/index.d.ts +2 -2
  19. package/dist/plugins/blog/query-keys.d.cts +2 -2
  20. package/dist/plugins/blog/query-keys.d.mts +2 -2
  21. package/dist/plugins/blog/query-keys.d.ts +2 -2
  22. package/dist/plugins/cms/api/index.d.cts +1 -1
  23. package/dist/plugins/cms/api/index.d.mts +1 -1
  24. package/dist/plugins/cms/api/index.d.ts +1 -1
  25. package/dist/plugins/cms/query-keys.d.cts +1 -1
  26. package/dist/plugins/cms/query-keys.d.mts +1 -1
  27. package/dist/plugins/cms/query-keys.d.ts +1 -1
  28. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  29. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  30. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  31. package/dist/plugins/ui-builder/client/index.d.cts +1 -1
  32. package/dist/plugins/ui-builder/client/index.d.mts +1 -1
  33. package/dist/plugins/ui-builder/client/index.d.ts +1 -1
  34. package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
  35. package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
  36. package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
  37. package/dist/shared/{stack.C5FZr-jJ.d.mts → stack.CHrtGKLz.d.mts} +9 -3
  38. package/dist/shared/{stack.CTneIW0u.d.mts → stack.CZ1h_T4k.d.mts} +88 -88
  39. package/dist/shared/{stack.DfrRYOIl.d.ts → stack.CnDMBj4z.d.ts} +5 -5
  40. package/dist/shared/{stack.94kN3pEK.d.mts → stack.D2y4gl03.d.mts} +5 -5
  41. package/dist/shared/{stack.D5b9l24t.d.ts → stack.DQpyA3Cq.d.ts} +88 -88
  42. package/dist/shared/{stack.Ce6V7eDj.d.cts → stack.DiYg7Jw1.d.cts} +88 -88
  43. package/dist/shared/{stack.xAiy2VtO.d.cts → stack.DzJgoNqJ.d.cts} +9 -3
  44. package/dist/shared/{stack.C-eQN_Jt.d.ts → stack.qMp2VzNw.d.ts} +9 -3
  45. package/dist/shared/{stack.BTStHVTy.d.cts → stack.rmWRtSjs.d.cts} +5 -5
  46. package/package.json +2 -2
  47. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +22 -2
  48. package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +103 -1
  49. package/src/plugins/ai-chat/client/components/chat-layout.tsx +197 -72
  50. package/src/plugins/ai-chat/client/localization/index.ts +4 -0
  51. package/dist/shared/{stack.CyalJ0j6.d.ts → stack.BMlTZnZp.d.ts} +16 -16
  52. package/dist/shared/{stack.D6Pg-ufX.d.cts → stack.BV0uNV4f.d.cts} +16 -16
  53. package/dist/shared/{stack.CKc_Bais.d.cts → stack.DtZgEX84.d.cts} +6 -6
  54. package/dist/shared/{stack.CKc_Bais.d.mts → stack.DtZgEX84.d.mts} +6 -6
  55. package/dist/shared/{stack.CKc_Bais.d.ts → stack.DtZgEX84.d.ts} +6 -6
  56. 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("applies classNames.container to the widget root", async () => {
223
- await render(<ChatLayout layout="widget" />);
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 = container.querySelector<HTMLButtonElement>(
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(600px, calc(100dvh - 112px))"` */
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(380px, calc(100vw - 32px))"` */
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(600px, calc(100dvh - 112px))")
155
+ ? (props.widgetHeight ?? "min(640px, calc(100dvh - 112px))")
143
156
  : undefined;
144
157
  const widgetWidth =
145
158
  props.layout === "widget"
146
- ? (props.widgetWidth ?? "min(380px, calc(100vw - 32px))")
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
- {/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
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
- "flex flex-col border rounded-xl overflow-hidden bg-background shadow-xl",
250
- widgetOpen ? "flex" : "hidden",
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
- {/* Widget header with page context badge and action buttons */}
255
- <div className="flex items-center gap-1.5 px-3 py-1.5 border-b bg-muted/40">
256
- <Sparkles className="h-3 w-3 text-muted-foreground" />
257
- {pageAIContext ? (
258
- <Badge
259
- variant="secondary"
260
- className="text-xs"
261
- data-testid="page-context-badge"
262
- >
263
- {pageAIContext.routeName}
264
- </Badge>
265
- ) : (
266
- <span className="text-xs text-muted-foreground font-medium">
267
- {tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
268
- </span>
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
- <div className="flex-1" />
271
- <Button
272
- variant="ghost"
273
- size="icon"
274
- className="h-5 w-5"
275
- onClick={() => {
276
- track({ type: "conversation_cleared" });
277
- onClear?.();
278
- setWidgetResetKey((prev) => prev + 1);
279
- }}
280
- aria-label={tr(
281
- "A11Y_CLEAR_CHAT",
282
- "aiChat.a11y.clearChat",
283
- "Clear chat",
284
- )}
285
- title={tr(
286
- "A11Y_CLEAR_CHAT",
287
- "aiChat.a11y.clearChat",
288
- "Clear chat",
289
- )}
290
- >
291
- <Trash2 className="h-3.5 w-3.5" />
292
- </Button>
293
- <Button
294
- variant="ghost"
295
- size="icon"
296
- className="h-5 w-5"
297
- onClick={() => {
298
- setWidgetOpen(false);
299
- track({ type: "widget_closed" });
300
- }}
301
- aria-label={tr(
302
- "A11Y_CLOSE_CHAT",
303
- "aiChat.a11y.closeChat",
304
- "Close chat",
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
- <X className="h-3.5 w-3.5" />
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;