Spaces:
Sleeping
Sleeping
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> | |
| ); | |
| } | |