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