claw-web-v2 / client /src /components /RightPanel.tsx
Claw Web
feat: complete P0 fixes + Manus UI overhaul phase 2
6aeba60
/**
* RightPanel β€” Manus-style tabbed right panel.
* Tabs: Actions (tool timeline), Files (explorer), Preview (live viewport).
*/
import { useState, useCallback, useEffect, useRef } from "react";
import {
Activity,
FolderTree,
Monitor,
Folder,
File,
ChevronRight,
ChevronDown,
RefreshCw,
Home,
ArrowUp,
FileText,
FileCode,
FileJson,
Image as ImageIcon,
Loader2,
ExternalLink,
} from "lucide-react";
import { ActionTree } from "./ActionTree";
import type { ChatMessage } from "@/hooks/useChat";
import { cn } from "@/lib/utils";
// ─── Tab definitions ──────────────────────────────────────────────────
type TabId = "actions" | "files" | "preview";
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "actions", label: "Actions", icon: Activity },
{ id: "files", label: "Files", icon: FolderTree },
{ id: "preview", label: "Preview", icon: Monitor },
];
// ─── File Explorer types ──────────────────────────────────────────────
interface FileNode {
name: string;
path: string;
isDirectory: boolean;
size?: number;
}
function getFileIcon(name: string) {
const ext = name.split(".").pop()?.toLowerCase() || "";
if (["ts", "tsx", "js", "jsx", "py", "rs", "go", "java", "c", "cpp", "rb", "sh"].includes(ext))
return FileCode;
if (["json", "yaml", "yml", "toml", "xml", "env"].includes(ext))
return FileJson;
if (["png", "jpg", "jpeg", "gif", "svg", "webp", "ico"].includes(ext))
return ImageIcon;
if (["md", "txt", "log", "csv"].includes(ext))
return FileText;
return File;
}
function formatSize(bytes?: number): string {
if (bytes === undefined) return "";
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
}
// ─── Inline File Explorer ─────────────────────────────────────────────
function FileExplorer() {
const [currentPath, setCurrentPath] = useState("/home/user");
const [files, setFiles] = useState<FileNode[]>([]);
const [loading, setLoading] = useState(false);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const fetchFiles = useCallback(async (dirPath: string) => {
setLoading(true);
try {
const response = await fetch("/api/chat/slash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "/files", args: `list ${dirPath}` }),
});
const data = await response.json();
if (data.files) {
setFiles(data.files);
} else if (data.result) {
// Parse text listing
const lines = data.result.split("\n").filter((l: string) => l.trim());
const parsed: FileNode[] = lines
.filter((l: string) => !l.startsWith("##") && !l.startsWith("---"))
.map((l: string) => {
const isDir = l.includes("[DIR]") || l.endsWith("/");
const name = l.replace("[DIR]", "").replace(/\/$/, "").trim().split(/\s+/).pop() || l.trim();
return { name, path: `${dirPath}/${name}`.replace("//", "/"), isDirectory: isDir };
})
.filter((f: FileNode) => f.name && f.name !== "." && f.name !== "..");
setFiles(parsed);
}
} catch (err) {
console.error("Failed to fetch files:", err);
setFiles([]);
} finally {
setLoading(false);
}
}, []);
const fetchFileContent = useCallback(async (filePath: string) => {
setLoadingContent(true);
try {
const response = await fetch("/api/chat/slash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "/files", args: `read ${filePath}` }),
});
const data = await response.json();
setFileContent(data.content || data.result || "Unable to read file");
} catch {
setFileContent("Error reading file");
} finally {
setLoadingContent(false);
}
}, []);
useEffect(() => {
fetchFiles(currentPath);
}, [currentPath, fetchFiles]);
const navigateUp = () => {
const parent = currentPath.split("/").slice(0, -1).join("/") || "/";
setCurrentPath(parent);
setSelectedFile(null);
setFileContent(null);
};
const handleClick = (file: FileNode) => {
if (file.isDirectory) {
setCurrentPath(file.path);
setSelectedFile(null);
setFileContent(null);
} else {
setSelectedFile(file.path);
fetchFileContent(file.path);
}
};
return (
<div className="h-full flex flex-col">
{/* Path bar */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border bg-secondary/20">
<button
onClick={() => { setCurrentPath("/home/user"); setSelectedFile(null); setFileContent(null); }}
className="p-1 rounded hover:bg-accent/50"
title="Home"
>
<Home className="size-3 text-muted-foreground" />
</button>
<button
onClick={navigateUp}
className="p-1 rounded hover:bg-accent/50"
title="Up"
>
<ArrowUp className="size-3 text-muted-foreground" />
</button>
<div className="flex-1 text-[10px] font-mono text-muted-foreground truncate px-1">
{currentPath}
</div>
<button
onClick={() => fetchFiles(currentPath)}
className="p-1 rounded hover:bg-accent/50"
title="Refresh"
>
<RefreshCw className={cn("size-3 text-muted-foreground", loading && "animate-spin")} />
</button>
</div>
{/* File list or content preview */}
{selectedFile && fileContent !== null ? (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-secondary/10">
<button
onClick={() => { setSelectedFile(null); setFileContent(null); }}
className="text-[10px] text-primary hover:underline"
>
Back
</button>
<span className="text-[10px] text-muted-foreground truncate">
{selectedFile.split("/").pop()}
</span>
</div>
<div className="flex-1 overflow-auto">
{loadingContent ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<pre className="text-[11px] font-mono p-2 whitespace-pre-wrap break-all text-foreground/80">
{fileContent.length > 10000
? fileContent.slice(0, 10000) + "\n\n[...truncated]"
: fileContent}
</pre>
)}
</div>
</div>
) : (
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center h-32 text-[11px] text-muted-foreground">
Empty directory
</div>
) : (
<div className="py-0.5">
{files
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
})
.map((file) => {
const Icon = file.isDirectory ? Folder : getFileIcon(file.name);
return (
<button
key={file.path}
onClick={() => handleClick(file)}
className={cn(
"flex items-center gap-1.5 w-full px-2 py-1 text-left hover:bg-accent/50 transition-colors",
selectedFile === file.path && "bg-accent"
)}
>
<Icon
className={cn(
"size-3.5 shrink-0",
file.isDirectory ? "text-amber-400" : "text-muted-foreground"
)}
/>
<span className="text-[11px] truncate flex-1">{file.name}</span>
{!file.isDirectory && file.size !== undefined && (
<span className="text-[9px] text-muted-foreground/50 font-mono">
{formatSize(file.size)}
</span>
)}
{file.isDirectory && (
<ChevronRight className="size-3 text-muted-foreground/30" />
)}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
}
// ─── Preview Panel ────────────────────────────────────────────────────
function PreviewPanel({ messages }: { messages: ChatMessage[] }) {
// Extract the latest tool output that could be previewed
// (bash output, web fetch results, file contents)
const latestPreviewable = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant" && msg.toolCalls) {
for (let j = msg.toolCalls.length - 1; j >= 0; j--) {
const tc = msg.toolCalls[j];
if (tc.result && !tc.isError) {
if (tc.name === "bash" || tc.name === "PowerShell") {
return { type: "terminal" as const, name: tc.name, content: tc.result, command: (() => { try { return JSON.parse(tc.arguments).command; } catch { return ""; } })() };
}
if (tc.name === "WebFetch" || tc.name === "WebSearch") {
return { type: "web" as const, name: tc.name, content: tc.result, url: (() => { try { return JSON.parse(tc.arguments).url || JSON.parse(tc.arguments).query; } catch { return ""; } })() };
}
if (tc.name === "read_file") {
return { type: "file" as const, name: tc.name, content: tc.result, path: (() => { try { return JSON.parse(tc.arguments).path; } catch { return ""; } })() };
}
}
}
}
}
return null;
})();
if (!latestPreviewable) {
return (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/40 gap-2 p-4">
<Monitor className="size-8" />
<p className="text-xs text-center">
Live preview will appear here when the agent runs commands or fetches content
</p>
</div>
);
}
return (
<div className="h-full flex flex-col">
{/* Preview header */}
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-secondary/20">
{latestPreviewable.type === "terminal" && (
<>
<div className="flex gap-1">
<div className="size-2 rounded-full bg-red-500/70" />
<div className="size-2 rounded-full bg-yellow-500/70" />
<div className="size-2 rounded-full bg-green-500/70" />
</div>
<span className="text-[10px] font-mono text-muted-foreground truncate">
$ {latestPreviewable.command?.slice(0, 60)}
</span>
</>
)}
{latestPreviewable.type === "web" && (
<>
<ExternalLink className="size-3 text-muted-foreground" />
<span className="text-[10px] font-mono text-muted-foreground truncate">
{latestPreviewable.url}
</span>
</>
)}
{latestPreviewable.type === "file" && (
<>
<FileText className="size-3 text-muted-foreground" />
<span className="text-[10px] font-mono text-muted-foreground truncate">
{latestPreviewable.path}
</span>
</>
)}
</div>
{/* Preview content */}
<div className="flex-1 overflow-auto bg-[#0d1117]">
<pre className="text-[11px] font-mono p-3 whitespace-pre-wrap break-all text-green-400/90">
{latestPreviewable.content.length > 15000
? latestPreviewable.content.slice(0, 15000) + "\n\n[...truncated]"
: latestPreviewable.content}
</pre>
</div>
</div>
);
}
// ─── Main RightPanel ──────────────────────────────────────────────────
interface RightPanelProps {
messages: ChatMessage[];
isStreaming: boolean;
}
export function RightPanel({ messages, isStreaming }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<TabId>("actions");
return (
<div className="h-full flex flex-col bg-background">
{/* Tab bar */}
<div className="flex border-b border-border bg-secondary/10">
{TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-2 text-[11px] font-medium transition-colors border-b-2 -mb-px",
isActive
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
)}
>
<Icon className="size-3.5" />
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{activeTab === "actions" && (
<ActionTree messages={messages} isStreaming={isStreaming} />
)}
{activeTab === "files" && (
<FileExplorer />
)}
{activeTab === "preview" && (
<PreviewPanel messages={messages} />
)}
</div>
</div>
);
}