@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.
- package/dist/packages/stack/src/client/components/compose.cjs +1 -2
- package/dist/packages/stack/src/client/components/compose.mjs +1 -2
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +71 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +68 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +87 -54
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +87 -54
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +89 -22
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +90 -23
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +110 -33
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +112 -35
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +1 -1
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +1 -1
- package/dist/packages/stack/src/plugins/ai-chat/client/plugin.cjs +14 -21
- package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +15 -22
- package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +17 -1
- package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +17 -1
- package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +28 -45
- package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +22 -39
- package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.cjs +15 -2
- package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.mjs +16 -3
- package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.cjs +24 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.mjs +24 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.cjs +26 -0
- package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.mjs +24 -0
- package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.cjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.mjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -0
- package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -0
- package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +23 -27
- package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +24 -28
- package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +48 -0
- package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +46 -0
- package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +21 -18
- package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +21 -18
- package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +11 -15
- package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +12 -16
- package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +58 -62
- package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +58 -62
- package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +12 -12
- package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +13 -13
- package/dist/packages/stack/src/plugins/kanban/api/mutations.cjs +91 -0
- package/dist/packages/stack/src/plugins/kanban/api/mutations.mjs +87 -0
- package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +92 -118
- package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +89 -115
- package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +7 -3
- package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +7 -3
- package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +22 -29
- package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +23 -30
- package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.cjs +89 -0
- package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.mjs +89 -0
- package/dist/packages/stack/src/plugins/ui-builder/client/plugin.cjs +8 -8
- package/dist/packages/stack/src/plugins/ui-builder/client/plugin.mjs +9 -9
- package/dist/packages/stack/src/plugins/utils.cjs +42 -0
- package/dist/packages/stack/src/plugins/utils.mjs +41 -1
- package/dist/plugins/ai-chat/api/index.d.cts +1 -1
- package/dist/plugins/ai-chat/api/index.d.mts +1 -1
- package/dist/plugins/ai-chat/api/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +92 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +84 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +84 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +84 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +88 -0
- package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -1
- package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -1
- package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/index.d.cts +10 -10
- package/dist/plugins/ai-chat/client/index.d.mts +10 -10
- package/dist/plugins/ai-chat/client/index.d.ts +10 -10
- package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
- package/dist/plugins/blog/api/index.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +13 -13
- package/dist/plugins/blog/client/index.d.mts +13 -13
- package/dist/plugins/blog/client/index.d.ts +13 -13
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/client/index.cjs +1 -0
- package/dist/plugins/client/index.d.cts +8 -1
- package/dist/plugins/client/index.d.mts +8 -1
- package/dist/plugins/client/index.d.ts +8 -1
- package/dist/plugins/client/index.mjs +1 -1
- package/dist/plugins/cms/api/index.cjs +2 -0
- package/dist/plugins/cms/api/index.d.cts +2 -2
- package/dist/plugins/cms/api/index.d.mts +2 -2
- package/dist/plugins/cms/api/index.d.ts +2 -2
- package/dist/plugins/cms/api/index.mjs +1 -0
- package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
- package/dist/plugins/cms/client/index.d.cts +6 -6
- package/dist/plugins/cms/client/index.d.mts +6 -6
- package/dist/plugins/cms/client/index.d.ts +6 -6
- package/dist/plugins/cms/query-keys.d.cts +2 -2
- package/dist/plugins/cms/query-keys.d.mts +2 -2
- package/dist/plugins/cms/query-keys.d.ts +2 -2
- package/dist/plugins/form-builder/api/index.d.cts +2 -2
- package/dist/plugins/form-builder/api/index.d.mts +2 -2
- package/dist/plugins/form-builder/api/index.d.ts +2 -2
- package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/index.d.cts +6 -6
- package/dist/plugins/form-builder/client/index.d.mts +6 -6
- package/dist/plugins/form-builder/client/index.d.ts +6 -6
- package/dist/plugins/form-builder/query-keys.d.cts +2 -2
- package/dist/plugins/form-builder/query-keys.d.mts +2 -2
- package/dist/plugins/form-builder/query-keys.d.ts +2 -2
- package/dist/plugins/kanban/api/index.cjs +4 -0
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/api/index.mjs +1 -0
- package/dist/plugins/kanban/client/index.d.cts +12 -12
- package/dist/plugins/kanban/client/index.d.mts +12 -12
- package/dist/plugins/kanban/client/index.d.ts +12 -12
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/ui-builder/client/index.d.cts +3 -3
- package/dist/plugins/ui-builder/client/index.d.mts +3 -3
- package/dist/plugins/ui-builder/client/index.d.ts +3 -3
- package/dist/plugins/ui-builder/index.d.cts +2 -2
- package/dist/plugins/ui-builder/index.d.mts +2 -2
- package/dist/plugins/ui-builder/index.d.ts +2 -2
- package/dist/shared/{stack.C-WUPMT6.d.cts → stack.B2xZTSiO.d.cts} +4 -4
- package/dist/shared/{stack.B1EeBt1b.d.ts → stack.B58oHdqm.d.mts} +33 -3
- package/dist/shared/{stack.CVDTkMoO.d.mts → stack.B8QD11QU.d.cts} +7 -7
- package/dist/shared/{stack.CVDTkMoO.d.cts → stack.B8QD11QU.d.mts} +7 -7
- package/dist/shared/{stack.CVDTkMoO.d.ts → stack.B8QD11QU.d.ts} +7 -7
- package/dist/shared/{stack.CIP6QS9l.d.ts → stack.BDVEpue1.d.ts} +1 -1
- package/dist/shared/{stack.C5dtIncc.d.mts → stack.BTvbxZvw.d.cts} +1 -1
- package/dist/shared/{stack.DaOcgmrM.d.ts → stack.BV9hnvu4.d.cts} +31 -7
- package/dist/shared/{stack.DaOcgmrM.d.cts → stack.BV9hnvu4.d.mts} +31 -7
- package/dist/shared/{stack.DaOcgmrM.d.mts → stack.BV9hnvu4.d.ts} +31 -7
- package/dist/shared/{stack.DdI5W6MB.d.mts → stack.BozPgbrZ.d.cts} +19 -19
- package/dist/shared/{stack.DdI5W6MB.d.ts → stack.BozPgbrZ.d.mts} +19 -19
- package/dist/shared/{stack.DdI5W6MB.d.cts → stack.BozPgbrZ.d.ts} +19 -19
- package/dist/shared/{stack.CP68pFEH.d.mts → stack.C9Mg2Q46.d.cts} +33 -3
- package/dist/shared/{stack.BeSm90va.d.ts → stack.CTDVxbrA.d.ts} +72 -14
- package/dist/shared/{stack.C-Ptrz8s.d.ts → stack.Cj_zKww4.d.ts} +4 -4
- package/dist/shared/{stack.TIBF2AOx.d.ts → stack.CxaFNQCV.d.mts} +89 -34
- package/dist/shared/{stack.CMh_EdxW.d.cts → stack.D-b5zbPm.d.cts} +72 -14
- package/dist/shared/{stack.Dw0Ly2TM.d.cts → stack.DTtmJPQO.d.mts} +1 -1
- package/dist/shared/{stack.BKfolAyK.d.ts → stack.DXnclTG7.d.ts} +11 -11
- package/dist/shared/{stack.snB1EDP7.d.cts → stack.DaZM10cp.d.cts} +11 -11
- package/dist/shared/{stack.Dg09R0oB.d.mts → stack.FVWf2JhZ.d.mts} +72 -14
- package/dist/shared/{stack.BIXEI6v_.d.mts → stack.cfCkioTe.d.mts} +11 -11
- package/dist/shared/{stack.6fUOjLs9.d.mts → stack.dH7u-TJH.d.mts} +4 -4
- package/dist/shared/{stack.BpolpQpf.d.cts → stack.j75TpKh2.d.ts} +89 -34
- package/dist/shared/{stack.rTy7-wQU.d.mts → stack.n1_i1p2B.d.cts} +89 -34
- package/dist/shared/{stack.IdtKDRka.d.cts → stack.sO33ZDhK.d.ts} +33 -3
- package/package.json +14 -1
- package/src/client/components/compose.tsx +7 -4
- package/src/plugins/ai-chat/api/page-tools.ts +111 -0
- package/src/plugins/ai-chat/api/plugin.ts +228 -72
- package/src/plugins/ai-chat/client/components/chat-input.tsx +2 -2
- package/src/plugins/ai-chat/client/components/chat-interface.tsx +154 -58
- package/src/plugins/ai-chat/client/components/chat-layout.tsx +166 -32
- package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +1 -1
- package/src/plugins/ai-chat/client/context/page-ai-context.tsx +240 -0
- package/src/plugins/ai-chat/client/plugin.tsx +23 -31
- package/src/plugins/ai-chat/schemas.ts +16 -0
- package/src/plugins/blog/api/plugin.ts +31 -47
- package/src/plugins/blog/client/components/forms/post-forms.tsx +29 -2
- package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +28 -0
- package/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts +38 -0
- package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +33 -1
- package/src/plugins/blog/client/components/pages/post-page.internal.tsx +20 -0
- package/src/plugins/blog/client/plugin.tsx +36 -39
- package/src/plugins/client/index.ts +5 -1
- package/src/plugins/cms/api/index.ts +4 -0
- package/src/plugins/cms/api/mutations.ts +84 -0
- package/src/plugins/cms/api/plugin.ts +23 -17
- package/src/plugins/cms/client/plugin.tsx +18 -21
- package/src/plugins/cms/types.ts +7 -7
- package/src/plugins/form-builder/api/plugin.ts +64 -64
- package/src/plugins/form-builder/client/plugin.tsx +19 -18
- package/src/plugins/form-builder/types.ts +19 -24
- package/src/plugins/kanban/api/index.ts +6 -0
- package/src/plugins/kanban/api/mutations.ts +169 -0
- package/src/plugins/kanban/api/plugin.ts +123 -136
- package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
- package/src/plugins/kanban/client/plugin.tsx +35 -41
- package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
- package/src/plugins/ui-builder/client/plugin.tsx +11 -10
- package/src/plugins/ui-builder/types.ts +4 -4
- package/src/plugins/utils.ts +92 -1
- package/dist/shared/{stack.CBON0dWL.d.mts → stack.BQmuNl5p.d.cts} +2 -2
- package/dist/shared/{stack.CBON0dWL.d.ts → stack.BQmuNl5p.d.mts} +2 -2
- 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 {
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
{
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
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
|
-
|
|
72
|
-
|
|
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-
|
|
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">
|