diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index 1bbd444d9598..dd89e5bc1789 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -347,6 +347,9 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin md.push_str("**Thinking:**\n"); md.push_str("> *Thinking was redacted*\n\n"); } + MessageContent::SummarizationRequested(summarization) => { + md.push_str(&format!("*{}*\n\n", summarization.msg)); + } _ => { md.push_str( "`WARNING: Message content type could not be rendered to Markdown`\n\n", diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 0e214d077dbd..a136681f8eed 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -185,6 +185,9 @@ pub fn render_message(message: &Message, debug: bool) { println!("\n{}", style("Thinking:").dim().italic()); print_markdown("Thinking was redacted", theme); } + MessageContent::SummarizationRequested(summarization) => { + println!("\n{}", style(&summarization.msg).yellow()); + } _ => { println!("WARNING: Message content type could not be rendered"); } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index cb676d025ca4..e78d645a680c 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -946,7 +946,7 @@ impl Agent { // If we compacted, yield the compaction message and history replacement event if let Some(compaction_msg) = compaction_msg { return Ok(Box::pin(async_stream::try_stream! { - yield AgentEvent::Message(Message::assistant().with_text(compaction_msg)); + yield AgentEvent::Message(Message::assistant().with_summarization_requested(compaction_msg)); yield AgentEvent::HistoryReplaced(messages.messages().clone()); // Continue with normal reply processing using compacted messages diff --git a/crates/goose/src/agents/context.rs b/crates/goose/src/agents/context.rs index c9aa367c9c50..1a6cd5b64a5c 100644 --- a/crates/goose/src/agents/context.rs +++ b/crates/goose/src/agents/context.rs @@ -84,10 +84,21 @@ impl Agent { // Add an assistant message to the summarized messages to ensure the assistant's response is included in the context. if new_messages.len() == 1 { - let assistant_message = Message::assistant().with_text( - "I ran into a context length exceeded error so I summarized our conversation.", - ); - let assistant_message_tokens: usize = 14; + let compaction_marker = Message::assistant() + .with_summarization_requested("Conversation compacted and summarized"); + let compaction_marker_tokens: usize = 8; + + // Insert the marker before the summary message + new_messages.insert(0, compaction_marker); + new_token_counts.insert(0, compaction_marker_tokens); + + // Add an assistant message to continue the conversation + let assistant_message = Message::assistant().with_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 + "); + let assistant_message_tokens: usize = 41; new_messages.push(assistant_message); new_token_counts.push(assistant_message_tokens); } diff --git a/crates/goose/src/context_mgmt/auto_compact.rs b/crates/goose/src/context_mgmt/auto_compact.rs index 2c3c5a4c5a1e..42e98240de6a 100644 --- a/crates/goose/src/context_mgmt/auto_compact.rs +++ b/crates/goose/src/context_mgmt/auto_compact.rs @@ -595,6 +595,8 @@ mod tests { create_test_message("First message"), create_test_message("Second message"), create_test_message("Third message"), + create_test_message("Fourth message"), + create_test_message("Fifth message"), ]; // Create session metadata with high token count to trigger compaction @@ -617,6 +619,7 @@ mod tests { // Verify the compacted messages are returned assert!(!result.messages.is_empty()); + // Should have fewer messages after compaction assert!(result.messages.len() <= messages.len()); } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 6d5a164423c4..096320a3f0de 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -20,7 +20,6 @@ * - Integrates with multiple custom hooks for separation of concerns: * - useChatEngine: Core chat functionality and API integration * - useRecipeManager: Recipe/agent configuration management - * - useSessionContinuation: Session persistence and resumption * - useFileDrop: Drag-and-drop file handling with previews * - useCostTracking: Token usage and cost calculation * @@ -51,12 +50,8 @@ import LoadingGoose from './LoadingGoose'; import RecipeActivities from './RecipeActivities'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; -import { SessionSummaryModal } from './context_management/SessionSummaryModal'; -import { - ChatContextManagerProvider, - useChatContextManager, -} from './context_management/ChatContextManager'; import { View, ViewOptions } from '../utils/navigationUtils'; +import { ContextManagerProvider, useContextManager } from './context_management/ContextManager'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; @@ -64,7 +59,6 @@ import { RecipeWarningModal } from './ui/RecipeWarningModal'; import ParameterInputModal from './ParameterInputModal'; import { useChatEngine } from '../hooks/useChatEngine'; import { useRecipeManager } from '../hooks/useRecipeManager'; -import { useSessionContinuation } from '../hooks/useSessionContinuation'; import { useFileDrop } from '../hooks/useFileDrop'; import { useCostTracking } from '../hooks/useCostTracking'; import { Message } from '../types/message'; @@ -125,21 +119,12 @@ function BaseChatContent({ const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); - const { - summaryContent, - summarizedThread, - isSummaryModalOpen, - isLoadingCompaction, - resetMessagesWithSummary, - closeSummaryModal, - updateSummary, - } = useChatContextManager(); + const { isCompacting, handleManualCompaction } = useContextManager(); // Use shared chat engine const { messages, filteredMessages, - ancestorMessages, setAncestorMessages, append, chatState, @@ -156,7 +141,6 @@ function BaseChatContent({ localOutputTokens, commandHistory, toolCallNotifications, - updateMessageStreamBody, sessionMetadata, isUserMessage, clearError, @@ -173,9 +157,6 @@ function BaseChatContent({ if (recipeConfig) { setHasStartedUsingRecipe(true); } - - // Create new session after message is sent if needed - createNewSessionIfNeeded(); }, enableLocalStorage, }); @@ -233,14 +214,6 @@ function BaseChatContent({ }); }, [handleAutoExecution, append, chatState]); - // Use shared session continuation - const { createNewSessionIfNeeded } = useSessionContinuation({ - chat, - setChat, - summarizedThread, - updateMessageStreamBody, - }); - // Use shared file drop const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); @@ -260,7 +233,7 @@ function BaseChatContent({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Empty dependency array means this runs once on mount - // Handle submit with summary reset support + // Handle submit const handleSubmit = (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; @@ -270,35 +243,12 @@ function BaseChatContent({ setHasStartedUsingRecipe(true); } - const onSummaryReset = - summarizedThread.length > 0 - ? () => { - resetMessagesWithSummary( - messages, - setMessages, - ancestorMessages, - setAncestorMessages, - summaryContent - ); - } - : undefined; - // Call the callback if provided (for Hub to handle navigation) if (onMessageSubmit && combinedTextFromInput.trim()) { onMessageSubmit(combinedTextFromInput); } - engineHandleSubmit(combinedTextFromInput, onSummaryReset); - - // Auto-scroll to bottom after submitting - if (onSummaryReset) { - // If we're resetting with summary, delay the scroll a bit more - setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 200); - } + engineHandleSubmit(combinedTextFromInput); }; // Wrapper for append that tracks recipe usage @@ -433,67 +383,49 @@ function BaseChatContent({ )} - {error && - !(error as Error & { isTokenLimitError?: boolean }).isTokenLimitError && ( - <> -
-
- {error.message || 'Honk! Goose experienced an error while responding'} -
+ {error && ( + <> +
+
+ {error.message || 'Honk! Goose experienced an error while responding'} +
- {/* Action buttons for non-token-limit errors */} -
-
{ - // Create a contextLengthExceeded message similar to token limit errors - const contextMessage: Message = { - id: `context-${Date.now()}`, - role: 'assistant', - created: Math.floor(Date.now() / 1000), - content: [ - { - type: 'contextLengthExceeded', - msg: 'Summarization requested due to error. Creating summary to help resolve the issue.', - }, - ], - display: true, - sendToLLM: false, - }; - - // Add the context message to trigger ContextHandler - const updatedMessages = [...messages, contextMessage]; - setMessages(updatedMessages); - - // Clear the error state since we're handling it with summarization - clearError(); - }} - > - 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) { - append(lastUserMessage); - } - }} - > - Retry Last Message -
+ {/* Action buttons for all errors including token limit errors */} +
+
{ + clearError(); + + await handleManualCompaction( + messages, + setMessages, + append, + setAncestorMessages + ); + }} + > + 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) { + append(lastUserMessage); + } + }} + > + Retry Last Message
- - )} - - {/* Token limit errors should be handled by ContextHandler, not shown here */} - {error && - (error as Error & { isTokenLimitError?: boolean }).isTokenLimitError && <>} +
+ + )}
) : showPopularTopics ? ( @@ -507,10 +439,10 @@ function BaseChatContent({ {/* Fixed loading indicator at bottom left of chat container */} - {chatState !== ChatState.Idle && ( + {(chatState !== ChatState.Idle || isCompacting) && (
@@ -541,21 +473,13 @@ function BaseChatContent({ recipeAccepted={recipeAccepted} initialPrompt={initialPrompt} autoSubmit={autoSubmit} + setAncestorMessages={setAncestorMessages} + append={append} {...customChatInputProps} />
- { - updateSummary(editedContent); - closeSummaryModal(); - }} - summaryContent={summaryContent} - /> - {/* Recipe Warning Modal */}
)} + + {/* No modals needed for the new simplified context manager */}
); } export default function BaseChat(props: BaseChatProps) { return ( - + - + ); } diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 605f7fbb8cc7..f1b69b155bf8 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -21,7 +21,7 @@ import { WaveformVisualizer } from './WaveformVisualizer'; import { toastError } from '../toasts'; import MentionPopover, { FileItemWithMatch } from './MentionPopover'; import { useDictationSettings } from '../hooks/useDictationSettings'; -import { useChatContextManager } from './context_management/ChatContextManager'; +import { useContextManager } from './context_management/ContextManager'; import { useChatContext } from '../contexts/ChatContext'; import { COST_TRACKING_ENABLED } from '../updates'; import { CostTracker } from './bottom_menu/CostTracker'; @@ -50,7 +50,6 @@ const MAX_IMAGE_SIZE_MB = 5; // Constants for token and tool alerts const TOKEN_LIMIT_DEFAULT = 128000; // fallback for custom models that the backend doesn't know about -const TOKEN_WARNING_THRESHOLD = 0.8; // warning shows at 80% of the token limit const TOOLS_MAX_SUGGESTED = 60; // max number of tools before we show a warning interface ModelLimit { @@ -85,6 +84,8 @@ interface ChatInputProps { recipeAccepted?: boolean; initialPrompt?: string; autoSubmit: boolean; + setAncestorMessages?: (messages: Message[]) => void; + append?: (message: Message) => void; } export default function ChatInput({ @@ -108,6 +109,8 @@ export default function ChatInput({ recipeAccepted, initialPrompt, autoSubmit = false, + append, + setAncestorMessages, }: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback @@ -129,7 +132,7 @@ export default function ChatInput({ null ) as React.RefObject; const toolCount = useToolCount(); - const { isLoadingCompaction, handleManualCompaction } = useChatContextManager(); + const { isCompacting, handleManualCompaction } = useContextManager(); const { getProviders, read } = useConfig(); const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider(); const [tokenLimit, setTokenLimit] = useState(TOKEN_LIMIT_DEFAULT); @@ -511,55 +514,27 @@ export default function ChatInput({ useEffect(() => { clearAlerts(); - // Always show token alerts if we have loaded the real token limit and have tokens - if (isTokenLimitLoaded && tokenLimit && numTokens && numTokens > 0) { - if (numTokens >= tokenLimit) { - // Only show error alert when limit reached - addAlert({ - type: AlertType.Error, - message: `Token limit reached (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()}) \n You've reached the model's conversation limit. The session will be saved — copy anything important and start a new one to continue.`, - autoShow: true, // Auto-show token limit errors - }); - } else if (numTokens >= tokenLimit * TOKEN_WARNING_THRESHOLD) { - // Only show warning alert when approaching limit - addAlert({ - type: AlertType.Warning, - message: `Approaching token limit (${numTokens.toLocaleString()}/${tokenLimit.toLocaleString()}) \n You're reaching the model's conversation limit. Consider compacting the conversation to continue.`, - autoShow: true, // Auto-show token limit warnings - }); - } else { - // Show info alert with summarize button - addAlert({ - type: AlertType.Info, - message: 'Context window', - progress: { - current: numTokens, - total: tokenLimit, - }, - showSummarizeButton: true, - onSummarize: () => { - handleManualCompaction(messages, setMessages); - }, - summarizeIcon: , - }); - } - } else if (isTokenLimitLoaded && tokenLimit) { - // Always show context window info even when no tokens are present (start of conversation) + // Show alert when either there is registered token usage, or we know the limit + if ((numTokens && numTokens > 0) || (isTokenLimitLoaded && tokenLimit)) { + // in these conditions we want it to be present but disabled + const compactButtonDisabled = !numTokens || isCompacting; + addAlert({ type: AlertType.Info, message: 'Context window', progress: { - current: 0, + current: numTokens || 0, total: tokenLimit, }, - showSummarizeButton: messages.length > 0, - onSummarize: - messages.length > 0 - ? () => { - handleManualCompaction(messages, setMessages); - } - : undefined, - summarizeIcon: messages.length > 0 ? : undefined, + showCompactButton: true, + compactButtonDisabled, + onCompact: () => { + // Hide the alert popup by dispatching a custom event that the popover can listen to + // Importantly, this leaves the alert so the dot still shows up, but hides the popover + window.dispatchEvent(new CustomEvent('hide-alert-popover')); + handleManualCompaction(messages, setMessages, append, setAncestorMessages); + }, + compactIcon: , }); } @@ -577,7 +552,7 @@ export default function ChatInput({ } // We intentionally omit setView as it shouldn't trigger a re-render of alerts // eslint-disable-next-line react-hooks/exhaustive-deps - }, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, clearAlerts]); + }, [numTokens, toolCount, tokenLimit, isTokenLimitLoaded, addAlert, isCompacting, clearAlerts]); // Cleanup effect for component unmount - prevent memory leaks useEffect(() => { @@ -1086,7 +1061,7 @@ export default function ChatInput({ const canSubmit = !isLoading && - !isLoadingCompaction && + !isCompacting && agentIsReady && (displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || @@ -1101,7 +1076,7 @@ export default function ChatInput({ e.preventDefault(); const canSubmit = !isLoading && - !isLoadingCompaction && + !isCompacting && agentIsReady && (displayValue.trim() || pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) || @@ -1380,7 +1355,7 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || - isLoadingCompaction || + isCompacting || !agentIsReady } className={`rounded-full px-10 py-2 flex items-center gap-2 ${ @@ -1389,7 +1364,7 @@ export default function ChatInput({ isAnyDroppedFileLoading || isRecording || isTranscribing || - isLoadingCompaction || + isCompacting || !agentIsReady ? 'bg-slate-600 text-white cursor-not-allowed opacity-50 border-slate-600' : 'bg-slate-600 text-white hover:bg-slate-700 border-slate-600 hover:cursor-pointer' @@ -1402,8 +1377,8 @@ export default function ChatInput({

- {isLoadingCompaction - ? 'Summarizing conversation...' + {isCompacting + ? 'Compacting conversation...' : isAnyImageLoading ? 'Waiting for images to save...' : isAnyDroppedFileLoading diff --git a/ui/desktop/src/components/ProgressiveMessageList.tsx b/ui/desktop/src/components/ProgressiveMessageList.tsx index a01b8f9d6850..37bdca8f4ed0 100644 --- a/ui/desktop/src/components/ProgressiveMessageList.tsx +++ b/ui/desktop/src/components/ProgressiveMessageList.tsx @@ -18,8 +18,8 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { Message } from '../types/message'; import GooseMessage from './GooseMessage'; import UserMessage from './UserMessage'; -import { ContextHandler } from './context_management/ContextHandler'; -import { useChatContextManager } from './context_management/ChatContextManager'; +import { CompactionMarker } from './context_management/CompactionMarker'; +import { useContextManager } from './context_management/ContextManager'; import { NotificationEvent } from '../hooks/useMessageStream'; import LoadingGoose from './LoadingGoose'; @@ -66,20 +66,15 @@ export default function ProgressiveMessageList({ message.content.every((c) => c.type === 'toolResponse'); // Try to use context manager, but don't require it for session history - let hasContextHandlerContent: ((message: Message) => boolean) | undefined; - let getContextHandlerType: - | ((message: Message) => 'contextLengthExceeded' | 'summarizationRequested') - | undefined; + let hasCompactionMarker: ((message: Message) => boolean) | undefined; try { - const contextManager = useChatContextManager(); - hasContextHandlerContent = contextManager.hasContextHandlerContent; - getContextHandlerType = contextManager.getContextHandlerType; + const contextManager = useContextManager(); + hasCompactionMarker = contextManager.hasCompactionMarker; } catch { // Context manager not available (e.g., in session history view) - // This is fine, we'll just skip context handler functionality - hasContextHandlerContent = undefined; - getContextHandlerType = undefined; + // This is fine, we'll just skip compaction marker functionality + hasCompactionMarker = undefined; } // Simple progressive loading - start immediately when component mounts if needed @@ -183,14 +178,8 @@ export default function ProgressiveMessageList({ > {isUser ? ( <> - {hasContextHandlerContent && hasContextHandlerContent(message) ? ( - + {hasCompactionMarker && hasCompactionMarker(message) ? ( + ) : ( !hasOnlyToolResponses(message) && ( @@ -199,14 +188,8 @@ export default function ProgressiveMessageList({ ) : ( <> - {hasContextHandlerContent && hasContextHandlerContent(message) ? ( - + {hasCompactionMarker && hasCompactionMarker(message) ? ( + ) : ( = { interface AlertBoxProps { alert: Alert; className?: string; + compactButtonEnabled?: boolean; } const alertStyles: Record = { @@ -60,17 +61,23 @@ export const AlertBox = ({ alert, className }: AlertBoxProps) => { : alert.progress!.total} - {alert.showSummarizeButton && alert.onSummarize && ( + {alert.showCompactButton && alert.onCompact && ( )} diff --git a/ui/desktop/src/components/alerts/__tests__/AlertBox.test.tsx b/ui/desktop/src/components/alerts/__tests__/AlertBox.test.tsx new file mode 100644 index 000000000000..a79ffa5c0e4e --- /dev/null +++ b/ui/desktop/src/components/alerts/__tests__/AlertBox.test.tsx @@ -0,0 +1,327 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AlertBox } from '../AlertBox'; +import { Alert, AlertType } from '../types'; + +describe('AlertBox', () => { + const mockOnCompact = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic Rendering', () => { + it('should render info alert with message', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Test info message', + }; + + render(); + + expect(screen.getByText('Test info message')).toBeInTheDocument(); + }); + + it('should render warning alert with correct styling', () => { + const alert: Alert = { + type: AlertType.Warning, + message: 'Test warning message', + }; + + const { container } = render(); + const alertElement = container.querySelector('.bg-\\[\\#cc4b03\\]'); + + expect(alertElement).toBeInTheDocument(); + expect(screen.getByText('Test warning message')).toBeInTheDocument(); + }); + + it('should render error alert with correct styling', () => { + const alert: Alert = { + type: AlertType.Error, + message: 'Test error message', + }; + + const { container } = render(); + const alertElement = container.querySelector('.bg-\\[\\#d7040e\\]'); + + expect(alertElement).toBeInTheDocument(); + expect(screen.getByText('Test error message')).toBeInTheDocument(); + }); + + it('should apply custom className', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Test message', + }; + + const { container } = render(); + const alertElement = container.firstChild as HTMLElement; + + expect(alertElement).toHaveClass('custom-class'); + }); + }); + + describe('Progress Bar', () => { + it('should render progress bar when progress is provided', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { + current: 50, + total: 100, + }, + }; + + render(); + + expect(screen.getByText('50')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + + // Check progress bar exists + const progressDots = screen.getByText('Context window').parentElement?.parentElement?.querySelectorAll('.h-\\[2px\\]'); + expect(progressDots).toBeDefined(); + }); + + it('should handle zero current value', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { + current: 0, + total: 100, + }, + }; + + render(); + + expect(screen.getByText('0')).toBeInTheDocument(); + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + }); + + it('should handle 100% progress', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { + current: 100, + total: 100, + }, + }; + + render(); + + // Use getAllByText since there are multiple "100" elements (current and total) + const hundredElements = screen.getAllByText('100'); + expect(hundredElements).toHaveLength(2); // One for current, one for total + expect(screen.getByText('100%')).toBeInTheDocument(); + }); + + it('should format large numbers with k suffix', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { + current: 1500, + total: 10000, + }, + }; + + render(); + + expect(screen.getByText('1.5k')).toBeInTheDocument(); + expect(screen.getByText('15%')).toBeInTheDocument(); + expect(screen.getByText('10k')).toBeInTheDocument(); + }); + + it('should handle progress over 100%', () => { + const alert: Alert = { + type: AlertType.Warning, + message: 'Context window', + progress: { + current: 150, + total: 100, + }, + }; + + render(); + + expect(screen.getByText('150')).toBeInTheDocument(); + expect(screen.getByText('150%')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + }); + }); + + describe('Compact Button', () => { + it('should render compact button when showCompactButton is true', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { current: 50, total: 100 }, + showCompactButton: true, + onCompact: mockOnCompact, + }; + + render(); + + expect(screen.getByText('Compact now')).toBeInTheDocument(); + }); + + it('should render compact button with custom icon', () => { + const CompactIcon = () => 📦; + + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { current: 50, total: 100 }, + showCompactButton: true, + onCompact: mockOnCompact, + compactIcon: , + }; + + render(); + + expect(screen.getByTestId('compact-icon')).toBeInTheDocument(); + expect(screen.getByText('Compact now')).toBeInTheDocument(); + }); + + it('should call onCompact when compact button is clicked', async () => { + const user = userEvent.setup(); + + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { current: 50, total: 100 }, + showCompactButton: true, + onCompact: mockOnCompact, + }; + + render(); + + const compactButton = screen.getByText('Compact now'); + await user.click(compactButton); + + expect(mockOnCompact).toHaveBeenCalledTimes(1); + }); + + it('should prevent event propagation when compact button is clicked', () => { + const mockParentClick = vi.fn(); + + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { current: 50, total: 100 }, + showCompactButton: true, + onCompact: mockOnCompact, + }; + + render( +

+ +
+ ); + + const compactButton = screen.getByText('Compact now'); + fireEvent.click(compactButton); + + expect(mockOnCompact).toHaveBeenCalledTimes(1); + expect(mockParentClick).not.toHaveBeenCalled(); + }); + + it('should not render compact button when showCompactButton is false', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { current: 50, total: 100 }, + showCompactButton: false, + onCompact: mockOnCompact, + }; + + render(); + + expect(screen.queryByText('Compact now')).not.toBeInTheDocument(); + }); + + it('should not render compact button when onCompact is not provided', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { current: 50, total: 100 }, + showCompactButton: true, + }; + + render(); + + expect(screen.queryByText('Compact now')).not.toBeInTheDocument(); + }); + }); + + describe('Combined Features', () => { + it('should render progress bar and compact button together', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { + current: 75, + total: 100, + }, + showCompactButton: true, + onCompact: mockOnCompact, + }; + + render(); + + expect(screen.getByText('75')).toBeInTheDocument(); + expect(screen.getByText('75%')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('Compact now')).toBeInTheDocument(); + }); + + it('should handle multiline messages', () => { + const alert: Alert = { + type: AlertType.Warning, + message: 'Line 1\nLine 2\nLine 3', + }; + + render(); + + // Use a function matcher to handle the whitespace-pre-line rendering + expect(screen.getByText((content) => content.includes('Line 1') && content.includes('Line 2') && content.includes('Line 3'))).toBeInTheDocument(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty message', () => { + const alert: Alert = { + type: AlertType.Info, + message: '', + }; + + const { container } = render(); + + // Should still render the alert container + const alertElement = container.querySelector('.flex.flex-col.gap-2'); + expect(alertElement).toBeInTheDocument(); + }); + + it('should handle progress with zero total', () => { + const alert: Alert = { + type: AlertType.Info, + message: 'Context window', + progress: { + current: 10, + total: 0, + }, + }; + + render(); + + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + // Progress percentage would be Infinity, but it should still render + expect(screen.getByText('Infinity%')).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/desktop/src/components/alerts/__tests__/useAlerts.test.tsx b/ui/desktop/src/components/alerts/__tests__/useAlerts.test.tsx new file mode 100644 index 000000000000..30f2cb235251 --- /dev/null +++ b/ui/desktop/src/components/alerts/__tests__/useAlerts.test.tsx @@ -0,0 +1,339 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useAlerts } from '../useAlerts'; +import { AlertType } from '../types'; + +describe('useAlerts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Initial State', () => { + it('should start with empty alerts array', () => { + const { result } = renderHook(() => useAlerts()); + + expect(result.current.alerts).toEqual([]); + expect(typeof result.current.addAlert).toBe('function'); + expect(typeof result.current.clearAlerts).toBe('function'); + }); + }); + + describe('Adding Alerts', () => { + it('should add a single alert', () => { + const { result } = renderHook(() => useAlerts()); + + const newAlert = { + type: AlertType.Info, + message: 'Test alert', + }; + + act(() => { + result.current.addAlert(newAlert); + }); + + expect(result.current.alerts).toHaveLength(1); + expect(result.current.alerts[0]).toMatchObject(newAlert); + }); + + it('should add multiple alerts', () => { + const { result } = renderHook(() => useAlerts()); + + const alert1 = { type: AlertType.Info, message: 'First alert' }; + const alert2 = { type: AlertType.Warning, message: 'Second alert' }; + const alert3 = { type: AlertType.Error, message: 'Third alert' }; + + act(() => { + result.current.addAlert(alert1); + result.current.addAlert(alert2); + result.current.addAlert(alert3); + }); + + expect(result.current.alerts).toHaveLength(3); + expect(result.current.alerts[0]).toMatchObject(alert1); + expect(result.current.alerts[1]).toMatchObject(alert2); + expect(result.current.alerts[2]).toMatchObject(alert3); + }); + + it('should add alerts with all optional properties', () => { + const { result } = renderHook(() => useAlerts()); + + const complexAlert = { + type: AlertType.Info, + message: 'Complex alert', + progress: { current: 50, total: 100 }, + showCompactButton: true, + onCompact: vi.fn(), + compactIcon: Icon, + autoShow: true, + }; + + act(() => { + result.current.addAlert(complexAlert); + }); + + expect(result.current.alerts).toHaveLength(1); + expect(result.current.alerts[0]).toMatchObject(complexAlert); + }); + }); + + describe('Clearing Alerts', () => { + it('should clear all alerts', () => { + const { result } = renderHook(() => useAlerts()); + + // Add some alerts first + act(() => { + result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' }); + result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' }); + result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' }); + }); + + expect(result.current.alerts).toHaveLength(3); + + // Clear all alerts + act(() => { + result.current.clearAlerts(); + }); + + expect(result.current.alerts).toHaveLength(0); + expect(result.current.alerts).toEqual([]); + }); + + it('should handle clearing when no alerts exist', () => { + const { result } = renderHook(() => useAlerts()); + + expect(result.current.alerts).toHaveLength(0); + + // Should not throw error + act(() => { + result.current.clearAlerts(); + }); + + expect(result.current.alerts).toHaveLength(0); + }); + }); + + describe('Alert Management Patterns', () => { + it('should handle rapid add and clear operations', () => { + const { result } = renderHook(() => useAlerts()); + + // Rapid operations + act(() => { + result.current.addAlert({ type: AlertType.Info, message: 'Alert 1' }); + result.current.addAlert({ type: AlertType.Warning, message: 'Alert 2' }); + result.current.clearAlerts(); + result.current.addAlert({ type: AlertType.Error, message: 'Alert 3' }); + }); + + expect(result.current.alerts).toHaveLength(1); + expect(result.current.alerts[0].message).toBe('Alert 3'); + }); + + it('should maintain alert order', () => { + const { result } = renderHook(() => useAlerts()); + + const alerts = [ + { type: AlertType.Info, message: 'First' }, + { type: AlertType.Warning, message: 'Second' }, + { type: AlertType.Error, message: 'Third' }, + { type: AlertType.Info, message: 'Fourth' }, + ]; + + act(() => { + alerts.forEach(alert => result.current.addAlert(alert)); + }); + + expect(result.current.alerts).toHaveLength(4); + alerts.forEach((alert, index) => { + expect(result.current.alerts[index].message).toBe(alert.message); + }); + }); + + it('should handle duplicate alerts', () => { + const { result } = renderHook(() => useAlerts()); + + const duplicateAlert = { type: AlertType.Info, message: 'Duplicate alert' }; + + act(() => { + result.current.addAlert(duplicateAlert); + result.current.addAlert(duplicateAlert); + result.current.addAlert(duplicateAlert); + }); + + // Should allow duplicates + expect(result.current.alerts).toHaveLength(3); + result.current.alerts.forEach(alert => { + expect(alert.message).toBe('Duplicate alert'); + }); + }); + }); + + describe('Alert Types', () => { + it('should handle all alert types', () => { + const { result } = renderHook(() => useAlerts()); + + const alertTypes = [ + { type: AlertType.Info, message: 'Info alert' }, + { type: AlertType.Warning, message: 'Warning alert' }, + { type: AlertType.Error, message: 'Error alert' }, + ]; + + act(() => { + alertTypes.forEach(alert => result.current.addAlert(alert)); + }); + + expect(result.current.alerts).toHaveLength(3); + expect(result.current.alerts[0].type).toBe(AlertType.Info); + expect(result.current.alerts[1].type).toBe(AlertType.Warning); + expect(result.current.alerts[2].type).toBe(AlertType.Error); + }); + }); + + describe('Progress Alerts', () => { + it('should handle alerts with progress', () => { + const { result } = renderHook(() => useAlerts()); + + const progressAlert = { + type: AlertType.Info, + message: 'Loading...', + progress: { current: 25, total: 100 }, + }; + + act(() => { + result.current.addAlert(progressAlert); + }); + + expect(result.current.alerts[0].progress).toEqual({ current: 25, total: 100 }); + }); + + it('should handle progress updates by replacing alerts', () => { + const { result } = renderHook(() => useAlerts()); + + // Add initial progress alert + act(() => { + result.current.addAlert({ + type: AlertType.Info, + message: 'Loading...', + progress: { current: 25, total: 100 }, + }); + }); + + expect(result.current.alerts[0].progress?.current).toBe(25); + + // Clear and add updated progress + act(() => { + result.current.clearAlerts(); + result.current.addAlert({ + type: AlertType.Info, + message: 'Loading...', + progress: { current: 75, total: 100 }, + }); + }); + + expect(result.current.alerts).toHaveLength(1); + expect(result.current.alerts[0].progress?.current).toBe(75); + }); + }); + + describe('Compact Button Alerts', () => { + it('should handle alerts with compact functionality', () => { + const { result } = renderHook(() => useAlerts()); + + const mockOnCompact = vi.fn(); + const compactAlert = { + type: AlertType.Info, + message: 'Context window full', + showCompactButton: true, + onCompact: mockOnCompact, + compactIcon: 📦, + }; + + act(() => { + result.current.addAlert(compactAlert); + }); + + const alert = result.current.alerts[0]; + expect(alert.showCompactButton).toBe(true); + expect(alert.onCompact).toBe(mockOnCompact); + expect(alert.compactIcon).toBeDefined(); + }); + }); + + describe('Auto-show Alerts', () => { + it('should handle autoShow property', () => { + const { result } = renderHook(() => useAlerts()); + + const autoShowAlert = { + type: AlertType.Error, + message: 'Critical error', + autoShow: true, + }; + + act(() => { + result.current.addAlert(autoShowAlert); + }); + + expect(result.current.alerts[0].autoShow).toBe(true); + }); + + it('should handle alerts without autoShow property', () => { + const { result } = renderHook(() => useAlerts()); + + const regularAlert = { + type: AlertType.Info, + message: 'Regular alert', + }; + + act(() => { + result.current.addAlert(regularAlert); + }); + + expect(result.current.alerts[0].autoShow).toBeUndefined(); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty message', () => { + const { result } = renderHook(() => useAlerts()); + + act(() => { + result.current.addAlert({ + type: AlertType.Info, + message: '', + }); + }); + + expect(result.current.alerts).toHaveLength(1); + expect(result.current.alerts[0].message).toBe(''); + }); + + it('should handle very long messages', () => { + const { result } = renderHook(() => useAlerts()); + + const longMessage = 'A'.repeat(1000); + + act(() => { + result.current.addAlert({ + type: AlertType.Info, + message: longMessage, + }); + }); + + expect(result.current.alerts[0].message).toBe(longMessage); + }); + + it('should handle special characters in messages', () => { + const { result } = renderHook(() => useAlerts()); + + const specialMessage = '🚨 Alert with émojis and spëcial chars! @#$%^&*()'; + + act(() => { + result.current.addAlert({ + type: AlertType.Warning, + message: specialMessage, + }); + }); + + expect(result.current.alerts[0].message).toBe(specialMessage); + }); + }); +}); diff --git a/ui/desktop/src/components/alerts/types.ts b/ui/desktop/src/components/alerts/types.ts index 44eeafbd19a0..ee5f187c400f 100644 --- a/ui/desktop/src/components/alerts/types.ts +++ b/ui/desktop/src/components/alerts/types.ts @@ -16,7 +16,8 @@ export interface Alert { current: number; total: number; }; - showSummarizeButton?: boolean; - onSummarize?: () => void; - summarizeIcon?: React.ReactNode; + showCompactButton?: boolean; + compactButtonDisabled?: boolean; + onCompact?: () => void; + compactIcon?: React.ReactNode; } diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx index 7632a39d3a4f..3415be9892bc 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuAlertPopover.tsx @@ -93,6 +93,8 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) { useEffect(() => { if (alerts.length > 0) { setShouldShowIndicator(true); + } else { + setShouldShowIndicator(false); } }, [alerts.length]); @@ -148,6 +150,27 @@ export default function BottomMenuAlertPopover({ alerts }: AlertPopoverProps) { }; }, [isOpen]); + // Listen for custom event to hide the popover + useEffect(() => { + const handleHidePopover = () => { + if (isOpen) { + setIsOpen(false); + setWasAutoShown(false); + setIsHovered(false); + // Clear any pending hide timer + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + } + }; + + window.addEventListener('hide-alert-popover', handleHidePopover); + return () => { + window.removeEventListener('hide-alert-popover', handleHidePopover); + }; + }, [isOpen]); + // Use shouldShowIndicator instead of alerts.length for rendering decision if (!shouldShowIndicator) { return null; diff --git a/ui/desktop/src/components/context_management/ChatContextManager.tsx b/ui/desktop/src/components/context_management/ChatContextManager.tsx deleted file mode 100644 index 4e3493d7d35f..000000000000 --- a/ui/desktop/src/components/context_management/ChatContextManager.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import React, { createContext, useContext, useState } from 'react'; -import { ScrollText } from 'lucide-react'; -import { Message } from '../../types/message'; -import { - manageContextFromBackend, - convertApiMessageToFrontendMessage, - createSummarizationRequestMessage, -} from './index'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '../ui/dialog'; -import { Button } from '../ui/button'; - -// Define the context management interface -interface ChatContextManagerState { - summaryContent: string; - summarizedThread: Message[]; - isSummaryModalOpen: boolean; - isLoadingCompaction: boolean; - errorLoadingSummary: boolean; - preparingManualSummary: boolean; - isConfirmationOpen: boolean; - pendingCompactionData: { messages: Message[]; setMessages: (messages: Message[]) => void } | null; -} - -interface ChatContextManagerActions { - updateSummary: (newSummaryContent: string) => void; - resetMessagesWithSummary: ( - messages: Message[], - setMessages: (messages: Message[]) => void, - ancestorMessages: Message[], - setAncestorMessages: (messages: Message[]) => void, - summaryContent: string - ) => void; - openSummaryModal: () => void; - closeSummaryModal: () => void; - hasContextHandlerContent: (message: Message) => boolean; - hasContextLengthExceededContent: (message: Message) => boolean; - hasSummarizationRequestedContent: (message: Message) => boolean; - getContextHandlerType: (message: Message) => 'contextLengthExceeded' | 'summarizationRequested'; - handleContextLengthExceeded: (messages: Message[]) => Promise; - handleManualCompaction: (messages: Message[], setMessages: (messages: Message[]) => void) => void; -} - -// Create the context -const ChatContextManagerContext = createContext< - (ChatContextManagerState & ChatContextManagerActions) | undefined ->(undefined); - -// Create the provider component -export const ChatContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const [summaryContent, setSummaryContent] = useState(''); - const [summarizedThread, setSummarizedThread] = useState([]); - const [isSummaryModalOpen, setIsSummaryModalOpen] = useState(false); - const [isLoadingCompaction, setIsLoadingCompaction] = useState(false); - const [errorLoadingSummary, setErrorLoadingSummary] = useState(false); - const [preparingManualSummary, setPreparingManualSummary] = useState(false); - const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); - const [pendingCompactionData, setPendingCompactionData] = useState<{ - messages: Message[]; - setMessages: (messages: Message[]) => void; - } | null>(null); - - const handleContextLengthExceeded = async (messages: Message[]): Promise => { - setIsLoadingCompaction(true); - setErrorLoadingSummary(false); - setPreparingManualSummary(true); - - try { - // 2. Now get the summary from the backend - const summaryResponse = await manageContextFromBackend({ - messages: messages, - manageAction: 'summarize', - }); - - // Convert API messages to frontend messages - const convertedMessages = summaryResponse.messages.map( - (apiMessage) => convertApiMessageToFrontendMessage(apiMessage, false, true) // do not show to user but send to llm - ); - - // Extract summary from the first message - const summaryMessage = convertedMessages[0].content[0]; - if (summaryMessage.type === 'text') { - const summary = summaryMessage.text; - setSummaryContent(summary); - setSummarizedThread(convertedMessages); - } - - setIsLoadingCompaction(false); - } catch (err) { - console.error('Error handling context length exceeded:', err); - setErrorLoadingSummary(true); - setIsLoadingCompaction(false); - } finally { - setPreparingManualSummary(false); - } - }; - - const handleManualCompaction = ( - messages: Message[], - setMessages: (messages: Message[]) => void - ): void => { - // Store the pending compaction data and open confirmation dialog - setPendingCompactionData({ messages, setMessages }); - setIsConfirmationOpen(true); - }; - - const handleCompactionConfirm = () => { - if (!pendingCompactionData) return; - - const { messages, setMessages } = pendingCompactionData; - - // add some messages to the message thread - // these messages will be filtered out in chat view - // but they will also be what allows us to render some text in the chatview itself, similar to CLE events - const summarizationRequest = createSummarizationRequestMessage( - messages, - 'Summarize the session and begin a new one' - ); - - // add the message to the message thread - setMessages([...messages, summarizationRequest]); - - setIsConfirmationOpen(false); - setPendingCompactionData(null); - }; - - const handleCompactionCancel = () => { - setIsConfirmationOpen(false); - setPendingCompactionData(null); - }; - - const updateSummary = (newSummaryContent: string) => { - // Update the summary content - setSummaryContent(newSummaryContent); - - // Update the thread if it exists - if (summarizedThread.length > 0) { - // Create a deep copy of the thread - const updatedThread = [...summarizedThread]; - - // Create a copy of the first message - const firstMessage = { ...updatedThread[0] }; - - // Create a copy of the content array - const updatedContent = [...firstMessage.content]; - - // Update the summary text in the first content item - if (updatedContent[0] && updatedContent[0].type === 'text') { - updatedContent[0] = { - ...updatedContent[0], - text: newSummaryContent, - }; - } - - // Update the message with the new content - firstMessage.content = updatedContent; - updatedThread[0] = firstMessage; - - // Update the thread - setSummarizedThread(updatedThread); - } - }; - - const resetMessagesWithSummary = ( - messages: Message[], - setMessages: (messages: Message[]) => void, - ancestorMessages: Message[], - setAncestorMessages: (messages: Message[]) => void, - summaryContent: string - ) => { - // Create a copy of the summarized thread - const updatedSummarizedThread = [...summarizedThread]; - - // Make sure there's at least one message in the summarized thread - if (updatedSummarizedThread.length > 0) { - // Get the first message - const firstMessage = { ...updatedSummarizedThread[0] }; - - // Make a copy of the content array - const contentCopy = [...firstMessage.content]; - - // Assuming the first content item is of type TextContent - if (contentCopy.length > 0 && 'text' in contentCopy[0]) { - // Update the text with the new summary content - contentCopy[0] = { - ...contentCopy[0], - text: summaryContent, - }; - - // Update the first message with the new content - firstMessage.content = contentCopy; - - // Update the first message in the thread - updatedSummarizedThread[0] = firstMessage; - } - } - - // Update metadata for the summarized thread - const finalUpdatedThread = updatedSummarizedThread.map((msg, index) => ({ - ...msg, - display: index === 0, // First message has display: true, others false - sendToLLM: true, // All messages have sendToLLM: true - })); - - // Update the messages state - setMessages(finalUpdatedThread); - - // If ancestorMessages already has items, extend it instead of replacing it - if (ancestorMessages.length > 0) { - // Convert current messages to ancestor format - const newAncestorMessages = messages.map((msg) => ({ - ...msg, - display: true, - sendToLLM: false, - })); - - // Append new ancestor messages to existing ones - setAncestorMessages([...ancestorMessages, ...newAncestorMessages]); - } else { - // Initial set of ancestor messages - const newAncestorMessages = messages.map((msg) => ({ - ...msg, - display: true, - sendToLLM: false, - })); - - setAncestorMessages(newAncestorMessages); - } - - // Clear the summarized thread and content - setSummarizedThread([]); - setSummaryContent(''); - }; - - const hasContextHandlerContent = (message: Message): boolean => { - return hasContextLengthExceededContent(message) || hasSummarizationRequestedContent(message); - }; - - const hasContextLengthExceededContent = (message: Message): boolean => { - return message.content.some((content) => content.type === 'contextLengthExceeded'); - }; - - const hasSummarizationRequestedContent = (message: Message): boolean => { - return message.content.some((content) => content.type === 'summarizationRequested'); - }; - - const getContextHandlerType = ( - message: Message - ): 'contextLengthExceeded' | 'summarizationRequested' => { - if (hasContextLengthExceededContent(message)) { - return 'contextLengthExceeded'; - } - return 'summarizationRequested'; - }; - - const openSummaryModal = () => { - setIsSummaryModalOpen(true); - }; - - const closeSummaryModal = () => { - setIsSummaryModalOpen(false); - }; - - const value = { - // State - summaryContent, - summarizedThread, - isSummaryModalOpen, - isLoadingCompaction, - errorLoadingSummary, - preparingManualSummary, - isConfirmationOpen, - pendingCompactionData, - - // Actions - updateSummary, - resetMessagesWithSummary, - openSummaryModal, - closeSummaryModal, - hasContextHandlerContent, - hasContextLengthExceededContent, - hasSummarizationRequestedContent, - getContextHandlerType, - handleContextLengthExceeded, - handleManualCompaction, - }; - - return ( - - {children} - - {/* Confirmation Modal */} - - - - - - Compact Conversation - - - This will compact your conversation by summarizing the context into a single message - and will help you save context space for future interactions. - - - -
-

- Previous messages will remain visible but only the summary will be included in the - active context for Goose. This is useful for long conversations that are approaching - the context limit. -

-
- - - - - -
-
-
- ); -}; - -// Create a hook to use the context -export const useChatContextManager = () => { - const context = useContext(ChatContextManagerContext); - if (context === undefined) { - throw new Error('useContextManager must be used within a ContextManagerProvider'); - } - return context; -}; diff --git a/ui/desktop/src/components/context_management/CompactionMarker.tsx b/ui/desktop/src/components/context_management/CompactionMarker.tsx new file mode 100644 index 000000000000..a08563855a40 --- /dev/null +++ b/ui/desktop/src/components/context_management/CompactionMarker.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Message, SummarizationRequestedContent } from '../../types/message'; + +interface CompactionMarkerProps { + message: Message; +} + +export const CompactionMarker: React.FC = ({ message }) => { + const compactionContent = message.content.find( + (content) => content.type === 'summarizationRequested' + ) as SummarizationRequestedContent | undefined; + + const markerText = compactionContent?.msg || 'Conversation compacted'; + + return
{markerText}
; +}; diff --git a/ui/desktop/src/components/context_management/ContextHandler.tsx b/ui/desktop/src/components/context_management/ContextHandler.tsx deleted file mode 100644 index 6685ada7580b..000000000000 --- a/ui/desktop/src/components/context_management/ContextHandler.tsx +++ /dev/null @@ -1,242 +0,0 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { Message } from '../../types/message'; -import { useChatContextManager } from './ChatContextManager'; -import { Button } from '../ui/button'; - -interface ContextHandlerProps { - messages: Message[]; - messageId: string; - chatId: string; - workingDir: string; - contextType: 'contextLengthExceeded' | 'summarizationRequested'; - onSummaryComplete?: () => void; // Add callback for when summary is complete -} - -export const ContextHandler: React.FC = ({ - messages, - messageId, - chatId, - workingDir, - contextType, - onSummaryComplete, -}) => { - const { - summaryContent, - isLoadingCompaction, - errorLoadingSummary, - openSummaryModal, - handleContextLengthExceeded, - } = useChatContextManager(); - const [hasFetchStarted, setHasFetchStarted] = useState(false); - const [retryCount, setRetryCount] = useState(0); - - const isContextLengthExceeded = contextType === 'contextLengthExceeded'; - - // Find the relevant message to check if it's the latest - const isCurrentMessageLatest = - messageId === messages[messages.length - 1]?.id || - messageId === String(messages[messages.length - 1]?.created); - - // Only allow interaction for the most recent context length exceeded event - const shouldAllowSummaryInteraction = isCurrentMessageLatest; - - // Use a ref to track if we've started the fetch - const fetchStartedRef = useRef(false); - const hasCalledSummaryComplete = useRef(false); - - // Call onSummaryComplete when summary is ready - useEffect(() => { - if (summaryContent && shouldAllowSummaryInteraction && !hasCalledSummaryComplete.current) { - hasCalledSummaryComplete.current = true; - // Delay the scroll slightly to ensure the content is rendered - setTimeout(() => { - onSummaryComplete?.(); - }, 100); - } - - // Reset the flag when summary is cleared - if (!summaryContent) { - hasCalledSummaryComplete.current = false; - } - }, [summaryContent, shouldAllowSummaryInteraction, onSummaryComplete]); - - // Scroll when summarization starts (loading state) - useEffect(() => { - if (isLoadingCompaction && shouldAllowSummaryInteraction) { - // Delay the scroll slightly to ensure the loading content is rendered - setTimeout(() => { - onSummaryComplete?.(); - }, 100); - } - }, [isLoadingCompaction, shouldAllowSummaryInteraction, onSummaryComplete]); - - // Function to trigger the async operation properly - const triggerContextLengthExceeded = () => { - setHasFetchStarted(true); - fetchStartedRef.current = true; - - // Call the async function without awaiting it in useEffect - handleContextLengthExceeded(messages).catch((err) => { - console.error('Error handling context length exceeded:', err); - }); - }; - - useEffect(() => { - if ( - !summaryContent && - !hasFetchStarted && - shouldAllowSummaryInteraction && - !fetchStartedRef.current - ) { - // Use the wrapper function instead of calling the async function directly - triggerContextLengthExceeded(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - hasFetchStarted, - messages, - shouldAllowSummaryInteraction, - summaryContent, - chatId, - workingDir, - ]); - - // Handle retry - Call the async function properly - const handleRetry = () => { - if (!shouldAllowSummaryInteraction) return; - - // Only increment retry counter if there's an error - if (errorLoadingSummary) { - setRetryCount((prevCount) => prevCount + 1); - } - - // Reset states for retry - setHasFetchStarted(false); - fetchStartedRef.current = false; - - // Trigger the process again - triggerContextLengthExceeded(); - }; - - // Function to open a new goose window - const openNewSession = () => { - try { - // Use the workingDir from props directly without reassignment to avoid TypeScript error - const sessionWorkingDir = window.appConfig?.get('GOOSE_WORKING_DIR') || workingDir; - console.log(`Creating new chat window with working dir: ${sessionWorkingDir}`); - window.electron.createChatWindow(undefined, sessionWorkingDir as string); - } catch (error) { - console.error('Error creating new window:', error); - // Fallback to basic window.open if the electron API fails - window.open('/', '_blank'); - } - }; - - // Render the notification UI - const renderLoadingState = () => ( -
- Preparing summary... - -
- ); - - const renderFailedState = () => ( - <> - - {isContextLengthExceeded - ? `Your conversation has exceeded the model's context capacity` - : `Summarization requested`} - - - {isContextLengthExceeded - ? `This conversation has too much information to continue. Extension data often takes up significant space.` - : `Summarization failed. Continue chatting or start a new session.`} - - - - ); - - const renderRetryState = () => ( - <> - - {isContextLengthExceeded - ? `Your conversation has exceeded the model's context capacity` - : `Summarization requested`} - - - - ); - - const renderSuccessState = () => ( - <> - - {isContextLengthExceeded - ? `Your conversation has exceeded the model's context capacity and a summary was prepared.` - : `A summary of your conversation was prepared as requested.`} - - - {isContextLengthExceeded - ? `Messages above this line remain viewable but specific details are not included in active context.` - : `This summary includes key points from your conversation.`} - - {shouldAllowSummaryInteraction && ( - - )} - - ); - - // Render persistent summarized notification when we shouldn't show interaction options - const renderPersistentMarker = () => ( - - Session summarized — messages above this line are not included in the conversation - - ); - - const renderContentState = () => { - // If this is not the latest context event message but we have a valid summary, - // show the persistent marker - if (!shouldAllowSummaryInteraction && summaryContent) { - return renderPersistentMarker(); - } - - // For the latest message with the context event - if (shouldAllowSummaryInteraction) { - if (errorLoadingSummary) { - return retryCount >= 2 ? renderFailedState() : renderRetryState(); - } - - if (summaryContent) { - return renderSuccessState(); - } - } - - // Fallback to showing at least the persistent marker - return renderPersistentMarker(); - }; - - return ( -
- {/* Horizontal line with text in the middle - shown regardless of loading state */} -
-
-
-
- - {isLoadingCompaction && shouldAllowSummaryInteraction - ? renderLoadingState() - : renderContentState()} -
- ); -}; diff --git a/ui/desktop/src/components/context_management/ContextManager.tsx b/ui/desktop/src/components/context_management/ContextManager.tsx new file mode 100644 index 000000000000..d2f660eeb558 --- /dev/null +++ b/ui/desktop/src/components/context_management/ContextManager.tsx @@ -0,0 +1,178 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { Message } from '../../types/message'; +import { manageContextFromBackend, convertApiMessageToFrontendMessage } from './index'; + +// Define the context management interface +interface ContextManagerState { + isCompacting: boolean; + compactionError: string | null; +} + +interface ContextManagerActions { + handleAutoCompaction: ( + messages: Message[], + setMessages: (messages: Message[]) => void, + append: (message: Message) => void, + setAncestorMessages?: (messages: Message[]) => void + ) => Promise; + handleManualCompaction: ( + messages: Message[], + setMessages: (messages: Message[]) => void, + append?: (message: Message) => void, + setAncestorMessages?: (messages: Message[]) => void + ) => Promise; + hasCompactionMarker: (message: Message) => boolean; +} + +// Create the context +const ContextManagerContext = createContext< + (ContextManagerState & ContextManagerActions) | undefined +>(undefined); + +// Create the provider component +export const ContextManagerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [isCompacting, setIsCompacting] = useState(false); + const [compactionError, setCompactionError] = useState(null); + + const performCompaction = useCallback( + async ( + messages: Message[], + setMessages: (messages: Message[]) => void, + append: (message: Message) => void, + isManual: boolean = false, + setAncestorMessages?: (messages: Message[]) => void + ) => { + setIsCompacting(true); + setCompactionError(null); + + try { + // Get the summary from the backend + const summaryResponse = await manageContextFromBackend({ + messages: messages, + manageAction: 'summarize', + }); + + // Convert API messages to frontend messages + const convertedMessages = summaryResponse.messages.map((apiMessage) => { + const isCompactionMarker = apiMessage.content.some( + (content) => content.type === 'summarizationRequested' + ); + + if (isCompactionMarker) { + // show to user but not model + return convertApiMessageToFrontendMessage(apiMessage, true, false); + } + + // show to model but not user + return convertApiMessageToFrontendMessage(apiMessage, false, true); + }); + + // Store the original messages as ancestor messages so they can still be scrolled to + if (setAncestorMessages) { + const ancestorMessages = messages.map((msg) => ({ + ...msg, + display: msg.display === false ? false : true, + sendToLLM: false, + })); + setAncestorMessages(ancestorMessages); + } + + // Replace messages with the server-provided messages + setMessages(convertedMessages); + + // 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]; + if (continuationMessage) { + setTimeout(() => { + append(continuationMessage); + }, 100); + } + } + + setIsCompacting(false); + } catch (err) { + console.error('Error during compaction:', err); + setCompactionError(err instanceof Error ? err.message : 'Unknown error during compaction'); + + // Create an error marker + const errorMarker: Message = { + id: `compaction-error-${Date.now()}`, + role: 'assistant', + created: Math.floor(Date.now() / 1000), + content: [ + { + type: 'summarizationRequested', + msg: 'Compaction failed. Please try again or start a new session.', + }, + ], + display: true, + sendToLLM: false, + }; + + setMessages([...messages, errorMarker]); + setIsCompacting(false); + } + }, + [] + ); + + const handleAutoCompaction = useCallback( + async ( + messages: Message[], + setMessages: (messages: Message[]) => void, + append: (message: Message) => void, + setAncestorMessages?: (messages: Message[]) => void + ) => { + await performCompaction(messages, setMessages, append, false, setAncestorMessages); + }, + [performCompaction] + ); + + const handleManualCompaction = useCallback( + async ( + messages: Message[], + setMessages: (messages: Message[]) => void, + append?: (message: Message) => void, + setAncestorMessages?: (messages: Message[]) => void + ) => { + await performCompaction( + messages, + setMessages, + append || (() => {}), + true, + setAncestorMessages + ); + }, + [performCompaction] + ); + + const hasCompactionMarker = useCallback((message: Message): boolean => { + return message.content.some((content) => content.type === 'summarizationRequested'); + }, []); + + const value = { + // State + isCompacting, + compactionError, + + // Actions + handleAutoCompaction, + handleManualCompaction, + hasCompactionMarker, + }; + + return {children}; +}; + +// Create a hook to use the context +export const useContextManager = () => { + const context = useContext(ContextManagerContext); + if (context === undefined) { + throw new Error('useContextManager must be used within a ContextManagerProvider'); + } + return context; +}; diff --git a/ui/desktop/src/components/context_management/ManualCompactButton.tsx b/ui/desktop/src/components/context_management/ManualCompactButton.tsx deleted file mode 100644 index bfc965cb2110..000000000000 --- a/ui/desktop/src/components/context_management/ManualCompactButton.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { useState } from 'react'; -import { ScrollText } from 'lucide-react'; -import { cn } from '../../utils'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '../ui/dialog'; -import { Button } from '../ui/button'; -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip'; -import { useChatContextManager } from './ChatContextManager'; -import { Message } from '../../types/message'; - -interface ManualCompactButtonProps { - messages: Message[]; - isLoading?: boolean; // need this prop to know if Goose is responding - setMessages: (messages: Message[]) => void; // context management is triggered via special message content types -} - -export const ManualCompactButton: React.FC = ({ - messages, - isLoading = false, - setMessages, -}) => { - const { handleManualCompaction, isLoadingCompaction } = useChatContextManager(); - - const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); - - const handleClick = () => { - setIsConfirmationOpen(true); - }; - - const handleCompaction = async () => { - setIsConfirmationOpen(false); - - try { - handleManualCompaction(messages, setMessages); - } catch (error) { - console.error('Error in handleCompaction:', error); - } - }; - - const handleClose = () => { - setIsConfirmationOpen(false); - }; - - return ( - <> -
-
- - - - - - {isLoadingCompaction ? 'Compacting conversation...' : 'Compact conversation context'} - - -
- - {/* Summarization Confirmation Modal */} - - - - - - Compact Conversation - - - This will compact your conversation by summarizing the context into a single message - and will help you save context space for future interactions. - - - -
-

- Previous messages will remain visible but only the summary will be included in the - active context for Goose. This is useful for long conversations that are approaching - the context limit. -

-
- - - - - -
-
- - ); -}; diff --git a/ui/desktop/src/components/context_management/SessionSummaryModal.tsx b/ui/desktop/src/components/context_management/SessionSummaryModal.tsx deleted file mode 100644 index 70668866efcd..000000000000 --- a/ui/desktop/src/components/context_management/SessionSummaryModal.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { useRef, useEffect } from 'react'; -import { Geese } from '../icons/Geese'; -import { Button } from '../ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '../ui/dialog'; - -interface SessionSummaryModalProps { - isOpen: boolean; - onClose: () => void; - onSave: (editedContent: string) => void; - summaryContent: string; -} - -export function SessionSummaryModal({ - isOpen, - onClose, - onSave, - summaryContent, -}: SessionSummaryModalProps) { - // Use a ref for the textarea for uncontrolled component - const textareaRef = useRef(null); - - // Initialize the textarea value when the modal opens - useEffect(() => { - if (isOpen && textareaRef.current) { - textareaRef.current.value = summaryContent; - } - }, [isOpen, summaryContent]); - - // Handle Save action with the edited content from the ref - const handleSave = () => { - const currentText = textareaRef.current ? textareaRef.current.value : ''; - onSave(currentText); - }; - - return ( - !open && onClose()}> - - - -
- -
- Session Summary -
- - This summary was created to manage your context limit. Review and edit to keep your - session running smoothly with the information that matters most. - -
- -
-
-

- Summarization -

- -