diff --git a/crates/goose/src/conversation/message.rs b/crates/goose/src/conversation/message.rs index b3c6ab6b7b..8b01a438b1 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 diff --git a/crates/goose/src/mcp_utils.rs b/crates/goose/src/mcp_utils.rs index 9f319e34a5..7360fa2b51 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/package-lock.json b/package-lock.json deleted file mode 100644 index 6deca24a71..0000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "goose", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index b6c2475f02..403a21af8b 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -44,6 +44,7 @@ import { useAgent, } from './hooks/useAgent'; import { useNavigation } from './hooks/useNavigation'; +import Pair2 from './components/Pair2'; // Route Components const HubRouteWrapper = ({ @@ -93,7 +94,16 @@ const PairRouteWrapper = ({ const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; - return ( + return process.env.ALPHA ? ( + + ) : ( (null); diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx new file mode 100644 index 0000000000..cdf5292fd0 --- /dev/null +++ b/ui/desktop/src/components/BaseChat2.tsx @@ -0,0 +1,463 @@ +import React, { useEffect, useRef, useState } 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, 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; + 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; + resumeSessionId?: string; // Optional session ID to resume on mount +} + +function BaseChatContent({ + chat, + setChat, + setView, + setIsGoosehintsModalOpen, + renderHeader, + renderBeforeMessages, + renderAfterMessages, + customChatInputProps = {}, + customMainLayoutProps = {}, + disableSearch = false, + resumeSessionId, +}: 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, + // }); + + // 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) { + setMessages(chat.messages); + } + }, [chat.messages, chat.sessionId]); + + const { chatState, handleSubmit, stopStreaming } = useChatStream({ + sessionId: chat.sessionId || '', + messages, + 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) => {}; + + 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 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]); + + //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 ( +
+

Warning: BaseChat2!

+ + {/* 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 || {}}*/} + {/* />*/} + {/*
*/} + {/*)}*/} + + {sessionLoadError && ( +
+
+

Failed to Load Session

+

{sessionLoadError}

+
+ +
+ )} + + {/* Messages or Popular Topics */} + { + 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 */} + {(messages.length === 0 || isCompacting) && !sessionLoadError && ( +
+ +
+ )} +
+ +
+ 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/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 1f36c5630d..3634b42157 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 cbc8aa6039..6c75bb03d1 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 3e5ad494a5..c73aab418b 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/Pair2.tsx b/ui/desktop/src/components/Pair2.tsx new file mode 100644 index 0000000000..fbec745714 --- /dev/null +++ b/ui/desktop/src/components/Pair2.tsx @@ -0,0 +1,35 @@ +import { View, ViewOptions } from '../utils/navigationUtils'; +import 'react-toastify/dist/ReactToastify.css'; + +import { ChatType } from '../types/chat'; +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; +} + +export default function Pair({ + chat, + setChat, + setView, + setIsGoosehintsModalOpen, + resumeSessionId, +}: PairProps & PairRouteState) { + return ( + + ); +} diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index 0b4e87f6dc..9bdd0ab689 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 ea02e343c4..a952c3771d 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 f58de292e8..ab0b080ed7 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 da52d2b00f..5bd6f0be8e 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, EmbeddedResource } from '../api'; interface ToolCallWithResponseProps { isCancelledMessage: boolean; @@ -22,16 +23,34 @@ 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 isEmbeddedResource(content: Content): content is EmbeddedResource { + return 'resource' in content && typeof (content as Record).resource === 'object'; +} + export default function ToolCallWithResponse({ isCancelledMessage, toolRequest, toolResponse, notifications, - isStreamingMessage = false, + isStreamingMessage, append, }: ToolCallWithResponseProps) { - const toolCall = toolRequest.toolCall.status === 'success' ? toolRequest.toolCall.value : null; - 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; } @@ -53,12 +72,15 @@ export default function ToolCallWithResponse({ /> {/* MCP UI — Inline */} - {toolResponse?.toolResult?.value && - toolResponse.toolResult.value.map((content, index) => { - if (isUIResource(content)) { + {toolResponse?.toolResult && + getToolResultValue(toolResponse.toolResult)?.map((content, index) => { + const resourceContent = isEmbeddedResource(content) + ? { ...content, type: 'resource' as const } + : null; + if (resourceContent && isUIResource(resourceContent)) { return ( -
- +
+
@@ -200,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 @@ -211,7 +233,9 @@ function ToolCallView({ ? shouldShowAsComplete ? 'success' : 'loading' - : toolResponse.toolResult.status; + : (toolResponse.toolResult as Record).status === 'error' + ? 'error' + : 'success'; // Tool call timing tracking const [startTime, setStartTime] = useState(null); @@ -547,19 +571,30 @@ interface ToolResultViewProps { } function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) { + 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 ( Output} isStartExpanded={isStartExpanded} >
- {result.type === 'text' && result.text && ( + {hasText(result) && ( )} - {result.type === 'image' && ( + {hasImage(result) && ( Tool result )} - {result.type === 'resource' && ( + {hasResource(result) && (
{JSON.stringify(result, null, 2)}
)}
diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 036a7c5b55..1292d4fbeb 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 a08563855a..f453aa9b06 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 4d5ad9d9e1..54c606bd88 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 8e1691ba24..3244bbdfce 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 53ac40b7a0..dcddf8137c 100644 --- a/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx +++ b/ui/desktop/src/components/context_management/__tests__/ContextManager.test.tsx @@ -1,20 +1,15 @@ 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', () => ({ manageContextFromBackend: vi.fn(), - convertApiMessageToFrontendMessage: vi.fn(), })); const mockManageContextFromBackend = vi.mocked(contextManagement.manageContextFromBackend); -const mockConvertApiMessageToFrontendMessage = vi.mocked( - contextManagement.convertApiMessageToFrontendMessage -); describe('ContextManager', () => { const mockMessages: Message[] = [ @@ -32,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(); @@ -113,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: [ { @@ -120,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: [ @@ -133,36 +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', - }, - ], - }; - - // 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,39 +144,28 @@ 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, - 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(() => { @@ -221,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 () => { @@ -290,8 +252,6 @@ describe('ContextManager', () => { tokenCounts: [100, 50], }); - mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage); - await act(async () => { await promise; }); @@ -363,30 +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', - }, - ], - }; - - mockConvertApiMessageToFrontendMessage - .mockReturnValueOnce(mockCompactionMarker) - .mockReturnValueOnce(mockSummaryMessage) - .mockReturnValueOnce(mockContinuationMessage); - const { result } = renderContextManager(); await act(async () => { @@ -405,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(() => { @@ -431,8 +382,6 @@ describe('ContextManager', () => { tokenCounts: [100, 50], }); - mockConvertApiMessageToFrontendMessage.mockReturnValue(mockSummaryMessage); - const { result } = renderContextManager(); await act(async () => { @@ -481,30 +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', - }, - ], - }; - - mockConvertApiMessageToFrontendMessage - .mockReturnValueOnce(mockCompactionMarker) - .mockReturnValueOnce(mockSummaryMessage) - .mockReturnValueOnce(mockContinuationMessage); - const { result } = renderContextManager(); await act(async () => { @@ -517,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(() => { @@ -559,20 +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' } }], - }; - - mockConvertApiMessageToFrontendMessage.mockReturnValue(mockMessageWithoutText); - const { result } = renderContextManager(); await act(async () => { @@ -588,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/context_management/index.ts b/ui/desktop/src/components/context_management/index.ts index c54d95eb0f..c4f7062196 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.` - ); - } -} - -// 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, - }; + // Check for errors in the result + if (result.error) { + throw new Error(`Context management failed: ${result.error}`); } - // 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, - }, - ], - }; + if (!result.data) { + throw new Error('Context management returned no data'); + } + + return result.data; } diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 781e42d841..4d297ae9d1 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 efd217ca05..e9a6fe450a 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/components/sessions/SessionsInsights.tsx b/ui/desktop/src/components/sessions/SessionsInsights.tsx index 3de44e413c..9cb8b1174f 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/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts index 6c7f0d4e0d..cb9716cfce 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', @@ -77,9 +75,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, }; @@ -159,13 +155,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 068ebaeb05..198b37625e 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 { useChatEngine } from './useChatEngine'; -import { Message, getTextContent } from '../types/message'; -import { ChatType } from '../types/chat'; +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 { getTextContent } from '../types/message'; +import { Message } from '../api'; +import { ChatType } from '../types/chat'; // 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 f0dd7fc381..6e99a519ad 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 0000000000..6534880430 --- /dev/null +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -0,0 +1,140 @@ +import { useState, useCallback, useRef } from 'react'; +import { ChatState } from '../types/chatState'; +import { Message } from '../api'; +import { getApiUrl } from '../config'; + +const TextDecoder = globalThis.TextDecoder; + +interface UseChatStreamProps { + sessionId: string; + messages: Message[]; + setMessages: (messages: Message[]) => void; + onStreamFinish?: () => 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, + 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(), + }; + + let currentMessages = [...messages, newMessage]; + setMessages(currentMessages); + setChatState(ChatState.Streaming); + + abortControllerRef.current = new AbortController(); + + try { + // 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', + 'X-Secret-Key': await window.electron.getSecretKey(), + }, + body: JSON.stringify({ + session_id: sessionId, + messages: currentMessages, + }), + 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; + currentMessages = pushMessage(currentMessages, msg); + setMessages(currentMessages); + } + + 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) { + if (error instanceof Error && 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 5058b7b231..1b3781839e 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 5abf9f47df..5758d97532 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/sessions.ts b/ui/desktop/src/sessions.ts index 5ea2413547..deca3b26da 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/sharedSessions.ts b/ui/desktop/src/sharedSessions.ts index 2dd469aa2a..4e6b7184ca 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 006e943ffb..70130ebcf9 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 578636d9c1..c5273c10c9 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/sessionCache.ts b/ui/desktop/src/utils/sessionCache.ts new file mode 100644 index 0000000000..b4a9ef7173 --- /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; +} diff --git a/ui/desktop/src/utils/timeUtils.ts b/ui/desktop/src/utils/timeUtils.ts index 2625e796d8..c7a9abb5ed 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 80f36383cb..7e5715f642 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[][] = [];