feat: non-collapsible widgets with tool call/result mapping

This commit is contained in:
Vivek R
2025-06-23 23:25:25 +05:30
parent 670630fb63
commit c52c29ebad
7 changed files with 415 additions and 470 deletions

View File

@@ -24,7 +24,6 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { StreamMessage } from "./StreamMessage";
import { ExecutionControlBar } from "./ExecutionControlBar";
import { ErrorBoundary } from "./ErrorBoundary";
import { enhanceMessages, type EnhancedMessage } from "@/types/enhanced-messages";
import { useVirtualizer } from "@tanstack/react-virtual";
interface AgentExecutionProps {
@@ -75,7 +74,6 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
const [model, setModel] = useState(agent.model || "sonnet");
const [isRunning, setIsRunning] = useState(false);
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [enhancedMessages, setEnhancedMessages] = useState<EnhancedMessage[]>([]);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
@@ -95,16 +93,74 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
const unlistenRefs = useRef<UnlistenFn[]>([]);
const elapsedTimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Filter out messages that shouldn't be displayed
const displayableMessages = React.useMemo(() => {
return messages.filter((message, index) => {
// Skip meta messages that don't have meaningful content
if (message.isMeta && !message.leafUuid && !message.summary) {
return false;
}
// Skip empty user messages
if (message.type === "user" && message.message) {
const msg = message.message;
if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) {
return false;
}
// Check if this is a user message with only tool results that are already displayed
if (Array.isArray(msg.content)) {
const hasOnlyHiddenToolResults = msg.content.every((content: any) => {
if (content.type !== "tool_result") return false;
// Check if this tool result should be hidden
let hasCorrespondingWidget = false;
if (content.tool_use_id) {
// Look for the matching tool_use in previous assistant messages
for (let i = index - 1; i >= 0; i--) {
const prevMsg = messages[i];
if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) {
const toolUse = prevMsg.message.content.find((c: any) =>
c.type === 'tool_use' && c.id === content.tool_use_id
);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = [
'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read',
'glob', 'bash', 'write', 'grep'
];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
hasCorrespondingWidget = true;
}
break;
}
}
}
}
return hasCorrespondingWidget && !content.is_error;
});
if (hasOnlyHiddenToolResults) {
return false;
}
}
}
return true;
});
}, [messages]);
// Virtualizers for efficient, smooth scrolling of potentially very long outputs
const rowVirtualizer = useVirtualizer({
count: enhancedMessages.length,
count: displayableMessages.length,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => 150, // fallback estimate; dynamically measured afterwards
overscan: 5,
});
const fullscreenRowVirtualizer = useVirtualizer({
count: enhancedMessages.length,
count: displayableMessages.length,
getScrollElement: () => fullscreenScrollRef.current,
estimateSize: () => 150,
overscan: 5,
@@ -132,19 +188,19 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
};
useEffect(() => {
if (enhancedMessages.length === 0) return;
if (displayableMessages.length === 0) return;
// Auto-scroll only if the user has not manually scrolled OR they are still at the bottom
const shouldAutoScroll = !hasUserScrolled || isAtBottom();
if (shouldAutoScroll) {
if (isFullscreenModalOpen) {
fullscreenRowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: "end", behavior: "smooth" });
fullscreenRowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: "end", behavior: "smooth" });
} else {
rowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: "end", behavior: "smooth" });
rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: "end", behavior: "smooth" });
}
}
}, [enhancedMessages.length, hasUserScrolled, isFullscreenModalOpen, rowVirtualizer, fullscreenRowVirtualizer]);
}, [displayableMessages.length, hasUserScrolled, isFullscreenModalOpen, rowVirtualizer, fullscreenRowVirtualizer]);
// Update elapsed time while running
useEffect(() => {
@@ -179,11 +235,6 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
setTotalTokens(tokens);
}, [messages]);
// Enhance messages whenever they change
useEffect(() => {
const enhanced = enhanceMessages(messages);
setEnhancedMessages(enhanced);
}, [messages]);
const handleSelectPath = async () => {
try {
@@ -620,7 +671,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
}}
>
<div ref={messagesContainerRef}>
{enhancedMessages.length === 0 && !isRunning && (
{messages.length === 0 && !isRunning && (
<div className="flex flex-col items-center justify-center h-full text-center">
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Ready to Execute</h3>
@@ -630,7 +681,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
</div>
)}
{isRunning && enhancedMessages.length === 0 && (
{isRunning && messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
@@ -645,7 +696,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
>
<AnimatePresence>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const message = enhancedMessages[virtualItem.index];
const message = displayableMessages[virtualItem.index];
return (
<motion.div
key={virtualItem.key}
@@ -658,7 +709,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
style={{ top: virtualItem.start }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={enhancedMessages} />
<StreamMessage message={message} streamMessages={messages} />
</ErrorBoundary>
</motion.div>
);
@@ -761,7 +812,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
}
}}
>
{enhancedMessages.length === 0 && !isRunning && (
{messages.length === 0 && !isRunning && (
<div className="flex flex-col items-center justify-center h-full text-center">
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Ready to Execute</h3>
@@ -771,7 +822,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
</div>
)}
{isRunning && enhancedMessages.length === 0 && (
{isRunning && messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
@@ -786,7 +837,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
>
<AnimatePresence>
{fullscreenRowVirtualizer.getVirtualItems().map((virtualItem) => {
const message = enhancedMessages[virtualItem.index];
const message = displayableMessages[virtualItem.index];
return (
<motion.div
key={virtualItem.key}
@@ -799,7 +850,7 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
style={{ top: virtualItem.start }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={enhancedMessages} />
<StreamMessage message={message} streamMessages={messages} />
</ErrorBoundary>
</motion.div>
);
@@ -817,4 +868,4 @@ export const AgentExecution: React.FC<AgentExecutionProps> = ({
};
// Import AGENT_ICONS for icon rendering
import { AGENT_ICONS } from "./CCAgents";
import { AGENT_ICONS } from "./CCAgents";

View File

@@ -32,7 +32,6 @@ import { SplitPane } from "@/components/ui/split-pane";
import { WebviewPreview } from "./WebviewPreview";
import { PreviewPromptDialog } from "./PreviewPromptDialog";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { enhanceMessages, type EnhancedMessage } from "@/types/enhanced-messages";
import { useVirtualizer } from "@tanstack/react-virtual";
interface ClaudeCodeSessionProps {
@@ -68,7 +67,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
}) => {
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [enhancedMessages, setEnhancedMessages] = useState<EnhancedMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
@@ -114,8 +112,66 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
return null;
}, [session, extractedSessionInfo, projectPath]);
// Filter out messages that shouldn't be displayed
const displayableMessages = useMemo(() => {
return messages.filter((message, index) => {
// Skip meta messages that don't have meaningful content
if (message.isMeta && !message.leafUuid && !message.summary) {
return false;
}
// Skip empty user messages
if (message.type === "user" && message.message) {
const msg = message.message;
if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) {
return false;
}
// Check if this is a user message with only tool results that are already displayed
if (Array.isArray(msg.content)) {
const hasOnlyHiddenToolResults = msg.content.every((content: any) => {
if (content.type !== "tool_result") return false;
// Check if this tool result should be hidden
let hasCorrespondingWidget = false;
if (content.tool_use_id) {
// Look for the matching tool_use in previous assistant messages
for (let i = index - 1; i >= 0; i--) {
const prevMsg = messages[i];
if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) {
const toolUse = prevMsg.message.content.find((c: any) =>
c.type === 'tool_use' && c.id === content.tool_use_id
);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = [
'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read',
'glob', 'bash', 'write', 'grep'
];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
hasCorrespondingWidget = true;
}
break;
}
}
}
}
return hasCorrespondingWidget && !content.is_error;
});
if (hasOnlyHiddenToolResults) {
return false;
}
}
}
return true;
});
}, [messages]);
const rowVirtualizer = useVirtualizer({
count: enhancedMessages.length,
count: displayableMessages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 150, // Estimate, will be dynamically measured
overscan: 5,
@@ -140,18 +196,13 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
}
}, [session]);
// Enhance messages whenever they change
useEffect(() => {
const enhanced = enhanceMessages(messages);
setEnhancedMessages(enhanced);
}, [messages]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (enhancedMessages.length > 0) {
rowVirtualizer.scrollToIndex(enhancedMessages.length - 1, { align: 'end', behavior: 'smooth' });
if (displayableMessages.length > 0) {
rowVirtualizer.scrollToIndex(displayableMessages.length - 1, { align: 'end', behavior: 'smooth' });
}
}, [enhancedMessages.length, rowVirtualizer]);
}, [displayableMessages.length, rowVirtualizer]);
// Calculate total tokens from messages
useEffect(() => {
@@ -586,7 +637,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
>
<AnimatePresence>
{rowVirtualizer.getVirtualItems().map((virtualItem) => {
const message = enhancedMessages[virtualItem.index];
const message = displayableMessages[virtualItem.index];
return (
<motion.div
key={virtualItem.key}
@@ -603,7 +654,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
>
<StreamMessage
message={message}
streamMessages={enhancedMessages}
streamMessages={messages}
onLinkDetected={handleLinkDetected}
/>
</motion.div>
@@ -778,7 +829,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</Tooltip>
</TooltipProvider>
{enhancedMessages.length > 0 && (
{messages.length > 0 && (
<Popover
trigger={
<Button
@@ -855,7 +906,7 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
)}
{isLoading && enhancedMessages.length === 0 && (
{isLoading && messages.length === 0 && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin" />
@@ -865,31 +916,6 @@ export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
</div>
</div>
)}
<AnimatePresence>
{enhancedMessages.map((message, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={enhancedMessages} />
</ErrorBoundary>
</motion.div>
))}
</AnimatePresence>
{/* Show loading indicator when processing, even if there are messages */}
{isLoading && enhancedMessages.length > 0 && (
<div className="flex items-center gap-2 p-4">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">
{isCancelling ? "Cancelling..." : "Processing..."}
</span>
</div>
)}
</div>
{/* Floating Prompt Input - Always visible */}

View File

@@ -1,189 +0,0 @@
import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
ChevronRight,
Loader2,
CheckCircle2,
AlertCircle,
Terminal,
FileText,
Search,
Edit,
FolderOpen,
Code
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { ToolCall, ToolResult } from "@/types/enhanced-messages";
interface CollapsibleToolResultProps {
toolCall: ToolCall;
toolResult?: ToolResult;
className?: string;
children?: React.ReactNode;
}
// Map tool names to icons
const toolIcons: Record<string, React.ReactNode> = {
read: <FileText className="h-4 w-4" />,
write: <Edit className="h-4 w-4" />,
edit: <Edit className="h-4 w-4" />,
multiedit: <Edit className="h-4 w-4" />,
bash: <Terminal className="h-4 w-4" />,
ls: <FolderOpen className="h-4 w-4" />,
glob: <Search className="h-4 w-4" />,
grep: <Search className="h-4 w-4" />,
task: <Code className="h-4 w-4" />,
default: <Terminal className="h-4 w-4" />
};
// Get tool icon based on tool name
function getToolIcon(toolName: string): React.ReactNode {
const lowerName = toolName.toLowerCase();
return toolIcons[lowerName] || toolIcons.default;
}
// Get display name for tools
function getToolDisplayName(toolName: string): string {
const displayNames: Record<string, string> = {
ls: "List directory",
read: "Read file",
write: "Write file",
edit: "Edit file",
multiedit: "Multi-edit file",
bash: "Run command",
glob: "Find files",
grep: "Search files",
task: "Run task",
todowrite: "Update todos",
todoread: "Read todos",
websearch: "Search web",
webfetch: "Fetch webpage"
};
const lowerName = toolName.toLowerCase();
return displayNames[lowerName] || toolName;
}
// Get a brief description of the tool call
function getToolDescription(toolCall: ToolCall): string {
const name = toolCall.name.toLowerCase();
const input = toolCall.input;
switch (name) {
case "read":
return input?.file_path ? `${input.file_path}` : "Reading file";
case "write":
return input?.file_path ? `${input.file_path}` : "Writing file";
case "edit":
case "multiedit":
return input?.file_path ? `${input.file_path}` : "Editing file";
case "bash":
return input?.command ? `${input.command}` : "Running command";
case "ls":
return input?.path ? `${input.path}` : "Listing directory";
case "glob":
return input?.pattern ? `${input.pattern}` : "Finding files";
case "grep":
return input?.pattern ? `${input.pattern}` : "Searching files";
case "task":
return input?.description || "Running task";
default:
return toolCall.name;
}
}
export const CollapsibleToolResult: React.FC<CollapsibleToolResultProps> = ({
toolCall,
toolResult,
className
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const isPending = !toolResult;
const isError = toolResult?.isError;
return (
<div className={cn("space-y-2", className)}>
{/* Tool Call Header */}
<div
className={cn(
"flex items-center gap-2 p-2 rounded-md border cursor-pointer transition-colors",
"hover:bg-muted/50",
isPending && "border-muted-foreground/20",
!isPending && !isError && "border-green-500/20",
isError && "border-destructive/20"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
{/* Expand/Collapse Icon */}
<motion.div
animate={{ rotate: isExpanded ? 90 : 0 }}
transition={{ duration: 0.2 }}
>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
</motion.div>
{/* Tool Icon */}
<div className="text-muted-foreground">
{getToolIcon(toolCall.name)}
</div>
{/* Tool Name */}
<span className="text-sm font-medium">
{getToolDisplayName(toolCall.name)}
</span>
{/* Tool Description */}
<span className="text-xs text-muted-foreground flex-1 truncate">
{getToolDescription(toolCall)}
</span>
{/* Status Icon */}
<div className="ml-auto">
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : isError ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : (
<CheckCircle2 className="h-4 w-4 text-green-500" />
)}
</div>
</div>
{/* Tool Result (collapsible) */}
<AnimatePresence>
{isExpanded && toolResult && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className={cn(
"ml-6 p-2 rounded-md border",
isError ? "border-destructive/20 bg-destructive/5" : "border-green-500/20 bg-green-500/5"
)}>
<div className="flex items-center gap-2 mb-2">
{isError ? (
<AlertCircle className="h-4 w-4 text-destructive" />
) : (
<CheckCircle2 className="h-4 w-4 text-green-500" />
)}
<span className="text-sm font-medium">
{isError ? "Tool Error" : "Tool Result"}
</span>
</div>
{/* Result Content */}
<div className="text-xs font-mono overflow-x-auto whitespace-pre-wrap">
{typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content, null, 2)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -12,7 +12,6 @@ import type { AgentRun } from '@/lib/api';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { StreamMessage } from './StreamMessage';
import { ErrorBoundary } from './ErrorBoundary';
import { enhanceMessages, type EnhancedMessage } from '@/types/enhanced-messages';
interface SessionOutputViewerProps {
session: AgentRun;
@@ -40,7 +39,6 @@ export interface ClaudeStreamMessage {
export function SessionOutputViewer({ session, onClose, className }: SessionOutputViewerProps) {
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [enhancedMessages, setEnhancedMessages] = useState<EnhancedMessage[]>([]);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
@@ -91,11 +89,6 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
}
}, [messages, hasUserScrolled, isFullscreen]);
// Enhance messages whenever they change
useEffect(() => {
const enhanced = enhanceMessages(messages);
setEnhancedMessages(enhanced);
}, [messages]);
const loadOutput = async (skipCache = false) => {
if (!session.id) return;
@@ -317,13 +310,13 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
</Badge>
)}
<span className="text-xs text-muted-foreground">
{enhancedMessages.length} messages
{messages.length} messages
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{enhancedMessages.length > 0 && (
{messages.length > 0 && (
<>
<Button
variant="outline"
@@ -410,7 +403,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
}
}}
>
{enhancedMessages.length === 0 ? (
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
{session.status === 'running' ? (
<>
@@ -439,7 +432,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
) : (
<>
<AnimatePresence>
{enhancedMessages.map((message, index) => (
{messages.map((message: ClaudeStreamMessage, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
@@ -447,7 +440,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
transition={{ duration: 0.2 }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={enhancedMessages} />
<StreamMessage message={message} streamMessages={messages} />
</ErrorBoundary>
</motion.div>
))}
@@ -544,7 +537,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
}
}}
>
{enhancedMessages.length === 0 ? (
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
{session.status === 'running' ? (
<>
@@ -563,7 +556,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
) : (
<>
<AnimatePresence>
{enhancedMessages.map((message, index) => (
{messages.map((message: ClaudeStreamMessage, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
@@ -571,7 +564,7 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
transition={{ duration: 0.2 }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={enhancedMessages} />
<StreamMessage message={message} streamMessages={messages} />
</ErrorBoundary>
</motion.div>
))}
@@ -596,4 +589,4 @@ export function SessionOutputViewer({ session, onClose, className }: SessionOutp
</ToastContainer>
</>
);
}
}

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useState, useEffect, useMemo } from "react";
import {
Terminal,
User,
@@ -13,8 +13,6 @@ import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
import type { ClaudeStreamMessage } from "./AgentExecution";
import { CollapsibleToolResult } from "./CollapsibleToolResult";
import type { EnhancedMessage } from "@/types/enhanced-messages";
import {
TodoWidget,
LSWidget,
@@ -39,9 +37,9 @@ import {
} from "./ToolWidgets";
interface StreamMessageProps {
message: ClaudeStreamMessage | EnhancedMessage;
message: ClaudeStreamMessage;
className?: string;
streamMessages: (ClaudeStreamMessage | EnhancedMessage)[];
streamMessages: ClaudeStreamMessage[];
onLinkDetected?: (url: string) => void;
}
@@ -49,6 +47,32 @@ interface StreamMessageProps {
* Component to render a single Claude Code stream message
*/
const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, className, streamMessages, onLinkDetected }) => {
// State to track tool results mapped by tool call ID
const [toolResults, setToolResults] = useState<Map<string, any>>(new Map());
// Extract all tool results from stream messages
useEffect(() => {
const results = new Map<string, any>();
// Iterate through all messages to find tool results
streamMessages.forEach(msg => {
if (msg.type === "user" && msg.message?.content && Array.isArray(msg.message.content)) {
msg.message.content.forEach((content: any) => {
if (content.type === "tool_result" && content.tool_use_id) {
results.set(content.tool_use_id, content);
}
});
}
});
setToolResults(results);
}, [streamMessages]);
// Helper to get tool result for a specific tool call ID
const getToolResult = (toolId: string | undefined): any => {
if (!toolId) return null;
return toolResults.get(toolId) || null;
};
try {
// Skip rendering for meta messages that don't have meaningful content
if (message.isMeta && !message.leafUuid && !message.summary) {
@@ -75,8 +99,6 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
// Assistant message
if (message.type === "assistant" && message.message) {
const msg = message.message;
const enhancedMsg = message as EnhancedMessage;
const hasToolCalls = enhancedMsg.toolCalls && enhancedMsg.toolCalls.length > 0;
return (
<Card className={cn("border-primary/20 bg-primary/5", className)}>
@@ -126,86 +148,73 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
if (content.type === "tool_use") {
const toolName = content.name?.toLowerCase();
const input = content.input;
const toolId = content.id;
// Get the tool result if available
const toolResult = getToolResult(toolId);
// Function to render the appropriate tool widget
const renderToolWidget = () => {
// Task tool - for sub-agent tasks
if (toolName === "task" && input) {
return <TaskWidget description={input.description} prompt={input.prompt} />;
return <TaskWidget description={input.description} prompt={input.prompt} result={toolResult} />;
}
// Edit tool
if (toolName === "edit" && input?.file_path) {
return <EditWidget {...input} />;
return <EditWidget {...input} result={toolResult} />;
}
// MultiEdit tool
if (toolName === "multiedit" && input?.file_path && input?.edits) {
return <MultiEditWidget {...input} />;
return <MultiEditWidget {...input} result={toolResult} />;
}
// MCP tools (starting with mcp__)
if (content.name?.startsWith("mcp__")) {
return <MCPWidget toolName={content.name} input={input} />;
return <MCPWidget toolName={content.name} input={input} result={toolResult} />;
}
// TodoWrite tool
if (toolName === "todowrite" && input?.todos) {
return <TodoWidget todos={input.todos} />;
return <TodoWidget todos={input.todos} result={toolResult} />;
}
// LS tool
if (toolName === "ls" && input?.path) {
return <LSWidget path={input.path} />;
return <LSWidget path={input.path} result={toolResult} />;
}
// Read tool
if (toolName === "read" && input?.file_path) {
return <ReadWidget filePath={input.file_path} />;
return <ReadWidget filePath={input.file_path} result={toolResult} />;
}
// Glob tool
if (toolName === "glob" && input?.pattern) {
return <GlobWidget pattern={input.pattern} />;
return <GlobWidget pattern={input.pattern} result={toolResult} />;
}
// Bash tool
if (toolName === "bash" && input?.command) {
return <BashWidget command={input.command} description={input.description} />;
return <BashWidget command={input.command} description={input.description} result={toolResult} />;
}
// Write tool
if (toolName === "write" && input?.file_path && input?.content) {
return <WriteWidget filePath={input.file_path} content={input.content} />;
return <WriteWidget filePath={input.file_path} content={input.content} result={toolResult} />;
}
// Grep tool
if (toolName === "grep" && input?.pattern) {
return <GrepWidget pattern={input.pattern} include={input.include} path={input.path} exclude={input.exclude} />;
return <GrepWidget pattern={input.pattern} include={input.include} path={input.path} exclude={input.exclude} result={toolResult} />;
}
// Default - return null, will be handled by CollapsibleToolResult
// Default - return null
return null;
};
// Check if we have enhanced message with tool results
if (hasToolCalls && enhancedMsg.toolResults && content.id) {
const toolCall = enhancedMsg.toolCalls?.find(tc => tc.id === content.id);
const toolResult = enhancedMsg.toolResults.get(content.id);
if (toolCall && toolResult) {
// Only use collapsible widget when we have both tool call AND result
return (
<CollapsibleToolResult
key={idx}
toolCall={toolCall}
toolResult={toolResult}
/>
);
}
}
// Render the normal tool widget (for pending tool calls or non-enhanced messages)
// Render the tool widget
const widget = renderToolWidget();
if (widget) {
return <div key={idx}>{widget}</div>;
@@ -301,6 +310,40 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
{Array.isArray(msg.content) && msg.content.map((content: any, idx: number) => {
// Tool result
if (content.type === "tool_result") {
// Skip rendering tool results that are already displayed by tool widgets
// We need to check if this result corresponds to a tool that has its own widget
// Find the corresponding tool use for this result
let hasCorrespondingWidget = false;
if (content.tool_use_id && streamMessages) {
// Look for the matching tool_use in previous assistant messages
for (let i = streamMessages.length - 1; i >= 0; i--) {
const prevMsg = streamMessages[i];
if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) {
const toolUse = prevMsg.message.content.find((c: any) =>
c.type === 'tool_use' && c.id === content.tool_use_id
);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
// List of tools that display their own results
const toolsWithWidgets = [
'task', 'edit', 'multiedit', 'todowrite', 'ls', 'read',
'glob', 'bash', 'write', 'grep'
];
// Also check for MCP tools
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
hasCorrespondingWidget = true;
}
break;
}
}
}
}
// If the tool has its own widget that displays results, skip rendering the duplicate
if (hasCorrespondingWidget && !content.is_error) {
return null;
}
// Extract the actual content string
let contentText = '';
if (typeof content.content === 'string') {
@@ -320,7 +363,7 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
}
}
// Check for system-reminder tags
// Always show system reminders regardless of widget status
const reminderMatch = contentText.match(/<system-reminder>(.*?)<\/system-reminder>/s);
if (reminderMatch) {
const reminderMessage = reminderMatch[1].trim();
@@ -634,4 +677,4 @@ const StreamMessageComponent: React.FC<StreamMessageProps> = ({ message, classNa
}
};
export const StreamMessage = React.memo(StreamMessageComponent);
export const StreamMessage = React.memo(StreamMessageComponent);

View File

@@ -55,7 +55,7 @@ import { detectLinks, makeLinksClickable } from "@/lib/linkDetector";
/**
* Widget for TodoWrite tool - displays a beautiful TODO list
*/
export const TodoWidget: React.FC<{ todos: any[] }> = ({ todos }) => {
export const TodoWidget: React.FC<{ todos: any[]; result?: any }> = ({ todos, result }) => {
const statusIcons = {
completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
in_progress: <Clock className="h-4 w-4 text-blue-500 animate-pulse" />,
@@ -112,7 +112,38 @@ export const TodoWidget: React.FC<{ todos: any[] }> = ({ todos }) => {
/**
* Widget for LS (List Directory) tool
*/
export const LSWidget: React.FC<{ path: string }> = ({ path }) => {
export const LSWidget: React.FC<{ path: string; result?: any }> = ({ path, result }) => {
// If we have a result, show it using the LSResultWidget
if (result) {
let resultContent = '';
if (typeof result.content === 'string') {
resultContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
resultContent = result.content.text;
} else if (Array.isArray(result.content)) {
resultContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
resultContent = JSON.stringify(result.content, null, 2);
}
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<FolderOpen className="h-4 w-4 text-primary" />
<span className="text-sm">Directory contents for:</span>
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded">
{path}
</code>
</div>
{resultContent && <LSResultWidget content={resultContent} />}
</div>
);
}
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<FolderOpen className="h-4 w-4 text-primary" />
@@ -120,6 +151,12 @@ export const LSWidget: React.FC<{ path: string }> = ({ path }) => {
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded">
{path}
</code>
{!result && (
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 bg-blue-500 rounded-full animate-pulse" />
<span>Loading...</span>
</div>
)}
</div>
);
};
@@ -298,7 +335,38 @@ export const LSResultWidget: React.FC<{ content: string }> = ({ content }) => {
/**
* Widget for Read tool
*/
export const ReadWidget: React.FC<{ filePath: string }> = ({ filePath }) => {
export const ReadWidget: React.FC<{ filePath: string; result?: any }> = ({ filePath, result }) => {
// If we have a result, show it using the ReadResultWidget
if (result) {
let resultContent = '';
if (typeof result.content === 'string') {
resultContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
resultContent = result.content.text;
} else if (Array.isArray(result.content)) {
resultContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
resultContent = JSON.stringify(result.content, null, 2);
}
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<FileText className="h-4 w-4 text-primary" />
<span className="text-sm">File content:</span>
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded flex-1 truncate">
{filePath}
</code>
</div>
{resultContent && <ReadResultWidget content={resultContent} filePath={filePath} />}
</div>
);
}
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<FileText className="h-4 w-4 text-primary" />
@@ -306,6 +374,12 @@ export const ReadWidget: React.FC<{ filePath: string }> = ({ filePath }) => {
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded flex-1 truncate">
{filePath}
</code>
{!result && (
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 bg-blue-500 rounded-full animate-pulse" />
<span>Loading...</span>
</div>
)}
</div>
);
};
@@ -478,14 +552,55 @@ export const ReadResultWidget: React.FC<{ content: string; filePath?: string }>
/**
* Widget for Glob tool
*/
export const GlobWidget: React.FC<{ pattern: string }> = ({ pattern }) => {
export const GlobWidget: React.FC<{ pattern: string; result?: any }> = ({ pattern, result }) => {
// Extract result content if available
let resultContent = '';
let isError = false;
if (result) {
isError = result.is_error || false;
if (typeof result.content === 'string') {
resultContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
resultContent = result.content.text;
} else if (Array.isArray(result.content)) {
resultContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
resultContent = JSON.stringify(result.content, null, 2);
}
}
}
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<Search className="h-4 w-4 text-primary" />
<span className="text-sm">Searching for pattern:</span>
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded">
{pattern}
</code>
<div className="space-y-2">
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
<Search className="h-4 w-4 text-primary" />
<span className="text-sm">Searching for pattern:</span>
<code className="text-sm font-mono bg-background px-2 py-0.5 rounded">
{pattern}
</code>
{!result && (
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 bg-blue-500 rounded-full animate-pulse" />
<span>Searching...</span>
</div>
)}
</div>
{/* Show result if available */}
{result && (
<div className={cn(
"p-3 rounded-md border text-xs font-mono whitespace-pre-wrap overflow-x-auto",
isError
? "border-red-500/20 bg-red-500/5 text-red-400"
: "border-green-500/20 bg-green-500/5 text-green-300"
)}>
{resultContent || (isError ? "Search failed" : "No matches found")}
</div>
)}
</div>
);
};
@@ -493,7 +608,32 @@ export const GlobWidget: React.FC<{ pattern: string }> = ({ pattern }) => {
/**
* Widget for Bash tool
*/
export const BashWidget: React.FC<{ command: string; description?: string }> = ({ command, description }) => {
export const BashWidget: React.FC<{
command: string;
description?: string;
result?: any;
}> = ({ command, description, result }) => {
// Extract result content if available
let resultContent = '';
let isError = false;
if (result) {
isError = result.is_error || false;
if (typeof result.content === 'string') {
resultContent = result.content;
} else if (result.content && typeof result.content === 'object') {
if (result.content.text) {
resultContent = result.content.text;
} else if (Array.isArray(result.content)) {
resultContent = result.content
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
.join('\n');
} else {
resultContent = JSON.stringify(result.content, null, 2);
}
}
}
return (
<div className="rounded-lg border bg-zinc-950 overflow-hidden">
<div className="px-4 py-2 bg-zinc-900/50 flex items-center gap-2 border-b">
@@ -505,11 +645,30 @@ export const BashWidget: React.FC<{ command: string; description?: string }> = (
<span className="text-xs text-muted-foreground">{description}</span>
</>
)}
{/* Show loading indicator when no result yet */}
{!result && (
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse" />
<span>Running...</span>
</div>
)}
</div>
<div className="p-4">
<code className="text-xs font-mono text-green-400">
<div className="p-4 space-y-3">
<code className="text-xs font-mono text-green-400 block">
$ {command}
</code>
{/* Show result if available */}
{result && (
<div className={cn(
"mt-3 p-3 rounded-md border text-xs font-mono whitespace-pre-wrap overflow-x-auto",
isError
? "border-red-500/20 bg-red-500/5 text-red-400"
: "border-green-500/20 bg-green-500/5 text-green-300"
)}>
{resultContent || (isError ? "Command failed" : "Command completed")}
</div>
)}
</div>
</div>
);
@@ -518,7 +677,7 @@ export const BashWidget: React.FC<{ command: string; description?: string }> = (
/**
* Widget for Write tool
*/
export const WriteWidget: React.FC<{ filePath: string; content: string }> = ({ filePath, content }) => {
export const WriteWidget: React.FC<{ filePath: string; content: string; result?: any }> = ({ filePath, content, result }) => {
const [isMaximized, setIsMaximized] = useState(false);
// Extract file extension for syntax highlighting
@@ -692,7 +851,8 @@ export const GrepWidget: React.FC<{
include?: string;
path?: string;
exclude?: string;
}> = ({ pattern, include, path, exclude }) => {
result?: any;
}> = ({ pattern, include, path, exclude, result }) => {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 mb-2">
@@ -782,8 +942,9 @@ const getLanguage = (path: string) => {
export const EditWidget: React.FC<{
file_path: string;
old_string: string;
new_string: string
}> = ({ file_path, old_string, new_string }) => {
new_string: string;
result?: any;
}> = ({ file_path, old_string, new_string, result }) => {
const diffResult = Diff.diffLines(old_string || '', new_string || '', {
newlineIsToken: true,
@@ -942,7 +1103,8 @@ export const EditResultWidget: React.FC<{ content: string }> = ({ content }) =>
export const MCPWidget: React.FC<{
toolName: string;
input?: any;
}> = ({ toolName, input }) => {
result?: any;
}> = ({ toolName, input, result }) => {
const [isExpanded, setIsExpanded] = useState(false);
// Parse the tool name to extract components
@@ -1243,7 +1405,8 @@ export const SummaryWidget: React.FC<{
export const MultiEditWidget: React.FC<{
file_path: string;
edits: Array<{ old_string: string; new_string: string }>;
}> = ({ file_path, edits }) => {
result?: any;
}> = ({ file_path, edits, result }) => {
const [isExpanded, setIsExpanded] = useState(false);
const language = getLanguage(file_path);
@@ -1653,7 +1816,8 @@ export const SystemInitializedWidget: React.FC<{
export const TaskWidget: React.FC<{
description?: string;
prompt?: string;
}> = ({ description, prompt }) => {
result?: any;
}> = ({ description, prompt, result }) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
@@ -1699,4 +1863,4 @@ export const TaskWidget: React.FC<{
</div>
</div>
);
};
};

View File

@@ -1,143 +0,0 @@
// Enhanced message types that map tool calls with their results
export interface ToolCall {
id: string;
name: string;
input: any;
timestamp?: number;
}
export interface ToolResult {
toolUseId: string;
content: any;
isError?: boolean;
timestamp?: number;
}
export interface EnhancedMessage {
type: "system" | "assistant" | "user" | "result";
subtype?: string;
message?: {
content?: any[];
usage?: {
input_tokens: number;
output_tokens: number;
};
};
usage?: {
input_tokens: number;
output_tokens: number;
};
// Enhanced fields for tool call mapping
toolCalls?: ToolCall[];
toolResults?: Map<string, ToolResult>;
[key: string]: any;
}
// Helper function to extract tool calls from assistant messages
export function extractToolCalls(message: any): ToolCall[] {
const toolCalls: ToolCall[] = [];
if (message.type === "assistant" && message.message?.content && Array.isArray(message.message.content)) {
for (const content of message.message.content) {
if (content.type === "tool_use" && content.id) {
toolCalls.push({
id: content.id,
name: content.name || "unknown",
input: content.input,
timestamp: Date.now()
});
}
}
}
return toolCalls;
}
// Helper function to extract tool results from user messages
export function extractToolResult(message: any): ToolResult | null {
if (message.type === "user" && message.message?.content && Array.isArray(message.message.content)) {
for (const content of message.message.content) {
if (content.type === "tool_result" && content.tool_use_id) {
return {
toolUseId: content.tool_use_id,
content: content.content,
isError: content.is_error || false,
timestamp: Date.now()
};
}
}
}
return null;
}
// Function to enhance messages with tool call/result mapping
export function enhanceMessages(rawMessages: any[]): EnhancedMessage[] {
const enhanced: EnhancedMessage[] = [];
// First pass: create enhanced messages and collect all tool calls
const toolCallMap = new Map<string, { message: EnhancedMessage, toolCall: ToolCall }>();
for (let i = 0; i < rawMessages.length; i++) {
const message = rawMessages[i];
const enhancedMessage: EnhancedMessage = { ...message };
// Extract tool calls from assistant messages
const toolCalls = extractToolCalls(message);
if (toolCalls.length > 0) {
enhancedMessage.toolCalls = toolCalls;
enhancedMessage.toolResults = new Map();
// Store reference to tool calls for later mapping
for (const toolCall of toolCalls) {
toolCallMap.set(toolCall.id, { message: enhancedMessage, toolCall });
}
}
enhanced.push(enhancedMessage);
}
// Second pass: extract tool results and attach them to corresponding tool calls
for (let i = 0; i < rawMessages.length; i++) {
const message = rawMessages[i];
// Extract tool results from user messages
if (message.type === "user" && message.message?.content && Array.isArray(message.message.content)) {
let hasOnlyMappedToolResults = true;
let hasAnyContent = false;
for (const content of message.message.content) {
if (content.type === "tool_result" && content.tool_use_id) {
hasAnyContent = true;
const toolCallInfo = toolCallMap.get(content.tool_use_id);
if (toolCallInfo) {
// Create tool result
const toolResult: ToolResult = {
toolUseId: content.tool_use_id,
content: content.content,
isError: content.is_error || false,
timestamp: Date.now()
};
// Attach result to the assistant message that contains the tool call
toolCallInfo.message.toolResults?.set(content.tool_use_id, toolResult);
} else {
// This tool result doesn't have a matching tool call
hasOnlyMappedToolResults = false;
}
} else if (content.type !== "tool_result") {
// This message has non-tool-result content
hasOnlyMappedToolResults = false;
hasAnyContent = true;
}
}
// Only mark as meta if the message contains ONLY tool results that have been mapped
if (hasAnyContent && hasOnlyMappedToolResults) {
enhanced[i].isMeta = true;
}
}
}
return enhanced;
}