diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json
index a6b6739296a0..d5e4c3db3981 100644
--- a/ui/desktop/openapi.json
+++ b/ui/desktop/openapi.json
@@ -4261,13 +4261,11 @@
"properties": {
"totalSessions": {
"type": "integer",
- "description": "Total number of sessions",
"minimum": 0
},
"totalTokens": {
"type": "integer",
- "format": "int64",
- "description": "Total tokens used across all sessions"
+ "format": "int64"
}
}
},
diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts
index 21a69069be11..9e0fcce058c0 100644
--- a/ui/desktop/src/api/types.gen.ts
+++ b/ui/desktop/src/api/types.gen.ts
@@ -729,13 +729,7 @@ export type SessionDisplayInfo = {
};
export type SessionInsights = {
- /**
- * Total number of sessions
- */
totalSessions: number;
- /**
- * Total tokens used across all sessions
- */
totalTokens: number;
};
diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx
index 9f1c260d65f8..e1924fedbbd7 100644
--- a/ui/desktop/src/components/BaseChat2.tsx
+++ b/ui/desktop/src/components/BaseChat2.tsx
@@ -67,6 +67,7 @@ function BaseChatContent({
useChatStream({
sessionId,
onStreamFinish,
+ initialMessage,
});
const handleFormSubmit = (e: React.FormEvent) => {
@@ -87,12 +88,6 @@ function BaseChatContent({
session,
});
- useEffect(() => {
- if (initialMessage && session && messages.length == 0) {
- handleSubmit(initialMessage);
- }
- }, [initialMessage, session, messages, handleSubmit]);
-
const recipe = session?.recipe;
useEffect(() => {
@@ -171,7 +166,8 @@ function BaseChatContent({
>
);
- const showPopularTopics = messages.length === 0;
+ const showPopularTopics =
+ messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
// TODO(Douwe): get this from the backend
const isCompacting = false;
@@ -184,6 +180,15 @@ function BaseChatContent({
};
const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : '';
+
+ // Map chatState to LoadingGoose message
+ const getLoadingMessage = (): string | undefined => {
+ if (isCompacting) return 'goose is compacting the conversation...';
+ if (messages.length === 0 && chatState === ChatState.Thinking) {
+ return 'loading conversation...';
+ }
+ return undefined;
+ };
return (
Warning: BaseChat2!
@@ -255,16 +260,7 @@ function BaseChatContent({
{(chatState !== ChatState.Idle || isCompacting) && !sessionLoadError && (
-
+
)}
diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts
index 66731769f65b..63be470c800d 100644
--- a/ui/desktop/src/hooks/useChatStream.ts
+++ b/ui/desktop/src/hooks/useChatStream.ts
@@ -5,9 +5,39 @@ import { getApiUrl } from '../config';
import { createUserMessage } from '../types/message';
const TextDecoder = globalThis.TextDecoder;
-
const resultsCache = new Map();
+// Debug logging - set to false in production
+const DEBUG_CHAT_STREAM = true;
+
+const log = {
+ session: (action: string, sessionId: string, details?: Record) => {
+ if (!DEBUG_CHAT_STREAM) return;
+ console.log(`[useChatStream:session] ${action}`, {
+ sessionId: sessionId.slice(0, 8),
+ ...details,
+ });
+ },
+ messages: (action: string, count: number, details?: Record) => {
+ if (!DEBUG_CHAT_STREAM) return;
+ console.log(`[useChatStream:messages] ${action}`, {
+ count,
+ ...details,
+ });
+ },
+ stream: (action: string, details?: Record) => {
+ if (!DEBUG_CHAT_STREAM) return;
+ console.log(`[useChatStream:stream] ${action}`, details);
+ },
+ state: (newState: ChatState, details?: Record) => {
+ if (!DEBUG_CHAT_STREAM) return;
+ console.log(`[useChatStream:state] → ${newState}`, details);
+ },
+ error: (context: string, error: unknown) => {
+ console.error(`[useChatStream:error] ${context}`, error);
+ },
+};
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
interface NotificationEvent {
@@ -33,6 +63,7 @@ type MessageEvent =
interface UseChatStreamProps {
sessionId: string;
onStreamFinish: () => void;
+ initialMessage?: string;
}
interface UseChatStreamReturn {
@@ -69,9 +100,12 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
async function streamFromResponse(
response: Response,
initialMessages: Message[],
- setMessages: (messages: Message[], log: string) => void,
+ updateMessages: (messages: Message[]) => void,
onFinish: (error?: string) => void
): Promise {
+ let chunkCount = 0;
+ let messageEventCount = 0;
+
try {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
@@ -80,10 +114,19 @@ async function streamFromResponse(
const decoder = new TextDecoder();
let currentMessages = initialMessages;
+ log.stream('reading-chunks');
+
while (true) {
const { done, value } = await reader.read();
- if (done) break;
+ if (done) {
+ log.stream('chunks-complete', {
+ totalChunks: chunkCount,
+ messageEvents: messageEventCount,
+ });
+ break;
+ }
+ chunkCount++;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
@@ -98,30 +141,51 @@ async function streamFromResponse(
switch (event.type) {
case 'Message': {
+ messageEventCount++;
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
- setMessages(currentMessages, 'streaming');
+
+ // Only log every 10th message event to avoid spam
+ if (messageEventCount % 10 === 0) {
+ log.stream('message-chunk', {
+ eventCount: messageEventCount,
+ messageCount: currentMessages.length,
+ });
+ }
+
+ // This calls the wrapped setMessagesAndLog with 'streaming' context
+ updateMessages(currentMessages);
break;
}
case 'Error': {
+ log.error('stream event error', event.error);
onFinish('Stream error: ' + event.error);
return;
}
case 'Finish': {
+ log.stream('finish-event', { reason: event.reason });
onFinish();
return;
}
case 'ModelChange': {
+ log.stream('model-change', {
+ model: event.model,
+ mode: event.mode,
+ });
break;
}
case 'UpdateConversation': {
- setMessages(event.conversation, 'update-conversation');
+ log.messages('conversation-update', event.conversation.length);
+ // This calls the wrapped setMessagesAndLog with 'streaming' context
+ updateMessages(event.conversation);
break;
}
case 'Notification': {
+ // Don't log notifications, too noisy
break;
}
case 'Ping': {
+ // Don't log pings
break;
}
default: {
@@ -130,12 +194,14 @@ async function streamFromResponse(
}
}
} catch (e) {
+ log.error('SSE parse failed', e);
onFinish('Failed to parse SSE:' + e);
}
}
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
+ log.error('stream read error', error);
onFinish('Stream error:' + error);
}
}
@@ -144,6 +210,7 @@ async function streamFromResponse(
export function useChatStream({
sessionId,
onStreamFinish,
+ initialMessage,
}: UseChatStreamProps): UseChatStreamReturn {
const [messages, setMessages] = useState([]);
const messagesRef = useRef([]);
@@ -152,14 +219,6 @@ export function useChatStream({
const [chatState, setChatState] = useState(ChatState.Idle);
const abortControllerRef = useRef(null);
- const setMessagesAndLog = useCallback(
- (messages: Message[], log: string) => {
- console.log(log, session, messages.length);
- setMessages(messages);
- },
- [session]
- );
-
useEffect(() => {
if (session) {
resultsCache.set(sessionId, { session, messages });
@@ -170,24 +229,41 @@ export function useChatStream({
renderCountRef.current += 1;
console.log(`useChatStream render #${renderCountRef.current}, ${session?.id}`);
- useEffect(() => {
- messagesRef.current = messages;
- }, [messages]);
+ const setMessagesAndLog = useCallback((newMessages: Message[], logContext: string) => {
+ log.messages(logContext, newMessages.length, {
+ lastMessageRole: newMessages[newMessages.length - 1]?.role,
+ lastMessageId: newMessages[newMessages.length - 1]?.id?.slice(0, 8),
+ });
+ setMessages(newMessages);
+ messagesRef.current = newMessages;
+ }, []);
const onFinish = useCallback(
(error?: string): void => {
- setSessionLoadError(error);
+ if (error) {
+ setSessionLoadError(error);
+ }
setChatState(ChatState.Idle);
onStreamFinish();
},
[onStreamFinish]
);
+ // Load session on mount or sessionId change
useEffect(() => {
if (!sessionId) return;
+ // Reset state when sessionId changes
+ log.session('loading', sessionId);
+ setMessagesAndLog([], 'session-reset');
+ setSession(undefined);
+ setSessionLoadError(undefined);
setChatState(ChatState.Thinking);
+ let cancelled = false;
+
+ log.state(ChatState.Thinking, { reason: 'session load start' });
+
(async () => {
try {
const response = await resumeAgent({
@@ -197,46 +273,103 @@ export function useChatStream({
},
throwOnError: true,
});
+ if (cancelled) return;
+
const session = response.data;
- console.log('resume agent returned', session?.id, session?.conversation?.length);
+ log.session('loaded', sessionId, {
+ messageCount: session?.conversation?.length || 0,
+ description: session?.description,
+ });
+
setSession(session);
setMessagesAndLog(session?.conversation || [], 'load-session');
+
+ log.state(ChatState.Idle, { reason: 'session load complete' });
setChatState(ChatState.Idle);
} catch (error) {
+ if (cancelled) return;
+
+ log.error('session load failed', error);
setSessionLoadError(error instanceof Error ? error.message : String(error));
+
+ log.state(ChatState.Idle, { reason: 'session load error' });
setChatState(ChatState.Idle);
}
})();
+
+ return () => {
+ cancelled = true;
+ };
}, [sessionId, setMessagesAndLog]);
const handleSubmit = useCallback(
async (userMessage: string) => {
+ log.messages('user-submit', messagesRef.current.length + 1, {
+ userMessageLength: userMessage.length,
+ });
+
const currentMessages = [...messagesRef.current, createUserMessage(userMessage)];
setMessagesAndLog(currentMessages, 'user-entered');
+
+ log.state(ChatState.Streaming, { reason: 'user submit' });
setChatState(ChatState.Streaming);
abortControllerRef.current = new AbortController();
- const response = await fetch(getApiUrl('/reply'), {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Secret-Key': await window.electron.getSecretKey(),
- },
- body: JSON.stringify({
- session_id: sessionId,
- messages: currentMessages,
- }),
- signal: abortControllerRef.current.signal,
- });
+ try {
+ log.stream('request-start', { sessionId: sessionId.slice(0, 8) });
- await streamFromResponse(response, currentMessages, setMessagesAndLog, onFinish);
+ const response = await fetch(getApiUrl('/reply'), {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Secret-Key': await window.electron.getSecretKey(),
+ },
+ body: JSON.stringify({
+ session_id: sessionId,
+ messages: currentMessages,
+ }),
+ signal: abortControllerRef.current.signal,
+ });
+
+ log.stream('response-received', {
+ status: response.status,
+ ok: response.ok,
+ });
+
+ await streamFromResponse(
+ response,
+ currentMessages,
+ (messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
+ onFinish
+ );
+
+ log.stream('stream-complete');
+ } catch (error) {
+ // AbortError is expected when user stops streaming
+ if (error instanceof Error && error.name === 'AbortError') {
+ log.stream('stream-aborted');
+ } else {
+ // Unexpected error during fetch setup (streamFromResponse handles its own errors)
+ log.error('submit failed', error);
+ onFinish('Submit error: ' + (error instanceof Error ? error.message : String(error)));
+ }
+ }
},
- [sessionId, onFinish, setMessagesAndLog]
+ [sessionId, setMessagesAndLog, onFinish]
);
+ useEffect(() => {
+ if (initialMessage && session && messages.length === 0 && chatState === ChatState.Idle) {
+ log.messages('auto-submit-initial', 0, { initialMessage: initialMessage.slice(0, 50) });
+ handleSubmit(initialMessage);
+ }
+ }, [initialMessage, session, messages.length, chatState, handleSubmit]);
+
const stopStreaming = useCallback(() => {
+ log.stream('stop-requested');
abortControllerRef.current?.abort();
+ log.state(ChatState.Idle, { reason: 'user stopped streaming' });
setChatState(ChatState.Idle);
}, []);