Clean room implementation of the chat process (#5079)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
This commit is contained in:
Douwe Osinga
2025-10-11 10:45:16 -04:00
committed by GitHub
parent 73f109237e
commit 0c2127124f
36 changed files with 1024 additions and 541 deletions
+1 -1
View File
@@ -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);
+463
View File
@@ -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<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
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<ScrollAreaHandle>(null);
const disableAnimation = location.state?.disableAnimation || false;
// const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
// const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(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<string | null>(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) => (
<>
<ProgressiveMessageList
messages={messages}
chat={chat}
// toolCallNotifications={toolCallNotifications}
// appendMessage={(newMessage) => {
// 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 (
<div className="h-full flex flex-col min-h-0">
<h2>Warning: BaseChat2!</h2>
<MainPanelLayout
backgroundColor={'bg-background-muted'}
removeTopPadding={true}
{...customMainLayoutProps}
>
{/* Custom header */}
{renderHeader && renderHeader()}
{/* Chat container with sticky recipe header */}
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
<ScrollArea
ref={scrollRef}
className={`flex-1 bg-background-default rounded-b-2xl min-h-0 relative ${contentClassName}`}
autoScroll
onDrop={handleDrop}
onDragOver={handleDragOver}
data-drop-zone="true"
paddingX={6}
paddingY={0}
>
{/*/!* Recipe agent header - sticky at top of chat container *!/*/}
{/*{recipe?.title && (*/}
{/* <div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">*/}
{/* <AgentHeader*/}
{/* title={recipe.title}*/}
{/* profileInfo={*/}
{/* recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined*/}
{/* }*/}
{/* onChangeProfile={() => {*/}
{/* console.log('Change profile clicked');*/}
{/* }}*/}
{/* showBorder={true}*/}
{/* />*/}
{/* </div>*/}
{/*)}*/}
{/* Custom content before messages */}
{renderBeforeMessages && renderBeforeMessages()}
{/*/!* Recipe Activities - always show when recipe is active and accepted *!/*/}
{/*{recipe && recipeAccepted && !suppressEmptyState && (*/}
{/* <div className={hasStartedUsingRecipe ? 'mb-6' : ''}>*/}
{/* <RecipeActivities*/}
{/* append={(text: string) => appendWithTracking(text)}*/}
{/* activities={Array.isArray(recipe.activities) ? recipe.activities : null}*/}
{/* title={recipe.title}*/}
{/* parameterValues={recipeParameters || {}}*/}
{/* />*/}
{/* </div>*/}
{/*)}*/}
{sessionLoadError && (
<div className="flex flex-col items-center justify-center p-8">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
<p className="text-sm">{sessionLoadError}</p>
</div>
<button
onClick={() => {
setSessionLoadError(null);
hasLoadedSessionRef.current = false;
}}
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
>
Retry
</button>
</div>
)}
{/* Messages or Popular Topics */}
{
messages.length > 0 || recipe ? (
<>
{disableSearch ? (
renderProgressiveMessageList(chat)
) : (
// Render messages with SearchView wrapper when search is enabled
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
)}
{/*{error && (*/}
{/* <>*/}
{/* <div className="flex flex-col items-center justify-center p-4">*/}
{/* <div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">*/}
{/* {error.message || 'Honk! Goose experienced an error while responding'}*/}
{/* </div>*/}
{/* /!* Action buttons for all errors including token limit errors *!/*/}
{/* <div className="flex gap-2 mt-2">*/}
{/* <div*/}
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
{/* onClick={async () => {*/}
{/* clearError();*/}
{/* await handleManualCompaction(*/}
{/* messages,*/}
{/* setMessages,*/}
{/* append,*/}
{/* chat.sessionId*/}
{/* );*/}
{/* }}*/}
{/* >*/}
{/* Summarize Conversation*/}
{/* </div>*/}
{/* <div*/}
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
{/* onClick={async () => {*/}
{/* // 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*/}
{/* </div>*/}
{/* </div>*/}
{/* </div>*/}
{/* </>*/}
{/*)}*/}
<div className="block h-8" />
</>
) : !recipe && showPopularTopics ? (
/* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */
<PopularChatTopics append={(text: string) => append(text)} />
) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */
}
{/* Custom content after messages */}
{renderAfterMessages && renderAfterMessages()}
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{(messages.length === 0 || isCompacting) && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={
messages.length === 0
? 'loading conversation...'
: isCompacting
? 'goose is compacting the conversation...'
: undefined
}
chatState={chatState}
/>
</div>
)}
</div>
<div
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
sessionId={chat?.sessionId || ''}
handleSubmit={handleFormSubmit}
chatState={chatState}
onStop={stopStreaming}
//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}
/>
</div>
</MainPanelLayout>
{/*/!* Recipe Warning Modal *!/*/}
{/*<RecipeWarningModal*/}
{/* isOpen={isRecipeWarningModalOpen}*/}
{/* onConfirm={handleRecipeAccept}*/}
{/* onCancel={handleRecipeCancel}*/}
{/* recipeDetails={{*/}
{/* title: recipe?.title,*/}
{/* description: recipe?.description,*/}
{/* instructions: recipe?.instructions || undefined,*/}
{/* }}*/}
{/* hasSecurityWarnings={hasSecurityWarnings}*/}
{/*/>*/}
{/*/!* Recipe Parameter Modal *!/*/}
{/*{isParameterModalOpen && filteredParameters.length > 0 && (*/}
{/* <ParameterInputModal*/}
{/* parameters={filteredParameters}*/}
{/* onSubmit={handleParameterSubmit}*/}
{/* onClose={() => setIsParameterModalOpen(false)}*/}
{/* />*/}
{/*)}*/}
{/*/!* Create Recipe from Session Modal *!/*/}
{/*<CreateRecipeFromSessionModal*/}
{/* isOpen={isCreateRecipeModalOpen}*/}
{/* onClose={() => setIsCreateRecipeModalOpen(false)}*/}
{/* sessionId={chat.sessionId}*/}
{/* onRecipeCreated={handleRecipeCreated}*/}
{/*/>*/}
</div>
);
}
export default function BaseChat(props: BaseChatProps) {
return (
<ContextManagerProvider>
<BaseChatContent {...props} />
</ContextManagerProvider>
);
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
@@ -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: {
+35
View File
@@ -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 (
<BaseChat2
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
resumeSessionId={resumeSessionId}
/>
);
}
@@ -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';
+2 -1
View File
@@ -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';
@@ -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({
@@ -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<string, unknown>): 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<string, unknown>).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<T> as { status: "success", value: T } or { status: "error", error: string }
const toolCallData = toolRequest.toolCall as Record<string, unknown>;
const toolCall =
toolCallData?.status === 'success'
? (toolCallData.value as { name: string; arguments: Record<string, unknown> })
: (toolCallData as { name: string; arguments: Record<string, unknown> });
if (!toolCall || !toolCall.name) {
return null;
}
@@ -53,12 +72,15 @@ export default function ToolCallWithResponse({
/>
</div>
{/* 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 (
<div key={`${content.type}-${index}`} className="mt-3">
<MCPUIResourceRenderer content={content} appendPromptToChat={append} />
<div key={index} className="mt-3">
<MCPUIResourceRenderer content={resourceContent} appendPromptToChat={append} />
<div className="mt-3 p-4 py-3 border border-borderSubtle rounded-lg bg-background-muted flex items-center">
<FlaskConical className="mr-2" size={20} />
<div className="text-sm font-sans">
@@ -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<string, unknown>).status === 'error'
? 'error'
: 'success';
// Tool call timing tracking
const [startTime, setStartTime] = useState<number | null>(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<string, unknown>).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<string, unknown>).mimeType;
return typeof mimeType === 'string' && mimeType.startsWith('image');
};
const hasResource = (c: Content): c is Content & { resource: unknown } => 'resource' in c;
return (
<ToolCallExpandable
label={<span className="pl-4 py-1 font-sans text-sm">Output</span>}
isStartExpanded={isStartExpanded}
>
<div className="pl-4 pr-4 py-4">
{result.type === 'text' && result.text && (
{hasText(result) && (
<MarkdownContent
content={result.text}
className="whitespace-pre-wrap max-w-full overflow-x-auto"
/>
)}
{result.type === 'image' && (
{hasImage(result) && (
<img
src={`data:${result.mimeType};base64,${result.data}`}
alt="Tool result"
@@ -570,7 +605,7 @@ function ToolResultView({ result, isStartExpanded }: ToolResultViewProps) {
}}
/>
)}
{result.type === 'resource' && (
{hasResource(result) && (
<pre className="font-sans text-sm">{JSON.stringify(result, null, 2)}</pre>
)}
</div>
+3 -2
View File
@@ -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';
@@ -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<CompactionMarkerProps> = ({ 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';
@@ -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);
@@ -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', () => {
@@ -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' } },
],
});
});
});
@@ -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<ContextManageResponse> {
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<string, unknown> | undefined,
};
} else if (apiContent.type === 'image') {
return {
type: 'image',
data: apiContent.data,
mimeType: apiContent.mimeType,
annotations: apiContent.annotations as Record<string, unknown> | undefined,
};
} else if (apiContent.type === 'toolRequest') {
// Ensure the toolCall has the correct type structure
const toolCall = apiContent.toolCall as unknown as ToolCallResult<ToolCall>;
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<FrontendContent[]>;
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<string, unknown>,
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;
}
@@ -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<SessionHistoryViewProps> = ({
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');
@@ -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<SessionMessagesProps> = ({
) : 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);
@@ -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', {