feat: non-collapsible widgets with tool call/result mapping
This commit is contained in:
@@ -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";
|
||||
|
@@ -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 */}
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
@@ -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;
|
||||
}
|
Reference in New Issue
Block a user