"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { ChevronRight, ChevronDown, Folder, FileText, RefreshCw, Download, Loader2, Image as ImageIcon, Database, Code2, File as FileIcon, } from "lucide-react"; import { useQueryState } from "nuqs"; import { cn } from "@/lib/utils"; import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog"; import type { WorkspaceEntry } from "@/app/api/workspace/route"; import { WORKSPACE_CHANGED_EVENT, type WorkspaceChangedDetail, } from "@/lib/workspaceEvents"; import { check as checkSelection, emptySelection, isChecked as isSelectionChecked, isIndeterminate as isSelectionIndeterminate, uncheck as uncheckSelection, toParams as selectionToParams, type WorkspaceSelection, } from "@/lib/workspaceSelection"; async function listDir( threadId: string, path: string ): Promise { const res = await fetch( `/api/workspace?${new URLSearchParams({ threadId, path })}` ); const body = await res.json().catch(() => null); if (!res.ok) throw new Error(body?.error || "Failed to list workspace."); return (body?.entries ?? []) as WorkspaceEntry[]; } async function listAll(threadId: string): Promise<{ entries: WorkspaceEntry[]; truncated: boolean; }> { const res = await fetch( `/api/workspace?${new URLSearchParams({ threadId, recursive: "1" })}` ); const body = await res.json().catch(() => null); if (!res.ok) throw new Error(body?.error || "Failed to load workspace."); return { entries: (body?.entries ?? []) as WorkspaceEntry[], truncated: !!body?.truncated, }; } // Research-artifact categories for the "by type" view. Order here is render order. const CATEGORIES = [ { key: "docs", label: "Papers & docs", Icon: FileText, exts: [ "pdf", "tex", "bib", "md", "markdown", "txt", "docx", "doc", "docm", "dotx", "dotm", "rtf", "odt", ], }, { key: "figures", label: "Figures", Icon: ImageIcon, exts: [ "png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "tiff", "tif", "eps", ], }, { key: "data", label: "Data", Icon: Database, exts: [ "json", "jsonl", "csv", "tsv", "xlsx", "xls", "xlsm", "xlsb", "xltx", "xltm", "ods", "parquet", "pkl", "npy", "npz", "h5", "hdf5", "db", "sqlite", "yaml", "yml", "xml", ], }, { key: "code", label: "Code", Icon: Code2, exts: [ "py", "ipynb", "js", "ts", "tsx", "jsx", "sh", "bash", "r", "jl", "cpp", "cc", "c", "h", "hpp", "java", "go", "rs", "m", "rb", ], }, ] as const; const OTHER = { key: "other", label: "Other", Icon: FileIcon } as const; const EXT_TO_CATEGORY: Record = {}; for (const cat of CATEGORIES) { for (const ext of cat.exts) EXT_TO_CATEGORY[ext] = cat.key; } type ViewMode = "tree" | "type"; export function WorkspacePanel() { const [threadId] = useQueryState("threadId"); const [view, setView] = useState("tree"); // --- Tree view state (listing cache keyed by dir path; "" = root) --- const [children, setChildren] = useState>( {} ); const [expanded, setExpanded] = useState>(new Set()); const [loading, setLoading] = useState>(new Set()); const [rootLoading, setRootLoading] = useState(false); // --- By-type view state (flat recursive listing) --- const [allFiles, setAllFiles] = useState(null); const [typeLoading, setTypeLoading] = useState(false); const [truncated, setTruncated] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState<{ path: string; size: number; } | null>(null); const navigateFile = useCallback( (p: string, s?: number) => setSelected({ path: p, size: s ?? 0 }), [] ); const [selection, setSelection] = useState( emptySelection() ); const toggleSelection = useCallback((path: string) => { setSelection((prev) => isSelectionChecked(prev, path) ? uncheckSelection(prev, path) : checkSelection(prev, path) ); }, []); const loadDir = useCallback( async (path: string) => { if (!threadId) { setChildren({}); setError("Start a conversation to use its workspace."); return []; } setLoading((prev) => new Set(prev).add(path)); try { const entries = await listDir(threadId, path); setChildren((prev) => ({ ...prev, [path]: entries })); if (path === "") setError(null); return entries; } catch (err) { if (path === "") { setError(err instanceof Error ? err.message : "Failed to load."); } throw err; } finally { setLoading((prev) => { const next = new Set(prev); next.delete(path); return next; }); } }, [threadId] ); const loadAll = useCallback(async () => { if (!threadId) { setAllFiles([]); setError("Start a conversation to use its workspace."); return; } setTypeLoading(true); try { const { entries, truncated } = await listAll(threadId); setAllFiles(entries); setTruncated(truncated); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Failed to load."); } finally { setTypeLoading(false); } }, [threadId]); // Initial tree load. loadDir surfaces root failures via `error`; catch the // rejection here so it doesn't become an unhandled promise rejection. useEffect(() => { setChildren({}); setAllFiles(null); setExpanded(new Set()); setSelected(null); setSelection(emptySelection()); setRootLoading(true); void loadDir("") .catch(() => {}) .finally(() => setRootLoading(false)); }, [threadId, loadDir]); // Load the flat listing the first time the by-type view is opened. useEffect(() => { if (view === "type" && allFiles === null && !typeLoading) void loadAll(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [view]); const refresh = useCallback(async () => { setError(null); if (view === "type") { await loadAll(); return; } setRootLoading(true); // Re-fetch the root plus every currently-expanded dir so an open tree stays // open and in sync with what the agent has written since. await Promise.allSettled(["", ...expanded].map((p) => loadDir(p))); setRootLoading(false); }, [view, expanded, loadDir, loadAll]); // A chat or background agent can write files without going through this // panel. Coalesce those notifications so a burst of tool calls results in a // single authoritative directory read after the filesystem write settles. const workspaceRefreshTimerRef = useRef< ReturnType | undefined >(undefined); useEffect(() => { const handleWorkspaceChanged = (event: Event) => { const changedThreadId = (event as CustomEvent) .detail?.threadId; if (!threadId || changedThreadId !== threadId) return; if (workspaceRefreshTimerRef.current) { clearTimeout(workspaceRefreshTimerRef.current); } workspaceRefreshTimerRef.current = setTimeout(() => { workspaceRefreshTimerRef.current = undefined; void refresh(); }, 250); }; window.addEventListener(WORKSPACE_CHANGED_EVENT, handleWorkspaceChanged); return () => { window.removeEventListener( WORKSPACE_CHANGED_EVENT, handleWorkspaceChanged ); if (workspaceRefreshTimerRef.current) { clearTimeout(workspaceRefreshTimerRef.current); workspaceRefreshTimerRef.current = undefined; } }; }, [refresh, threadId]); const toggleDir = useCallback( (path: string) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); if (!children[path]) void loadDir(path).catch(() => {}); } return next; }); }, [children, loadDir] ); // Group the flat listing into categories (newest first within each group). const grouped = useMemo(() => { const map: Record = {}; for (const f of allFiles ?? []) { const key = EXT_TO_CATEGORY[f.ext] ?? OTHER.key; (map[key] ??= []).push(f); } for (const list of Object.values(map)) { list.sort((a, b) => b.mtime - a.mtime); } return map; }, [allFiles]); const selectionCount = selection.checked.size; // Native download so large archives stream straight to disk. The // URL-length guard keeps a huge selection from hitting server/header limits // — the fix is to select fewer, higher-level folders instead. const selectionDownload = useMemo<{ href: string | null; tooLarge: boolean; }>(() => { if (!threadId || selectionCount === 0) return { href: null, tooLarge: false }; const { include, exclude } = selectionToParams(selection); const params = new URLSearchParams({ threadId }); for (const p of include) params.append("include", p); for (const p of exclude) params.append("exclude", p); const href = `/api/workspace/download?${params}`; return href.length <= 7000 ? { href, tooLarge: false } : { href: null, tooLarge: true }; }, [threadId, selection, selectionCount]); const refreshing = view === "type" ? typeLoading : rootLoading; const renderEntries = (path: string, depth: number): React.ReactNode => { const entries = children[path]; if (!entries) return null; if (entries.length === 0 && depth === 0) { return (

No files in the workspace yet

); } return entries.map((entry) => { const isOpen = expanded.has(entry.path); const isLoadingDir = loading.has(entry.path); const entryChecked = isSelectionChecked(selection, entry.path); const entryIndeterminate = entry.type === "dir" && isSelectionIndeterminate(selection, entry.path); return (
{ if (el) el.indeterminate = entryIndeterminate; }} onChange={() => toggleSelection(entry.path)} aria-label={`Select ${entry.name}`} />
{entry.type === "dir" && isOpen && renderEntries(entry.path, depth + 1)}
); }); }; const renderByType = (): React.ReactNode => { if (!allFiles) return null; if (allFiles.length === 0) { return (

No files in the workspace yet

); } const groups = [...CATEGORIES, OTHER]; return (
{truncated && (

Showing the first files only — the workspace has more than the limit.

)} {groups.map((cat) => { const files = grouped[cat.key]; if (!files || files.length === 0) return null; const Icon = cat.Icon; return (
{cat.label} ({files.length})
{files.map((f) => { const dir = f.path.includes("/") ? f.path.slice(0, f.path.lastIndexOf("/")) : ""; return ( ); })}
); })}
); }; return (
{/* Tree / By-type toggle */}
{(["tree", "type"] as const).map((m) => ( ))}
{error ? (

{error}

) : view === "tree" ? ( rootLoading && !children[""] ? (
) : (
{renderEntries("", 0)}
) ) : typeLoading && allFiles === null ? (
) : ( renderByType() )} setSelected(null)} onChanged={refresh} onNavigate={navigateFile} />
); }