claw-web-v2 / client /src /components /ActionTree.tsx
Claw Web
feat: openlawX personality, anti-censorship prefill, progress indicators, jump to live
d2fe867
/**
* ActionTree — Manus-style right panel showing execution tree.
* Displays tool calls, file operations, and terminal output in a
* hierarchical timeline view instead of inline in chat messages.
*/
import { useState, useRef, useEffect } from "react";
import {
ChevronRight,
ChevronDown,
Terminal,
FileText,
Search,
Globe,
Code,
CheckCircle2,
XCircle,
Loader2,
Clock,
Eye,
Copy,
Maximize2,
Minimize2,
} from "lucide-react";
import type { ChatMessage } from "@/hooks/useChat";
import { CodeDiffViewer } from "./CodeDiffViewer";
// Tool category mapping for icons and colors
const TOOL_CATEGORIES: Record<string, { icon: typeof Terminal; color: string; label: string }> = {
bash: { icon: Terminal, color: "text-green-400", label: "Shell" },
PowerShell: { icon: Terminal, color: "text-blue-400", label: "PowerShell" },
read_file: { icon: FileText, color: "text-sky-400", label: "Read File" },
write_file: { icon: FileText, color: "text-amber-400", label: "Write File" },
edit_file: { icon: FileText, color: "text-orange-400", label: "Edit File" },
multi_edit_file: { icon: FileText, color: "text-orange-400", label: "Multi Edit" },
glob_search: { icon: Search, color: "text-purple-400", label: "Glob Search" },
grep_search: { icon: Search, color: "text-purple-400", label: "Grep Search" },
WebSearch: { icon: Globe, color: "text-cyan-400", label: "Web Search" },
WebFetch: { icon: Globe, color: "text-cyan-400", label: "Fetch URL" },
TodoWrite: { icon: Code, color: "text-primary", label: "Plan Update" },
TodoRead: { icon: Code, color: "text-primary", label: "Plan Read" },
StructuredOutput: { icon: Code, color: "text-indigo-400", label: "Output" },
SendUserMessage: { icon: Code, color: "text-yellow-400", label: "Ask User" },
};
function getToolMeta(name: string) {
return TOOL_CATEGORIES[name] || { icon: Code, color: "text-muted-foreground", label: name };
}
interface ToolCall {
id: string;
name: string;
arguments: string;
result?: string;
isError?: boolean;
isExecuting?: boolean;
durationMs?: number;
}
// Extract a short summary from tool arguments
function getToolSummary(name: string, args: string): string {
try {
const parsed = JSON.parse(args);
switch (name) {
case "bash":
case "PowerShell":
return parsed.command?.slice(0, 80) || "...";
case "read_file":
return parsed.path?.split("/").pop() || parsed.path || "...";
case "write_file":
case "edit_file":
case "multi_edit_file":
return parsed.path?.split("/").pop() || parsed.path || "...";
case "glob_search":
return parsed.pattern || "...";
case "grep_search":
return parsed.pattern || parsed.query || "...";
case "WebSearch":
return parsed.query || "...";
case "WebFetch":
return parsed.url?.slice(0, 60) || "...";
case "TodoWrite":
return `${(parsed.todos || []).length} items`;
default:
return Object.values(parsed).filter(v => typeof v === "string").join(", ").slice(0, 60) || "...";
}
} catch {
return args.slice(0, 60) || "...";
}
}
interface ActionNodeProps {
tool: ToolCall;
index: number;
isLast: boolean;
}
function ActionNode({ tool, index, isLast }: ActionNodeProps) {
const [expanded, setExpanded] = useState(false);
const meta = getToolMeta(tool.name);
const Icon = meta.icon;
const summary = getToolSummary(tool.name, tool.arguments);
const [copied, setCopied] = useState(false);
const statusIcon = tool.isExecuting ? (
<Loader2 className="size-3 animate-spin text-primary" />
) : tool.isError ? (
<XCircle className="size-3 text-destructive" />
) : (
<CheckCircle2 className="size-3 text-green-500" />
);
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="relative">
{/* Timeline connector */}
{!isLast && (
<div className="absolute left-[15px] top-[28px] bottom-0 w-px bg-border" />
)}
{/* Node header */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-start gap-2 w-full text-left px-2 py-1.5 rounded-md hover:bg-accent/50 transition-colors group"
>
{/* Timeline dot */}
<div className={`mt-0.5 size-[30px] rounded-full border border-border bg-background flex items-center justify-center shrink-0 ${meta.color}`}>
<Icon className="size-3.5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{expanded ? (
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
<span className="text-xs font-medium">{meta.label}</span>
{statusIcon}
{tool.durationMs !== undefined && (
<span className="text-[10px] text-muted-foreground/60 font-mono flex items-center gap-0.5">
<Clock className="size-2.5" />
{tool.durationMs < 1000
? `${tool.durationMs}ms`
: `${(tool.durationMs / 1000).toFixed(1)}s`}
</span>
)}
</div>
<p className="text-[11px] text-muted-foreground truncate mt-0.5 font-mono">
{summary}
</p>
</div>
</button>
{/* Expanded content */}
{expanded && (
<div className="ml-[39px] mr-2 mb-2 mt-1">
{/* Input */}
<div className="relative group/block">
<div className="text-[10px] text-muted-foreground/60 mb-1 flex items-center gap-1">
<Eye className="size-2.5" /> Input
<button
onClick={() => handleCopy(tool.arguments)}
className="ml-auto opacity-0 group-hover/block:opacity-100 transition-opacity"
>
<Copy className="size-2.5 text-muted-foreground hover:text-foreground" />
</button>
</div>
<pre className="text-[11px] font-mono bg-secondary/30 rounded-md p-2 overflow-x-auto max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all text-foreground/80">
{(() => {
try {
return JSON.stringify(JSON.parse(tool.arguments), null, 2);
} catch {
return tool.arguments;
}
})()}
</pre>
</div>
{/* Output — with CodeDiffViewer for edit/write operations */}
{tool.result && (
<div className="relative group/block mt-2">
{(tool.name === "edit_file" || tool.name === "multi_edit_file") && tool.result && !tool.isError ? (
(() => {
// Try to extract old/new content from edit result
try {
const parsed = JSON.parse(tool.arguments);
const fileName = parsed.path?.split("/").pop() || parsed.path;
// Show diff viewer with result as the "after" content
return (
<CodeDiffViewer
oldContent={parsed.old_string || parsed.old_text || ""}
newContent={parsed.new_string || parsed.new_text || parsed.old_string || ""}
fileName={fileName}
/>
);
} catch {
return null;
}
})() || (
<pre className="text-[11px] font-mono bg-secondary/30 rounded-md p-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-all text-foreground/80">
{tool.result.length > 5000 ? tool.result.slice(0, 5000) + "\n\n[...truncated]" : tool.result}
</pre>
)
) : (
<>
<div className="text-[10px] text-muted-foreground/60 mb-1 flex items-center gap-1">
<Terminal className="size-2.5" /> Output
{tool.isError && (
<span className="text-destructive text-[9px]">ERROR</span>
)}
<button
onClick={() => handleCopy(tool.result!)}
className="ml-auto opacity-0 group-hover/block:opacity-100 transition-opacity"
>
<Copy className="size-2.5 text-muted-foreground hover:text-foreground" />
</button>
</div>
<pre
className={`text-[11px] font-mono rounded-md p-2 overflow-x-auto max-h-[300px] overflow-y-auto whitespace-pre-wrap break-all ${
tool.isError
? "bg-destructive/10 text-destructive/80 border border-destructive/20"
: "bg-secondary/30 text-foreground/80"
}`}
>
{tool.result.length > 5000
? tool.result.slice(0, 5000) + "\n\n[...truncated]"
: tool.result}
</pre>
</>
)}
</div>
)}
</div>
)}
</div>
);
}
interface ActionTreeProps {
messages: ChatMessage[];
isStreaming: boolean;
}
export function ActionTree({ messages, isStreaming }: ActionTreeProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
// Collect all tool calls from all assistant messages
const allToolCalls: ToolCall[] = [];
for (const msg of messages) {
if (msg.role === "assistant" && msg.toolCalls) {
for (const tc of msg.toolCalls) {
allToolCalls.push(tc);
}
}
}
// Auto-scroll to bottom when new tool calls appear
useEffect(() => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [allToolCalls.length, autoScroll]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
setAutoScroll(isAtBottom);
};
if (allToolCalls.length === 0) {
return (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/40 gap-2 p-4">
<Terminal className="size-8" />
<p className="text-xs text-center">
Actions will appear here as the agent works
</p>
</div>
);
}
return (
<div className="h-full flex flex-col relative">
{/* Header with progress indicators */}
{(() => {
const completed = allToolCalls.filter(tc => tc.result && !tc.isError && !tc.isExecuting).length;
const errors = allToolCalls.filter(tc => tc.isError).length;
const running = allToolCalls.filter(tc => tc.isExecuting).length;
const total = allToolCalls.length;
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<div className="border-b border-border">
<div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold">Actions</span>
<span className="text-[10px] text-muted-foreground font-mono bg-secondary/50 px-1.5 py-0.5 rounded">
{total}
</span>
</div>
<div className="flex items-center gap-2">
{completed > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-green-500">
<CheckCircle2 className="size-3" />{completed}
</span>
)}
{errors > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-destructive">
<XCircle className="size-3" />{errors}
</span>
)}
{running > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-primary">
<Loader2 className="size-3 animate-spin" />{running}
</span>
)}
{isStreaming && running === 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-primary">
<Loader2 className="size-3 animate-spin" />thinking
</span>
)}
</div>
</div>
{/* Progress bar */}
{total > 0 && (
<div className="h-0.5 bg-secondary/30">
<div
className={`h-full transition-all duration-300 ${errors > 0 ? 'bg-destructive/60' : 'bg-green-500/60'}`}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
);
})()}
{/* Jump to live button */}
{!autoScroll && (
<button
onClick={() => {
setAutoScroll(true);
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
}}
className="absolute bottom-4 right-4 z-10 flex items-center gap-1 px-2.5 py-1 rounded-full bg-primary text-primary-foreground text-[10px] font-medium shadow-lg hover:bg-primary/90 transition-colors"
>
<Loader2 className="size-3 animate-spin" />
Jump to live
</button>
)}
{/* Timeline */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-2 space-y-0.5"
>
{allToolCalls.map((tc, i) => (
<ActionNode
key={tc.id || i}
tool={tc}
index={i}
isLast={i === allToolCalls.length - 1}
/>
))}
</div>
</div>
);
}