From e14fa24ee71f9ecf3563d57679552a1cca6f8c43 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 7 Oct 2025 15:47:46 -0400 Subject: [PATCH 1/8] First --- package-lock.json | 6 - ui/desktop/src/components/BaseChat2.tsx | 414 ++++++++++++++++++++++++ ui/desktop/src/components/Pair2.tsx | 74 +++++ 3 files changed, 488 insertions(+), 6 deletions(-) delete mode 100644 package-lock.json create mode 100644 ui/desktop/src/components/BaseChat2.tsx create mode 100644 ui/desktop/src/components/Pair2.tsx diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 6deca24a71b8..000000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "goose", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx new file mode 100644 index 000000000000..9e045ea185b7 --- /dev/null +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -0,0 +1,414 @@ +/** + * BaseChat Component + * + * BaseChat is the foundational chat component that provides the core conversational interface + * for the Goose Desktop application. It serves as the shared base for both Hub and Pair components, + * offering a flexible and extensible chat experience. + * + * Key Responsibilities: + * - Manages the complete chat lifecycle (messages, input, submission, responses) + * - Handles file drag-and-drop functionality with preview generation + * - Integrates with multiple specialized hooks for chat engine, recipes, sessions, etc. + * - Provides context management and session summarization capabilities + * - Supports both user and assistant message rendering with tool call integration + * - Manages loading states, error handling, and retry functionality + * - Offers customization points through render props and configuration options + * + * Architecture: + * - Uses a provider pattern (ChatContextManagerProvider) for state management + * - Leverages composition through render props for flexible UI customization + * - Integrates with multiple custom hooks for separation of concerns: + * - useChatEngine: Core chat functionality and API integration + * - useRecipeManager: Recipe/agent configuration management + * - useFileDrop: Drag-and-drop file handling with previews + * - useCostTracking: Token usage and cost calculation + * + * Customization Points: + * - renderHeader(): Custom header content (used by Hub for insights/recipe controls) + * - renderBeforeMessages(): Content before message list (used by Hub for SessionInsights) + * - renderAfterMessages(): Content after message list + * - customChatInputProps: Props passed to ChatInput for specialized behavior + * - customMainLayoutProps: Props passed to MainPanelLayout + * - contentClassName: Custom CSS classes for the content area + * + * File Handling: + * - Supports drag-and-drop of files with visual feedback + * - Generates image previews for supported file types + * - Integrates dropped files with chat input for seamless attachment + * - Uses data-drop-zone="true" to designate safe drop areas + * + * The component is designed to be the single source of truth for chat functionality + * while remaining flexible enough to support different UI contexts (Hub vs Pair). + */ + +import React, { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import { SearchView } from './conversation/SearchView'; +import LoadingGoose from './LoadingGoose'; +import PopularChatTopics from './PopularChatTopics'; +import ProgressiveMessageList from './ProgressiveMessageList'; +import { View, ViewOptions } from '../utils/navigationUtils'; +import { ContextManagerProvider } from './context_management/ContextManager'; +import { MainPanelLayout } from './Layout/MainPanelLayout'; +import ChatInput from './ChatInput'; +import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; +import { useFileDrop } from '../hooks/useFileDrop'; +import { Message } from '../types/message'; +import { ChatState } from '../types/chatState'; +import { ChatType } from '../types/chat'; +import { useIsMobile } from '../hooks/use-mobile'; +import { useSidebar } from './ui/sidebar'; +import { cn } from '../utils'; + +interface BaseChatProps { + chat: ChatType | null; + setChat: (chat: ChatType) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; + setIsGoosehintsModalOpen?: (isOpen: boolean) => void; + onMessageStreamFinish?: () => void; + onMessageSubmit?: (message: string) => void; + renderHeader?: () => React.ReactNode; + renderBeforeMessages?: () => React.ReactNode; + renderAfterMessages?: () => React.ReactNode; + customChatInputProps?: Record; + customMainLayoutProps?: Record; + contentClassName?: string; + disableSearch?: boolean; + showPopularTopics?: boolean; + suppressEmptyState?: boolean; + autoSubmit?: boolean; +} + +function BaseChatContent({ + chat, + setView, + setIsGoosehintsModalOpen, + renderHeader, + renderBeforeMessages, + renderAfterMessages, + customChatInputProps = {}, + customMainLayoutProps = {}, + disableSearch = false, +}: BaseChatProps) { + const location = useLocation(); + const scrollRef = useRef(null); + + const disableAnimation = location.state?.disableAnimation || false; + // const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); + // const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); + // const { isCompacting, handleManualCompaction } = useContextManager(); + const isMobile = useIsMobile(); + const { state: sidebarState } = useSidebar(); + + const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11'); + + // Use shared file drop + const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); + + // Use shared cost tracking + // const { sessionCosts } = useCostTracking({ + // sessionInputTokens, + // sessionOutputTokens, + // localInputTokens, + // localOutputTokens, + // session: sessionMetadata, + // }); + + // TODO(Douwe): send this to the chatbox instead, possibly autosubmit? or backend + const append = (_txt: string) => {}; + + useEffect(() => { + window.electron.logInfo( + 'Initial messages when resuming session: ' + JSON.stringify(messages, null, 2) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Track if this is the initial render for session resuming + const initialRenderRef = useRef(true); + + const messages = chat?.messages || []; + const recipe = chat?.recipe; + + // Auto-scroll when messages are loaded (for session resuming) + const handleRenderingComplete = React.useCallback(() => { + // Only force scroll on the very first render + if (initialRenderRef.current && messages.length > 0) { + initialRenderRef.current = false; + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + } else if (scrollRef.current?.isFollowing) { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + } + }, [messages.length]); + + // Handle submit + // const handleSubmit = (e: React.FormEvent) => { + // const customEvent = e as unknown as CustomEvent; + // const combinedTextFromInput = customEvent.detail?.value || ''; + // engineHandleSubmit(combinedTextFromInput); + // }; + + //const toolCount = useToolCount(chat.sessionId); + + // Wrapper for append that tracks recipe usage + // const appendWithTracking = (text: string | Message) => { + // // Mark that user has started using the recipe when they use append + // if (recipe) { + // setHasStartedUsingRecipe(true); + // } + // append(text); + // }; + + // Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions) + useEffect(() => { + const handleGlobalScrollRequest = () => { + // Add a small delay to ensure content has been rendered + setTimeout(() => { + if (scrollRef.current?.scrollToBottom) { + scrollRef.current.scrollToBottom(); + } + }, 200); + }; + + window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); + return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); + }, []); + + const renderProgressiveMessageList = (chat: ChatType) => ( + <> + { + // const updatedMessages = [...messages, newMessage]; + // setMessages(updatedMessages); + // }} + isUserMessage={(m: Message) => m.role === 'user'} + // isStreamingMessage={chatState !== ChatState.Idle} + // onMessageUpdate={onMessageUpdate} + onRenderingComplete={handleRenderingComplete} + /> + + ); + + const showPopularTopics = messages.length === 0; + // TODO(Douwe): get this from the backend + const isCompacting = false; + + const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : ''; + return ( +
+ + {/* Custom header */} + {renderHeader && renderHeader()} + + {/* Chat container with sticky recipe header */} +
+ + {/*/!* Recipe agent header - sticky at top of chat container *!/*/} + {/*{recipe?.title && (*/} + {/*
*/} + {/* {*/} + {/* console.log('Change profile clicked');*/} + {/* }}*/} + {/* showBorder={true}*/} + {/* />*/} + {/*
*/} + {/*)}*/} + + {/* Custom content before messages */} + {renderBeforeMessages && renderBeforeMessages()} + + {/*/!* Recipe Activities - always show when recipe is active and accepted *!/*/} + {/*{recipe && recipeAccepted && !suppressEmptyState && (*/} + {/*
*/} + {/* appendWithTracking(text)}*/} + {/* activities={Array.isArray(recipe.activities) ? recipe.activities : null}*/} + {/* title={recipe.title}*/} + {/* parameterValues={recipeParameters || {}}*/} + {/* />*/} + {/*
*/} + {/*)}*/} + + {/* Messages or Popular Topics */} + { + !chat ? null : messages.length > 0 || recipe ? ( + <> + {disableSearch ? ( + renderProgressiveMessageList(chat) + ) : ( + // Render messages with SearchView wrapper when search is enabled + {renderProgressiveMessageList(chat)} + )} + + {/*{error && (*/} + {/* <>*/} + {/*
*/} + {/*
*/} + {/* {error.message || 'Honk! Goose experienced an error while responding'}*/} + {/*
*/} + + {/* /!* Action buttons for all errors including token limit errors *!/*/} + {/*
*/} + {/* {*/} + {/* clearError();*/} + + {/* await handleManualCompaction(*/} + {/* messages,*/} + {/* setMessages,*/} + {/* append,*/} + {/* chat.sessionId*/} + {/* );*/} + {/* }}*/} + {/* >*/} + {/* Summarize Conversation*/} + {/*
*/} + {/* {*/} + {/* // Find the last user message*/} + {/* const lastUserMessage = messages.reduceRight(*/} + {/* (found, m) => found || (m.role === 'user' ? m : null),*/} + {/* null as Message | null*/} + {/* );*/} + {/* if (lastUserMessage) {*/} + {/* await append(lastUserMessage);*/} + {/* }*/} + {/* }}*/} + {/* >*/} + {/* Retry Last Message*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/* */} + {/*)}*/} + +
+ + ) : !recipe && showPopularTopics ? ( + /* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */ + append(text)} /> + ) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */ + } + + {/* Custom content after messages */} + {renderAfterMessages && renderAfterMessages()} + + + {/* Fixed loading indicator at bottom left of chat container */} + {(!chat || isCompacting) && ( +
+ +
+ )} +
+ +
+ {}} + chatState={ChatState.Idle} + //onStop={onStopGoose} + //commandHistory={commandHistory} + initialValue={initialPrompt} + setView={setView} + // numTokens={sessionTokenCount} + // inputTokens={sessionInputTokens || localInputTokens} + // outputTokens={sessionOutputTokens || localOutputTokens} + droppedFiles={droppedFiles} + onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing + messages={messages} + setMessages={(_m) => {}} + disableAnimation={disableAnimation} + //sessionCosts={sessionCosts} + setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} + recipe={recipe} + //recipeAccepted={recipeAccepted} + initialPrompt={initialPrompt} + //toolCount={toolCount || 0} + toolCount={0} + //autoSubmit={autoSubmit} + autoSubmit={false} + //append={append} + {...customChatInputProps} + /> +
+ + + {/*/!* Recipe Warning Modal *!/*/} + {/**/} + + {/*/!* Recipe Parameter Modal *!/*/} + {/*{isParameterModalOpen && filteredParameters.length > 0 && (*/} + {/* setIsParameterModalOpen(false)}*/} + {/* />*/} + {/*)}*/} + + {/*/!* Create Recipe from Session Modal *!/*/} + {/* setIsCreateRecipeModalOpen(false)}*/} + {/* sessionId={chat.sessionId}*/} + {/* onRecipeCreated={handleRecipeCreated}*/} + {/*/>*/} + + ); +} + +export default function BaseChat(props: BaseChatProps) { + return ( + + + + ); +} diff --git a/ui/desktop/src/components/Pair2.tsx b/ui/desktop/src/components/Pair2.tsx new file mode 100644 index 000000000000..c7bb1b220acc --- /dev/null +++ b/ui/desktop/src/components/Pair2.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; +import { View, ViewOptions } from '../utils/navigationUtils'; +import { AgentState, InitializationContext } from '../hooks/useAgent'; +import 'react-toastify/dist/ReactToastify.css'; + +import { ChatType } from '../types/chat'; +import { useSearchParams } from 'react-router-dom'; +import BaseChat2 from './BaseChat2'; + +export interface PairRouteState { + resumeSessionId?: string; + initialMessage?: string; +} + +interface PairProps { + chat: ChatType; + setChat: (chat: ChatType) => void; + setView: (view: View, viewOptions?: ViewOptions) => void; + setIsGoosehintsModalOpen: (isOpen: boolean) => void; + setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; + setAgentWaitingMessage: (msg: string | null) => void; + agentState: AgentState; + loadCurrentChat: (context: InitializationContext) => Promise; +} + +export default function Pair({ + setView, + setIsGoosehintsModalOpen, + setFatalError, + setAgentWaitingMessage, + agentState, + loadCurrentChat, + resumeSessionId, +}: PairProps & PairRouteState) { + const [_searchParams, setSearchParams] = useSearchParams(); + const [chat, setChat] = useState(null); + + useEffect(() => { + const initializeFromState = async () => { + try { + const chat = await loadCurrentChat({ + resumeSessionId, + setAgentWaitingMessage, + }); + setChat(chat); + setSearchParams((prev) => { + prev.set('resumeSessionId', chat.sessionId); + return prev; + }); + } catch (error) { + console.log(error); + setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`); + } + }; + initializeFromState(); + }, [ + agentState, + setChat, + setFatalError, + setAgentWaitingMessage, + loadCurrentChat, + resumeSessionId, + setSearchParams, + ]); + + return ( + + ); +} From be4567ecfa826746e79fb9d079e73ddaa0d3a3b3 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Tue, 7 Oct 2025 21:06:54 -0400 Subject: [PATCH 2/8] Change it all --- crates/goose/src/conversation/message.rs | 33 ++-- crates/goose/src/mcp_utils.rs | 2 +- ui/desktop/openapi.json | 6 +- ui/desktop/src/components/BaseChat.tsx | 2 +- ui/desktop/src/components/BaseChat2.tsx | 14 +- ui/desktop/src/components/ChatInput.tsx | 2 +- ui/desktop/src/components/GooseMessage.tsx | 2 +- .../src/components/MCPUIResourceRenderer.tsx | 5 +- .../src/components/ProgressiveMessageList.tsx | 2 +- ui/desktop/src/components/ToolCallChain.tsx | 3 +- .../src/components/ToolCallConfirmation.tsx | 6 +- .../src/components/ToolCallWithResponse.tsx | 48 +++--- ui/desktop/src/components/UserMessage.tsx | 5 +- .../context_management/CompactionMarker.tsx | 7 +- .../context_management/ContextManager.tsx | 15 +- .../__tests__/CompactionMarker.test.tsx | 2 +- .../__tests__/ContextManager.test.tsx | 55 +------ .../components/context_management/index.ts | 146 ++---------------- .../sessions/SessionHistoryView.tsx | 6 +- .../sessions/SessionViewComponents.tsx | 11 +- ui/desktop/src/hooks/useAgent.ts | 14 +- ui/desktop/src/hooks/useChatEngine.test.ts | 9 +- ui/desktop/src/hooks/useChatEngine.ts | 33 +--- ui/desktop/src/hooks/useChatStream.ts | 114 ++++++++++++++ ui/desktop/src/hooks/useMessageStream.ts | 4 +- ui/desktop/src/hooks/useRecipeManager.ts | 4 +- ui/desktop/src/sharedSessions.ts | 2 +- ui/desktop/src/types/chat.ts | 2 +- ui/desktop/src/types/message.ts | 146 ++---------------- ui/desktop/src/utils/timeUtils.ts | 5 +- ui/desktop/src/utils/toolCallChaining.ts | 3 +- 31 files changed, 261 insertions(+), 447 deletions(-) create mode 100644 ui/desktop/src/hooks/useChatStream.ts diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index b3c6ab6b7bc7..b5846d9b0fc8 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -39,28 +39,44 @@ where Ok(content) } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] +#[serde(tag = "status", rename_all = "lowercase")] +pub enum ToolCallResult { + Success { value: T }, + Error { error: String }, +} + +impl From> for ToolCallResult { + fn from(result: ToolResult) -> Self { + match result { + Ok(value) => ToolCallResult::Success { value }, + Err(error) => ToolCallResult::Error { + error: error.to_string(), + }, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[derive(ToSchema)] pub struct ToolRequest { pub id: String, - #[serde(with = "tool_result_serde")] - #[schema(value_type = Object)] - pub tool_call: ToolResult, + pub tool_call: ToolCallResult, } impl ToolRequest { pub fn to_readable_string(&self) -> String { match &self.tool_call { - Ok(tool_call) => { + ToolCallResult::Success { value } => { format!( "Tool: {}, Args: {}", - tool_call.name, - serde_json::to_string_pretty(&tool_call.arguments) + value.name, + serde_json::to_string_pretty(&value.arguments) .unwrap_or_else(|_| "<>".to_string()) ) } - Err(e) => format!("Invalid tool call: {}", e), + ToolCallResult::Error { error } => format!("Invalid tool call: {}", error), } } } @@ -71,7 +87,6 @@ impl ToolRequest { pub struct ToolResponse { pub id: String, #[serde(with = "tool_result_serde")] - #[schema(value_type = Object)] pub tool_result: ToolResult>, } @@ -100,8 +115,6 @@ pub struct RedactedThinkingContent { #[serde(rename_all = "camelCase")] pub struct FrontendToolRequest { pub id: String, - #[serde(with = "tool_result_serde")] - #[schema(value_type = Object)] pub tool_call: ToolResult, } diff --git a/crates/goose/src/mcp_utils.rs b/crates/goose/src/mcp_utils.rs index 9f319e34a51c..7360fa2b51aa 100644 --- a/crates/goose/src/mcp_utils.rs +++ b/crates/goose/src/mcp_utils.rs @@ -1,4 +1,4 @@ pub use rmcp::model::ErrorData; /// Type alias for tool results -pub type ToolResult = std::result::Result; +pub type ToolResult = Result; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index a8398f713d3d..4877cef6f03d 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2754,7 +2754,7 @@ "type": "string" }, "toolCall": { - "type": "object" + "$ref": "#/components/schemas/ToolResult" } } }, @@ -4339,7 +4339,7 @@ "type": "string" }, "toolCall": { - "type": "object" + "$ref": "#/components/schemas/ToolResult" } } }, @@ -4354,7 +4354,7 @@ "type": "string" }, "toolResult": { - "type": "object" + "$ref": "#/components/schemas/ToolResult" } } }, diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 07231583ab38..c03c3e87be39 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -61,10 +61,10 @@ import { useChatEngine } from '../hooks/useChatEngine'; import { useRecipeManager } from '../hooks/useRecipeManager'; import { useFileDrop } from '../hooks/useFileDrop'; import { useCostTracking } from '../hooks/useCostTracking'; -import { Message } from '../types/message'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useToolCount } from './alerts/useToolCount'; +import { Message } from '../api'; // Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 9e045ea185b7..5ffaa64ec607 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -53,12 +53,13 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import { useFileDrop } from '../hooks/useFileDrop'; -import { Message } from '../types/message'; +import { Message } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; import { useSidebar } from './ui/sidebar'; import { cn } from '../utils'; +import { useChatStream } from '../hooks/useChatStream'; interface BaseChatProps { chat: ChatType | null; @@ -114,6 +115,17 @@ function BaseChatContent({ // session: sessionMetadata, // }); + const { chatState, handleSubmit, stopStreaming } = useChatStream({ + sessionId: chat?.sessionId || '', + messages, + setMessages: (newMessages) => { + if (chat) { + setChat({ ...chat, messages: newMessages }); + } + }, + onStreamFinish: onMessageStreamFinish, + }); + // TODO(Douwe): send this to the chatbox instead, possibly autosubmit? or backend const append = (_txt: string) => {}; diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 92763ddea8d4..33916c6cb433 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -8,7 +8,7 @@ import { Attach, Send, Close, Microphone } from './icons'; import { ChatState } from '../types/chatState'; import debounce from 'lodash/debounce'; import { LocalMessageStorage } from '../utils/localMessageStorage'; -import { Message } from '../types/message'; +import { Message } from '../api'; import { DirSwitcher } from './bottom_menu/DirSwitcher'; import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar'; import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection'; diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index cbc8aa6039d2..6c75bb03d191 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -11,13 +11,13 @@ import { getChainForMessage, } from '../utils/toolCallChaining'; import { - Message, getTextContent, getToolRequests, getToolResponses, getToolConfirmationContent, createToolErrorResponseMessage, } from '../types/message'; +import { Message } from '../api'; import ToolCallConfirmation from './ToolCallConfirmation'; import MessageCopyLink from './MessageCopyLink'; import { NotificationEvent } from '../hooks/useMessageStream'; diff --git a/ui/desktop/src/components/MCPUIResourceRenderer.tsx b/ui/desktop/src/components/MCPUIResourceRenderer.tsx index 3e5ad494a564..c73aab418b2c 100644 --- a/ui/desktop/src/components/MCPUIResourceRenderer.tsx +++ b/ui/desktop/src/components/MCPUIResourceRenderer.tsx @@ -7,13 +7,14 @@ import { UIActionResultToolCall, } from '@mcp-ui/client'; import { useState, useEffect } from 'react'; -import { ResourceContent } from '../types/message'; import { toast } from 'react-toastify'; +import { EmbeddedResource } from '../api'; interface MCPUIResourceRendererProps { - content: ResourceContent; + content: EmbeddedResource & { type: 'resource' }; appendPromptToChat?: (value: string) => void; } + type UISizeChange = { type: 'ui-size-change'; payload: { diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 0b4e87f6dc04..9bdd0ab689ab 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -15,7 +15,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Message } from '../types/message'; +import { Message } from '../api'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; import { CompactionMarker } from './context_management/CompactionMarker'; diff --git a/ui/desktop/src/components/ToolCallChain.tsx b/ui/desktop/src/components/ToolCallChain.tsx index ea02e343c42f..a952c3771d92 100644 --- a/ui/desktop/src/components/ToolCallChain.tsx +++ b/ui/desktop/src/components/ToolCallChain.tsx @@ -1,5 +1,6 @@ import { formatMessageTimestamp } from '../utils/timeUtils'; -import { Message, getToolRequests } from '../types/message'; +import { Message } from '../api'; +import { getToolRequests } from '../types/message'; import { NotificationEvent } from '../hooks/useMessageStream'; import ToolCallWithResponse from './ToolCallWithResponse'; diff --git a/ui/desktop/src/components/ToolCallConfirmation.tsx b/ui/desktop/src/components/ToolCallConfirmation.tsx index f58de292e888..ab0b080ed78a 100644 --- a/ui/desktop/src/components/ToolCallConfirmation.tsx +++ b/ui/desktop/src/components/ToolCallConfirmation.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { snakeToTitleCase } from '../utils'; import PermissionModal from './settings/permission/PermissionModal'; import { ChevronRight } from 'lucide-react'; -import { confirmPermission } from '../api'; +import { confirmPermission, ToolConfirmationRequest } from '../api'; import { Button } from './ui/button'; const ALLOW_ONCE = 'allow_once'; @@ -20,13 +20,11 @@ const toolConfirmationState = new Map< } >(); -import { ToolConfirmationRequestMessageContent } from '../types/message'; - interface ToolConfirmationProps { sessionId: string; isCancelledMessage: boolean; isClicked: boolean; - toolConfirmationContent: ToolConfirmationRequestMessageContent; + toolConfirmationContent: ToolConfirmationRequest & { type: 'toolConfirmationRequest' }; } export default function ToolConfirmation({ diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index da52d2b00ff0..a2c5fc3bc689 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments'; import MarkdownContent from './MarkdownContent'; -import { Content, ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message'; +import { ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message'; import { cn, snakeToTitleCase } from '../utils'; import { LoadingStatus } from './ui/Dot'; import { NotificationEvent } from '../hooks/useMessageStream'; @@ -12,6 +12,7 @@ import { ChevronRight, FlaskConical } from 'lucide-react'; import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper'; import MCPUIResourceRenderer from './MCPUIResourceRenderer'; import { isUIResource } from '@mcp-ui/client'; +import { Content } from '../api'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; @@ -27,10 +28,10 @@ export default function ToolCallWithResponse({ toolRequest, toolResponse, notifications, - isStreamingMessage = false, + isStreamingMessage, append, }: ToolCallWithResponseProps) { - const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; + const toolCall = toolRequest.toolCall as { name: string; arguments: Record }; if (!toolCall) { return null; } @@ -53,11 +54,12 @@ export default function ToolCallWithResponse({ /> {/* MCP UI — Inline */} - {toolResponse?.toolResult?.value && - toolResponse.toolResult.value.map((content, index) => { + {toolResponse?.toolResult && + Array.isArray((toolResponse.toolResult as any).value) && + (toolResponse.toolResult as any).value.map((content: Content, index: number) => { if (isUIResource(content)) { return ( -
+
@@ -211,7 +213,7 @@ function ToolCallView({ ? shouldShowAsComplete ? 'success' : 'loading' - : toolResponse.toolResult.status; + : (toolResponse.toolResult as any).status || 'success'; // Tool call timing tracking const [startTime, setStartTime] = useState(null); @@ -547,30 +549,34 @@ interface ToolResultViewProps { } function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { + const resultAny = result as any; + return ( Output} isStartExpanded={isStartExpanded} >
- {result.type === 'text' && result.text && ( + {'text' in resultAny && resultAny.text && ( )} - {result.type === 'image' && ( - Tool result { - console.error('Failed to load image'); - e.currentTarget.style.display = 'none'; - }} - /> - )} - {result.type === 'resource' && ( + {'mimeType' in resultAny && + 'data' in resultAny && + resultAny.mimeType?.startsWith('image') && ( + Tool result { + console.error('Failed to load image'); + e.currentTarget.style.display = 'none'; + }} + /> + )} + {'resource' in resultAny && (
{JSON.stringify(result, null, 2)}
)}
diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 036a7c5b5544..1292d4fbeba6 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -1,8 +1,9 @@ -import { useRef, useMemo, useState, useEffect, useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ImagePreview from './ImagePreview'; import { extractImagePaths, removeImagePathsFromText } from '../utils/imageUtils'; import MarkdownContent from './MarkdownContent'; -import { Message, getTextContent } from '../types/message'; +import { getTextContent } from '../types/message'; +import { Message } from '../api'; import MessageCopyLink from './MessageCopyLink'; import { formatMessageTimestamp } from '../utils/timeUtils'; import Edit from './icons/Edit'; diff --git a/ui/desktop/src/components/context_management/CompactionMarker.tsx b/ui/desktop/src/components/context_management/CompactionMarker.tsx index a08563855a40..f453aa9b0638 100644 --- a/ui/desktop/src/components/context_management/CompactionMarker.tsx +++ b/ui/desktop/src/components/context_management/CompactionMarker.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Message, SummarizationRequestedContent } from '../../types/message'; +import { Message, SummarizationRequested } from '../../api'; interface CompactionMarkerProps { message: Message; @@ -7,8 +7,9 @@ interface CompactionMarkerProps { export const CompactionMarker: React.FC = ({ message }) => { const compactionContent = message.content.find( - (content) => content.type === 'summarizationRequested' - ) as SummarizationRequestedContent | undefined; + (content): content is SummarizationRequested & { type: 'summarizationRequested' } => + content.type === 'summarizationRequested' + ); const markerText = compactionContent?.msg || 'Conversation compacted'; diff --git a/ui/desktop/src/components/context_management/ContextManager.tsx b/ui/desktop/src/components/context_management/ContextManager.tsx index 4d5ad9d9e1d7..54c606bd8852 100644 --- a/ui/desktop/src/components/context_management/ContextManager.tsx +++ b/ui/desktop/src/components/context_management/ContextManager.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useState, useCallback } from 'react'; -import { Message } from '../../types/message'; -import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index'; +import { manageContextFromBackend } from './index'; +import { Message } from '../../api'; // Define the context management interface interface ContextManagerState { @@ -53,21 +53,14 @@ export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ( sessionId: sessionId, }); - // Convert API messages to frontend messages - // The server now handles all visibility - we just display what we receive - const convertedMessages = summaryResponse.messages.map((apiMessage) => - convertApiMessageToFrontendMessage(apiMessage) - ); - - // Replace messages with the server-provided messages - setMessages(convertedMessages); + setMessages(summaryResponse.messages); // Only automatically submit the continuation message for auto-compaction (context limit reached) // Manual compaction should just compact without continuing the conversation if (!isManual) { // Automatically submit the continuation message to continue the conversation // This should be the third message (index 2) which contains the "I ran into a context length exceeded error..." text - const continuationMessage = convertedMessages[2]; + const continuationMessage = summaryResponse.messages[2]; if (continuationMessage) { setTimeout(() => { append(continuationMessage); diff --git a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx b/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx index 8e1691ba24b9..3244bbdfcecc 100644 --- a/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/CompactionMarker.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { CompactionMarker } from '../CompactionMarker'; -import { Message } from '../../../types/message'; +import { Message } from '../../../api'; describe('CompactionMarker', () => { it('should render default message when no summarizationRequested content found', () => { diff --git a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx index 53ac40b7a07d..fe2f4134dc32 100644 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx @@ -1,9 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { ContextManagerProvider, useContextManager } from '../ContextManager'; -import { Message } from '../../../types/message'; import * as contextManagement from '../index'; -import { ContextManageResponse } from '../../../api'; +import { ContextManageResponse, Message } from '../../../api'; // Mock the context management functions vi.mock('../index', () => ({ @@ -12,9 +11,6 @@ vi.mock('../index', () => ({ })); const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend); -const mockConvertApiMessageToFrontendMessage = vi.mocked( - contextManagement.convertApiMessageToFrontendMessage -); describe('ContextManager', () => { const mockMessages: Message[] = [ @@ -157,12 +153,6 @@ describe('ContextManager', () => { ], }; - // Mock the conversion function to return different messages based on call order - mockConvertApiMessageToFrontendMessage - .mockReturnValueOnce(mockCompactionMarker) // First call - compaction marker - .mockReturnValueOnce(mockSummaryMessage) // Second call - summary - .mockReturnValueOnce(mockContinuationMessage); // Third call - continuation - const { result } = renderContextManager(); await act(async () => { @@ -180,33 +170,6 @@ describe('ContextManager', () => { sessionId: 'test-session-id', }); - // Verify conversion calls with correct parameters - expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - content: [ - { type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }, - ], - }) - ); - expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - content: [{ type: 'text', text: 'Summary content' }], - }) - ); - expect(mockConvertApiMessageToFrontendMessage).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - content: [ - { - type: 'text', - text: expect.stringContaining('The previous message contains a summary'), - }, - ], - }) - ); - // Expect setMessages to be called with all 3 converted messages expect(mockSetMessages).toHaveBeenCalledWith([ mockCompactionMarker, @@ -290,8 +253,6 @@ describe('ContextManager', () => { tokenCounts: [100, 50], }); - mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage); - await act(async () => { await promise; }); @@ -382,11 +343,6 @@ describe('ContextManager', () => { ], }; - mockConvertApiMessageToFrontendMessage - .mockReturnValueOnce(mockCompactionMarker) - .mockReturnValueOnce(mockSummaryMessage) - .mockReturnValueOnce(mockContinuationMessage); - const { result } = renderContextManager(); await act(async () => { @@ -431,8 +387,6 @@ describe('ContextManager', () => { tokenCounts: [100, 50], }); - mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage); - const { result } = renderContextManager(); await act(async () => { @@ -500,11 +454,6 @@ describe('ContextManager', () => { ], }; - mockConvertApiMessageToFrontendMessage - .mockReturnValueOnce(mockCompactionMarker) - .mockReturnValueOnce(mockSummaryMessage) - .mockReturnValueOnce(mockContinuationMessage); - const { result } = renderContextManager(); await act(async () => { @@ -571,8 +520,6 @@ describe('ContextManager', () => { content: [{ type: 'toolResponse', id: 'test', toolResult: { status: 'success' } }], }; - mockConvertApiMessageToFrontendMessage.mockReturnValue(mockMessageWithoutText); - const { result } = renderContextManager(); await act(async () => { diff --git a/ui/desktop/src/components/context_management/index.ts b/ui/desktop/src/components/context_management/index.ts index c54d95eb0ffe..c4f706219678 100644 --- a/ui/desktop/src/components/context_management/index.ts +++ b/ui/desktop/src/components/context_management/index.ts @@ -1,149 +1,29 @@ -import { - Message as FrontendMessage, - Content as FrontendContent, - MessageContent as FrontendMessageContent, - ToolCallResult, - ToolCall, - Role, -} from '../../types/message'; -import { - ContextManageRequest, - ContextManageResponse, - manageContext, - Message as ApiMessage, - MessageContent as ApiMessageContent, -} from '../../api'; -import { generateId } from 'ai'; +import { ContextManageRequest, ContextManageResponse, manageContext, Message } from '../../api'; export async function manageContextFromBackend({ messages, manageAction, sessionId, }: { - messages: FrontendMessage[]; + messages: Message[]; manageAction: 'truncation' | 'summarize'; sessionId: string; }): Promise { - try { - const contextManagementRequest = { manageAction, messages, sessionId }; + const contextManagementRequest = { manageAction, messages, sessionId }; - // Cast to the API-expected type - const result = await manageContext({ - body: contextManagementRequest as unknown as ContextManageRequest, - }); + // Cast to the API-expected type + const result = await manageContext({ + body: contextManagementRequest as unknown as ContextManageRequest, + }); - // Check for errors in the result - if (result.error) { - throw new Error(`Context management failed: ${result.error}`); - } - - // Extract the actual data from the result - if (!result.data) { - throw new Error('Context management returned no data'); - } - - return result.data; - } catch (error) { - console.error(`Context management failed: ${error || 'Unknown error'}`); - throw new Error( - `Context management failed: ${error || 'Unknown error'}\n\nStart a new session.` - ); + // Check for errors in the result + if (result.error) { + throw new Error(`Context management failed: ${result.error}`); } -} - -// Function to convert API Message to frontend Message -export function convertApiMessageToFrontendMessage(apiMessage: ApiMessage): FrontendMessage { - return { - id: generateId(), - role: apiMessage.role as Role, - created: apiMessage.created ?? Math.floor(Date.now() / 1000), - content: apiMessage.content - .map((apiContent) => mapApiContentToFrontendMessageContent(apiContent)) - .filter((content): content is FrontendMessageContent => content !== null), - }; -} - -// Function to convert API MessageContent to frontend MessageContent -function mapApiContentToFrontendMessageContent( - apiContent: ApiMessageContent -): FrontendMessageContent | null { - // Handle each content type specifically based on its "type" property - if (apiContent.type === 'text') { - return { - type: 'text', - text: apiContent.text, - annotations: apiContent.annotations as Record | undefined, - }; - } else if (apiContent.type === 'image') { - return { - type: 'image', - data: apiContent.data, - mimeType: apiContent.mimeType, - annotations: apiContent.annotations as Record | undefined, - }; - } else if (apiContent.type === 'toolRequest') { - // Ensure the toolCall has the correct type structure - const toolCall = apiContent.toolCall as unknown as ToolCallResult; - return { - type: 'toolRequest', - id: apiContent.id, - toolCall: toolCall, - }; - } else if (apiContent.type === 'toolResponse') { - // Ensure the toolResult has the correct type structure - const toolResult = apiContent.toolResult as unknown as ToolCallResult; - - return { - type: 'toolResponse', - id: apiContent.id, - toolResult: toolResult, - }; - } else if (apiContent.type === 'toolConfirmationRequest') { - return { - type: 'toolConfirmationRequest', - id: apiContent.id, - toolName: apiContent.toolName, - arguments: apiContent.arguments as Record, - prompt: apiContent.prompt === null ? undefined : apiContent.prompt, - }; - } else if (apiContent.type === 'contextLengthExceeded') { - return { - type: 'contextLengthExceeded', - msg: apiContent.msg, - }; - } else if (apiContent.type === 'summarizationRequested') { - return { - type: 'summarizationRequested', - msg: apiContent.msg, - }; + if (!result.data) { + throw new Error('Context management returned no data'); } - // For types that exist in API but not in frontend, either skip or convert - console.warn(`Skipping unsupported content type: ${apiContent.type}`); - return null; -} - -export function createSummarizationRequestMessage( - messages: FrontendMessage[], - requestMessage: string -): FrontendMessage { - // Get the last message - const lastMessage = messages[messages.length - 1]; - - // Determine the next role (opposite of the last message) - const nextRole: Role = lastMessage.role === 'user' ? 'assistant' : 'user'; - - // Create the new message with SummarizationRequestedContent - return { - id: generateId(), - role: nextRole, - created: Math.floor(Date.now() / 1000), - content: [ - { - type: 'summarizationRequested', - msg: requestMessage, - }, - ], - }; + return result.data; } diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 781e42d84137..4d297ae9d1ff 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -29,11 +29,9 @@ import { import ProgressiveMessageList from '../ProgressiveMessageList'; import { SearchView } from '../conversation/SearchView'; import { ContextManagerProvider } from '../context_management/ContextManager'; -import { Message } from '../../types/message'; import BackButton from '../ui/BackButton'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'; -import { Session } from '../../api'; -import { convertApiMessageToFrontendMessage } from '../context_management'; +import { Message, Session } from '../../api'; // Helper function to determine if a message is a user message (same as useChatEngine) const isUserMessage = (message: Message): boolean => { @@ -153,7 +151,7 @@ const SessionHistoryView: React.FC = ({ const [isCopied, setIsCopied] = useState(false); const [canShare, setCanShare] = useState(false); - const messages = (session.conversation || []).map(convertApiMessageToFrontendMessage); + const messages = session.conversation || []; useEffect(() => { const savedSessionConfig = localStorage.getItem('session_sharing_config'); diff --git a/ui/desktop/src/components/sessions/SessionViewComponents.tsx b/ui/desktop/src/components/sessions/SessionViewComponents.tsx index efd217ca052c..e9a6fe450a4a 100644 --- a/ui/desktop/src/components/sessions/SessionViewComponents.tsx +++ b/ui/desktop/src/components/sessions/SessionViewComponents.tsx @@ -8,13 +8,13 @@ import MarkdownContent from '../MarkdownContent'; import ToolCallWithResponse from '../ToolCallWithResponse'; import ImagePreview from '../ImagePreview'; import { + getTextContent, ToolRequestMessageContent, ToolResponseMessageContent, - TextContent, } from '../../types/message'; -import { type Message } from '../../types/message'; import { formatMessageTimestamp } from '../../utils/timeUtils'; import { extractImagePaths, removeImagePathsFromText } from '../../utils/imageUtils'; +import { Message } from '../../api'; /** * Get tool responses map from messages @@ -111,12 +111,7 @@ export const SessionMessages: React.FC = ({ ) : messages?.length > 0 ? ( messages .map((message, index) => { - // Extract text content from the message - let textContent = message.content - .filter((c): c is TextContent => c.type === 'text') - .map((c) => c.text) - .join('\n'); - + const textContent = getTextContent(message); // Extract image paths from the message const imagePaths = extractImagePaths(textContent); diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index dc588c1fa151..ecffcee8a992 100644 --- a/ui/desktop/src/hooks/useAgent.ts +++ b/ui/desktop/src/hooks/useAgent.ts @@ -6,7 +6,6 @@ import { initializeCostDatabase } from '../utils/costDatabase'; import { backupConfig, initConfig, - Message as ApiMessage, readAllConfig, Recipe, recoverConfig, @@ -15,7 +14,6 @@ import { validateConfig, } from '../api'; import { COST_TRACKING_ENABLED } from '../updates'; -import { convertApiMessageToFrontendMessage } from '../components/context_management'; export enum AgentState { UNINITIALIZED = 'uninitialized', @@ -78,9 +76,7 @@ export function useAgent(): UseAgentReturn { sessionId: agentSession.id, title: agentSession.recipe?.title || agentSession.description, messageHistoryIndex: 0, - messages: messages?.map((message: ApiMessage) => - convertApiMessageToFrontendMessage(message) - ), + messages, recipe: agentSession.recipe, recipeParameters: agentSession.user_recipe_values || null, }; @@ -160,13 +156,7 @@ export function useAgent(): UseAgentReturn { const conversation = agentSession.conversation || []; // If we're loading a recipe from initContext (new recipe load), start with empty messages // Otherwise, use the messages from the session - const messages = - initContext.recipe && !initContext.resumeSessionId - ? [] - : conversation.map((message: ApiMessage) => - convertApiMessageToFrontendMessage(message) - ); - + const messages = initContext.recipe && !initContext.resumeSessionId ? [] : conversation; let initChat: ChatType = { sessionId: agentSession.id, title: agentSession.recipe?.title || agentSession.description, diff --git a/ui/desktop/src/hooks/useChatEngine.test.ts b/ui/desktop/src/hooks/useChatEngine.test.ts index 068ebaeb0521..198b37625e8d 100644 --- a/ui/desktop/src/hooks/useChatEngine.test.ts +++ b/ui/desktop/src/hooks/useChatEngine.test.ts @@ -1,12 +1,13 @@ /** * @vitest-environment jsdom */ -import { renderHook, act } from '@testing-library/react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import type { Mock } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useChatEngine } from './useChatEngine'; -import { Message, getTextContent } from '../types/message'; +import { getTextContent } from '../types/message'; +import { Message } from '../api'; import { ChatType } from '../types/chat'; -import type { Mock } from 'vitest'; // Mock the useMessageStream hook which is a dependency of useChatEngine vi.mock('./useMessageStream', () => ({ diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts index f0dd7fc38170..6e99a519ad59 100644 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -2,20 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { getApiUrl } from '../config'; import { useMessageStream } from './useMessageStream'; import { LocalMessageStorage } from '../utils/localMessageStorage'; -import { - Message, - createUserMessage, - ToolCall, - ToolCallResult, - ToolRequestMessageContent, - ToolResponseMessageContent, - ToolConfirmationRequestMessageContent, - getTextContent, - TextContent, -} from '../types/message'; +import { createUserMessage, getTextContent, ToolResponseMessageContent } from '../types/message'; +import { getSession, Message } from '../api'; import { ChatType } from '../types/chat'; import { ChatState } from '../types/chatState'; -import { getSession } from '../api'; // Helper function to determine if a message is a user message const isUserMessage = (message: Message): boolean => { @@ -308,11 +298,7 @@ export const useChatEngine = ({ // isUserMessage also checks if the message is a toolConfirmationRequest // check if the last message is a real user's message if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) { - // Get the text content from the last message before removing it - const textContent = lastMessage.content.find((c): c is TextContent => c.type === 'text'); - const textValue = textContent?.text || ''; - - // Set the text back to the input field + const textValue = getTextContent(lastMessage); _setInput(textValue); // Also add to local storage history as a backup so cmd+up can retrieve it @@ -327,19 +313,15 @@ export const useChatEngine = ({ setMessages([]); } } else if (!isUserMessage(lastMessage)) { - // the last message was an assistant message - // check if we have any tool requests or tool confirmation requests - const toolRequests: [string, ToolCallResult][] = lastMessage.content + const toolRequests: [string, Record][] = lastMessage.content .filter( - (content): content is ToolRequestMessageContent | ToolConfirmationRequestMessageContent => - content.type === 'toolRequest' || content.type === 'toolConfirmationRequest' + (content) => content.type === 'toolRequest' || content.type === 'toolConfirmationRequest' ) .map((content) => { if (content.type === 'toolRequest') { return [content.id, content.toolCall]; } else { - // extract tool call from confirmation - const toolCall: ToolCallResult = { + const toolCall = { status: 'success', value: { name: content.toolName, @@ -391,8 +373,7 @@ export const useChatEngine = ({ return filteredMessages .reduce((history, message) => { if (isUserMessage(message)) { - const textContent = message.content.find((c): c is TextContent => c.type === 'text'); - const text = textContent?.text?.trim(); + const text = getTextContent(message).trim(); if (text) { history.push(text); } diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts new file mode 100644 index 000000000000..7d5c321c55f4 --- /dev/null +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -0,0 +1,114 @@ +import { useState, useCallback, useRef } from 'react'; +import { ChatState } from '../types/chatState'; +import { Message } from '../api'; + +const TextDecoder = globalThis.TextDecoder; + +interface UseChatStreamProps { + sessionId: string; + messages: Message[]; + setMessages: (messages: Message[]) => void; + onStreamFinish?: () => void; +} + +export function useChatStream({ + sessionId, + messages, + setMessages, + onStreamFinish, +}: UseChatStreamProps) { + const [chatState, setChatState] = useState(ChatState.Idle); + const abortControllerRef = useRef(null); + + const handleSubmit = useCallback( + async (userMessage: string) => { + const newMessage: Message = { + role: 'user', + content: [{ type: 'text', text: userMessage }], + created: Date.now(), + }; + + const updatedMessages = [...messages, newMessage]; + setMessages(updatedMessages); + setChatState(ChatState.Streaming); + + abortControllerRef.current = new AbortController(); + + try { + const response = await fetch('/reply', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + session_id: sessionId, + messages: updatedMessages.map((m) => ({ + role: m.role, + content: m.content, + })), + }), + signal: abortControllerRef.current.signal, + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (!response.body) throw new Error('No response body'); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const event = JSON.parse(data); + + if (event.message) { + const msg = event.message as Message; + setMessages([...updatedMessages, msg]); + } + + if (event.error) { + console.error('Stream error:', event.error); + setChatState(ChatState.Idle); + return; + } + + if (event.finish) { + setChatState(ChatState.Idle); + onStreamFinish?.(); + return; + } + } catch (e) { + console.error('Failed to parse SSE:', e); + } + } + } + } catch (error: any) { + if (error.name !== 'AbortError') { + console.error('Stream error:', error); + } + setChatState(ChatState.Idle); + } + }, + [sessionId, messages, setMessages, onStreamFinish] + ); + + const stopStreaming = useCallback(() => { + abortControllerRef.current?.abort(); + setChatState(ChatState.Idle); + }, []); + + return { + chatState, + handleSubmit, + stopStreaming, + }; +} diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index 5058b7b23197..1b3781839e77 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'; import useSWR from 'swr'; -import { createUserMessage, hasCompletedToolCalls, Message, Role } from '../types/message'; +import { createUserMessage, hasCompletedToolCalls } from '../types/message'; +import { Message, Role } from '../api'; + import { getSession, Session } from '../api'; import { ChatState } from '../types/chatState'; diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index e95d6130731e..695e7751575f 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState, useRef } from 'react'; import { Recipe, scanRecipe } from '../recipe'; -import { Message, createUserMessage } from '../types/message'; +import { createUserMessage } from '../types/message'; +import { Message } from '../api'; + import { updateSystemPromptWithParameters, substituteParameters, diff --git a/ui/desktop/src/sharedSessions.ts b/ui/desktop/src/sharedSessions.ts index 2dd469aa2ad2..4e6b7184ca1e 100644 --- a/ui/desktop/src/sharedSessions.ts +++ b/ui/desktop/src/sharedSessions.ts @@ -1,5 +1,5 @@ -import { Message } from './types/message'; import { safeJsonParse } from './utils/jsonUtils'; +import { Message } from './api'; export interface SharedSessionDetails { share_token: string; diff --git a/ui/desktop/src/types/chat.ts b/ui/desktop/src/types/chat.ts index 006e943ffbdc..70130ebcf935 100644 --- a/ui/desktop/src/types/chat.ts +++ b/ui/desktop/src/types/chat.ts @@ -1,5 +1,5 @@ -import { Message } from './message'; import { Recipe } from '../recipe'; +import { Message } from '../api'; export interface ChatType { sessionId: string; diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index 578636d9c152..c5273c10c99d 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -1,116 +1,8 @@ -/** - * Message types that match the Rust message structures - * for direct serialization between client and server - */ +import { Content, Message, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api'; -export type Role = 'user' | 'assistant'; +export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' }; +export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' }; -export interface TextContent { - type: 'text'; - text: string; - annotations?: Record; -} - -export interface ImageContent { - type: 'image'; - data: string; - mimeType: string; - annotations?: Record; -} - -export interface ResourceContent { - type: 'resource'; - resource: { - uri: string; - mimeType: string; - text?: string; - blob?: string; - }; - annotations?: Record; -} - -export type Content = TextContent | ImageContent | ResourceContent; - -export interface ToolCall { - name: string; - arguments: Record; -} - -export interface ToolCallResult { - status: 'success' | 'error'; - value?: T; - error?: string; -} - -export interface ToolRequest { - id: string; - toolCall: ToolCallResult; -} - -export interface ToolResponse { - id: string; - toolResult: ToolCallResult; -} - -export interface ToolRequestMessageContent { - type: 'toolRequest'; - id: string; - toolCall: ToolCallResult; -} - -export interface ToolResponseMessageContent { - type: 'toolResponse'; - id: string; - toolResult: ToolCallResult; -} - -export interface ToolConfirmationRequestMessageContent { - type: 'toolConfirmationRequest'; - id: string; - toolName: string; - arguments: Record; - prompt?: string; -} - -export interface ExtensionCall { - name: string; - arguments: Record; - extensionName: string; -} - -export interface ExtensionCallResult { - status: 'success' | 'error'; - value?: T; - error?: string; -} - -export interface ContextLengthExceededContent { - type: 'contextLengthExceeded'; - msg: string; -} - -export interface SummarizationRequestedContent { - type: 'summarizationRequested'; - msg: string; -} - -export type MessageContent = - | TextContent - | ImageContent - | ToolRequestMessageContent - | ToolResponseMessageContent - | ToolConfirmationRequestMessageContent - | ContextLengthExceededContent - | SummarizationRequestedContent; - -export interface Message { - id?: string; - role: Role; - created: number; - content: MessageContent[]; -} - -// Helper functions to create messages export function createUserMessage(text: string): Message { return { id: generateId(), @@ -190,56 +82,42 @@ export function createToolErrorResponseMessage(id: string, error: string): Messa }; } -// Generate a unique ID for messages function generateId(): string { return Math.random().toString(36).substring(2, 10); } -// Helper functions to extract content from messages export function getTextContent(message: Message): string { return message.content - .filter( - (content): content is TextContent | ContextLengthExceededContent => - content.type === 'text' || content.type === 'contextLengthExceeded' - ) .map((content) => { - if (content.type === 'text') { - return content.text; - } else if (content.type === 'contextLengthExceeded') { - return content.msg; - } + if (content.type === 'text') return content.text; + if (content.type === 'contextLengthExceeded') return content.msg; return ''; }) .join(''); } -export function getToolRequests(message: Message): ToolRequestMessageContent[] { +export function getToolRequests(message: Message): (ToolRequest & { type: 'toolRequest' })[] { return message.content.filter( - (content): content is ToolRequestMessageContent => content.type === 'toolRequest' + (content): content is ToolRequest & { type: 'toolRequest' } => content.type === 'toolRequest' ); } -export function getToolResponses(message: Message): ToolResponseMessageContent[] { +export function getToolResponses(message: Message): (ToolResponse & { type: 'toolResponse' })[] { return message.content.filter( - (content): content is ToolResponseMessageContent => content.type === 'toolResponse' + (content): content is ToolResponse & { type: 'toolResponse' } => content.type === 'toolResponse' ); } export function getToolConfirmationContent( message: Message -): ToolConfirmationRequestMessageContent | undefined { +): (ToolConfirmationRequest & { type: 'toolConfirmationRequest' }) | undefined { return message.content.find( - (content): content is ToolConfirmationRequestMessageContent => + (content): content is ToolConfirmationRequest & { type: 'toolConfirmationRequest' } => content.type === 'toolConfirmationRequest' ); } export function hasCompletedToolCalls(message: Message): boolean { const toolRequests = getToolRequests(message); - if (toolRequests.length === 0) return false; - - // For now, we'll assume all tool calls are completed when this is checked - // In a real implementation, you'd need to check if all tool requests have responses - // by looking through subsequent messages - return true; + return toolRequests.length > 0; } diff --git a/ui/desktop/src/utils/timeUtils.ts b/ui/desktop/src/utils/timeUtils.ts index 2625e796d841..c7a9abb5ed62 100644 --- a/ui/desktop/src/utils/timeUtils.ts +++ b/ui/desktop/src/utils/timeUtils.ts @@ -1,6 +1,5 @@ -export function formatMessageTimestamp(timestamp: number): string { - // Convert from Unix timestamp (seconds) to milliseconds - const date = new Date(timestamp * 1000); +export function formatMessageTimestamp(timestamp?: number): string { + const date = timestamp ? new Date(timestamp * 1000) : new Date(); const now = new Date(); // Format time as HH:MM AM/PM diff --git a/ui/desktop/src/utils/toolCallChaining.ts b/ui/desktop/src/utils/toolCallChaining.ts index 80f36383cb22..7e5715f64258 100644 --- a/ui/desktop/src/utils/toolCallChaining.ts +++ b/ui/desktop/src/utils/toolCallChaining.ts @@ -1,4 +1,5 @@ -import { Message, getToolRequests, getTextContent, getToolResponses } from '../types/message'; +import { getToolRequests, getTextContent, getToolResponses } from '../types/message'; +import { Message } from '../api'; export function identifyConsecutiveToolCalls(messages: Message[]): number[][] { const chains: number[][] = []; From b2bc02862aecc5fb5b20782b6dbeef97a5906a7b Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 8 Oct 2025 10:56:47 -0400 Subject: [PATCH 3/8] step --- crates/goose/src/conversation/message.rs | 39 ++++++++----------- ui/desktop/openapi.json | 6 +-- .../src/components/ToolCallWithResponse.tsx | 22 ++++++++--- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index b5846d9b0fc8..8b01a438b181 100644 --- a/crates/goose/src/conversation/message.rs +++ b/crates/goose/src/conversation/message.rs @@ -13,6 +13,12 @@ use utoipa::ToSchema; use crate::conversation::tool_result_serde; use crate::utils::sanitize_unicode_tags; +#[derive(ToSchema)] +pub enum ToolCallResult { + Success { value: T }, + Error { error: String }, +} + /// Custom deserializer for MessageContent that sanitizes Unicode Tags in text content fn deserialize_sanitized_content<'de, D>(deserializer: D) -> Result, D::Error> where @@ -39,44 +45,28 @@ where Ok(content) } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)] -#[serde(tag = "status", rename_all = "lowercase")] -pub enum ToolCallResult { - Success { value: T }, - Error { error: String }, -} - -impl From> for ToolCallResult { - fn from(result: ToolResult) -> Self { - match result { - Ok(value) => ToolCallResult::Success { value }, - Err(error) => ToolCallResult::Error { - error: error.to_string(), - }, - } - } -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[derive(ToSchema)] pub struct ToolRequest { pub id: String, - pub tool_call: ToolCallResult, + #[serde(with = "tool_result_serde")] + #[schema(value_type = Object)] + pub tool_call: ToolResult, } impl ToolRequest { pub fn to_readable_string(&self) -> String { match &self.tool_call { - ToolCallResult::Success { value } => { + Ok(tool_call) => { format!( "Tool: {}, Args: {}", - value.name, - serde_json::to_string_pretty(&value.arguments) + tool_call.name, + serde_json::to_string_pretty(&tool_call.arguments) .unwrap_or_else(|_| "<>".to_string()) ) } - ToolCallResult::Error { error } => format!("Invalid tool call: {}", error), + Err(e) => format!("Invalid tool call: {}", e), } } } @@ -87,6 +77,7 @@ impl ToolRequest { pub struct ToolResponse { pub id: String, #[serde(with = "tool_result_serde")] + #[schema(value_type = Object)] pub tool_result: ToolResult>, } @@ -115,6 +106,8 @@ pub struct RedactedThinkingContent { #[serde(rename_all = "camelCase")] pub struct FrontendToolRequest { pub id: String, + #[serde(with = "tool_result_serde")] + #[schema(value_type = Object)] pub tool_call: ToolResult, } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index ca2674611d8c..a3e3ae4f9d33 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2843,7 +2843,7 @@ "type": "string" }, "toolCall": { - "$ref": "#/components/schemas/ToolResult" + "type": "object" } } }, @@ -4439,7 +4439,7 @@ "type": "string" }, "toolCall": { - "$ref": "#/components/schemas/ToolResult" + "type": "object" } } }, @@ -4454,7 +4454,7 @@ "type": "string" }, "toolResult": { - "$ref": "#/components/schemas/ToolResult" + "type": "object" } } }, diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index a2c5fc3bc689..c2f100e84fd6 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -12,7 +12,7 @@ import { ChevronRight, FlaskConical } from 'lucide-react'; import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper'; import MCPUIResourceRenderer from './MCPUIResourceRenderer'; import { isUIResource } from '@mcp-ui/client'; -import { Content } from '../api'; +import { Content, RawResource } from '../api'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; @@ -23,6 +23,17 @@ interface ToolCallWithResponseProps { append?: (value: string) => void; // Function to append messages to the chat } +function getToolResultValue(toolResult: Record): Content[] | null { + if ('value' in toolResult && Array.isArray(toolResult.value)) { + return toolResult.value as Content[]; + } + return null; +} + +function isRawResource(content: Content): content is RawResource { + return 'uri' in content && 'name' in content; +} + export default function ToolCallWithResponse({ isCancelledMessage, toolRequest, @@ -55,11 +66,10 @@ export default function ToolCallWithResponse({
{/* MCP UI — Inline */} {toolResponse?.toolResult && - Array.isArray((toolResponse.toolResult as any).value) && - (toolResponse.toolResult as any).value.map((content: Content, index: number) => { - if (isUIResource(content)) { + getToolResultValue(toolResponse.toolResult)?.map((content: Content, index: number) => { + if (isRawResource(content) && isUIResource(content)) { return ( -
+
@@ -72,7 +82,7 @@ export default function ToolCallWithResponse({ } else { return null; } - })} + })}{' '} ); } From 54c50dd52b4d8c6ff4d4e9b8406890ad2c757255 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 8 Oct 2025 11:43:07 -0400 Subject: [PATCH 4/8] At least it compiles --- ui/desktop/src/components/BaseChat2.tsx | 81 ++++++------------- .../src/components/ToolCallWithResponse.tsx | 62 ++++++++------ 2 files changed, 62 insertions(+), 81 deletions(-) diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 5ffaa64ec607..fec42026d7d5 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -1,47 +1,4 @@ -/** - * BaseChat Component - * - * BaseChat is the foundational chat component that provides the core conversational interface - * for the Goose Desktop application. It serves as the shared base for both Hub and Pair components, - * offering a flexible and extensible chat experience. - * - * Key Responsibilities: - * - Manages the complete chat lifecycle (messages, input, submission, responses) - * - Handles file drag-and-drop functionality with preview generation - * - Integrates with multiple specialized hooks for chat engine, recipes, sessions, etc. - * - Provides context management and session summarization capabilities - * - Supports both user and assistant message rendering with tool call integration - * - Manages loading states, error handling, and retry functionality - * - Offers customization points through render props and configuration options - * - * Architecture: - * - Uses a provider pattern (ChatContextManagerProvider) for state management - * - Leverages composition through render props for flexible UI customization - * - Integrates with multiple custom hooks for separation of concerns: - * - useChatEngine: Core chat functionality and API integration - * - useRecipeManager: Recipe/agent configuration management - * - useFileDrop: Drag-and-drop file handling with previews - * - useCostTracking: Token usage and cost calculation - * - * Customization Points: - * - renderHeader(): Custom header content (used by Hub for insights/recipe controls) - * - renderBeforeMessages(): Content before message list (used by Hub for SessionInsights) - * - renderAfterMessages(): Content after message list - * - customChatInputProps: Props passed to ChatInput for specialized behavior - * - customMainLayoutProps: Props passed to MainPanelLayout - * - contentClassName: Custom CSS classes for the content area - * - * File Handling: - * - Supports drag-and-drop of files with visual feedback - * - Generates image previews for supported file types - * - Integrates dropped files with chat input for seamless attachment - * - Uses data-drop-zone="true" to designate safe drop areas - * - * The component is designed to be the single source of truth for chat functionality - * while remaining flexible enough to support different UI contexts (Hub vs Pair). - */ - -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; import LoadingGoose from './LoadingGoose'; @@ -115,17 +72,30 @@ function BaseChatContent({ // session: sessionMetadata, // }); + const [messages, setMessages] = useState(chat?.messages || []); + const { chatState, handleSubmit, stopStreaming } = useChatStream({ sessionId: chat?.sessionId || '', messages, - setMessages: (newMessages) => { - if (chat) { - setChat({ ...chat, messages: newMessages }); - } - }, - onStreamFinish: onMessageStreamFinish, + setMessages, + onStreamFinish: () => {}, }); + const handleFormSubmit = (e: React.FormEvent) => { + const customEvent = e as unknown as CustomEvent; + const textValue = customEvent.detail?.value || ''; + + // if (recipe && textValue.trim()) { + // setHasStartedUsingRecipe(true); + // } + // + // if (onMessageSubmit && textValue.trim()) { + // onMessageSubmit(textValue); + // } + + handleSubmit(textValue); + }; + // TODO(Douwe): send this to the chatbox instead, possibly autosubmit? or backend const append = (_txt: string) => {}; @@ -139,7 +109,6 @@ function BaseChatContent({ // Track if this is the initial render for session resuming const initialRenderRef = useRef(true); - const messages = chat?.messages || []; const recipe = chat?.recipe; // Auto-scroll when messages are loaded (for session resuming) @@ -201,7 +170,7 @@ function BaseChatContent({ // setMessages(updatedMessages); // }} isUserMessage={(m: Message) => m.role === 'user'} - // isStreamingMessage={chatState !== ChatState.Idle} + isStreamingMessage={chatState !== ChatState.Idle} // onMessageUpdate={onMessageUpdate} onRenderingComplete={handleRenderingComplete} /> @@ -344,7 +313,7 @@ function BaseChatContent({ ? 'goose is compacting the conversation...' : undefined } - chatState={ChatState.Idle} + chatState={chatState} />
)} @@ -355,9 +324,9 @@ function BaseChatContent({ > {}} - chatState={ChatState.Idle} - //onStop={onStopGoose} + handleSubmit={handleFormSubmit} + chatState={chatState} + onStop={stopStreaming} //commandHistory={commandHistory} initialValue={initialPrompt} setView={setView} diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index c2f100e84fd6..744a69714269 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -12,7 +12,7 @@ import { ChevronRight, FlaskConical } from 'lucide-react'; import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper'; import MCPUIResourceRenderer from './MCPUIResourceRenderer'; import { isUIResource } from '@mcp-ui/client'; -import { Content, RawResource } from '../api'; +import { Content, EmbeddedResource } from '../api'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; @@ -30,8 +30,8 @@ function getToolResultValue(toolResult: Record): Content[] | nu return null; } -function isRawResource(content: Content): content is RawResource { - return 'uri' in content && 'name' in content; +function isEmbeddedResource(content: Content): content is EmbeddedResource { + return 'resource' in content && typeof (content as Record).resource === 'object'; } export default function ToolCallWithResponse({ @@ -66,11 +66,14 @@ export default function ToolCallWithResponse({
{/* MCP UI — Inline */} {toolResponse?.toolResult && - getToolResultValue(toolResponse.toolResult)?.map((content: Content, index: number) => { - if (isRawResource(content) && isUIResource(content)) { + getToolResultValue(toolResponse.toolResult)?.map((content, index) => { + const resourceContent = isEmbeddedResource(content) + ? { ...content, type: 'resource' as const } + : null; + if (resourceContent && isUIResource(resourceContent)) { return (
- +
@@ -82,7 +85,7 @@ export default function ToolCallWithResponse({ } else { return null; } - })}{' '} + })} ); } @@ -223,7 +226,9 @@ function ToolCallView({ ? shouldShowAsComplete ? 'success' : 'loading' - : (toolResponse.toolResult as any).status || 'success'; + : (toolResponse.toolResult as Record).status === 'error' + ? 'error' + : 'success'; // Tool call timing tracking const [startTime, setStartTime] = useState(null); @@ -559,7 +564,16 @@ interface ToolResultViewProps { } function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { - const resultAny = result as any; + const hasText = (c: Content): c is Content & { text: string } => + 'text' in c && typeof (c as Record).text === 'string'; + + const hasImage = (c: Content): c is Content & { data: string; mimeType: string } => { + if (!('data' in c && 'mimeType' in c)) return false; + const mimeType = (c as Record).mimeType; + return typeof mimeType === 'string' && mimeType.startsWith('image'); + }; + + const hasResource = (c: Content): c is Content & { resource: unknown } => 'resource' in c; return (
- {'text' in resultAny && resultAny.text && ( + {hasText(result) && ( )} - {'mimeType' in resultAny && - 'data' in resultAny && - resultAny.mimeType?.startsWith('image') && ( - Tool result { - console.error('Failed to load image'); - e.currentTarget.style.display = 'none'; - }} - /> - )} - {'resource' in resultAny && ( + {hasImage(result) && ( + Tool result { + console.error('Failed to load image'); + e.currentTarget.style.display = 'none'; + }} + /> + )} + {hasResource(result) && (
{JSON.stringify(result, null, 2)}
)}
From 49560f45e8608f3f4fc519684ddeae0102c6a196 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 8 Oct 2025 13:06:21 -0400 Subject: [PATCH 5/8] make it stream --- ui/desktop/src/App.tsx | 17 ++++++++- ui/desktop/src/components/BaseChat2.tsx | 1 + ui/desktop/src/hooks/useChatStream.ts | 48 +++++++++++++++++++------ ui/desktop/src/updates.ts | 2 ++ 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index b6c2475f02f2..dac9226eee3d 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -44,6 +44,8 @@ import { useAgent, } from './hooks/useAgent'; import { useNavigation } from './hooks/useNavigation'; +import { USE_NEW_CHAT } from './updates'; +import Pair2 from './components/Pair2'; // Route Components const HubRouteWrapper = ({ @@ -93,7 +95,20 @@ const PairRouteWrapper = ({ const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; - return ( + return USE_NEW_CHAT ? ( + + ) : ( +

Warning: BaseChat2!

void; } +function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] { + const lastMsg = currentMessages[currentMessages.length - 1]; + + if (lastMsg?.id && lastMsg.id === incomingMsg.id) { + const lastContent = lastMsg.content[lastMsg.content.length - 1]; + const newContent = incomingMsg.content[incomingMsg.content.length - 1]; + + if ( + lastContent?.type === 'text' && + newContent?.type === 'text' && + incomingMsg.content.length === 1 + ) { + lastContent.text += newContent.text; + } else { + lastMsg.content.push(...incomingMsg.content); + } + return [...currentMessages]; + } else { + return [...currentMessages, incomingMsg]; + } +} + export function useChatStream({ sessionId, messages, @@ -28,22 +51,24 @@ export function useChatStream({ created: Date.now(), }; - const updatedMessages = [...messages, newMessage]; - setMessages(updatedMessages); + let currentMessages = [...messages, newMessage]; + setMessages(currentMessages); setChatState(ChatState.Streaming); abortControllerRef.current = new AbortController(); try { - const response = await fetch('/reply', { + // TODO(Douwe): this side steps our API. heyapi does support streaming though which should make + // this all nice & typed + const response = await fetch(getApiUrl('/reply'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': await window.electron.getSecretKey(), + }, body: JSON.stringify({ session_id: sessionId, - messages: updatedMessages.map((m) => ({ - role: m.role, - content: m.content, - })), + messages: currentMessages, }), signal: abortControllerRef.current.signal, }); @@ -72,7 +97,8 @@ export function useChatStream({ if (event.message) { const msg = event.message as Message; - setMessages([...updatedMessages, msg]); + currentMessages = pushMessage(currentMessages, msg); + setMessages(currentMessages); } if (event.error) { @@ -91,8 +117,8 @@ export function useChatStream({ } } } - } catch (error: any) { - if (error.name !== 'AbortError') { + } catch (error) { + if (error instanceof Error && error.name !== 'AbortError') { console.error('Stream error:', error); } setChatState(ChatState.Idle); diff --git a/ui/desktop/src/updates.ts b/ui/desktop/src/updates.ts index 978b7624298d..f98cbfc77d8c 100644 --- a/ui/desktop/src/updates.ts +++ b/ui/desktop/src/updates.ts @@ -2,3 +2,5 @@ export const UPDATES_ENABLED = true; export const COST_TRACKING_ENABLED = true; export const ANNOUNCEMENTS_ENABLED = false; export const CONFIGURATION_ENABLED = true; + +export const USE_NEW_CHAT = true; From 3ebe311f8b6b405c0fe6b030d46c11ff8edc00a1 Mon Sep 17 00:00:00 2001 From: Douwe Osinga Date: Wed, 8 Oct 2025 15:13:16 -0400 Subject: [PATCH 6/8] remove now duped --- ui/desktop/src/components/BaseChat2.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 9acb4467c7dd..be51b70ddbfb 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -126,13 +126,6 @@ function BaseChatContent({ } }, [messages.length]); - // Handle submit - // const handleSubmit = (e: React.FormEvent) => { - // const customEvent = e as unknown as CustomEvent; - // const combinedTextFromInput = customEvent.detail?.value || ''; - // engineHandleSubmit(combinedTextFromInput); - // }; - //const toolCount = useToolCount(chat.sessionId); // Wrapper for append that tracks recipe usage From 06231fbb71dd30502e826edbac7fe41c720608dd Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:10:40 -0700 Subject: [PATCH 7/8] Clean room cleanup and resume chat in same window (#5083) --- ui/desktop/src/App.tsx | 3 +- ui/desktop/src/components/BaseChat2.tsx | 7 + ui/desktop/src/components/Pair2.tsx | 1 - .../src/components/ToolCallWithResponse.tsx | 13 +- .../__tests__/ContextManager.test.tsx | 182 +++++++++--------- .../components/sessions/SessionsInsights.tsx | 4 +- ui/desktop/src/sessions.ts | 24 ++- ui/desktop/src/updates.ts | 2 - 8 files changed, 124 insertions(+), 112 deletions(-) diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index dac9226eee3d..19c61da947bf 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -44,7 +44,6 @@ import { useAgent, } from './hooks/useAgent'; import { useNavigation } from './hooks/useNavigation'; -import { USE_NEW_CHAT } from './updates'; import Pair2 from './components/Pair2'; // Route Components @@ -95,7 +94,7 @@ const PairRouteWrapper = ({ const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; - return USE_NEW_CHAT ? ( + return process.env.ALPHA ? ( { + if (chat?.messages) { + setMessages(chat.messages); + } + }, [chat?.messages, chat?.sessionId]); + const { chatState, handleSubmit, stopStreaming } = useChatStream({ sessionId: chat?.sessionId || '', messages, diff --git a/ui/desktop/src/components/Pair2.tsx b/ui/desktop/src/components/Pair2.tsx index c7bb1b220acc..bef51b4148f6 100644 --- a/ui/desktop/src/components/Pair2.tsx +++ b/ui/desktop/src/components/Pair2.tsx @@ -48,7 +48,6 @@ export default function Pair({ return prev; }); } catch (error) { - console.log(error); setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`); } }; diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 744a69714269..5bd6f0be8e14 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -42,8 +42,15 @@ export default function ToolCallWithResponse({ isStreamingMessage, append, }: ToolCallWithResponseProps) { - const toolCall = toolRequest.toolCall as { name: string; arguments: Record }; - if (!toolCall) { + // Handle both the wrapped ToolResult format and the unwrapped format + // The server serializes ToolResult as { status: "success", value: T } or { status: "error", error: string } + const toolCallData = toolRequest.toolCall as Record; + const toolCall = + toolCallData?.status === 'success' + ? (toolCallData.value as { name: string; arguments: Record }) + : (toolCallData as { name: string; arguments: Record }); + + if (!toolCall || !toolCall.name) { return null; } @@ -215,7 +222,7 @@ function ToolCallView({ } })(); - const isToolDetails = Object.entries(toolCall?.arguments).length > 0; + const isToolDetails = toolCall?.arguments && Object.entries(toolCall.arguments).length > 0; // Check if streaming has finished but no tool response was received // This is a workaround for cases where the backend doesn't send tool responses diff --git a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx index fe2f4134dc32..dcddf8137c4e 100644 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx @@ -7,7 +7,6 @@ import { ContextManageResponse, Message } from '../../../api'; // Mock the context management functions vi.mock('../index', () => ({ manageContextFromBackend: vi.fn(), - convertApiMessageToFrontendMessage: vi.fn(), })); const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend); @@ -28,13 +27,6 @@ describe('ContextManager', () => { }, ]; - const mockSummaryMessage: Message = { - id: 'summary-1', - role: 'assistant', - created: 3000, - content: [{ type: 'text', text: 'This is a summary of the conversation.' }], - }; - const mockSetMessages = vi.fn(); const mockAppend = vi.fn(); @@ -109,6 +101,7 @@ describe('ContextManager', () => { describe('handleAutoCompaction', () => { it('should successfully perform auto compaction with server-provided messages', async () => { // Mock the backend response with 3 messages: marker, summary, continuation + // Note: Server messages may not have id/created, which will be added by the code mockManageContextFromBackend.mockResolvedValue({ messages: [ { @@ -116,11 +109,11 @@ describe('ContextManager', () => { content: [ { type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }, ], - }, + } as Message, { role: 'assistant', content: [{ type: 'text', text: 'Summary content' }], - }, + } as Message, { role: 'assistant', content: [ @@ -129,30 +122,11 @@ describe('ContextManager', () => { text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', }, ], - }, + } as Message, ], tokenCounts: [8, 100, 50], }); - const mockCompactionMarker: Message = { - id: 'marker-1', - role: 'assistant', - created: 3000, - content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }], - }; - - const mockContinuationMessage: Message = { - id: 'continuation-1', - role: 'assistant', - created: 3000, - content: [ - { - type: 'text', - text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', - }, - ], - }; - const { result } = renderContextManager(); await act(async () => { @@ -170,12 +144,28 @@ describe('ContextManager', () => { sessionId: 'test-session-id', }); - // Expect setMessages to be called with all 3 converted messages - expect(mockSetMessages).toHaveBeenCalledWith([ - mockCompactionMarker, - mockSummaryMessage, - mockContinuationMessage, - ]); + // Expect setMessages to be called with all 3 messages from server + // Note: Server doesn't provide id/created fields, so we don't check for them + expect(mockSetMessages).toHaveBeenCalledTimes(1); + const setMessagesCall = mockSetMessages.mock.calls[0][0]; + expect(setMessagesCall).toHaveLength(3); + expect(setMessagesCall[0]).toMatchObject({ + role: 'assistant', + content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }], + }); + expect(setMessagesCall[1]).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'Summary content' }], + }); + expect(setMessagesCall[2]).toMatchObject({ + role: 'assistant', + content: [ + { + type: 'text', + text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', + }, + ], + }); // Fast-forward timers to trigger the append call act(() => { @@ -184,7 +174,16 @@ describe('ContextManager', () => { // Should append the continuation message (index 2) for auto-compaction expect(mockAppend).toHaveBeenCalledTimes(1); - expect(mockAppend).toHaveBeenCalledWith(mockContinuationMessage); + const appendedMessage = mockAppend.mock.calls[0][0]; + expect(appendedMessage).toMatchObject({ + role: 'assistant', + content: [ + { + type: 'text', + text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', + }, + ], + }); }); it('should handle compaction errors gracefully', async () => { @@ -324,25 +323,6 @@ describe('ContextManager', () => { tokenCounts: [8, 100, 50], }); - const mockCompactionMarker: Message = { - id: 'marker-1', - role: 'assistant', - created: 3000, - content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }], - }; - - const mockContinuationMessage: Message = { - id: 'continuation-1', - role: 'assistant', - created: 3000, - content: [ - { - type: 'text', - text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', - }, - ], - }; - const { result } = renderContextManager(); await act(async () => { @@ -361,11 +341,26 @@ describe('ContextManager', () => { }); // Verify all three messages are set - expect(mockSetMessages).toHaveBeenCalledWith([ - mockCompactionMarker, - mockSummaryMessage, - mockContinuationMessage, - ]); + expect(mockSetMessages).toHaveBeenCalledTimes(1); + const setMessagesCall = mockSetMessages.mock.calls[0][0]; + expect(setMessagesCall).toHaveLength(3); + expect(setMessagesCall[0]).toMatchObject({ + role: 'assistant', + content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }], + }); + expect(setMessagesCall[1]).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'Manual summary content' }], + }); + expect(setMessagesCall[2]).toMatchObject({ + role: 'assistant', + content: [ + { + type: 'text', + text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', + }, + ], + }); // Fast-forward timers to check if append would be called act(() => { @@ -435,25 +430,6 @@ describe('ContextManager', () => { tokenCounts: [8, 100, 50], }); - const mockCompactionMarker: Message = { - id: 'marker-1', - role: 'assistant', - created: 3000, - content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }], - }; - - const mockContinuationMessage: Message = { - id: 'continuation-1', - role: 'assistant', - created: 3000, - content: [ - { - type: 'text', - text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', - }, - ], - }; - const { result } = renderContextManager(); await act(async () => { @@ -466,11 +442,26 @@ describe('ContextManager', () => { }); // Verify all three messages are set - expect(mockSetMessages).toHaveBeenCalledWith([ - mockCompactionMarker, - mockSummaryMessage, - mockContinuationMessage, - ]); + expect(mockSetMessages).toHaveBeenCalledTimes(1); + const setMessagesCall = mockSetMessages.mock.calls[0][0]; + expect(setMessagesCall).toHaveLength(3); + expect(setMessagesCall[0]).toMatchObject({ + role: 'assistant', + content: [{ type: 'summarizationRequested', msg: 'Conversation compacted and summarized' }], + }); + expect(setMessagesCall[1]).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'Manual summary content' }], + }); + expect(setMessagesCall[2]).toMatchObject({ + role: 'assistant', + content: [ + { + type: 'text', + text: 'The previous message contains a summary that was prepared because a context limit was reached. Do not mention that you read a summary or that conversation summarization occurred Just continue the conversation naturally based on the summarized context', + }, + ], + }); // Fast-forward timers to check if append would be called act(() => { @@ -508,18 +499,11 @@ describe('ContextManager', () => { content: [ { type: 'toolResponse', id: 'test', toolResult: { content: 'Not text content' } }, ], - }, + } as Message, ], tokenCounts: [100, 50], }); - const mockMessageWithoutText: Message = { - id: 'summary-1', - role: 'assistant', - created: 3000, - content: [{ type: 'toolResponse', id: 'test', toolResult: { status: 'success' } }], - }; - const { result } = renderContextManager(); await act(async () => { @@ -535,8 +519,16 @@ describe('ContextManager', () => { expect(result.current.isCompacting).toBe(false); expect(result.current.compactionError).toBe(null); - // Should still set messages with the converted message - expect(mockSetMessages).toHaveBeenCalledWith([mockMessageWithoutText]); + // Should still set messages from server + expect(mockSetMessages).toHaveBeenCalledTimes(1); + const setMessagesCall = mockSetMessages.mock.calls[0][0]; + expect(setMessagesCall).toHaveLength(1); + expect(setMessagesCall[0]).toMatchObject({ + role: 'assistant', + content: [ + { type: 'toolResponse', id: 'test', toolResult: { content: 'Not text content' } }, + ], + }); }); }); diff --git a/ui/desktop/src/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx index 3de44e413cf4..9cb8b1174f76 100644 --- a/ui/desktop/src/components/sessions/SessionsInsights.tsx +++ b/ui/desktop/src/components/sessions/SessionsInsights.tsx @@ -86,7 +86,9 @@ export function SessionInsights() { const handleSessionClick = async (session: Session) => { try { - resumeSession(session); + resumeSession(session, (sessionId: string) => { + navigate(`/pair?resumeSessionId=${sessionId}`); + }); } catch (error) { console.error('Failed to start session:', error); navigate('/sessions', { diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 5ea2413547e3..deca3b26da78 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,16 +1,24 @@ import { Session } from './api'; -export function resumeSession(session: Session) { - console.log('Launching session in new window:', session.description || session.id); +export function resumeSession( + session: Session, + navigateInSameWindow?: (sessionId: string) => void +) { const workingDir = session.working_dir; if (!workingDir) { throw new Error('Cannot resume session: working directory is missing in session'); } - window.electron.createChatWindow( - undefined, // query - workingDir, - undefined, // version - session.id - ); + // When ALPHA is true and we have a navigation callback, resume in the same window + // Otherwise, open in a new window (old behavior) + if (process.env.ALPHA && navigateInSameWindow) { + navigateInSameWindow(session.id); + } else { + window.electron.createChatWindow( + undefined, // query + workingDir, + undefined, // version + session.id + ); + } } diff --git a/ui/desktop/src/updates.ts b/ui/desktop/src/updates.ts index f98cbfc77d8c..978b7624298d 100644 --- a/ui/desktop/src/updates.ts +++ b/ui/desktop/src/updates.ts @@ -2,5 +2,3 @@ export const UPDATES_ENABLED = true; export const COST_TRACKING_ENABLED = true; export const ANNOUNCEMENTS_ENABLED = false; export const CONFIGURATION_ENABLED = true; - -export const USE_NEW_CHAT = true; From 67e9d7496ffe5deb30c2dabaddff9037c14fce44 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Thu, 9 Oct 2025 12:28:34 -0700 Subject: [PATCH 8/8] basecamp2 - Resume chat session from server with a cache (#5088) --- ui/desktop/src/App.tsx | 5 - ui/desktop/src/components/BaseChat2.tsx | 85 ++++++++++++++-- ui/desktop/src/components/Pair2.tsx | 44 +------- ui/desktop/src/utils/sessionCache.ts | 130 ++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 55 deletions(-) create mode 100644 ui/desktop/src/utils/sessionCache.ts diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 19c61da947bf..403a21af8bb1 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -99,10 +99,6 @@ const PairRouteWrapper = ({ chat={chat} setChat={setChat} setView={setView} - agentState={agentState} - loadCurrentChat={loadCurrentChat} - setFatalError={setFatalError} - setAgentWaitingMessage={setAgentWaitingMessage} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} resumeSessionId={resumeSessionId} initialMessage={initialMessage} @@ -346,7 +342,6 @@ export function AppInner() { setAgentWaitingMessage, setIsExtensionsLoading, }); - // Update the chat state with the loaded session to ensure sessionId is available globally setChat(loadedChat); } catch (e) { if (e instanceof NoProviderOrModelError) { diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx index 12de9f437dea..cdf5292fd089 100644 --- a/ui/desktop/src/components/BaseChat2.tsx +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -10,16 +10,17 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; import { useFileDrop } from '../hooks/useFileDrop'; -import { Message } from '../api'; +import { Message, Session } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; import { useIsMobile } from '../hooks/use-mobile'; import { useSidebar } from './ui/sidebar'; import { cn } from '../utils'; import { useChatStream } from '../hooks/useChatStream'; +import { loadSession } from '../utils/sessionCache'; interface BaseChatProps { - chat: ChatType | null; + chat: ChatType; setChat: (chat: ChatType) => void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen?: (isOpen: boolean) => void; @@ -35,10 +36,12 @@ interface BaseChatProps { showPopularTopics?: boolean; suppressEmptyState?: boolean; autoSubmit?: boolean; + resumeSessionId?: string; // Optional session ID to resume on mount } function BaseChatContent({ chat, + setChat, setView, setIsGoosehintsModalOpen, renderHeader, @@ -47,6 +50,7 @@ function BaseChatContent({ customChatInputProps = {}, customMainLayoutProps = {}, disableSearch = false, + resumeSessionId, }: BaseChatProps) { const location = useLocation(); const scrollRef = useRef(null); @@ -72,17 +76,62 @@ function BaseChatContent({ // session: sessionMetadata, // }); - const [messages, setMessages] = useState(chat?.messages || []); + // Session loading state + const [sessionLoadError, setSessionLoadError] = useState(null); + const hasLoadedSessionRef = useRef(false); + + const [messages, setMessages] = useState(chat.messages || []); + + // Load session on mount if resumeSessionId is provided + useEffect(() => { + const needsLoad = resumeSessionId && !hasLoadedSessionRef.current; + + if (needsLoad) { + hasLoadedSessionRef.current = true; + setSessionLoadError(null); + + // Set chat to empty session to indicate loading state + // todo: set to null instead and handle that in other places + const emptyChat: ChatType = { + sessionId: resumeSessionId, + title: 'Loading...', + messageHistoryIndex: 0, + messages: [], + recipe: null, + recipeParameters: null, + }; + setChat(emptyChat); + + loadSession(resumeSessionId) + .then((session: Session) => { + const conversation = session.conversation || []; + const loadedChat: ChatType = { + sessionId: session.id, + title: session.description || 'Untitled Chat', + messageHistoryIndex: 0, + messages: conversation, + recipe: null, + recipeParameters: null, + }; + + setChat(loadedChat); + }) + .catch((error: Error) => { + const errorMessage = error.message || 'Failed to load session'; + setSessionLoadError(errorMessage); + }); + } + }, [resumeSessionId, setChat]); // Update messages when chat changes (e.g., when resuming a session) useEffect(() => { - if (chat?.messages) { + if (chat.messages) { setMessages(chat.messages); } - }, [chat?.messages, chat?.sessionId]); + }, [chat.messages, chat.sessionId]); const { chatState, handleSubmit, stopStreaming } = useChatStream({ - sessionId: chat?.sessionId || '', + sessionId: chat.sessionId || '', messages, setMessages, onStreamFinish: () => {}, @@ -236,9 +285,27 @@ function BaseChatContent({ {/*
*/} {/*)}*/} + {sessionLoadError && ( +
+
+

Failed to Load Session

+

{sessionLoadError}

+
+ +
+ )} + {/* Messages or Popular Topics */} { - !chat ? null : messages.length > 0 || recipe ? ( + messages.length > 0 || recipe ? ( <> {disableSearch ? ( renderProgressiveMessageList(chat) @@ -304,11 +371,11 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {(!chat || isCompacting) && ( + {(messages.length === 0 || isCompacting) && !sessionLoadError && (
void; setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; - setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; - setAgentWaitingMessage: (msg: string | null) => void; - agentState: AgentState; - loadCurrentChat: (context: InitializationContext) => Promise; } export default function Pair({ + chat, + setChat, setView, setIsGoosehintsModalOpen, - setFatalError, - setAgentWaitingMessage, - agentState, - loadCurrentChat, resumeSessionId, }: PairProps & PairRouteState) { - const [_searchParams, setSearchParams] = useSearchParams(); - const [chat, setChat] = useState(null); - - useEffect(() => { - const initializeFromState = async () => { - try { - const chat = await loadCurrentChat({ - resumeSessionId, - setAgentWaitingMessage, - }); - setChat(chat); - setSearchParams((prev) => { - prev.set('resumeSessionId', chat.sessionId); - return prev; - }); - } catch (error) { - setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`); - } - }; - initializeFromState(); - }, [ - agentState, - setChat, - setFatalError, - setAgentWaitingMessage, - loadCurrentChat, - resumeSessionId, - setSearchParams, - ]); - return ( ); } diff --git a/ui/desktop/src/utils/sessionCache.ts b/ui/desktop/src/utils/sessionCache.ts new file mode 100644 index 000000000000..b4a9ef7173ea --- /dev/null +++ b/ui/desktop/src/utils/sessionCache.ts @@ -0,0 +1,130 @@ +import { Session } from '../api'; +import { getApiUrl } from '../config'; + +/** + * In-memory cache for session data + * Maps session ID to Session object + */ +const sessionCache = new Map(); + +/** + * In-flight request tracking to prevent duplicate fetches + * Maps session ID to Promise of Session + */ +const inFlightRequests = new Map>(); + +/** + * Load a session from the server using the /agent/resume endpoint + * Implements caching to avoid redundant fetches + * + * @param sessionId - The unique identifier for the session + * @param forceRefresh - If true, bypass cache and fetch fresh data + * @returns Promise resolving to the Session object + * @throws Error if the request fails or session not found + */ +export async function loadSession(sessionId: string, forceRefresh = false): Promise { + if (!forceRefresh && sessionCache.has(sessionId)) { + return sessionCache.get(sessionId)!; + } + + if (inFlightRequests.has(sessionId)) { + return inFlightRequests.get(sessionId)!; + } + + const fetchPromise = (async () => { + try { + const url = getApiUrl('/agent/resume'); + const secretKey = await window.electron.getSecretKey(); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': secretKey, + }, + body: JSON.stringify({ + session_id: sessionId, + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`Failed to load session: HTTP ${response.status} - ${errorText}`); + } + + const session: Session = await response.json(); + sessionCache.set(sessionId, session); + + return session; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Error loading session ${sessionId}: ${error.message}`); + } + throw new Error(`Error loading session ${sessionId}: Unknown error`); + } finally { + inFlightRequests.delete(sessionId); + } + })(); + + inFlightRequests.set(sessionId, fetchPromise); + return fetchPromise; +} + +/** + * Clear a specific session from the cache + * Useful when a session has been updated and needs to be refetched + * + * @param sessionId - The unique identifier for the session to clear + */ +export function clearSessionCache(sessionId: string): void { + sessionCache.delete(sessionId); +} + +/** + * Clear all sessions from the cache + * Useful for logout or when switching contexts + */ +export function clearAllSessionCache(): void { + sessionCache.clear(); +} + +/** + * Check if a session is currently cached + * + * @param sessionId - The unique identifier for the session + * @returns true if the session is in cache, false otherwise + */ +export function isSessionCached(sessionId: string): boolean { + return sessionCache.has(sessionId); +} + +/** + * Get a session from cache without fetching + * Returns undefined if not cached + * + * @param sessionId - The unique identifier for the session + * @returns The cached Session object or undefined + */ +export function getCachedSession(sessionId: string): Session | undefined { + return sessionCache.get(sessionId); +} + +/** + * Preload a session into cache + * Useful when you already have session data from another source + * + * @param session - The Session object to cache + */ +export function preloadSession(session: Session): void { + sessionCache.set(session.id, session); +} + +/** + * Get the current cache size + * Useful for debugging and monitoring + * + * @returns The number of sessions currently cached + */ +export function getCacheSize(): number { + return sessionCache.size; +}