Skip to content

Commit a1b28f4

Browse files
committed
CR fixes
Signed-off-by: Hubert Zub <hubert.zub@databricks.com>
1 parent 063ed6c commit a1b28f4

9 files changed

Lines changed: 96 additions & 308 deletions

File tree

integrations/appkit-agent/client/src/components/ChatPanel.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { useEffect, useState } from 'react';
2-
import { useSearchParams } from 'react-router-dom';
32
import type { ReactNode } from 'react';
43
import { useChatStream, type UseChatStreamOptions } from '@/hooks/use-chat-stream';
54
import { ChatHeader, type ChatHeaderProps } from './chat-header';
@@ -43,14 +42,11 @@ export function ChatPanel({
4342
const chat = useChatStream(streamOptions);
4443

4544
// Handle query param auto-send (for ?query=... deep-links)
46-
let searchParams: URLSearchParams | undefined;
47-
try {
48-
[searchParams] = useSearchParams();
49-
} catch {
50-
// Not inside a router — skip query param handling
51-
}
52-
53-
const query = searchParams?.get('query');
45+
// Avoid router hooks here so ChatPanel can render outside a Router.
46+
const query =
47+
typeof window === 'undefined'
48+
? null
49+
: new URLSearchParams(window.location.search).get('query');
5450
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
5551

5652
useEffect(() => {
Lines changed: 47 additions & 263 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,29 @@
1-
import type { LanguageModelUsage, UIMessageChunk } from 'ai';
2-
import { useChat } from '@ai-sdk/react';
3-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4-
import { useSWRConfig } from 'swr';
1+
import type { LanguageModelUsage } from 'ai';
2+
import { useEffect, useState } from 'react';
3+
import { useSearchParams } from 'react-router-dom';
54
import { ChatHeader } from '@/components/chat-header';
6-
import { fetchWithErrorHandlers, generateUUID } from '@/lib/utils';
7-
import { MultimodalInput } from './multimodal-input';
8-
import { Messages } from './messages';
5+
import { useChatStream } from '@/hooks/use-chat-stream';
6+
import { softNavigateToChatId } from '@/lib/navigation';
7+
import { useAppConfig } from '@/contexts/AppConfigContext';
98
import type {
109
Attachment,
1110
ChatMessage,
1211
FeedbackMap,
1312
VisibilityType,
1413
ClientSession,
1514
} from '@/types';
16-
import { ChatSDKError } from '@/types';
17-
import { apiUrl } from '@/lib/config';
18-
import { unstable_serialize } from 'swr/infinite';
19-
import { getChatHistoryPaginationKey } from './sidebar-history';
20-
import { toast } from './toast';
21-
import { useSearchParams } from 'react-router-dom';
22-
import { useChatVisibility } from '@/hooks/use-chat-visibility';
23-
import { isCredentialErrorMessage } from '@/lib/oauth-error-utils';
24-
import { ChatTransport } from '../lib/ChatTransport';
25-
import { softNavigateToChatId } from '@/lib/navigation';
26-
import { useAppConfig } from '@/contexts/AppConfigContext';
2715
import { Greeting } from './greeting';
16+
import { Messages } from './messages';
17+
import { MultimodalInput } from './multimodal-input';
2818

2919
export function Chat({
3020
id,
3121
initialMessages,
3222
initialChatModel,
3323
initialVisibilityType,
3424
isReadonly,
35-
initialLastContext,
25+
session: _session,
26+
initialLastContext: _initialLastContext,
3627
feedback = {},
3728
title,
3829
}: {
@@ -46,218 +37,16 @@ export function Chat({
4637
feedback?: FeedbackMap;
4738
title?: string;
4839
}) {
49-
const { visibilityType } = useChatVisibility({
50-
chatId: id,
51-
initialVisibilityType,
52-
});
53-
54-
const { mutate } = useSWRConfig();
5540
const { chatHistoryEnabled } = useAppConfig();
5641

57-
const [input, setInput] = useState<string>('');
58-
const [_usage, setUsage] = useState<LanguageModelUsage | undefined>(
59-
initialLastContext,
60-
);
61-
62-
const [lastPart, setLastPart] = useState<UIMessageChunk | undefined>();
63-
const lastPartRef = useRef<UIMessageChunk | undefined>(lastPart);
64-
lastPartRef.current = lastPart;
65-
66-
// Single counter for resume attempts - reset when stream parts are received
67-
const resumeAttemptCountRef = useRef(0);
68-
const maxResumeAttempts = 3;
69-
70-
const abortController = useRef<AbortController | null>(new AbortController());
71-
useEffect(() => {
72-
return () => {
73-
abortController.current?.abort('ABORT_SIGNAL');
74-
};
75-
}, []);
76-
77-
const fetchWithAbort = useMemo(() => {
78-
return async (input: RequestInfo | URL, init?: RequestInit) => {
79-
// useChat does not cancel /stream requests when the component is unmounted
80-
const signal = abortController.current?.signal;
81-
return fetchWithErrorHandlers(input, { ...init, signal });
82-
};
83-
}, []);
84-
85-
const stop = useCallback(() => {
86-
abortController.current?.abort('USER_ABORT_SIGNAL');
87-
}, []);
88-
89-
const isNewChat = initialMessages.length === 0;
90-
const didFetchHistoryOnNewChat = useRef(false);
91-
const fetchChatHistory = useCallback(() => {
92-
mutate(unstable_serialize(getChatHistoryPaginationKey));
93-
}, [mutate]);
94-
95-
// For new chats, the title arrives via a `data-title` stream part
96-
// once backend title generation completes — no separate fetch needed.
97-
const [streamTitle, setStreamTitle] = useState<string | undefined>();
98-
const [titlePending, setTitlePending] = useState(false);
99-
const displayTitle = title ?? streamTitle;
100-
101-
const {
102-
messages,
103-
setMessages,
104-
sendMessage,
105-
status,
106-
resumeStream,
107-
clearError,
108-
addToolApprovalResponse,
109-
regenerate,
110-
} = useChat<ChatMessage>({
42+
const chat = useChatStream({
11143
id,
112-
messages: initialMessages,
113-
experimental_throttle: 100,
114-
generateId: generateUUID,
115-
resume: id !== undefined && initialMessages.length > 0, // Enable automatic stream resumption
116-
transport: new ChatTransport({
117-
onStreamPart: (part) => {
118-
if (isNewChat && !didFetchHistoryOnNewChat.current) {
119-
fetchChatHistory();
120-
if (chatHistoryEnabled) {
121-
setTitlePending(true);
122-
}
123-
didFetchHistoryOnNewChat.current = true;
124-
}
125-
// Reset resume attempts when we successfully receive stream parts
126-
resumeAttemptCountRef.current = 0;
127-
setLastPart(part);
128-
},
129-
api: apiUrl('/'),
130-
fetch: fetchWithAbort,
131-
prepareSendMessagesRequest({ messages, id, body }) {
132-
const lastMessage = messages.at(-1);
133-
const isUserMessage = lastMessage?.role === 'user';
134-
135-
// For continuations (non-user messages like tool results), we must always
136-
// send previousMessages because the tool result only exists client-side
137-
// and hasn't been saved to the database yet.
138-
const needsPreviousMessages = !chatHistoryEnabled || !isUserMessage;
139-
140-
return {
141-
body: {
142-
id,
143-
// Only include message field for user messages (new messages)
144-
// For continuation (assistant messages with tool results), omit message field
145-
...(isUserMessage ? { message: lastMessage } : {}),
146-
selectedChatModel: initialChatModel,
147-
selectedVisibilityType: visibilityType,
148-
nextMessageId: generateUUID(),
149-
// Send previous messages when:
150-
// 1. Database is disabled (ephemeral mode) - always need client-side messages
151-
// 2. Continuation request (tool results) - tool result only exists client-side
152-
...(needsPreviousMessages
153-
? {
154-
previousMessages: isUserMessage
155-
? messages.slice(0, -1)
156-
: messages,
157-
}
158-
: {}),
159-
...body,
160-
},
161-
};
162-
},
163-
prepareReconnectToStreamRequest({ id }) {
164-
return {
165-
api: apiUrl(`/${id}/stream`),
166-
credentials: 'include',
167-
};
168-
},
169-
}),
170-
onData: (dataPart) => {
171-
if (dataPart.type === 'data-usage') {
172-
setUsage(dataPart.data as LanguageModelUsage);
173-
}
174-
if (dataPart.type === 'data-title') {
175-
setStreamTitle(dataPart.data as string);
176-
setTitlePending(false);
177-
fetchChatHistory();
178-
}
179-
},
180-
onFinish: ({
181-
isAbort,
182-
isDisconnect,
183-
isError,
184-
messages: finishedMessages,
185-
}) => {
186-
didFetchHistoryOnNewChat.current = false;
187-
setTitlePending(false);
188-
189-
// If user aborted, don't try to resume
190-
if (isAbort) {
191-
console.log('[Chat onFinish] Stream was aborted by user, not resuming');
192-
fetchChatHistory();
193-
return;
194-
}
195-
196-
// Check if the last message contains an OAuth credential error
197-
// If so, don't try to resume - the user needs to authenticate first
198-
const lastMessage = finishedMessages?.at(-1);
199-
const hasOAuthError = lastMessage?.parts?.some(
200-
(part) =>
201-
part.type === 'data-error' &&
202-
typeof part.data === 'string' &&
203-
isCredentialErrorMessage(part.data),
204-
);
205-
206-
if (hasOAuthError) {
207-
console.log(
208-
'[Chat onFinish] OAuth credential error detected, not resuming',
209-
);
210-
fetchChatHistory();
211-
clearError();
212-
return;
213-
}
214-
215-
// Determine if we should attempt to resume:
216-
// 1. Stream didn't end with a 'finish' part (incomplete)
217-
// 2. It was a disconnect/error that terminated the stream
218-
// 3. We haven't exceeded max resume attempts
219-
const streamIncomplete = lastPartRef.current?.type !== 'finish';
220-
const shouldResume =
221-
streamIncomplete &&
222-
(isDisconnect || isError || lastPartRef.current === undefined);
223-
224-
if (shouldResume && resumeAttemptCountRef.current < maxResumeAttempts) {
225-
console.log(
226-
'[Chat onFinish] Resuming stream. Attempt:',
227-
resumeAttemptCountRef.current + 1,
228-
);
229-
resumeAttemptCountRef.current++;
230-
// Ref: https://github.com/vercel/ai/issues/8477#issuecomment-3603209884
231-
queueMicrotask(() => {
232-
resumeStream();
233-
})
234-
} else {
235-
// Stream completed normally or we've exhausted resume attempts
236-
if (resumeAttemptCountRef.current >= maxResumeAttempts) {
237-
console.warn('[Chat onFinish] Max resume attempts reached');
238-
}
239-
fetchChatHistory();
240-
}
241-
},
242-
onError: (error) => {
243-
console.log('[Chat onError] Error occurred:', error);
244-
245-
// Only show toast for explicit ChatSDKError (backend validation errors)
246-
// Other errors (network, schema validation) are handled silently or in message parts
247-
if (error instanceof ChatSDKError) {
248-
toast({
249-
type: 'error',
250-
description: error.message,
251-
});
252-
} else {
253-
// Non-ChatSDKError: Could be network error or in-stream error
254-
// Log but don't toast - errors during streaming may be informational
255-
console.warn('[Chat onError] Error during streaming:', error.message);
256-
}
257-
// Note: We don't call resumeStream here because onError can be called
258-
// while the stream is still active (e.g., for data-error parts).
259-
// Resume logic is handled exclusively in onFinish.
260-
},
44+
initialMessages,
45+
model: initialChatModel,
46+
initialVisibility: initialVisibilityType,
47+
isReadonly,
48+
feedback,
49+
title,
26150
});
26251

26352
const [searchParams] = useSearchParams();
@@ -267,33 +56,34 @@ export function Chat({
26756

26857
useEffect(() => {
26958
if (query && !hasAppendedQuery) {
270-
sendMessage({
59+
chat.sendMessage({
27160
role: 'user' as const,
27261
parts: [{ type: 'text', text: query }],
27362
});
27463

27564
setHasAppendedQuery(true);
276-
softNavigateToChatId(id, chatHistoryEnabled);
65+
softNavigateToChatId(chat.id, chatHistoryEnabled);
27766
}
278-
}, [query, sendMessage, hasAppendedQuery, id, chatHistoryEnabled]);
67+
}, [query, chat.sendMessage, hasAppendedQuery, chat.id, chatHistoryEnabled]);
27968

69+
const [input, setInput] = useState<string>('');
28070
const [attachments, setAttachments] = useState<Array<Attachment>>([]);
28171

28272
const inputElement = <MultimodalInput
283-
chatId={id}
73+
chatId={chat.id}
28474
input={input}
28575
setInput={setInput}
286-
status={status}
287-
stop={stop}
76+
status={chat.status}
77+
stop={chat.stop}
28878
attachments={attachments}
28979
setAttachments={setAttachments}
290-
messages={messages}
291-
setMessages={setMessages}
292-
sendMessage={sendMessage}
293-
selectedVisibilityType={visibilityType}
80+
messages={chat.messages}
81+
setMessages={chat.setMessages}
82+
sendMessage={chat.sendMessage}
83+
selectedVisibilityType={chat.visibilityType}
29484
/>
29585

296-
if (messages.length === 0) {
86+
if (chat.messages.length === 0) {
29787
return (
29888
<div className="flex h-dvh min-w-0 flex-col bg-background">
29989
<ChatHeader empty />
@@ -308,30 +98,24 @@ export function Chat({
30898
}
30999

310100
return (
311-
<>
312-
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
313-
<ChatHeader title={displayTitle} isLoadingTitle={titlePending && !displayTitle} />
314-
315-
<Messages
316-
status={status}
317-
messages={messages}
318-
setMessages={setMessages}
319-
addToolApprovalResponse={addToolApprovalResponse}
320-
regenerate={regenerate}
321-
sendMessage={sendMessage}
322-
isReadonly={isReadonly}
323-
selectedModelId={initialChatModel}
324-
feedback={feedback}
325-
/>
326-
327-
328-
329-
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
330-
{!isReadonly && (
331-
inputElement
332-
)}
333-
</div>
101+
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
102+
<ChatHeader title={chat.title} isLoadingTitle={chat.isTitleLoading} />
103+
104+
<Messages
105+
status={chat.status}
106+
messages={chat.messages}
107+
setMessages={chat.setMessages}
108+
addToolApprovalResponse={chat.addToolApprovalResponse}
109+
regenerate={chat.regenerate}
110+
sendMessage={chat.sendMessage}
111+
isReadonly={chat.isReadonly}
112+
selectedModelId={chat.model}
113+
feedback={chat.feedback}
114+
/>
115+
116+
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
117+
{!chat.isReadonly && inputElement}
334118
</div>
335-
</>
119+
</div>
336120
);
337121
}

0 commit comments

Comments
 (0)