@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.
Files changed (94) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +15 -9
  3. package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +16 -10
  4. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +28 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +29 -2
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +5 -0
  7. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +5 -0
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +91 -8
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +91 -8
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +242 -68
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +246 -72
  12. package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.cjs +17 -0
  13. package/dist/packages/stack/src/plugins/ai-chat/client/components/tool-call-display.mjs +18 -1
  14. package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.cjs +42 -0
  15. package/dist/packages/stack/src/plugins/ai-chat/client/context/chat-analytics.mjs +39 -0
  16. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.cjs +2 -1
  17. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +2 -1
  18. package/dist/plugins/ai-chat/api/index.d.cts +2 -2
  19. package/dist/plugins/ai-chat/api/index.d.mts +2 -2
  20. package/dist/plugins/ai-chat/api/index.d.ts +2 -2
  21. package/dist/plugins/ai-chat/client/components/index.d.cts +2 -1
  22. package/dist/plugins/ai-chat/client/components/index.d.mts +2 -1
  23. package/dist/plugins/ai-chat/client/components/index.d.ts +2 -1
  24. package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +4 -0
  25. package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +6 -2
  26. package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +6 -2
  27. package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +6 -2
  28. package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +4 -1
  29. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  30. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  31. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  32. package/dist/plugins/ai-chat/client/index.d.cts +7 -3
  33. package/dist/plugins/ai-chat/client/index.d.mts +7 -3
  34. package/dist/plugins/ai-chat/client/index.d.ts +7 -3
  35. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  36. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  37. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  38. package/dist/plugins/blog/api/index.d.cts +2 -2
  39. package/dist/plugins/blog/api/index.d.mts +2 -2
  40. package/dist/plugins/blog/api/index.d.ts +2 -2
  41. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  42. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  43. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  44. package/dist/plugins/blog/client/index.d.cts +2 -2
  45. package/dist/plugins/blog/client/index.d.mts +2 -2
  46. package/dist/plugins/blog/client/index.d.ts +2 -2
  47. package/dist/plugins/blog/query-keys.d.cts +2 -2
  48. package/dist/plugins/blog/query-keys.d.mts +2 -2
  49. package/dist/plugins/blog/query-keys.d.ts +2 -2
  50. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  51. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  52. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  53. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  54. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  55. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  56. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  57. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  58. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  59. package/dist/shared/{stack.B5yPgHsp.d.mts → stack.B7Mj74BQ.d.mts} +18 -2
  60. package/dist/shared/{stack.rVF0f509.d.ts → stack.CHrtGKLz.d.mts} +66 -4
  61. package/dist/shared/{stack.BggTHKlo.d.mts → stack.CZ1h_T4k.d.mts} +90 -90
  62. package/dist/shared/{stack.DfrRYOIl.d.ts → stack.CnDMBj4z.d.ts} +5 -5
  63. package/dist/shared/{stack.94kN3pEK.d.mts → stack.D2y4gl03.d.mts} +5 -5
  64. package/dist/shared/{stack.3Sr6qRtT.d.ts → stack.DBH1Bjko.d.ts} +18 -2
  65. package/dist/shared/{stack.lPKwd_v0.d.ts → stack.DQpyA3Cq.d.ts} +90 -90
  66. package/dist/shared/{stack.C4oVW2qK.d.cts → stack.Dgt3MCtW.d.cts} +18 -2
  67. package/dist/shared/{stack.CtrnoQgH.d.cts → stack.DiYg7Jw1.d.cts} +90 -90
  68. package/dist/shared/{stack.rVF0f509.d.cts → stack.DzJgoNqJ.d.cts} +66 -4
  69. package/dist/shared/{stack.rVF0f509.d.mts → stack.qMp2VzNw.d.ts} +66 -4
  70. package/dist/shared/{stack.BTStHVTy.d.cts → stack.rmWRtSjs.d.cts} +5 -5
  71. package/package.json +2 -2
  72. package/src/plugins/ai-chat/__tests__/authorization.test.ts +106 -1
  73. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +26 -3
  74. package/src/plugins/ai-chat/__tests__/page-content.test.ts +177 -0
  75. package/src/plugins/ai-chat/__tests__/page-widget.test.tsx +675 -0
  76. package/src/plugins/ai-chat/api/operations.ts +29 -13
  77. package/src/plugins/ai-chat/api/page-tools.ts +56 -2
  78. package/src/plugins/ai-chat/api/plugin.ts +13 -0
  79. package/src/plugins/ai-chat/client/analytics.ts +35 -0
  80. package/src/plugins/ai-chat/client/components/chat-interface.tsx +113 -10
  81. package/src/plugins/ai-chat/client/components/chat-layout.tsx +304 -68
  82. package/src/plugins/ai-chat/client/components/tool-call-display.tsx +29 -1
  83. package/src/plugins/ai-chat/client/context/chat-analytics.tsx +46 -0
  84. package/src/plugins/ai-chat/client/context/page-ai-context.tsx +9 -1
  85. package/src/plugins/ai-chat/client/index.ts +2 -0
  86. package/src/plugins/ai-chat/client/localization/index.ts +12 -0
  87. package/src/plugins/ai-chat/client/overrides.ts +5 -0
  88. package/src/plugins/ai-chat/client/plugin.tsx +3 -0
  89. package/dist/shared/{stack.BuxUQqL8.d.ts → stack.CGmh-Lpe.d.ts} +12 -12
  90. package/dist/shared/{stack.Egr3RWhA.d.mts → stack.Doy2_wgl.d.mts} +12 -12
  91. package/dist/shared/{stack.CKc_Bais.d.cts → stack.DtZgEX84.d.cts} +6 -6
  92. package/dist/shared/{stack.CKc_Bais.d.mts → stack.DtZgEX84.d.mts} +6 -6
  93. package/dist/shared/{stack.CKc_Bais.d.ts → stack.DtZgEX84.d.ts} +6 -6
  94. package/dist/shared/{stack.CX83YNuS.d.cts → stack.zch70Loj.d.cts} +12 -12
@@ -1,6 +1,19 @@
1
1
  "use client";
2
2
 
3
- import { useState, useCallback } from "react";
3
+ import {
4
+ useState,
5
+ useCallback,
6
+ useEffect,
7
+ useRef,
8
+ type CSSProperties,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { createPortal } from "react-dom";
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogTitle,
16
+ } from "@workspace/ui/components/dialog";
4
17
  import { Button } from "@workspace/ui/components/button";
5
18
  import { Badge } from "@workspace/ui/components/badge";
6
19
  import {
@@ -13,6 +26,8 @@ import {
13
26
  PanelLeftClose,
14
27
  PanelLeft,
15
28
  Sparkles,
29
+ Maximize2,
30
+ Minimize2,
16
31
  Trash2,
17
32
  X,
18
33
  } from "lucide-react";
@@ -20,10 +35,18 @@ import { cn } from "@workspace/ui/lib/utils";
20
35
  import { ChatSidebar } from "./chat-sidebar";
21
36
  import { ChatInterface } from "./chat-interface";
22
37
  import type { UIMessage } from "ai";
23
- import { usePageAIContext } from "../context/page-ai-context";
38
+ import {
39
+ usePageAIContext,
40
+ PageAIContextScope,
41
+ type PageAIContextConfig,
42
+ } from "../context/page-ai-context";
24
43
  import { usePluginOverrides, useStack } from "@btst/stack/context";
25
44
  import type { AiChatPluginOverrides } from "../overrides";
26
45
  import { resolveAiChatMode } from "../overrides";
46
+ import {
47
+ ChatAnalyticsPageKey,
48
+ useChatAnalytics,
49
+ } from "../context/chat-analytics";
27
50
  import { useAiChatTranslation } from "../localization";
28
51
 
29
52
  interface ChatLayoutBaseProps {
@@ -31,6 +54,15 @@ interface ChatLayoutBaseProps {
31
54
  conversationId?: string;
32
55
  /** Additional class name for the container */
33
56
  className?: string;
57
+ /** Additional container styles, including application-specific floating offsets. */
58
+ style?: CSSProperties;
59
+ /** Instance-local context. Omit to inherit page registrations; null disables them. */
60
+ pageContext?: PageAIContextConfig | null;
61
+ /**
62
+ * Page identity. Changing it resets the panel, tip, and conversation, stopping
63
+ * an active stream. Supply initialMessages/conversationId for this page only.
64
+ */
65
+ pageKey?: string;
34
66
  /** Whether to show the sidebar */
35
67
  showSidebar?: boolean;
36
68
  /** Initial messages to populate the chat (useful for localStorage persistence in public mode) */
@@ -44,10 +76,18 @@ interface ChatLayoutBaseProps {
44
76
  interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
45
77
  /** Widget mode: compact embeddable panel with a floating trigger button */
46
78
  layout: "widget";
47
- /** Height of the widget panel. Default: `"600px"` */
79
+ /** Height of the widget panel. Default: `"min(640px, calc(100dvh - 112px))"` */
48
80
  widgetHeight?: string | number;
49
- /** Width of the widget panel. Default: `"380px"` */
81
+ /** Width of the widget panel. Default: `"min(440px, calc(100vw - 32px))"` */
50
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;
85
+ /** Fix the widget to the bottom right. Default: false (embedded). Override offsets with className or style. */
86
+ floating?: boolean;
87
+ /** Optional dismissible introduction shown while the widget is closed. */
88
+ introTip?: string;
89
+ /** Milliseconds before hiding the introduction. Default: 6000. */
90
+ introTipDuration?: number;
51
91
  /**
52
92
  * Whether the widget panel starts open. Default: `false`.
53
93
  * Set to `true` when embedding inside an already-open container such as a
@@ -62,6 +102,8 @@ interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
62
102
  * so that the built-in button does not appear alongside your own UI.
63
103
  */
64
104
  showTrigger?: boolean;
105
+ /** Icon shown in the closed widget's trigger button. Defaults to Sparkles. */
106
+ triggerIcon?: ReactNode;
65
107
  }
66
108
 
67
109
  interface ChatLayoutFullProps extends ChatLayoutBaseProps {
@@ -77,10 +119,22 @@ export type ChatLayoutProps = ChatLayoutWidgetProps | ChatLayoutFullProps;
77
119
  * or a compact widget mode for embedding.
78
120
  */
79
121
  export function ChatLayout(props: ChatLayoutProps) {
122
+ return (
123
+ <PageAIContextScope.Provider value={props.pageContext}>
124
+ <ChatAnalyticsPageKey.Provider value={props.pageKey}>
125
+ <ChatLayoutContent key={props.pageKey} {...props} />
126
+ </ChatAnalyticsPageKey.Provider>
127
+ </PageAIContextScope.Provider>
128
+ );
129
+ }
130
+
131
+ function ChatLayoutContent(props: ChatLayoutProps) {
132
+ const track = useChatAnalytics();
80
133
  const {
81
134
  conversationId,
82
135
  layout = "full",
83
136
  className,
137
+ style,
84
138
  showSidebar: requestedShowSidebar = true,
85
139
  initialMessages,
86
140
  onMessagesChange,
@@ -97,13 +151,18 @@ export function ChatLayout(props: ChatLayoutProps) {
97
151
 
98
152
  // Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here
99
153
  const widgetHeight =
100
- props.layout === "widget" ? (props.widgetHeight ?? "600px") : "600px";
154
+ props.layout === "widget"
155
+ ? (props.widgetHeight ?? "min(640px, calc(100dvh - 112px))")
156
+ : undefined;
101
157
  const widgetWidth =
102
- props.layout === "widget" ? (props.widgetWidth ?? "380px") : "380px";
158
+ props.layout === "widget"
159
+ ? (props.widgetWidth ?? "min(440px, calc(100vw - 32px))")
160
+ : undefined;
103
161
  const defaultOpen =
104
162
  props.layout === "widget" ? (props.defaultOpen ?? false) : false;
105
163
  const showTrigger =
106
164
  props.layout === "widget" ? (props.showTrigger ?? true) : true;
165
+ const triggerIcon = props.layout === "widget" ? props.triggerIcon : undefined;
107
166
 
108
167
  const [sidebarOpen, setSidebarOpen] = useState(true);
109
168
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
@@ -111,6 +170,40 @@ export function ChatLayout(props: ChatLayoutProps) {
111
170
  const [chatResetKey, setChatResetKey] = useState(0);
112
171
  // Widget open/closed state — starts with defaultOpen value
113
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
+ );
114
207
  // Key to force widget ChatInterface remount on clear
115
208
  const [widgetResetKey, setWidgetResetKey] = useState(0);
116
209
  // Only mount the widget ChatInterface after the widget has been opened at least once.
@@ -118,6 +211,38 @@ export function ChatLayout(props: ChatLayoutProps) {
118
211
  // so suggestion chips and tool hints appear immediately on first open.
119
212
  // When defaultOpen is true the widget is pre-opened, so we mark it as ever-opened immediately.
120
213
  const [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen);
214
+ const [tipDismissed, setTipDismissed] = useState(defaultOpen);
215
+ const introTip = props.layout === "widget" ? props.introTip : undefined;
216
+ const introTipDuration =
217
+ props.layout === "widget" ? (props.introTipDuration ?? 6000) : 6000;
218
+ const tipShown = useRef(false);
219
+ const tipDismissedRef = useRef(defaultOpen);
220
+ const dismissTip = useCallback(
221
+ (reason: "timeout" | "manual" | "open") => {
222
+ if (tipShown.current && !tipDismissedRef.current) {
223
+ tipDismissedRef.current = true;
224
+ track({ type: "intro_tip_dismissed", reason });
225
+ }
226
+ setTipDismissed(true);
227
+ },
228
+ [track],
229
+ );
230
+ useEffect(() => {
231
+ if (!introTip || !showTrigger || tipDismissed) return;
232
+ if (!tipShown.current) {
233
+ tipShown.current = true;
234
+ track({ type: "intro_tip_shown" });
235
+ }
236
+ const timeout = setTimeout(() => dismissTip("timeout"), introTipDuration);
237
+ return () => clearTimeout(timeout);
238
+ }, [
239
+ introTip,
240
+ introTipDuration,
241
+ showTrigger,
242
+ tipDismissed,
243
+ dismissTip,
244
+ track,
245
+ ]);
121
246
 
122
247
  // Read page AI context to show badge in header
123
248
  const pageAIContext = usePageAIContext();
@@ -136,88 +261,197 @@ export function ChatLayout(props: ChatLayoutProps) {
136
261
  <div
137
262
  className={cn(
138
263
  "flex flex-col items-end gap-3",
264
+ props.layout === "widget" &&
265
+ props.floating &&
266
+ "fixed right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] z-50",
139
267
  className,
140
268
  classNames?.container,
141
269
  )}
270
+ style={style}
142
271
  >
143
- {/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
144
- <div
145
- className={cn(
146
- "flex flex-col border rounded-xl overflow-hidden bg-background shadow-xl",
147
- widgetOpen ? "flex" : "hidden",
148
- )}
149
- style={{ height: widgetHeight, width: widgetWidth }}
150
- >
151
- {/* Widget header with page context badge and action buttons */}
152
- <div className="flex items-center gap-1.5 px-3 py-1.5 border-b bg-muted/40">
153
- <Sparkles className="h-3 w-3 text-muted-foreground" />
154
- {pageAIContext ? (
155
- <Badge
156
- variant="secondary"
157
- className="text-xs"
158
- data-testid="page-context-badge"
159
- >
160
- {pageAIContext.routeName}
161
- </Badge>
162
- ) : (
163
- <span className="text-xs text-muted-foreground font-medium">
164
- {tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
165
- </span>
166
- )}
167
- <div className="flex-1" />
168
- <Button
169
- variant="ghost"
170
- size="icon"
171
- className="h-5 w-5"
172
- onClick={() => {
173
- onClear?.();
174
- setWidgetResetKey((prev) => prev + 1);
175
- }}
176
- aria-label={tr(
177
- "A11Y_CLEAR_CHAT",
178
- "aiChat.a11y.clearChat",
179
- "Clear chat",
180
- )}
181
- title={tr(
182
- "A11Y_CLEAR_CHAT",
183
- "aiChat.a11y.clearChat",
184
- "Clear chat",
185
- )}
186
- >
187
- <Trash2 className="h-3.5 w-3.5" />
188
- </Button>
272
+ {introTip && showTrigger && !tipDismissed && !widgetOpen && (
273
+ <div
274
+ role="status"
275
+ className="flex max-w-[min(280px,calc(100vw-32px))] items-center gap-2 rounded-lg border bg-background p-3 text-sm text-foreground shadow-lg"
276
+ >
277
+ <span>{introTip}</span>
189
278
  <Button
279
+ type="button"
190
280
  variant="ghost"
191
281
  size="icon"
192
- className="h-5 w-5"
193
- onClick={() => setWidgetOpen(false)}
282
+ className="h-7 w-7 shrink-0"
283
+ onClick={() => dismissTip("manual")}
194
284
  aria-label={tr(
195
- "A11Y_CLOSE_CHAT",
196
- "aiChat.a11y.closeChat",
197
- "Close chat",
285
+ "A11Y_DISMISS_CHAT_TIP",
286
+ "aiChat.a11y.dismissChatTip",
287
+ "Dismiss chat tip",
198
288
  )}
199
289
  >
200
- <X className="h-3.5 w-3.5" />
290
+ <X className="h-4 w-4" aria-hidden="true" />
201
291
  </Button>
202
292
  </div>
203
- {widgetEverOpened && (
204
- <ChatInterface
205
- key={`widget-${conversationId ?? "new"}-${widgetResetKey}`}
206
- id={conversationId}
207
- variant="widget"
208
- initialMessages={initialMessages}
209
- onMessagesChange={onMessagesChange}
210
- />
293
+ )}
294
+ {/* Compact panel remains available when the fullscreen dialog closes. */}
295
+ <div
296
+ ref={compactPanelRef}
297
+ role="dialog"
298
+ tabIndex={-1}
299
+ aria-label={tr("A11Y_CHAT_TITLE", "aiChat.a11y.title", "AI Chat")}
300
+ className={cn(
301
+ "overflow-hidden rounded-xl border bg-background shadow-xl",
302
+ (!widgetOpen || widgetExpanded) && "hidden",
303
+ )}
304
+ style={{ height: widgetHeight, width: widgetWidth }}
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)]",
334
+ )}
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
+ />
439
+ )}
440
+ </>,
441
+ panelContainer,
211
442
  )}
212
- </div>
213
443
 
214
444
  {/* Trigger button — rendered only when showTrigger is true */}
215
445
  {showTrigger && (
216
446
  <Button
447
+ ref={triggerRef}
217
448
  size="icon"
218
449
  className="h-12 w-12 rounded-full shadow-lg"
219
450
  onClick={() => {
451
+ dismissTip("open");
452
+ track({ type: widgetOpen ? "widget_closed" : "widget_opened" });
220
453
  setWidgetOpen((prev) => !prev);
454
+ setExpanded(false);
221
455
  setWidgetEverOpened(true);
222
456
  }}
223
457
  aria-label={
@@ -230,7 +464,7 @@ export function ChatLayout(props: ChatLayoutProps) {
230
464
  {widgetOpen ? (
231
465
  <X className="h-5 w-5" />
232
466
  ) : (
233
- <Sparkles className="h-5 w-5" />
467
+ (triggerIcon ?? <Sparkles className="h-5 w-5" />)
234
468
  )}
235
469
  </Button>
236
470
  )}
@@ -247,6 +481,7 @@ export function ChatLayout(props: ChatLayoutProps) {
247
481
  classNames?.container,
248
482
  )}
249
483
  data-testid="chat-layout"
484
+ style={style}
250
485
  >
251
486
  {/* Desktop Sidebar */}
252
487
  {showSidebar && (
@@ -347,6 +582,7 @@ export function ChatLayout(props: ChatLayoutProps) {
347
582
  variant="ghost"
348
583
  size="icon"
349
584
  onClick={() => {
585
+ track({ type: "conversation_cleared" });
350
586
  onClear();
351
587
  setChatResetKey((prev) => prev + 1);
352
588
  }}
@@ -10,7 +10,7 @@ import { MarkdownContent } from "@workspace/ui/components/markdown-content";
10
10
  import { Skeleton } from "@workspace/ui/components/skeleton";
11
11
  import { cn } from "@workspace/ui/lib/utils";
12
12
  import { Wrench, Check, AlertCircle, Loader2 } from "lucide-react";
13
- import { usePluginOverrides } from "@btst/stack/context";
13
+ import { usePluginOverrides, useStack } from "@btst/stack/context";
14
14
  import type {
15
15
  AiChatPluginOverrides,
16
16
  ToolCallProps,
@@ -106,6 +106,34 @@ export function ToolCallDisplay({
106
106
  Partial<AiChatPluginOverrides>
107
107
  >("aiChat", {});
108
108
  const tr = useAiChatTranslation(localization);
109
+ const { plugins } = useStack();
110
+ if (
111
+ toolName === "readPage" &&
112
+ plugins?.aiChat?.config?.pageContent === true
113
+ ) {
114
+ const failed = state === "output-error";
115
+ return (
116
+ <p className="text-xs text-muted-foreground" role="status">
117
+ {failed
118
+ ? tr(
119
+ "PAGE_CONTENT_ERROR",
120
+ "aiChat.pageContent.error",
121
+ "Unable to read this page.",
122
+ )
123
+ : state === "output-available"
124
+ ? tr(
125
+ "PAGE_CONTENT_READ",
126
+ "aiChat.pageContent.read",
127
+ "Read page content",
128
+ )
129
+ : tr(
130
+ "PAGE_CONTENT_READING",
131
+ "aiChat.pageContent.reading",
132
+ "Reading page content…",
133
+ )}
134
+ </p>
135
+ );
136
+ }
109
137
  const displayName = formatToolName(toolName);
110
138
  const statusLabel =
111
139
  isLoading || state === "input-streaming"
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useRef } from "react";
4
+ import { usePluginOverrides, useStack } from "@btst/stack/context";
5
+ import { usePageAIContext } from "./page-ai-context";
6
+ import { resolveAiChatMode, type AiChatPluginOverrides } from "../overrides";
7
+ import type { AiChatAnalyticsEvent } from "../analytics";
8
+
9
+ export const ChatAnalyticsPageKey = createContext<string | undefined>(
10
+ undefined,
11
+ );
12
+ type EventDetails<T> = T extends unknown
13
+ ? Omit<T, "pageKey" | "routeName" | "mode">
14
+ : never;
15
+
16
+ export function useChatAnalytics() {
17
+ const { onAnalyticsEvent } = usePluginOverrides<
18
+ AiChatPluginOverrides,
19
+ Partial<AiChatPluginOverrides>
20
+ >("aiChat", {});
21
+ const { plugins } = useStack();
22
+ const pageKey = useContext(ChatAnalyticsPageKey);
23
+ const routeName = usePageAIContext()?.routeName;
24
+ const latest = useRef({
25
+ onAnalyticsEvent,
26
+ pageKey,
27
+ routeName,
28
+ mode: resolveAiChatMode(plugins?.aiChat?.config),
29
+ });
30
+ latest.current = {
31
+ onAnalyticsEvent,
32
+ pageKey,
33
+ routeName,
34
+ mode: resolveAiChatMode(plugins?.aiChat?.config),
35
+ };
36
+ return useCallback((event: EventDetails<AiChatAnalyticsEvent>) => {
37
+ const { onAnalyticsEvent: callback, ...context } = latest.current;
38
+ try {
39
+ void Promise.resolve(callback?.({ ...context, ...event })).catch(
40
+ () => {},
41
+ );
42
+ } catch {
43
+ // Optional instrumentation must never interrupt chat interactions.
44
+ }
45
+ }, []);
46
+ }
@@ -33,7 +33,8 @@ export interface PageAIContextConfig {
33
33
  /**
34
34
  * Human-readable description of the current page and its content.
35
35
  * Injected into the AI system prompt so it understands what the user is looking at.
36
- * Capped at 8,000 characters server-side.
36
+ * Limited to 16,000 characters server-side. For longer content, pass a page
37
+ * path and configure the backend pageContent resolver.
37
38
  */
38
39
  pageDescription: string;
39
40
 
@@ -74,6 +75,11 @@ const PageAIAPIContext = createContext<PageAIAPIContextValue | null>(null);
74
75
  */
75
76
  const PageAIVersionContext = createContext<number>(0);
76
77
 
78
+ /** Instance-local context used by ChatLayout; undefined inherits page registrations. */
79
+ export const PageAIContextScope = createContext<
80
+ PageAIContextConfig | null | undefined
81
+ >(undefined);
82
+
77
83
  /**
78
84
  * Provider that enables route-aware AI context across the app.
79
85
  *
@@ -235,6 +241,8 @@ export function usePageAIContext(): PageAIContextConfig | null {
235
241
  // registers or unregisters context, then read the latest active config.
236
242
  useContext(PageAIVersionContext);
237
243
  const ctx = useContext(PageAIAPIContext);
244
+ const scoped = useContext(PageAIContextScope);
245
+ if (scoped !== undefined) return scoped;
238
246
  if (!ctx) return null;
239
247
  return ctx.getActive();
240
248
  }
@@ -24,3 +24,5 @@ export { ToolCallDisplay } from "./components/tool-call-display";
24
24
 
25
25
  // Re-export UIMessage type from AI SDK for consumer convenience
26
26
  export type { UIMessage } from "ai";
27
+
28
+ export type { AiChatAnalyticsEvent } from "./analytics";
@@ -86,7 +86,13 @@ 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;
92
+ A11Y_DISMISS_CHAT_TIP: string;
93
+ PAGE_CONTENT_READING: string;
94
+ PAGE_CONTENT_READ: string;
95
+ PAGE_CONTENT_ERROR: string;
90
96
  A11Y_OPEN_MENU: string;
91
97
  A11Y_CLOSE_SIDEBAR: string;
92
98
  A11Y_OPEN_SIDEBAR: string;
@@ -190,7 +196,13 @@ export const AI_CHAT_LOCALIZATION: AiChatLocalization = {
190
196
  A11Y_CHAT_TITLE: "AI Chat",
191
197
  A11Y_CLEAR_CHAT: "Clear chat",
192
198
  A11Y_CLOSE_CHAT: "Close chat",
199
+ A11Y_EXPAND_CHAT: "Expand chat",
200
+ A11Y_COLLAPSE_CHAT: "Collapse chat",
193
201
  A11Y_OPEN_CHAT: "Open chat",
202
+ A11Y_DISMISS_CHAT_TIP: "Dismiss chat tip",
203
+ PAGE_CONTENT_READING: "Reading page content…",
204
+ PAGE_CONTENT_READ: "Read page content",
205
+ PAGE_CONTENT_ERROR: "Unable to read this page.",
194
206
  A11Y_OPEN_MENU: "Open menu",
195
207
  A11Y_CLOSE_SIDEBAR: "Close sidebar",
196
208
  A11Y_OPEN_SIDEBAR: "Open sidebar",
@@ -1,3 +1,4 @@
1
+ import type { AiChatAnalyticsEvent } from "./analytics";
1
2
  import type { ComponentType } from "react";
2
3
  import { normalizePath } from "@btst/stack/client";
3
4
  import type { AiChatLocalization } from "./localization";
@@ -13,6 +14,8 @@ export type AiChatMode = "authenticated" | "public";
13
14
  export interface AiChatProviderConfig {
14
15
  /** Conversation behavior selected by `aiChatClientPlugin()`. */
15
16
  readonly mode: AiChatMode;
17
+ /** Whether built-in page reader permission checks and status UI are enabled. */
18
+ readonly pageContent?: boolean;
16
19
  }
17
20
 
18
21
  /** Resolve the registered AI Chat client factory mode. */
@@ -143,6 +146,8 @@ export interface RouteContext {
143
146
  * plugin-specific components and behavior.
144
147
  */
145
148
  export interface AiChatPluginOverrides {
149
+ /** Optional content-free usage telemetry. Exceptions and rejections are isolated from chat. */
150
+ onAnalyticsEvent?: (event: AiChatAnalyticsEvent) => void | Promise<void>;
146
151
  /**
147
152
  * Function used to upload a file and return its URL.
148
153
  * Called for images, PDFs, text files, and other supported file types.