diff --git a/src/renderer/src/components/ChatView.tsx b/src/renderer/src/components/ChatView.tsx index c46c4b4445..ccf352cb16 100644 --- a/src/renderer/src/components/ChatView.tsx +++ b/src/renderer/src/components/ChatView.tsx @@ -6,7 +6,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import type { StoredSessionUpdate } from '../../../shared/types' import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { ChevronDown, CheckCircle2, Circle, Loader2, Folder } from 'lucide-react' +import { ChevronDown, ChevronRight, CheckCircle2, Circle, Loader2, Folder } from 'lucide-react' import { ToolCallItem, type ToolCall, type AnsweredResponse } from './ToolCallItem' import { PermissionRequestItem } from './permission' import { usePermissionStore } from '../stores/permissionStore' @@ -91,7 +91,12 @@ export function ChatView({
{messages.map((msg, idx) => ( - + ))} {/* Permission request - show in feed (only for current session) */} @@ -486,10 +491,13 @@ function groupUpdatesIntoMessages(updates: StoredSessionUpdate[]): Message[] { interface MessageBubbleProps { message: Message + isLastMessage: boolean + isProcessing: boolean } -function MessageBubble({ message }: MessageBubbleProps) { +function MessageBubble({ message, isLastMessage, isProcessing }: MessageBubbleProps) { const isUser = message.role === 'user' + const isComplete = !isLastMessage || !isProcessing // User message - bubble style with support for images if (isUser) { @@ -519,42 +527,135 @@ function MessageBubble({ message }: MessageBubbleProps) { ) } - // Assistant message - render blocks in order to preserve time sequence + // Assistant message - use collapsible wrapper for completed messages + return +} + +// Render a single content block +function renderContentBlock(block: ContentBlock, idx: number) { + switch (block.type) { + case 'thought': + return + case 'tool_call': + return + case 'text': + return + case 'image': + return ( + {`Image + ) + case 'plan': + return + case 'error': + return ( + + ) + default: + return null + } +} + +// Collapsible assistant message - collapses tool calls and thoughts when message is complete +// Collapse condition: tool + thought >= 2 +// Collapse range: from first tool/thought to last tool/thought (inclusive), with all content in between +function CollapsibleAssistantMessage({ + blocks, + isComplete +}: { + blocks: ContentBlock[] + isComplete: boolean +}) { + const [isExpanded, setIsExpanded] = useState(false) + + // Count tool calls and thoughts (used for collapse condition) + const toolCallCount = blocks.filter((b) => b.type === 'tool_call').length + const thoughtCount = blocks.filter((b) => b.type === 'thought').length + + // Collapse condition: tool + thought >= 2 + const shouldCollapse = isComplete && toolCallCount + thoughtCount >= 2 + + // Not collapsible - render all blocks normally + if (!shouldCollapse) { + return ( +
{blocks.map((block, idx) => renderContentBlock(block, idx))}
+ ) + } + + // Find first and last tool/thought indices (needed for both expanded and collapsed states) + const firstCollapsibleIdx = blocks.findIndex( + (b) => b.type === 'tool_call' || b.type === 'thought' + ) + const lastCollapsibleIdx = blocks.findLastIndex( + (b) => b.type === 'tool_call' || b.type === 'thought' + ) + + // Split blocks: + // - beforeBlocks: content before first tool/thought (always visible) + // - collapsedBlocks: from first to last tool/thought inclusive (collapsible) + // - afterBlocks: content after last tool/thought (always visible) + const beforeBlocks = blocks.slice(0, firstCollapsibleIdx) + const collapsedBlocks = blocks.slice(firstCollapsibleIdx, lastCollapsibleIdx + 1) + const afterBlocks = blocks.slice(lastCollapsibleIdx + 1) + + // Count items within the collapsed region for summary + const collapsedToolCount = collapsedBlocks.filter((b) => b.type === 'tool_call').length + const collapsedThoughtCount = collapsedBlocks.filter((b) => b.type === 'thought').length + const collapsedMessageCount = collapsedBlocks.filter((b) => b.type === 'text').length + + // Build summary text + const summaryParts: string[] = [] + if (collapsedToolCount > 0) { + summaryParts.push(`${collapsedToolCount} tool call${collapsedToolCount > 1 ? 's' : ''}`) + } + if (collapsedThoughtCount > 0) { + summaryParts.push(`${collapsedThoughtCount} thought${collapsedThoughtCount > 1 ? 's' : ''}`) + } + if (collapsedMessageCount > 0) { + summaryParts.push(`${collapsedMessageCount} message${collapsedMessageCount > 1 ? 's' : ''}`) + } + return (
- {message.blocks.map((block, idx) => { - switch (block.type) { - case 'thought': - return - case 'tool_call': - return - case 'text': - return - case 'image': - return ( - {`Image - ) - case 'plan': - return - case 'error': - return ( - - ) - default: - return null - } - })} + {/* Content before first tool/thought (always visible) */} + {beforeBlocks.map((block, idx) => renderContentBlock(block, idx))} + + {/* Collapsible section using Collapsible component */} + + + + {summaryParts.join(', ')} + + + + {collapsedBlocks.map((block, idx) => + renderContentBlock(block, firstCollapsibleIdx + idx) + )} + + + + {/* Content after last tool/thought (always visible) */} + {afterBlocks.map((block, idx) => + renderContentBlock(block, firstCollapsibleIdx + collapsedBlocks.length + idx) + )}
) }