"use client"; import { Suspense, useEffect, useState, useCallback, useRef, useMemo } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar"; import { type TreeNode } from "../components/workspace/file-manager-tree"; import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher"; import { ObjectTable } from "../components/workspace/object-table"; import { ObjectKanban } from "../components/workspace/object-kanban"; import { DocumentView } from "../components/workspace/document-view"; import { FileViewer, isSpreadsheetFile } from "../components/workspace/file-viewer"; import { HtmlViewer } from "../components/workspace/html-viewer"; import { CodeViewer } from "../components/workspace/code-viewer"; import { MediaViewer, detectMediaType, type MediaType } from "../components/workspace/media-viewer"; import { DatabaseViewer, DuckDBMissing } from "../components/workspace/database-viewer"; import { Breadcrumbs } from "../components/workspace/breadcrumbs"; import { ChatSessionsSidebar } from "../components/workspace/chat-sessions-sidebar"; import { EmptyState } from "../components/workspace/empty-state"; import { ReportViewer } from "../components/charts/report-viewer"; import { ChatPanel, type ChatPanelHandle, type SubagentSpawnInfo } from "../components/chat-panel"; import { SubagentPanel } from "../components/subagent-panel"; import { EntryDetailModal } from "../components/workspace/entry-detail-modal"; import { useSearchIndex } from "@/lib/search-index"; import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links"; import { isCodeFile } from "@/lib/report-utils"; import { CronDashboard } from "../components/cron/cron-dashboard"; import { CronJobDetail } from "../components/cron/cron-job-detail"; import type { CronJob, CronJobsResponse } from "../types/cron"; import { useIsMobile } from "../hooks/use-mobile"; import { ObjectFilterBar } from "../components/workspace/object-filter-bar"; import { type FilterGroup, type SortRule, type SavedView, emptyFilterGroup, serializeFilters } from "@/lib/object-filters"; import { UnicodeSpinner } from "../components/unicode-spinner"; // --- Types --- type WorkspaceContext = { exists: boolean; organization?: { id?: string; name?: string; slug?: string }; members?: Array<{ id: string; name: string; email: string; role: string }>; }; type ReverseRelation = { fieldName: string; sourceObjectName: string; sourceObjectId: string; displayField: string; entries: Record>; }; type ObjectData = { object: { id: string; name: string; description?: string; icon?: string; default_view?: string; display_field?: string; }; fields: Array<{ id: string; name: string; type: string; enum_values?: string[]; enum_colors?: string[]; enum_multiple?: boolean; related_object_id?: string; relationship_type?: string; related_object_name?: string; sort_order?: number; }>; statuses: Array<{ id: string; name: string; color?: string; sort_order?: number; }>; entries: Record[]; relationLabels?: Record>; reverseRelations?: ReverseRelation[]; effectiveDisplayField?: string; savedViews?: import("@/lib/object-filters").SavedView[]; activeView?: string; totalCount?: number; page?: number; pageSize?: number; }; type FileData = { content: string; type: "markdown" | "yaml" | "code" | "text"; }; type ContentState = | { kind: "none" } | { kind: "loading" } | { kind: "object"; data: ObjectData } | { kind: "document"; data: FileData; title: string } | { kind: "file"; data: FileData; filename: string } | { kind: "code"; data: FileData; filename: string } | { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string } | { kind: "spreadsheet"; url: string; filename: string } | { kind: "html"; rawUrl: string; contentUrl: string; filename: string } | { kind: "database"; dbPath: string; filename: string } | { kind: "report"; reportPath: string; filename: string } | { kind: "directory"; node: TreeNode } | { kind: "cron-dashboard" } | { kind: "cron-job"; jobId: string; job: CronJob } | { kind: "duckdb-missing" }; type SidebarPreviewContent = | { kind: "document"; data: FileData; title: string } | { kind: "file"; data: FileData; filename: string } | { kind: "code"; data: FileData; filename: string } | { kind: "media"; url: string; mediaType: MediaType; filename: string; filePath: string } | { kind: "database"; dbPath: string; filename: string } | { kind: "directory"; path: string; name: string }; type ChatSidebarPreviewState = | { status: "loading"; path: string; filename: string } | { status: "error"; path: string; filename: string; message: string } | { status: "ready"; path: string; filename: string; content: SidebarPreviewContent }; type WebSession = { id: string; title: string; createdAt: number; updatedAt: number; messageCount: number; }; // --- Helpers --- /** Detect virtual paths (skills, memories) that live outside the main workspace. */ function isVirtualPath(path: string): boolean { return path.startsWith("~") && !path.startsWith("~/"); } /** Detect absolute filesystem paths (browse mode). */ function isAbsolutePath(path: string): boolean { return path.startsWith("/"); } /** Detect home-relative filesystem paths (e.g. ~/Desktop/file.txt). */ function isHomeRelativePath(path: string): boolean { return path.startsWith("~/"); } /** Pick the right file API endpoint based on virtual vs real vs absolute paths. */ function fileApiUrl(path: string): string { if (isVirtualPath(path)) { return `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`; } if (isAbsolutePath(path) || isHomeRelativePath(path)) { return `/api/workspace/browse-file?path=${encodeURIComponent(path)}`; } return `/api/workspace/file?path=${encodeURIComponent(path)}`; } /** Pick the right raw file URL for media preview. */ function rawFileUrl(path: string): string { if (isAbsolutePath(path) || isHomeRelativePath(path)) { return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`; } return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`; } const LEFT_SIDEBAR_MIN = 200; const LEFT_SIDEBAR_MAX = 480; const RIGHT_SIDEBAR_MIN = 260; const RIGHT_SIDEBAR_MAX = 900; const STORAGE_LEFT = "ironclaw-workspace-left-sidebar-width"; const STORAGE_RIGHT = "ironclaw-workspace-right-sidebar-width"; function clamp(n: number, min: number, max: number): number { return Math.min(max, Math.max(min, n)); } /** Vertical resize handle; uses cursor position so the handle follows the mouse (no stuck-at-limit). */ function ResizeHandle({ mode, containerRef, min, max, onResize, }: { mode: "left" | "right"; containerRef: React.RefObject; min: number; max: number; onResize: (width: number) => void; }) { const [isDragging, setIsDragging] = useState(false); const onMouseDown = useCallback( (e: React.MouseEvent) => { e.preventDefault(); setIsDragging(true); const move = (ev: MouseEvent) => { const el = containerRef.current; if (!el) {return;} const rect = el.getBoundingClientRect(); const width = mode === "left" ? ev.clientX - rect.left : rect.right - ev.clientX; onResize(clamp(width, min, max)); }; const up = () => { setIsDragging(false); document.removeEventListener("mousemove", move); document.removeEventListener("mouseup", up); document.body.style.removeProperty("user-select"); document.body.style.removeProperty("cursor"); document.body.classList.remove("resizing"); }; document.body.style.setProperty("user-select", "none"); document.body.style.setProperty("cursor", "col-resize"); document.body.classList.add("resizing"); document.addEventListener("mousemove", move); document.addEventListener("mouseup", up); }, [containerRef, mode, min, max, onResize], ); const showHover = isDragging || undefined; return (
); } /** Find a node in the tree by exact path. */ function findNode( tree: TreeNode[], path: string, ): TreeNode | null { for (const node of tree) { if (node.path === path) {return node;} if (node.children) { const found = findNode(node.children, path); if (found) {return found;} } } return null; } /** Extract the object name from a tree path (last segment). */ function objectNameFromPath(path: string): string { const segments = path.split("/"); return segments[segments.length - 1]; } /** Infer a tree node type from filename extension for ad-hoc path previews. */ function inferNodeTypeFromFileName(fileName: string): TreeNode["type"] { const ext = fileName.split(".").pop()?.toLowerCase() ?? ""; if (ext === "md" || ext === "mdx") {return "document";} if (ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db") {return "database";} return "file"; } /** Normalize chat path references (supports file:// URLs). */ function normalizeChatPath(path: string): string { const trimmed = path.trim(); if (!trimmed.startsWith("file://")) { return trimmed; } try { const url = new URL(trimmed); if (url.protocol !== "file:") { return trimmed; } const decoded = decodeURIComponent(url.pathname); // Windows file URLs are /C:/... in URL form if (/^\/[A-Za-z]:\//.test(decoded)) { return decoded.slice(1); } return decoded; } catch { return trimmed; } } /** * Resolve a path with fallback strategies: * 1. Exact match * 2. Try with knowledge/ prefix * 3. Try stripping knowledge/ prefix * 4. Match last segment against object names */ function resolveNode( tree: TreeNode[], path: string, ): TreeNode | null { let node = findNode(tree, path); if (node) {return node;} if (!path.startsWith("knowledge/")) { node = findNode(tree, `knowledge/${path}`); if (node) {return node;} } if (path.startsWith("knowledge/")) { node = findNode(tree, path.slice("knowledge/".length)); if (node) {return node;} } const lastSegment = path.split("/").pop(); if (lastSegment) { function findByName(nodes: TreeNode[]): TreeNode | null { for (const n of nodes) { if (n.type === "object" && objectNameFromPath(n.path) === lastSegment) {return n;} if (n.children) { const found = findByName(n.children); if (found) {return found;} } } return null; } node = findByName(tree); if (node) {return node;} } return null; } // --- Main Page --- export default function WorkspacePage() { return (
}> ); } function WorkspacePageInner() { const searchParams = useSearchParams(); const router = useRouter(); const initialPathHandled = useRef(false); // Chat panel ref for session management const chatRef = useRef(null); // Compact (file-scoped) chat panel ref for sidebar drag-and-drop const compactChatRef = useRef(null); // Root layout ref for resize handle position (handle follows cursor) const layoutRef = useRef(null); // Live-reactive tree via SSE watcher (with browse-mode support) const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree, reconnect: reconnectWorkspace, browseDir, setBrowseDir, parentDir: browseParentDir, workspaceRoot, openclawDir, activeProfile, showHidden, setShowHidden, } = useWorkspaceWatcher(); // handleProfileSwitch is defined below fetchSessions/fetchCronJobs (avoids TDZ) // Search index for @ mention fuzzy search (files + entries) const { search: searchIndex } = useSearchIndex(); const [context, setContext] = useState(null); const [activePath, setActivePath] = useState(null); const [content, setContent] = useState({ kind: "none" }); const [showChatSidebar, setShowChatSidebar] = useState(true); const [chatSidebarPreview, setChatSidebarPreview] = useState(null); // Chat session state const [activeSessionId, setActiveSessionId] = useState(null); const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(true); const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0); const [streamingSessionIds, setStreamingSessionIds] = useState>(new Set()); // Subagent tracking const [subagents, setSubagents] = useState([]); const [activeSubagentKey, setActiveSubagentKey] = useState(null); const handleSubagentSpawned = useCallback((info: SubagentSpawnInfo) => { setSubagents((prev) => { const idx = prev.findIndex((sa) => sa.childSessionKey === info.childSessionKey); if (idx >= 0) { // Update status if changed if (prev[idx].status === info.status) {return prev;} const updated = [...prev]; updated[idx] = { ...prev[idx], ...info }; return updated; } return [...prev, info]; }); }, []); const handleSelectSubagent = useCallback((sessionKey: string) => { setActiveSubagentKey(sessionKey); }, []); const handleBackFromSubagent = useCallback(() => { setActiveSubagentKey(null); }, []); // Navigate to a subagent panel when its card is clicked in the chat const handleSubagentClickFromChat = useCallback((task: string) => { const match = subagents.find((sa) => sa.task === task); if (match) { setActiveSubagentKey(match.childSessionKey); } }, [subagents]); // Find the active subagent's info for the panel const activeSubagent = useMemo(() => { if (!activeSubagentKey) {return null;} return subagents.find((sa) => sa.childSessionKey === activeSubagentKey) ?? null; }, [activeSubagentKey, subagents]); // Cron jobs state const [cronJobs, setCronJobs] = useState([]); // Entry detail modal state const [entryModal, setEntryModal] = useState<{ objectName: string; entryId: string; } | null>(null); // Mobile responsive state const isMobile = useIsMobile(); const [sidebarOpen, setSidebarOpen] = useState(false); const [chatSessionsOpen, setChatSessionsOpen] = useState(false); // Sidebar collapse state (desktop only). const [leftSidebarCollapsed, setLeftSidebarCollapsed] = useState(false); const [rightSidebarCollapsed, setRightSidebarCollapsed] = useState(false); // Resizable sidebar widths (desktop only; persisted in localStorage). // Use static defaults so server and client match on first render (avoid hydration mismatch). const [leftSidebarWidth, setLeftSidebarWidth] = useState(260); const [rightSidebarWidth, setRightSidebarWidth] = useState(320); useEffect(() => { const left = window.localStorage.getItem(STORAGE_LEFT); const nLeft = left ? parseInt(left, 10) : NaN; if (Number.isFinite(nLeft)) { setLeftSidebarWidth(clamp(nLeft, LEFT_SIDEBAR_MIN, LEFT_SIDEBAR_MAX)); } const right = window.localStorage.getItem(STORAGE_RIGHT); const nRight = right ? parseInt(right, 10) : NaN; if (Number.isFinite(nRight)) { setRightSidebarWidth(clamp(nRight, RIGHT_SIDEBAR_MIN, RIGHT_SIDEBAR_MAX)); } }, []); useEffect(() => { window.localStorage.setItem(STORAGE_LEFT, String(leftSidebarWidth)); }, [leftSidebarWidth]); useEffect(() => { window.localStorage.setItem(STORAGE_RIGHT, String(rightSidebarWidth)); }, [rightSidebarWidth]); // Keyboard shortcuts: Cmd+B = toggle left sidebar, Cmd+Shift+B = toggle right sidebar useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") { e.preventDefault(); if (e.shiftKey) { setRightSidebarCollapsed((v) => !v); } else { setLeftSidebarCollapsed((v) => !v); } } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, []); // Derive file context for chat sidebar directly from activePath (stable across loading). // Exclude reserved virtual paths (~chats, ~cron, etc.) where file-scoped chat is irrelevant. const fileContext = useMemo(() => { if (!activePath) {return undefined;} if (isVirtualPath(activePath)) {return undefined;} const filename = activePath.split("/").pop() || activePath; return { path: activePath, filename, isDirectory: content.kind === "directory" }; }, [activePath, content.kind]); // Update content state when the agent edits the file (live reload) const handleFileChanged = useCallback((newContent: string) => { setContent((prev) => { if (prev.kind === "document") { return { ...prev, data: { ...prev.data, content: newContent } }; } if (prev.kind === "file" || prev.kind === "code") { return { ...prev, data: { ...prev.data, content: newContent } }; } return prev; }); }, []); // Fetch workspace context on mount useEffect(() => { let cancelled = false; async function loadContext() { try { const res = await fetch("/api/workspace/context"); const data = await res.json(); if (!cancelled) {setContext(data);} } catch { // ignore } } void loadContext(); return () => { cancelled = true; }; }, []); // Fetch chat sessions const fetchSessions = useCallback(async () => { setSessionsLoading(true); try { const res = await fetch("/api/web-sessions"); const data = await res.json(); setSessions(data.sessions ?? []); } catch { // ignore } finally { setSessionsLoading(false); } }, []); useEffect(() => { void fetchSessions(); }, [fetchSessions, sidebarRefreshKey]); const refreshSessions = useCallback(() => { setSidebarRefreshKey((k) => k + 1); }, []); const handleDeleteSession = useCallback( async (sessionId: string) => { const res = await fetch(`/api/web-sessions/${sessionId}`, { method: "DELETE" }); if (!res.ok) {return;} if (activeSessionId === sessionId) { setActiveSessionId(null); setActiveSubagentKey(null); const remaining = sessions.filter((s) => s.id !== sessionId); if (remaining.length > 0) { const next = remaining[0]; setActiveSessionId(next.id); void chatRef.current?.loadSession(next.id); } else { void chatRef.current?.newSession(); } } void fetchSessions(); }, [activeSessionId, sessions, fetchSessions], ); const handleRenameSession = useCallback( async (sessionId: string, newTitle: string) => { await fetch(`/api/web-sessions/${sessionId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: newTitle }), }); void fetchSessions(); }, [fetchSessions], ); // Poll for active (streaming) agent runs so the sidebar can show indicators. useEffect(() => { let cancelled = false; const poll = async () => { try { const res = await fetch("/api/chat/active"); if (cancelled) {return;} const data = await res.json(); const ids: string[] = data.sessionIds ?? []; setStreamingSessionIds((prev) => { // Only update state if the set actually changed (avoid re-renders). if (prev.size === ids.length && ids.every((id) => prev.has(id))) {return prev;} return new Set(ids); }); } catch { // ignore } }; void poll(); const id = setInterval(poll, 3_000); return () => { cancelled = true; clearInterval(id); }; }, []); // Fetch cron jobs for sidebar const fetchCronJobs = useCallback(async () => { try { const res = await fetch("/api/cron/jobs"); const data: CronJobsResponse = await res.json(); setCronJobs(data.jobs ?? []); } catch { // ignore - cron might not be configured } }, []); useEffect(() => { void fetchCronJobs(); const id = setInterval(fetchCronJobs, 30_000); return () => clearInterval(id); }, [fetchCronJobs]); // After profile switch or workspace creation, reconnect SSE + refresh all data const handleProfileSwitch = useCallback(() => { reconnectWorkspace(); void fetchSessions(); void fetchCronJobs(); setActivePath(null); setContent({ kind: "none" }); setActiveSessionId(null); setSubagents([]); setActiveSubagentKey(null); }, [reconnectWorkspace, fetchSessions, fetchCronJobs]); // Load content when path changes const loadContent = useCallback( async (node: TreeNode) => { setActivePath(node.path); setContent({ kind: "loading" }); try { if (node.type === "object") { const name = objectNameFromPath(node.path); const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`); if (!res.ok) { const errData = await res.json().catch(() => ({})); if (errData.code === "DUCKDB_NOT_INSTALLED") { setContent({ kind: "duckdb-missing" }); return; } setContent({ kind: "none" }); return; } const data: ObjectData = await res.json(); setContent({ kind: "object", data }); } else if (node.type === "document") { // Use virtual-file API for ~skills/ paths const res = await fetch(fileApiUrl(node.path)); if (!res.ok) { setContent({ kind: "none" }); return; } const data: FileData = await res.json(); setContent({ kind: "document", data, title: node.name.replace(/\.md$/, ""), }); } else if (node.type === "database") { setContent({ kind: "database", dbPath: node.path, filename: node.name }); } else if (node.type === "report") { setContent({ kind: "report", reportPath: node.path, filename: node.name }); } else if (node.type === "file") { // Spreadsheet files get their own binary viewer if (isSpreadsheetFile(node.name)) { const url = rawFileUrl(node.path); setContent({ kind: "spreadsheet", url, filename: node.name }); return; } // HTML files get an iframe preview const ext = node.name.split(".").pop()?.toLowerCase() ?? ""; if (ext === "html" || ext === "htm") { setContent({ kind: "html", rawUrl: rawFileUrl(node.path), contentUrl: fileApiUrl(node.path), filename: node.name }); return; } // Check if this is a media file (image/video/audio/pdf) const mediaType = detectMediaType(node.name); if (mediaType) { const url = rawFileUrl(node.path); setContent({ kind: "media", url, mediaType, filename: node.name, filePath: node.path }); return; } const res = await fetch(fileApiUrl(node.path)); if (!res.ok) { setContent({ kind: "none" }); return; } const data: FileData = await res.json(); // Route code files to the syntax-highlighted CodeViewer if (isCodeFile(node.name)) { setContent({ kind: "code", data, filename: node.name }); } else { setContent({ kind: "file", data, filename: node.name }); } } else if (node.type === "folder") { setContent({ kind: "directory", node }); } } catch { setContent({ kind: "none" }); } }, [], ); const handleNodeSelect = useCallback( (node: TreeNode) => { // --- Browse-mode: detect special OpenClaw directories --- // When the user clicks a known OpenClaw folder while browsing the // filesystem, switch back to workspace mode or show the appropriate // dashboard instead of showing raw files. if (browseDir && isAbsolutePath(node.path)) { // Clicking the workspace root → restore full workspace mode if (workspaceRoot && node.path === workspaceRoot) { setBrowseDir(null); return; } if (openclawDir) { // Clicking the cron directory → show cron dashboard if (node.path === openclawDir + "/cron") { setBrowseDir(null); setActivePath("~cron"); setContent({ kind: "cron-dashboard" }); return; } // Clicking any web-chat directory → switch to workspace mode & open chats if (openclawDir && node.path.startsWith(openclawDir + "/web-chat")) { setBrowseDir(null); setActivePath(null); setContent({ kind: "none" }); void chatRef.current?.newSession(); return; } } // Clicking a folder in browse mode → navigate into it so the tree // is fetched fresh, AND show it in the main panel with the chat sidebar. // Children come from the live tree (same data source as the sidebar), // not from the stale node snapshot. if (node.type === "folder") { setBrowseDir(node.path); setActivePath(node.path); setContent({ kind: "directory", node: { name: node.name, path: node.path, type: "folder" } }); return; } } // --- Virtual path handlers (workspace mode) --- // Intercept chat folder item clicks if (node.path.startsWith("~chats/")) { const sessionId = node.path.slice("~chats/".length); setActivePath(null); setContent({ kind: "none" }); setActiveSessionId(sessionId); void chatRef.current?.loadSession(sessionId); // URL is synced by the activeSessionId effect return; } // Clicking the Chats folder itself opens a new chat if (node.path === "~chats") { setActivePath(null); setContent({ kind: "none" }); void chatRef.current?.newSession(); router.replace("/workspace", { scroll: false }); return; } // Intercept cron job item clicks if (node.path.startsWith("~cron/")) { const jobId = node.path.slice("~cron/".length); const job = cronJobs.find((j) => j.id === jobId); if (job) { setActivePath(node.path); setContent({ kind: "cron-job", jobId, job }); router.replace("/workspace", { scroll: false }); return; } } // Clicking the Cron folder itself opens the dashboard if (node.path === "~cron") { setActivePath(node.path); setContent({ kind: "cron-dashboard" }); router.replace("/workspace", { scroll: false }); return; } void loadContent(node); }, [loadContent, router, cronJobs, browseDir, workspaceRoot, openclawDir, setBrowseDir], ); const loadSidebarPreviewFromNode = useCallback( async (node: TreeNode): Promise => { if (node.type === "folder") { return { kind: "directory", path: node.path, name: node.name }; } if (node.type === "database") { return { kind: "database", dbPath: node.path, filename: node.name }; } const mediaType = detectMediaType(node.name); if (mediaType) { return { kind: "media", url: rawFileUrl(node.path), mediaType, filename: node.name, filePath: node.path, }; } const res = await fetch(fileApiUrl(node.path)); if (!res.ok) {return null;} const data: FileData = await res.json(); if (node.type === "document" || data.type === "markdown") { return { kind: "document", data, title: node.name.replace(/\.mdx?$/, ""), }; } if (isCodeFile(node.name)) { return { kind: "code", data, filename: node.name }; } return { kind: "file", data, filename: node.name }; }, [], ); // Open inline file-path mentions from chat. // In chat mode, render a Dropbox-style preview in the right sidebar. const handleFilePathClickFromChat = useCallback( async (rawPath: string) => { const inputPath = normalizeChatPath(rawPath); if (!inputPath) {return false;} // Desktop behavior: always use right-sidebar preview for chat path clicks. const shouldPreviewInSidebar = !isMobile; const openNode = async (node: TreeNode) => { if (!shouldPreviewInSidebar) { handleNodeSelect(node); setShowChatSidebar(true); return true; } // Ensure we are in main-chat layout so the preview panel is visible. if (activePath || content.kind !== "none") { setActivePath(null); setContent({ kind: "none" }); router.replace("/workspace", { scroll: false }); } setChatSidebarPreview({ status: "loading", path: node.path, filename: node.name, }); const previewContent = await loadSidebarPreviewFromNode(node); if (!previewContent) { setChatSidebarPreview({ status: "error", path: node.path, filename: node.name, message: "Could not preview this file.", }); return false; } setChatSidebarPreview({ status: "ready", path: node.path, filename: node.name, content: previewContent, }); return true; }; // For workspace-relative paths, prefer the live tree so we preserve semantics. if ( !isAbsolutePath(inputPath) && !isHomeRelativePath(inputPath) && !inputPath.startsWith("./") && !inputPath.startsWith("../") ) { const node = resolveNode(tree, inputPath); if (node) { return await openNode(node); } } try { const res = await fetch(`/api/workspace/path-info?path=${encodeURIComponent(inputPath)}`); if (!res.ok) {return false;} const info = await res.json() as { path?: string; name?: string; type?: "file" | "directory" | "other"; }; if (!info.path || !info.name || !info.type) {return false;} // If this absolute path is inside the current workspace, map it // back to a workspace-relative node first. if (workspaceRoot && (info.path === workspaceRoot || info.path.startsWith(`${workspaceRoot}/`))) { const relPath = info.path === workspaceRoot ? "" : info.path.slice(workspaceRoot.length + 1); if (relPath) { const node = resolveNode(tree, relPath); if (node) { return await openNode(node); } } } if (info.type === "directory") { const dirNode: TreeNode = { name: info.name, path: info.path, type: "folder" }; if (shouldPreviewInSidebar) { return await openNode(dirNode); } setBrowseDir(info.path); setActivePath(info.path); setContent({ kind: "directory", node: { name: info.name, path: info.path, type: "folder" }, }); setShowChatSidebar(true); return true; } if (info.type === "file") { const fileNode: TreeNode = { name: info.name, path: info.path, type: inferNodeTypeFromFileName(info.name), }; if (shouldPreviewInSidebar) { return await openNode(fileNode); } const parentDir = info.path.split("/").slice(0, -1).join("/") || "/"; if (isAbsolutePath(info.path)) { setBrowseDir(parentDir); } await loadContent(fileNode); setShowChatSidebar(true); return true; } } catch { // Ignore -- chat message bubble shows inline error state. } return false; }, [activePath, content.kind, isMobile, tree, handleNodeSelect, workspaceRoot, loadSidebarPreviewFromNode, setBrowseDir, loadContent, router], ); // Build the enhanced tree: real tree + Cron virtual folder at the bottom // (Chat sessions live in the right sidebar, not in the tree.) // In browse mode, skip virtual folders (they only apply to workspace mode) const enhancedTree = useMemo(() => { if (browseDir) { return tree; } const cronStatusIcon = (job: CronJob) => { if (!job.enabled) {return "\u25CB";} // circle outline if (job.state.runningAtMs) {return "\u25CF";} // filled circle if (job.state.lastStatus === "error") {return "\u25C6";} // diamond if (job.state.lastStatus === "ok") {return "\u2713";} // check return "\u25CB"; }; const cronChildren: TreeNode[] = cronJobs.map((j) => ({ name: `${cronStatusIcon(j)} ${j.name}`, path: `~cron/${j.id}`, type: "file" as const, virtual: true, })); const cronFolder: TreeNode = { name: "Cron", path: "~cron", type: "folder", virtual: true, children: cronChildren.length > 0 ? cronChildren : undefined, }; return [...tree, cronFolder]; }, [tree, cronJobs, browseDir]); // Compute the effective parentDir for ".." navigation. // In browse mode: use browseParentDir from the API. // In workspace mode: use the parent of the workspace root (allows escaping workspace). const effectiveParentDir = useMemo(() => { if (browseDir) { return browseParentDir; } // In workspace mode, allow ".." to go up from workspace root if (workspaceRoot) { const parent = workspaceRoot === "/" ? null : workspaceRoot.split("/").slice(0, -1).join("/") || "/"; return parent; } return null; }, [browseDir, browseParentDir, workspaceRoot]); // Handle ".." navigation const handleNavigateUp = useCallback(() => { if (effectiveParentDir != null) { setBrowseDir(effectiveParentDir); } }, [effectiveParentDir, setBrowseDir]); // Return to workspace mode const handleGoHome = useCallback(() => { setBrowseDir(null); }, [setBrowseDir]); // Navigate to the main chat / home panel const handleGoToChat = useCallback(() => { setActivePath(null); setContent({ kind: "none" }); router.replace("/workspace", { scroll: false }); }, [router]); // Insert a file mention into the chat editor when a sidebar item is dropped on the chat input. // Try the main chat panel first; fall back to the compact (file-scoped) panel. const handleSidebarExternalDrop = useCallback((node: TreeNode) => { const target = chatRef.current ?? compactChatRef.current; target?.insertFileMention?.(node.name, node.path); }, []); // Handle file search selection: navigate sidebar to the file's location and open it const handleFileSearchSelect = useCallback( (item: { name: string; path: string; type: string }) => { if (item.type === "folder") { // Navigate the sidebar into the folder and show it in the main panel. // Children come from the live tree (same data source as the sidebar). setBrowseDir(item.path); setActivePath(item.path); setContent({ kind: "directory", node: { name: item.name, path: item.path, type: "folder" } }); } else { // Navigate the sidebar to the parent directory of the file const parentOfFile = item.path.split("/").slice(0, -1).join("/") || "/"; setBrowseDir(parentOfFile); // Open the file in the main panel const node: TreeNode = { name: item.name, path: item.path, type: item.type as TreeNode["type"], }; void loadContent(node); } }, [setBrowseDir, loadContent], ); // Sync URL bar with active content / chat state. // Uses window.location instead of searchParams in the comparison to // avoid a circular dependency (searchParams updates → effect fires → // router.replace → searchParams updates → …). useEffect(() => { const current = new URLSearchParams(window.location.search); if (activePath) { // File / content mode — path takes priority over chat. if (current.get("path") !== activePath || current.has("chat")) { const params = new URLSearchParams(); params.set("path", activePath); const entry = current.get("entry"); if (entry) {params.set("entry", entry);} router.push(`/workspace?${params.toString()}`, { scroll: false }); } } else if (activeSessionId) { // Chat mode — no file selected. if (current.get("chat") !== activeSessionId || current.has("path")) { router.push(`/workspace?chat=${encodeURIComponent(activeSessionId)}`, { scroll: false }); } } // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally excludes searchParams to avoid infinite loop }, [activePath, activeSessionId, router]); // Open entry modal handler const handleOpenEntry = useCallback( (objectName: string, entryId: string) => { setEntryModal({ objectName, entryId }); const params = new URLSearchParams(searchParams.toString()); params.set("entry", `${objectName}:${entryId}`); router.push(`/workspace?${params.toString()}`, { scroll: false }); }, [searchParams, router], ); // Close entry modal handler const handleCloseEntry = useCallback(() => { setEntryModal(null); const params = new URLSearchParams(searchParams.toString()); params.delete("entry"); const qs = params.toString(); router.replace(qs ? `/workspace?${qs}` : "/workspace", { scroll: false }); }, [searchParams, router]); // Auto-navigate to path/chat from URL query params after tree loads useEffect(() => { if (initialPathHandled.current || treeLoading || tree.length === 0) {return;} const pathParam = searchParams.get("path"); const entryParam = searchParams.get("entry"); const chatParam = searchParams.get("chat"); if (pathParam) { const node = resolveNode(tree, pathParam); if (node) { initialPathHandled.current = true; void loadContent(node); } } else if (chatParam) { // Restore the active chat session from URL initialPathHandled.current = true; setActiveSessionId(chatParam); setActivePath(null); setContent({ kind: "none" }); void chatRef.current?.loadSession(chatParam); } // Also open entry modal from URL if present if (entryParam && entryParam.includes(":")) { const [objName, eid] = entryParam.split(":", 2); if (objName && eid) { setEntryModal({ objectName: objName, entryId: eid }); } } }, [tree, treeLoading, searchParams, loadContent]); // Handle ?send= URL parameter: open a new chat session and auto-send the message. // Used by the "Install DuckDB" button and similar in-app triggers. useEffect(() => { const sendParam = searchParams.get("send"); if (!sendParam) {return;} // Clear the send param from the URL immediately router.replace("/workspace", { scroll: false }); // Show the main chat (clear any active file/content) setActivePath(null); setContent({ kind: "none" }); // Give ChatPanel a frame to mount, then send the message requestAnimationFrame(() => { void chatRef.current?.sendNewMessage(sendParam); }); }, [searchParams, router]); const handleBreadcrumbNavigate = useCallback( (path: string) => { if (!path) { setActivePath(null); setContent({ kind: "none" }); return; } // Absolute paths (browse mode): navigate the sidebar directly. // Intermediate parent folders aren't in the browse-mode tree, so // resolveNode would fail — call setBrowseDir to update the sidebar. if (isAbsolutePath(path)) { const name = path.split("/").pop() || path; setBrowseDir(path); setActivePath(path); setContent({ kind: "directory", node: { name, path, type: "folder" } }); return; } // Relative paths (workspace mode): resolve and navigate via handleNodeSelect // so virtual paths, chat context, etc. are all handled properly. const node = resolveNode(tree, path); if (node) { handleNodeSelect(node); } }, [tree, handleNodeSelect, setBrowseDir], ); // Navigate to an object by name (used by relation links) const handleNavigateToObject = useCallback( (objectName: string) => { function findObjectNode(nodes: TreeNode[]): TreeNode | null { for (const node of nodes) { if (node.type === "object" && objectNameFromPath(node.path) === objectName) { return node; } if (node.children) { const found = findObjectNode(node.children); if (found) {return found;} } } return null; } const node = findObjectNode(tree); if (node) {void loadContent(node);} }, [tree, loadContent], ); /** * Unified navigate handler for links in the editor and read mode. * Handles /workspace?entry=..., /workspace?path=..., and legacy relative paths. */ const handleEditorNavigate = useCallback( (href: string) => { // Try parsing as a workspace URL first (/workspace?entry=... or /workspace?path=...) const parsed = parseWorkspaceLink(href); if (parsed) { if (parsed.kind === "entry") { handleOpenEntry(parsed.objectName, parsed.entryId); return; } // File/object link -- resolve using the path from the URL const node = resolveNode(tree, parsed.path); if (node) { handleNodeSelect(node); return; } } // Fallback: treat as a raw relative path (legacy links) const node = resolveNode(tree, href); if (node) { handleNodeSelect(node); } }, [tree, handleNodeSelect, handleOpenEntry], ); // Refresh the currently displayed object (e.g. after changing display field) const refreshCurrentObject = useCallback(async () => { if (content.kind !== "object") {return;} const name = content.data.object.name; try { const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`); if (!res.ok) {return;} const data: ObjectData = await res.json(); setContent({ kind: "object", data }); } catch { // ignore } }, [content]); // Auto-refresh the current object view when the workspace tree updates. // The SSE watcher triggers tree refreshes on any file change (including // .object.yaml edits by the AI agent). We track the tree reference and // re-fetch the object data so saved views/filters update live. const prevTreeRef = useRef(tree); useEffect(() => { if (prevTreeRef.current === tree) {return;} prevTreeRef.current = tree; if (content.kind === "object") { void refreshCurrentObject(); } }, [tree, content.kind, refreshCurrentObject]); // Top-level safety net: catch workspace link clicks anywhere in the page // to prevent full-page navigation and handle via client-side state instead. const handleContainerClick = useCallback( (event: React.MouseEvent) => { const target = event.target as HTMLElement; const link = target.closest("a"); if (!link) {return;} const href = link.getAttribute("href"); if (!href) {return;} // Intercept /workspace?... links to handle them in-app if (isWorkspaceLink(href)) { event.preventDefault(); event.stopPropagation(); handleEditorNavigate(href); } }, [handleEditorNavigate], ); // Cron navigation handlers const handleSelectCronJob = useCallback((jobId: string) => { const job = cronJobs.find((j) => j.id === jobId); if (job) { setActivePath(`~cron/${jobId}`); setContent({ kind: "cron-job", jobId, job }); router.replace("/workspace", { scroll: false }); } }, [cronJobs, router]); const handleBackToCronDashboard = useCallback(() => { setActivePath("~cron"); setContent({ kind: "cron-dashboard" }); router.replace("/workspace", { scroll: false }); }, [router]); // Derive the active session's title for the header / right sidebar const activeSessionTitle = useMemo(() => { if (!activeSessionId) {return undefined;} const s = sessions.find((sess) => sess.id === activeSessionId); return s?.title || undefined; }, [activeSessionId, sessions]); // Whether to show the main ChatPanel (no file/content selected) const showMainChat = !activePath || content.kind === "none"; return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
{/* Left sidebar — static on desktop (resizable), drawer overlay on mobile */} {isMobile ? ( sidebarOpen && ( { handleNodeSelect(node); setSidebarOpen(false); }} onRefresh={refreshTree} orgName={context?.organization?.name} loading={treeLoading} browseDir={browseDir} parentDir={effectiveParentDir} onNavigateUp={handleNavigateUp} onGoHome={handleGoHome} onFileSearchSelect={(item) => { handleFileSearchSelect?.(item); setSidebarOpen(false); }} workspaceRoot={workspaceRoot} onGoToChat={() => { handleGoToChat(); setSidebarOpen(false); }} onExternalDrop={handleSidebarExternalDrop} activeProfile={activeProfile} onProfileSwitch={handleProfileSwitch} showHidden={showHidden} onToggleHidden={() => setShowHidden((v) => !v)} mobile onClose={() => setSidebarOpen(false)} /> ) ) : ( <> {!leftSidebarCollapsed && (
setShowHidden((v) => !v)} width={leftSidebarWidth} onCollapse={() => setLeftSidebarCollapsed(true)} />
)} )} {/* Expand left sidebar button (shown when collapsed) */} {!isMobile && leftSidebarCollapsed && (
)} {/* Main content */}
{/* Mobile top bar — always visible on mobile */} {isMobile && (
{activePath ? activePath.split("/").pop() : (context?.organization?.name || "Workspace")}
{activePath && content.kind !== "none" && ( )} {showMainChat && ( )}
)} {/* When a file is selected: show top bar with breadcrumbs (desktop only, mobile has unified top bar) */} {!isMobile && activePath && content.kind !== "none" && (
{/* Back to chat button */} {/* Chat sidebar toggle (hidden for reserved/virtual paths) */} {fileContext && ( )}
)} {/* Content area */}
{showMainChat ? ( /* Main chat view (default when no file is selected) */ <>
{activeSubagent ? ( ) : ( { setActiveSessionId(id); setActiveSubagentKey(null); }} onSessionsChange={refreshSessions} onSubagentSpawned={handleSubagentSpawned} onSubagentClick={handleSubagentClickFromChat} onFilePathClick={handleFilePathClickFromChat} onDeleteSession={handleDeleteSession} onRenameSession={handleRenameSession} compact={isMobile} /> )}
{/* Chat sessions sidebar — static on desktop, drawer overlay on mobile */} {isMobile ? ( chatSessionsOpen && ( { setActiveSessionId(sessionId); setActiveSubagentKey(null); void chatRef.current?.loadSession(sessionId); }} onNewSession={() => { setActiveSessionId(null); setActiveSubagentKey(null); void chatRef.current?.newSession(); router.replace("/workspace", { scroll: false }); setChatSessionsOpen(false); }} onSelectSubagent={handleSelectSubagent} onDeleteSession={handleDeleteSession} onRenameSession={handleRenameSession} mobile onClose={() => setChatSessionsOpen(false)} /> ) ) : ( <> {!rightSidebarCollapsed && (
{chatSidebarPreview ? ( setChatSidebarPreview(null)} /> ) : ( { setActiveSessionId(sessionId); setActiveSubagentKey(null); void chatRef.current?.loadSession(sessionId); }} onNewSession={() => { setActiveSessionId(null); setActiveSubagentKey(null); void chatRef.current?.newSession(); router.replace("/workspace", { scroll: false }); }} onSelectSubagent={handleSelectSubagent} onDeleteSession={handleDeleteSession} onRenameSession={handleRenameSession} onCollapse={() => setRightSidebarCollapsed(true)} width={rightSidebarWidth} /> )}
)} {rightSidebarCollapsed && (
)} )} ) : ( <> {/* File content area */}
{/* Chat sidebar (file/folder-scoped) — hidden for reserved paths, hidden on mobile */} {!isMobile && fileContext && showChatSidebar && !rightSidebarCollapsed && ( <> )} )}
{/* Entry detail modal (rendered on top of everything) */} {entryModal && ( handleOpenEntry(objName, eid)} onNavigateObject={(objName) => { handleCloseEntry(); handleNavigateToObject(objName); }} onRefresh={refreshCurrentObject} /> )}
); } function previewFileTypeBadge(filename: string): { label: string; color: string } { const ext = filename.split(".").pop()?.toLowerCase() ?? ""; if (ext === "pdf") {return { label: "PDF", color: "#ef4444" };} if (["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "heic", "avif"].includes(ext)) {return { label: "Image", color: "#3b82f6" };} if (["mp4", "webm", "mov", "avi", "mkv"].includes(ext)) {return { label: "Video", color: "#8b5cf6" };} if (["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(ext)) {return { label: "Audio", color: "#f59e0b" };} if (["md", "mdx"].includes(ext)) {return { label: "Markdown", color: "#10b981" };} if (["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "rb", "swift", "kt", "c", "cpp", "h"].includes(ext)) {return { label: ext.toUpperCase(), color: "#3b82f6" };} if (["json", "yaml", "yml", "toml", "xml", "csv"].includes(ext)) {return { label: ext.toUpperCase(), color: "#6b7280" };} if (["duckdb", "sqlite", "sqlite3", "db"].includes(ext)) {return { label: "Database", color: "#6366f1" };} return { label: ext.toUpperCase() || "File", color: "#6b7280" }; } function shortenPreviewPath(p: string): string { return p.replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~"); } function ChatSidebarPreview({ preview, onClose, }: { preview: ChatSidebarPreviewState; onClose: () => void; }) { const badge = previewFileTypeBadge(preview.filename); const openInFinder = useCallback(async () => { try { await fetch("/api/workspace/open-file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: preview.path, reveal: true }), }); } catch { /* ignore */ } }, [preview.path]); const openWithSystem = useCallback(async () => { try { await fetch("/api/workspace/open-file", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: preview.path }), }); } catch { /* ignore */ } }, [preview.path]); const downloadUrl = preview.status === "ready" && preview.content.kind === "media" ? preview.content.url : null; let body: React.ReactNode; if (preview.status === "loading") { body = (

Loading preview...

); } else if (preview.status === "error") { body = (

Preview unavailable

{preview.message}

); } else { const c = preview.content; switch (c.kind) { case "media": if (c.mediaType === "pdf") { // Hide the browser's built-in PDF toolbar for a cleaner look const pdfUrl = c.url + (c.url.includes("#") ? "&" : "#") + "toolbar=0&navpanes=0&scrollbar=1"; body = (