Unify loading goose messages and usechatstream determines chat state (#5306)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Zane
2025-10-23 15:51:01 -07:00
committed by GitHub
parent 6e76bb825c
commit 4acb4871b7
6 changed files with 76 additions and 36 deletions
+9 -10
View File
@@ -2,7 +2,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import LoadingGoose from './LoadingGoose';
import { getThinkingMessage } from '../types/message';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { MainPanelLayout } from './Layout/MainPanelLayout';
@@ -23,6 +22,7 @@ import { scanRecipe } from '../recipe';
import { useCostTracking } from '../hooks/useCostTracking';
import RecipeActivities from './recipes/RecipeActivities';
import { useToolCount } from './alerts/useToolCount';
import { getThinkingMessage } from '../types/message';
interface BaseChatProps {
setChat: (chat: ChatType) => void;
@@ -179,13 +179,6 @@ function BaseChatContent({
const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : '';
// Map chatState to LoadingGoose message
const getLoadingMessage = (): string | undefined => {
if (messages.length === 0 && chatState === ChatState.Thinking) {
return 'loading conversation...';
}
return getThinkingMessage(messages[messages.length - 1]);
};
return (
<div className="h-full flex flex-col min-h-0">
<h2>Warning: BaseChat2!</h2>
@@ -255,10 +248,16 @@ function BaseChatContent({
) : null}
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{chatState !== ChatState.Idle && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose message={getLoadingMessage()} chatState={chatState} />
<LoadingGoose
chatState={chatState}
message={
messages.length > 0
? getThinkingMessage(messages[messages.length - 1])
: undefined
}
/>
</div>
)}
</div>
+24 -21
View File
@@ -8,18 +8,29 @@ interface LoadingGooseProps {
chatState?: ChatState;
}
const STATE_MESSAGES: Record<ChatState, string> = {
[ChatState.LoadingConversation]: 'loading conversation...',
[ChatState.Thinking]: 'goose is thinking…',
[ChatState.Streaming]: 'goose is working on it…',
[ChatState.WaitingForUserInput]: 'goose is waiting…',
[ChatState.Compacting]: 'goose is compacting the conversation...',
[ChatState.Idle]: 'goose is working on it…',
};
const STATE_ICONS: Record<ChatState, React.ReactNode> = {
[ChatState.LoadingConversation]: <AnimatedIcons className="flex-shrink-0" cycleInterval={600} />,
[ChatState.Thinking]: <AnimatedIcons className="flex-shrink-0" cycleInterval={600} />,
[ChatState.Streaming]: <FlyingBird className="flex-shrink-0" cycleInterval={150} />,
[ChatState.WaitingForUserInput]: (
<AnimatedIcons className="flex-shrink-0" cycleInterval={600} variant="waiting" />
),
[ChatState.Compacting]: <AnimatedIcons className="flex-shrink-0" cycleInterval={600} />,
[ChatState.Idle]: <GooseLogo size="small" hover={false} />,
};
const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps) => {
// Determine the appropriate message based on state
const getLoadingMessage = () => {
if (message) return message; // Custom message takes priority
if (chatState === ChatState.Thinking) return 'goose is thinking…';
if (chatState === ChatState.Streaming) return 'goose is working on it…';
if (chatState === ChatState.WaitingForUserInput) return 'goose is waiting…';
// Default fallback
return 'goose is working on it…';
};
const displayMessage = message || STATE_MESSAGES[chatState];
const icon = STATE_ICONS[chatState];
return (
<div className="w-full animate-fade-slide-up">
@@ -27,16 +38,8 @@ const LoadingGoose = ({ message, chatState = ChatState.Idle }: LoadingGooseProps
data-testid="loading-indicator"
className="flex items-center gap-2 text-xs text-textStandard py-2"
>
{chatState === ChatState.Thinking ? (
<AnimatedIcons className="flex-shrink-0" cycleInterval={600} />
) : chatState === ChatState.Streaming ? (
<FlyingBird className="flex-shrink-0" cycleInterval={150} />
) : chatState === ChatState.WaitingForUserInput ? (
<AnimatedIcons className="flex-shrink-0" cycleInterval={600} variant="waiting" />
) : (
<GooseLogo size="small" hover={false} />
)}
{getLoadingMessage()}
{icon}
{displayMessage}
</div>
</div>
);
+13 -3
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { ChatState } from '../types/chatState';
import { Conversation, Message, resumeAgent, Session } from '../api';
import { getApiUrl } from '../config';
import { createUserMessage } from '../types/message';
import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message';
const TextDecoder = globalThis.TextDecoder;
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
@@ -101,6 +101,7 @@ async function streamFromResponse(
response: Response,
initialMessages: Message[],
updateMessages: (messages: Message[]) => void,
updateChatState: (state: ChatState) => void,
onFinish: (error?: string) => void
): Promise<void> {
let chunkCount = 0;
@@ -145,6 +146,14 @@ async function streamFromResponse(
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
if (getCompactingMessage(msg)) {
log.state(ChatState.Compacting, { reason: 'compacting notification' });
updateChatState(ChatState.Compacting);
} else if (getThinkingMessage(msg)) {
log.state(ChatState.Thinking, { reason: 'thinking notification' });
updateChatState(ChatState.Thinking);
}
// Only log every 10th message event to avoid spam
if (messageEventCount % 10 === 0) {
log.stream('message-chunk', {
@@ -259,11 +268,11 @@ export function useChatStream({
setMessagesAndLog([], 'session-reset');
setSession(undefined);
setSessionLoadError(undefined);
setChatState(ChatState.Thinking);
setChatState(ChatState.LoadingConversation);
let cancelled = false;
log.state(ChatState.Thinking, { reason: 'session load start' });
log.state(ChatState.LoadingConversation, { reason: 'session load start' });
(async () => {
try {
@@ -342,6 +351,7 @@ export function useChatStream({
response,
currentMessages,
(messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
setChatState,
onFinish
);
+9 -2
View File
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
import useSWR from 'swr';
import { createUserMessage, getThinkingMessage, hasCompletedToolCalls } from '../types/message';
import {
createUserMessage,
getThinkingMessage,
getCompactingMessage,
hasCompletedToolCalls,
} from '../types/message';
import { Conversation, Message, Role } from '../api';
import { getSession, Session } from '../api';
@@ -307,7 +312,9 @@ export function useMessageStream({
mutateChatState(ChatState.WaitingForUserInput);
}
if (getThinkingMessage(newMessage)) {
if (getCompactingMessage(newMessage)) {
mutateChatState(ChatState.Compacting);
} else if (getThinkingMessage(newMessage)) {
mutateChatState(ChatState.Thinking);
}
+2
View File
@@ -3,4 +3,6 @@ export enum ChatState {
Thinking = 'thinking',
Streaming = 'streaming',
WaitingForUserInput = 'waitingForUserInput',
Compacting = 'compacting',
LoadingConversation = 'loadingConversation',
}
+19
View File
@@ -3,6 +3,9 @@ import { Message, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../
export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' };
export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' };
// Compaction response message - must match backend constant
const COMPACTION_THINKING_TEXT = 'goose is compacting the conversation...';
export function createUserMessage(text: string): Message {
return {
id: generateMessageId(),
@@ -84,3 +87,19 @@ export function getThinkingMessage(message: Message | undefined): string | undef
return undefined;
}
export function getCompactingMessage(message: Message | undefined): string | undefined {
if (!message || message.role !== 'assistant') {
return undefined;
}
for (const content of message.content) {
if (content.type === 'systemNotification' && content.notificationType === 'thinkingMessage') {
if (content.msg === COMPACTION_THINKING_TEXT) {
return content.msg;
}
}
}
return undefined;
}