@btst/stack 2.3.0 → 2.5.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 (208) hide show
  1. package/dist/packages/stack/src/client/components/compose.cjs +1 -2
  2. package/dist/packages/stack/src/client/components/compose.mjs +1 -2
  3. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +71 -0
  4. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +68 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +87 -54
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +87 -54
  7. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +2 -2
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +2 -2
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +89 -22
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +90 -23
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +110 -33
  12. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +112 -35
  13. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +1 -1
  14. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +1 -1
  15. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.cjs +14 -21
  16. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +15 -22
  17. package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +17 -1
  18. package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +17 -1
  19. package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +28 -45
  20. package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +22 -39
  21. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.cjs +15 -2
  22. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.mjs +16 -3
  23. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.cjs +24 -1
  24. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.mjs +24 -1
  25. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.cjs +26 -0
  26. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.mjs +24 -0
  27. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.cjs +30 -1
  28. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.mjs +30 -1
  29. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -0
  30. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -0
  31. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +23 -27
  32. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +24 -28
  33. package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +48 -0
  34. package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +46 -0
  35. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +21 -18
  36. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +21 -18
  37. package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +11 -15
  38. package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +12 -16
  39. package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +58 -62
  40. package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +58 -62
  41. package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +12 -12
  42. package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +13 -13
  43. package/dist/packages/stack/src/plugins/kanban/api/mutations.cjs +91 -0
  44. package/dist/packages/stack/src/plugins/kanban/api/mutations.mjs +87 -0
  45. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +92 -118
  46. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +89 -115
  47. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +7 -3
  48. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +7 -3
  49. package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +22 -29
  50. package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +23 -30
  51. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.cjs +89 -0
  52. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.mjs +89 -0
  53. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.cjs +8 -8
  54. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.mjs +9 -9
  55. package/dist/packages/stack/src/plugins/utils.cjs +42 -0
  56. package/dist/packages/stack/src/plugins/utils.mjs +41 -1
  57. package/dist/plugins/ai-chat/api/index.d.cts +1 -1
  58. package/dist/plugins/ai-chat/api/index.d.mts +1 -1
  59. package/dist/plugins/ai-chat/api/index.d.ts +1 -1
  60. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  61. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  62. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  63. package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +92 -0
  64. package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +84 -0
  65. package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +84 -0
  66. package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +84 -0
  67. package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +88 -0
  68. package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -1
  69. package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -1
  70. package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -1
  71. package/dist/plugins/ai-chat/client/index.d.cts +10 -10
  72. package/dist/plugins/ai-chat/client/index.d.mts +10 -10
  73. package/dist/plugins/ai-chat/client/index.d.ts +10 -10
  74. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  75. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  76. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  77. package/dist/plugins/blog/api/index.d.cts +2 -2
  78. package/dist/plugins/blog/api/index.d.mts +2 -2
  79. package/dist/plugins/blog/api/index.d.ts +2 -2
  80. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  81. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  82. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  83. package/dist/plugins/blog/client/index.d.cts +13 -13
  84. package/dist/plugins/blog/client/index.d.mts +13 -13
  85. package/dist/plugins/blog/client/index.d.ts +13 -13
  86. package/dist/plugins/blog/query-keys.d.cts +2 -2
  87. package/dist/plugins/blog/query-keys.d.mts +2 -2
  88. package/dist/plugins/blog/query-keys.d.ts +2 -2
  89. package/dist/plugins/client/index.cjs +1 -0
  90. package/dist/plugins/client/index.d.cts +8 -1
  91. package/dist/plugins/client/index.d.mts +8 -1
  92. package/dist/plugins/client/index.d.ts +8 -1
  93. package/dist/plugins/client/index.mjs +1 -1
  94. package/dist/plugins/cms/api/index.cjs +2 -0
  95. package/dist/plugins/cms/api/index.d.cts +2 -2
  96. package/dist/plugins/cms/api/index.d.mts +2 -2
  97. package/dist/plugins/cms/api/index.d.ts +2 -2
  98. package/dist/plugins/cms/api/index.mjs +1 -0
  99. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  100. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  101. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  102. package/dist/plugins/cms/client/index.d.cts +6 -6
  103. package/dist/plugins/cms/client/index.d.mts +6 -6
  104. package/dist/plugins/cms/client/index.d.ts +6 -6
  105. package/dist/plugins/cms/query-keys.d.cts +2 -2
  106. package/dist/plugins/cms/query-keys.d.mts +2 -2
  107. package/dist/plugins/cms/query-keys.d.ts +2 -2
  108. package/dist/plugins/form-builder/api/index.d.cts +2 -2
  109. package/dist/plugins/form-builder/api/index.d.mts +2 -2
  110. package/dist/plugins/form-builder/api/index.d.ts +2 -2
  111. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  112. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  113. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  114. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  115. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  116. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  117. package/dist/plugins/form-builder/client/index.d.cts +6 -6
  118. package/dist/plugins/form-builder/client/index.d.mts +6 -6
  119. package/dist/plugins/form-builder/client/index.d.ts +6 -6
  120. package/dist/plugins/form-builder/query-keys.d.cts +2 -2
  121. package/dist/plugins/form-builder/query-keys.d.mts +2 -2
  122. package/dist/plugins/form-builder/query-keys.d.ts +2 -2
  123. package/dist/plugins/kanban/api/index.cjs +4 -0
  124. package/dist/plugins/kanban/api/index.d.cts +1 -1
  125. package/dist/plugins/kanban/api/index.d.mts +1 -1
  126. package/dist/plugins/kanban/api/index.d.ts +1 -1
  127. package/dist/plugins/kanban/api/index.mjs +1 -0
  128. package/dist/plugins/kanban/client/index.d.cts +12 -12
  129. package/dist/plugins/kanban/client/index.d.mts +12 -12
  130. package/dist/plugins/kanban/client/index.d.ts +12 -12
  131. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  132. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  133. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  134. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  135. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  136. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  137. package/dist/plugins/ui-builder/client/index.d.cts +3 -3
  138. package/dist/plugins/ui-builder/client/index.d.mts +3 -3
  139. package/dist/plugins/ui-builder/client/index.d.ts +3 -3
  140. package/dist/plugins/ui-builder/index.d.cts +2 -2
  141. package/dist/plugins/ui-builder/index.d.mts +2 -2
  142. package/dist/plugins/ui-builder/index.d.ts +2 -2
  143. package/dist/shared/{stack.C-WUPMT6.d.cts → stack.B2xZTSiO.d.cts} +4 -4
  144. package/dist/shared/{stack.B1EeBt1b.d.ts → stack.B58oHdqm.d.mts} +33 -3
  145. package/dist/shared/{stack.CVDTkMoO.d.mts → stack.B8QD11QU.d.cts} +7 -7
  146. package/dist/shared/{stack.CVDTkMoO.d.cts → stack.B8QD11QU.d.mts} +7 -7
  147. package/dist/shared/{stack.CVDTkMoO.d.ts → stack.B8QD11QU.d.ts} +7 -7
  148. package/dist/shared/{stack.CIP6QS9l.d.ts → stack.BDVEpue1.d.ts} +1 -1
  149. package/dist/shared/{stack.C5dtIncc.d.mts → stack.BTvbxZvw.d.cts} +1 -1
  150. package/dist/shared/{stack.DaOcgmrM.d.ts → stack.BV9hnvu4.d.cts} +31 -7
  151. package/dist/shared/{stack.DaOcgmrM.d.cts → stack.BV9hnvu4.d.mts} +31 -7
  152. package/dist/shared/{stack.DaOcgmrM.d.mts → stack.BV9hnvu4.d.ts} +31 -7
  153. package/dist/shared/{stack.DdI5W6MB.d.mts → stack.BozPgbrZ.d.cts} +19 -19
  154. package/dist/shared/{stack.DdI5W6MB.d.ts → stack.BozPgbrZ.d.mts} +19 -19
  155. package/dist/shared/{stack.DdI5W6MB.d.cts → stack.BozPgbrZ.d.ts} +19 -19
  156. package/dist/shared/{stack.CP68pFEH.d.mts → stack.C9Mg2Q46.d.cts} +33 -3
  157. package/dist/shared/{stack.BeSm90va.d.ts → stack.CTDVxbrA.d.ts} +72 -14
  158. package/dist/shared/{stack.C-Ptrz8s.d.ts → stack.Cj_zKww4.d.ts} +4 -4
  159. package/dist/shared/{stack.TIBF2AOx.d.ts → stack.CxaFNQCV.d.mts} +89 -34
  160. package/dist/shared/{stack.CMh_EdxW.d.cts → stack.D-b5zbPm.d.cts} +72 -14
  161. package/dist/shared/{stack.Dw0Ly2TM.d.cts → stack.DTtmJPQO.d.mts} +1 -1
  162. package/dist/shared/{stack.BKfolAyK.d.ts → stack.DXnclTG7.d.ts} +11 -11
  163. package/dist/shared/{stack.snB1EDP7.d.cts → stack.DaZM10cp.d.cts} +11 -11
  164. package/dist/shared/{stack.Dg09R0oB.d.mts → stack.FVWf2JhZ.d.mts} +72 -14
  165. package/dist/shared/{stack.BIXEI6v_.d.mts → stack.cfCkioTe.d.mts} +11 -11
  166. package/dist/shared/{stack.6fUOjLs9.d.mts → stack.dH7u-TJH.d.mts} +4 -4
  167. package/dist/shared/{stack.BpolpQpf.d.cts → stack.j75TpKh2.d.ts} +89 -34
  168. package/dist/shared/{stack.rTy7-wQU.d.mts → stack.n1_i1p2B.d.cts} +89 -34
  169. package/dist/shared/{stack.IdtKDRka.d.cts → stack.sO33ZDhK.d.ts} +33 -3
  170. package/package.json +14 -1
  171. package/src/client/components/compose.tsx +7 -4
  172. package/src/plugins/ai-chat/api/page-tools.ts +111 -0
  173. package/src/plugins/ai-chat/api/plugin.ts +228 -72
  174. package/src/plugins/ai-chat/client/components/chat-input.tsx +2 -2
  175. package/src/plugins/ai-chat/client/components/chat-interface.tsx +154 -58
  176. package/src/plugins/ai-chat/client/components/chat-layout.tsx +166 -32
  177. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +1 -1
  178. package/src/plugins/ai-chat/client/context/page-ai-context.tsx +240 -0
  179. package/src/plugins/ai-chat/client/plugin.tsx +23 -31
  180. package/src/plugins/ai-chat/schemas.ts +16 -0
  181. package/src/plugins/blog/api/plugin.ts +31 -47
  182. package/src/plugins/blog/client/components/forms/post-forms.tsx +29 -2
  183. package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +28 -0
  184. package/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts +38 -0
  185. package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +33 -1
  186. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +20 -0
  187. package/src/plugins/blog/client/plugin.tsx +36 -39
  188. package/src/plugins/client/index.ts +5 -1
  189. package/src/plugins/cms/api/index.ts +4 -0
  190. package/src/plugins/cms/api/mutations.ts +84 -0
  191. package/src/plugins/cms/api/plugin.ts +23 -17
  192. package/src/plugins/cms/client/plugin.tsx +18 -21
  193. package/src/plugins/cms/types.ts +7 -7
  194. package/src/plugins/form-builder/api/plugin.ts +64 -64
  195. package/src/plugins/form-builder/client/plugin.tsx +19 -18
  196. package/src/plugins/form-builder/types.ts +19 -24
  197. package/src/plugins/kanban/api/index.ts +6 -0
  198. package/src/plugins/kanban/api/mutations.ts +169 -0
  199. package/src/plugins/kanban/api/plugin.ts +123 -136
  200. package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
  201. package/src/plugins/kanban/client/plugin.tsx +35 -41
  202. package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
  203. package/src/plugins/ui-builder/client/plugin.tsx +11 -10
  204. package/src/plugins/ui-builder/types.ts +4 -4
  205. package/src/plugins/utils.ts +92 -1
  206. package/dist/shared/{stack.CBON0dWL.d.mts → stack.BQmuNl5p.d.cts} +2 -2
  207. package/dist/shared/{stack.CBON0dWL.d.ts → stack.BQmuNl5p.d.mts} +2 -2
  208. package/dist/shared/{stack.CBON0dWL.d.cts → stack.BQmuNl5p.d.ts} +2 -2
@@ -7,7 +7,11 @@ import { ChatMessage } from "./chat-message";
7
7
  import { ChatInput, type AttachedFile } from "./chat-input";
8
8
  import { StackAttribution } from "@workspace/ui/components/stack-attribution";
9
9
  import { ScrollArea } from "@workspace/ui/components/scroll-area";
10
- import { DefaultChatTransport, type UIMessage } from "ai";
10
+ import {
11
+ DefaultChatTransport,
12
+ lastAssistantMessageIsCompleteWithToolCalls,
13
+ type UIMessage,
14
+ } from "ai";
11
15
  import { cn } from "@workspace/ui/lib/utils";
12
16
  import { usePluginOverrides, useBasePath } from "@btst/stack/context";
13
17
  import type { AiChatPluginOverrides } from "../overrides";
@@ -20,6 +24,7 @@ import {
20
24
  useConversations,
21
25
  type SerializedConversation,
22
26
  } from "../hooks/chat-hooks";
27
+ import { usePageAIContext } from "../context/page-ai-context";
23
28
 
24
29
  interface ChatInterfaceProps {
25
30
  apiPath?: string;
@@ -56,6 +61,9 @@ export function ChatInterface({
56
61
  const basePath = useBasePath();
57
62
  const isPublicMode = mode === "public";
58
63
 
64
+ // Read page AI context registered by the current page
65
+ const pageAIContext = usePageAIContext();
66
+
59
67
  const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization };
60
68
  const queryClient = useQueryClient();
61
69
 
@@ -126,6 +134,13 @@ export function ChatInterface({
126
134
  !initialMessages || initialMessages.length === 0,
127
135
  );
128
136
 
137
+ // Ref to always have the latest pageAIContext in the transport callback
138
+ // without recreating the transport on every context change
139
+ const pageAIContextRef = useRef(pageAIContext);
140
+ useEffect(() => {
141
+ pageAIContextRef.current = pageAIContext;
142
+ }, [pageAIContext]);
143
+
129
144
  // Memoize the transport to prevent recreation on every render
130
145
  const transport = useMemo(
131
146
  () =>
@@ -135,8 +150,21 @@ export function ChatInterface({
135
150
  body: isPublicMode
136
151
  ? undefined
137
152
  : () => ({ conversationId: conversationIdRef.current }),
138
- // Handle edit operations by using truncated messages from the ref
153
+ // Handle edit operations and inject page context
139
154
  prepareSendMessagesRequest: ({ messages: hookMessages }) => {
155
+ const currentPageContext = pageAIContextRef.current;
156
+
157
+ // Build page context fields to include in every request
158
+ const pageContextBody = currentPageContext?.pageDescription
159
+ ? {
160
+ pageContext: currentPageContext.pageDescription,
161
+ availableTools: Object.keys(
162
+ currentPageContext.clientTools ?? {},
163
+ ),
164
+ routeName: currentPageContext.routeName,
165
+ }
166
+ : {};
167
+
140
168
  // If we're in an edit operation, use the truncated messages + new user message
141
169
  if (editMessagesRef.current !== null) {
142
170
  const newUserMessage = hookMessages[hookMessages.length - 1];
@@ -150,6 +178,7 @@ export function ChatInterface({
150
178
  body: {
151
179
  messages: messagesToSend,
152
180
  conversationId: conversationIdRef.current,
181
+ ...pageContextBody,
153
182
  },
154
183
  };
155
184
  }
@@ -158,6 +187,7 @@ export function ChatInterface({
158
187
  body: {
159
188
  messages: hookMessages,
160
189
  conversationId: conversationIdRef.current,
190
+ ...pageContextBody,
161
191
  },
162
192
  };
163
193
  },
@@ -165,48 +195,99 @@ export function ChatInterface({
165
195
  [apiPath, isPublicMode],
166
196
  );
167
197
 
168
- const { messages, sendMessage, status, error, setMessages, regenerate } =
169
- useChat({
170
- transport,
171
- onError: (err) => {
172
- console.error("useChat onError:", err);
173
- // Reset first-message tracking if the send failed before a conversation was created.
174
- // Without this, isFirstMessageSentRef stays true and the next successful send
175
- // skips the "first message" navigation logic, corrupting the conversation flow.
176
- if (!id && !hasNavigatedRef.current) {
177
- isFirstMessageSentRef.current = false;
178
- }
179
- },
180
- onFinish: async () => {
181
- // In public mode, skip all persistence-related operations
182
- if (isPublicMode) return;
198
+ // Use a ref so addToolOutput is always current inside the onToolCall closure
199
+ const addToolOutputRef = useRef<
200
+ ReturnType<typeof useChat>["addToolOutput"] | null
201
+ >(null);
183
202
 
184
- // Invalidate conversation list to show new/updated conversations
185
- await queryClient.invalidateQueries({
203
+ const {
204
+ messages,
205
+ sendMessage,
206
+ status,
207
+ error,
208
+ setMessages,
209
+ regenerate,
210
+ addToolOutput,
211
+ } = useChat({
212
+ transport,
213
+ // Automatically resubmit after all client-side tool results are provided
214
+ sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
215
+ onToolCall: async ({ toolCall }) => {
216
+ // Dispatch client-side tool calls to the handler registered by the current page.
217
+ // In AI SDK v5, onToolCall returns void — addToolOutput must be called explicitly.
218
+ const toolName = toolCall.toolName;
219
+ const handler = pageAIContextRef.current?.clientTools?.[toolName];
220
+ if (handler) {
221
+ try {
222
+ const result = await handler(toolCall.input);
223
+ // No await — avoids potential deadlocks with sendAutomaticallyWhen
224
+ addToolOutputRef.current?.({
225
+ tool: toolName,
226
+ toolCallId: toolCall.toolCallId,
227
+ output: result,
228
+ });
229
+ } catch (err) {
230
+ addToolOutputRef.current?.({
231
+ tool: toolName,
232
+ toolCallId: toolCall.toolCallId,
233
+ state: "output-error",
234
+ errorText:
235
+ err instanceof Error ? err.message : "Tool execution failed",
236
+ });
237
+ }
238
+ } else {
239
+ // No handler found — this happens when the user navigates away while a
240
+ // tool-call response is streaming and the page context changes. Always
241
+ // call addToolOutput so sendAutomaticallyWhen can unblock; without this
242
+ // the conversation gets permanently stuck waiting for a missing output.
243
+ addToolOutputRef.current?.({
244
+ tool: toolName,
245
+ toolCallId: toolCall.toolCallId,
246
+ state: "output-error",
247
+ errorText: `No client-side handler registered for tool "${toolName}". The page context may have changed while the response was streaming.`,
248
+ });
249
+ }
250
+ },
251
+ onError: (err) => {
252
+ console.error("useChat onError:", err);
253
+ // Reset first-message tracking if the send failed before a conversation was created.
254
+ // Without this, isFirstMessageSentRef stays true and the next successful send
255
+ // skips the "first message" navigation logic, corrupting the conversation flow.
256
+ if (!id && !hasNavigatedRef.current) {
257
+ isFirstMessageSentRef.current = false;
258
+ }
259
+ },
260
+ onFinish: async () => {
261
+ // In public mode, skip all persistence-related operations
262
+ if (isPublicMode) return;
263
+
264
+ // Invalidate conversation list to show new/updated conversations
265
+ await queryClient.invalidateQueries({
266
+ queryKey: conversationsListQueryKey,
267
+ });
268
+
269
+ // If this was the first message on a new chat, update the URL without full navigation
270
+ // This avoids losing the in-memory messages during component remount
271
+ if (isFirstMessageSentRef.current && !id && !hasNavigatedRef.current) {
272
+ hasNavigatedRef.current = true;
273
+ // Wait for the invalidation to complete and refetch conversations
274
+ await queryClient.refetchQueries({
186
275
  queryKey: conversationsListQueryKey,
187
276
  });
188
-
189
- // If this was the first message on a new chat, update the URL without full navigation
190
- // This avoids losing the in-memory messages during component remount
191
- if (isFirstMessageSentRef.current && !id && !hasNavigatedRef.current) {
192
- hasNavigatedRef.current = true;
193
- // Wait for the invalidation to complete and refetch conversations
194
- await queryClient.refetchQueries({
195
- queryKey: conversationsListQueryKey,
196
- });
197
- // Get the updated conversations from cache
198
- const cachedConversations = queryClient.getQueryData<
199
- SerializedConversation[]
200
- >(conversationsListQueryKey);
201
- if (cachedConversations && cachedConversations.length > 0) {
202
- // The most recently updated conversation should be the one we just created
203
- const newConversation = cachedConversations[0];
204
- if (newConversation) {
205
- // Update our local state
206
- setCurrentConversationId(newConversation.id);
207
- conversationIdRef.current = newConversation.id;
208
- // Update URL without navigation to preserve in-memory messages
209
- // Use replaceState to avoid adding to history stack
277
+ // Get the updated conversations from cache
278
+ const cachedConversations = queryClient.getQueryData<
279
+ SerializedConversation[]
280
+ >(conversationsListQueryKey);
281
+ if (cachedConversations && cachedConversations.length > 0) {
282
+ // The most recently updated conversation should be the one we just created
283
+ const newConversation = cachedConversations[0];
284
+ if (newConversation) {
285
+ // Update our local state
286
+ setCurrentConversationId(newConversation.id);
287
+ conversationIdRef.current = newConversation.id;
288
+ // Only update the URL in full-page mode; in widget mode the chat is
289
+ // embedded in another page and clobbering the URL is disruptive.
290
+ if (variant === "full") {
210
291
  const newUrl = `${basePath}/chat/${newConversation.id}`;
211
292
  if (typeof window !== "undefined") {
212
293
  window.history.replaceState(
@@ -218,8 +299,14 @@ export function ChatInterface({
218
299
  }
219
300
  }
220
301
  }
221
- },
222
- });
302
+ }
303
+ },
304
+ });
305
+
306
+ // Keep addToolOutputRef in sync so onToolCall always has the latest reference
307
+ useEffect(() => {
308
+ addToolOutputRef.current = addToolOutput;
309
+ }, [addToolOutput]);
223
310
 
224
311
  // Load existing conversation messages when navigating to a conversation
225
312
  useEffect(() => {
@@ -484,23 +571,32 @@ export function ChatInterface({
484
571
  >
485
572
  {messages.length === 0 ? (
486
573
  <div className="flex flex-col h-full min-h-[300px]">
487
- <div className="flex-1 flex items-center justify-center text-muted-foreground">
574
+ <div className="flex-1 flex items-center justify-center text-muted-foreground mb-4">
488
575
  <p>{localization.CHAT_EMPTY_STATE}</p>
489
576
  </div>
490
- {chatSuggestions && chatSuggestions.length > 0 && (
491
- <div className="flex flex-wrap justify-center gap-2 pb-4 max-w-md mx-auto">
492
- {chatSuggestions.map((suggestion, index) => (
493
- <button
494
- key={index}
495
- type="button"
496
- onClick={() => setInput(suggestion)}
497
- className="px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground"
498
- >
499
- {suggestion}
500
- </button>
501
- ))}
502
- </div>
503
- )}
577
+ {(() => {
578
+ // Merge static suggestions from overrides with dynamic ones from page context.
579
+ // Page context suggestions appear first (most relevant to current page).
580
+ const pageSuggestions = pageAIContext?.suggestions ?? [];
581
+ const allSuggestions = [
582
+ ...pageSuggestions,
583
+ ...(chatSuggestions ?? []),
584
+ ];
585
+ return allSuggestions.length > 0 ? (
586
+ <div className="flex flex-wrap justify-center gap-2 pb-4 max-w-md mx-auto">
587
+ {allSuggestions.map((suggestion, index) => (
588
+ <button
589
+ key={index}
590
+ type="button"
591
+ onClick={() => setInput(suggestion)}
592
+ className="px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground"
593
+ >
594
+ {suggestion}
595
+ </button>
596
+ ))}
597
+ </div>
598
+ ) : null;
599
+ })()}
504
600
  </div>
505
601
  ) : (
506
602
  messages.map((m, index) => (
@@ -2,57 +2,116 @@
2
2
 
3
3
  import { useState, useCallback } from "react";
4
4
  import { Button } from "@workspace/ui/components/button";
5
+ import { Badge } from "@workspace/ui/components/badge";
5
6
  import {
6
7
  Sheet,
7
8
  SheetContent,
8
9
  SheetTrigger,
9
10
  } from "@workspace/ui/components/sheet";
10
- import { Menu, PanelLeftClose, PanelLeft } from "lucide-react";
11
+ import {
12
+ Menu,
13
+ PanelLeftClose,
14
+ PanelLeft,
15
+ Sparkles,
16
+ Trash2,
17
+ X,
18
+ } from "lucide-react";
11
19
  import { cn } from "@workspace/ui/lib/utils";
12
20
  import { ChatSidebar } from "./chat-sidebar";
13
21
  import { ChatInterface } from "./chat-interface";
14
22
  import type { UIMessage } from "ai";
23
+ import { usePageAIContext } from "../context/page-ai-context";
15
24
 
16
- export interface ChatLayoutProps {
25
+ interface ChatLayoutBaseProps {
17
26
  /** API base URL */
18
27
  apiBaseURL: string;
19
28
  /** API base path */
20
29
  apiBasePath: string;
21
30
  /** Current conversation ID (if viewing existing conversation) */
22
31
  conversationId?: string;
23
- /** Layout mode: 'full' for full page with sidebar, 'widget' for embeddable widget */
24
- layout?: "full" | "widget";
25
32
  /** Additional class name for the container */
26
33
  className?: string;
27
- /** Whether to show the sidebar (default: true for full layout) */
34
+ /** Whether to show the sidebar */
28
35
  showSidebar?: boolean;
29
- /** Height of the widget (only applies to widget layout) */
30
- widgetHeight?: string | number;
31
36
  /** Initial messages to populate the chat (useful for localStorage persistence in public mode) */
32
37
  initialMessages?: UIMessage[];
33
38
  /** Called whenever messages change (for persistence). Only fires in public mode. */
34
39
  onMessagesChange?: (messages: UIMessage[]) => void;
35
40
  }
36
41
 
42
+ interface ChatLayoutWidgetProps extends ChatLayoutBaseProps {
43
+ /** Widget mode: compact embeddable panel with a floating trigger button */
44
+ layout: "widget";
45
+ /** Height of the widget panel. Default: `"600px"` */
46
+ widgetHeight?: string | number;
47
+ /** Width of the widget panel. Default: `"380px"` */
48
+ widgetWidth?: string | number;
49
+ /**
50
+ * Whether the widget panel starts open. Default: `false`.
51
+ * Set to `true` when embedding inside an already-open container such as a
52
+ * Next.js intercepting-route modal — the panel will be immediately visible
53
+ * without the user needing to click the trigger button.
54
+ */
55
+ defaultOpen?: boolean;
56
+ /**
57
+ * Whether to render the built-in floating trigger button. Default: `true`.
58
+ * Set to `false` when you control open/close externally (e.g. a Next.js
59
+ * parallel-route slot, a custom button, or a `router.back()` dismiss action)
60
+ * so that the built-in button does not appear alongside your own UI.
61
+ */
62
+ showTrigger?: boolean;
63
+ }
64
+
65
+ interface ChatLayoutFullProps extends ChatLayoutBaseProps {
66
+ /** Full-page mode with sidebar navigation (default) */
67
+ layout?: "full";
68
+ }
69
+
70
+ /** Props for the ChatLayout component */
71
+ export type ChatLayoutProps = ChatLayoutWidgetProps | ChatLayoutFullProps;
72
+
37
73
  /**
38
74
  * ChatLayout component that provides a full-page chat experience with sidebar
39
75
  * or a compact widget mode for embedding.
40
76
  */
41
- export function ChatLayout({
42
- apiBaseURL,
43
- apiBasePath,
44
- conversationId,
45
- layout = "full",
46
- className,
47
- showSidebar = true,
48
- widgetHeight = "600px",
49
- initialMessages,
50
- onMessagesChange,
51
- }: ChatLayoutProps) {
77
+ export function ChatLayout(props: ChatLayoutProps) {
78
+ const {
79
+ apiBaseURL,
80
+ apiBasePath,
81
+ conversationId,
82
+ layout = "full",
83
+ className,
84
+ showSidebar = true,
85
+ initialMessages,
86
+ onMessagesChange,
87
+ } = props;
88
+
89
+ // Widget-specific props — TypeScript narrows props to ChatLayoutWidgetProps here
90
+ const widgetHeight =
91
+ props.layout === "widget" ? (props.widgetHeight ?? "600px") : "600px";
92
+ const widgetWidth =
93
+ props.layout === "widget" ? (props.widgetWidth ?? "380px") : "380px";
94
+ const defaultOpen =
95
+ props.layout === "widget" ? (props.defaultOpen ?? false) : false;
96
+ const showTrigger =
97
+ props.layout === "widget" ? (props.showTrigger ?? true) : true;
98
+
52
99
  const [sidebarOpen, setSidebarOpen] = useState(true);
53
100
  const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
54
101
  // Key to force ChatInterface remount when starting a new chat
55
102
  const [chatResetKey, setChatResetKey] = useState(0);
103
+ // Widget open/closed state — starts with defaultOpen value
104
+ const [widgetOpen, setWidgetOpen] = useState(defaultOpen);
105
+ // Key to force widget ChatInterface remount on clear
106
+ const [widgetResetKey, setWidgetResetKey] = useState(0);
107
+ // Only mount the widget ChatInterface after the widget has been opened at least once.
108
+ // This ensures pageAIContext is already registered before ChatInterface first renders,
109
+ // so suggestion chips and tool hints appear immediately on first open.
110
+ // When defaultOpen is true the widget is pre-opened, so we mark it as ever-opened immediately.
111
+ const [widgetEverOpened, setWidgetEverOpened] = useState(defaultOpen);
112
+
113
+ // Read page AI context to show badge in header
114
+ const pageAIContext = usePageAIContext();
56
115
 
57
116
  const apiPath = `${apiBaseURL}${apiBasePath}/chat`;
58
117
 
@@ -67,20 +126,83 @@ export function ChatLayout({
67
126
 
68
127
  if (layout === "widget") {
69
128
  return (
70
- <div
71
- className={cn(
72
- "flex flex-col w-full border rounded-xl overflow-hidden bg-background shadow-sm",
73
- className,
129
+ <div className={cn("flex flex-col items-end gap-3", className)}>
130
+ {/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
131
+ <div
132
+ className={cn(
133
+ "flex flex-col border rounded-xl overflow-hidden bg-background shadow-xl",
134
+ widgetOpen ? "flex" : "hidden",
135
+ )}
136
+ style={{ height: widgetHeight, width: widgetWidth }}
137
+ >
138
+ {/* Widget header with page context badge and action buttons */}
139
+ <div className="flex items-center gap-1.5 px-3 py-1.5 border-b bg-muted/40">
140
+ <Sparkles className="h-3 w-3 text-muted-foreground" />
141
+ {pageAIContext ? (
142
+ <Badge
143
+ variant="secondary"
144
+ className="text-xs"
145
+ data-testid="page-context-badge"
146
+ >
147
+ {pageAIContext.routeName}
148
+ </Badge>
149
+ ) : (
150
+ <span className="text-xs text-muted-foreground font-medium">
151
+ AI Chat
152
+ </span>
153
+ )}
154
+ <div className="flex-1" />
155
+ <Button
156
+ variant="ghost"
157
+ size="icon"
158
+ className="h-5 w-5"
159
+ onClick={() => setWidgetResetKey((prev) => prev + 1)}
160
+ aria-label="Clear chat"
161
+ title="Clear chat"
162
+ >
163
+ <Trash2 className="h-3.5 w-3.5" />
164
+ </Button>
165
+ <Button
166
+ variant="ghost"
167
+ size="icon"
168
+ className="h-5 w-5"
169
+ onClick={() => setWidgetOpen(false)}
170
+ aria-label="Close chat"
171
+ >
172
+ <X className="h-3.5 w-3.5" />
173
+ </Button>
174
+ </div>
175
+ {widgetEverOpened && (
176
+ <ChatInterface
177
+ key={`widget-${conversationId ?? "new"}-${widgetResetKey}`}
178
+ apiPath={apiPath}
179
+ id={conversationId}
180
+ variant="widget"
181
+ initialMessages={initialMessages}
182
+ onMessagesChange={onMessagesChange}
183
+ />
184
+ )}
185
+ </div>
186
+
187
+ {/* Trigger button — rendered only when showTrigger is true */}
188
+ {showTrigger && (
189
+ <Button
190
+ size="icon"
191
+ className="h-12 w-12 rounded-full shadow-lg"
192
+ onClick={() => {
193
+ setWidgetOpen((prev) => !prev);
194
+ setWidgetEverOpened(true);
195
+ }}
196
+ aria-label={widgetOpen ? "Close chat" : "Open chat"}
197
+ data-testid="widget-trigger"
198
+ >
199
+ {widgetOpen ? (
200
+ <X className="h-5 w-5" />
201
+ ) : (
202
+ <Sparkles className="h-5 w-5" />
203
+ )}
204
+ </Button>
74
205
  )}
75
- style={{ height: widgetHeight }}
76
- >
77
- <ChatInterface
78
- apiPath={apiPath}
79
- id={conversationId}
80
- variant="widget"
81
- initialMessages={initialMessages}
82
- onMessagesChange={onMessagesChange}
83
- />
84
206
  </div>
85
207
  );
86
208
  }
@@ -115,7 +237,7 @@ export function ChatLayout({
115
237
  {/* Main Chat Area */}
116
238
  <div className="flex-1 flex flex-col min-w-0">
117
239
  {/* Header */}
118
- <div className="flex items-center gap-2 p-2 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
240
+ <div className="flex items-center gap-2 p-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60">
119
241
  {/* Mobile menu button */}
120
242
  {showSidebar && (
121
243
  <Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}>
@@ -159,6 +281,18 @@ export function ChatLayout({
159
281
  )}
160
282
 
161
283
  <div className="flex-1" />
284
+
285
+ {/* Page context badge — shown when a page has registered AI context */}
286
+ {pageAIContext && (
287
+ <Badge
288
+ variant="secondary"
289
+ className="text-xs gap-1 mr-2"
290
+ data-testid="page-context-badge"
291
+ >
292
+ <Sparkles className="h-3 w-3" />
293
+ {pageAIContext.routeName}
294
+ </Badge>
295
+ )}
162
296
  </div>
163
297
 
164
298
  <ChatInterface
@@ -163,7 +163,7 @@ export function ChatSidebar({
163
163
  </div>
164
164
 
165
165
  {/* Conversations List */}
166
- <ScrollArea className="flex-1">
166
+ <ScrollArea className="flex-1 [&_[data-slot=scroll-area-viewport]>div]:!block">
167
167
  <div className="p-2">
168
168
  {isLoading ? (
169
169
  <div className="p-4 text-center text-sm text-muted-foreground">