From 84757c538f597b05c2d36d42e2756a88bf2b8875 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 20:03:03 +0700 Subject: [PATCH] fix: add documents navigation and panel --- src/app/[[...panel]]/page.tsx | 3 + src/components/layout/header-bar.tsx | 1 + src/components/layout/nav-rail.tsx | 11 + src/components/panels/documents-panel.tsx | 321 ++++++++++++++++++++++ 4 files changed, 336 insertions(+) create mode 100644 src/components/panels/documents-panel.tsx diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index e7d05dd..3ac8bf2 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -32,6 +32,7 @@ import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel' import { SuperAdminPanel } from '@/components/panels/super-admin-panel' import { OfficePanel } from '@/components/panels/office-panel' import { GitHubSyncPanel } from '@/components/panels/github-sync-panel' +import { DocumentsPanel } from '@/components/panels/documents-panel' import { ChatPanel } from '@/components/chat/chat-panel' import { ErrorBoundary } from '@/components/ErrorBoundary' import { LocalModeBanner } from '@/components/layout/local-mode-banner' @@ -266,6 +267,8 @@ function ContentRouter({ tab }: { tab: string }) { return case 'office': return + case 'documents': + return case 'super-admin': return case 'workspaces': diff --git a/src/components/layout/header-bar.tsx b/src/components/layout/header-bar.tsx index e0aa3a3..2939917 100644 --- a/src/components/layout/header-bar.tsx +++ b/src/components/layout/header-bar.tsx @@ -29,6 +29,7 @@ export function HeaderBar() { agents: 'Agent Squad', tasks: 'Task Board', sessions: 'Sessions', + documents: 'Documents', activity: 'Activity Feed', notifications: 'Notifications', standup: 'Daily Standup', diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index 996e004..a4671e1 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -27,6 +27,7 @@ const navGroups: NavGroup[] = [ { id: 'tasks', label: 'Tasks', icon: , priority: true }, { id: 'sessions', label: 'Sessions', icon: , priority: false }, { id: 'office', label: 'Office', icon: , priority: false }, + { id: 'documents', label: 'Documents', icon: , priority: false }, ], }, { @@ -637,3 +638,13 @@ function OfficeIcon() { ) } + +function DocumentsIcon() { + return ( + + + + + + ) +} diff --git a/src/components/panels/documents-panel.tsx b/src/components/panels/documents-panel.tsx new file mode 100644 index 0000000..3c5323d --- /dev/null +++ b/src/components/panels/documents-panel.tsx @@ -0,0 +1,321 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import { MarkdownRenderer } from '@/components/markdown-renderer' + +interface DocsTreeNode { + path: string + name: string + type: 'file' | 'directory' + size?: number + modified?: number + children?: DocsTreeNode[] +} + +interface DocsTreeResponse { + roots: string[] + tree: DocsTreeNode[] + error?: string +} + +interface DocsContentResponse { + path: string + content: string + size: number + modified: number + error?: string +} + +interface DocsSearchResult { + path: string + name: string + matches: number +} + +interface DocsSearchResponse { + results: DocsSearchResult[] + error?: string +} + +function collectFilePaths(nodes: DocsTreeNode[]): string[] { + const filePaths: string[] = [] + for (const node of nodes) { + if (node.type === 'file') { + filePaths.push(node.path) + continue + } + if (node.children && node.children.length > 0) { + filePaths.push(...collectFilePaths(node.children)) + } + } + return filePaths +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} B` + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB` + return `${(value / (1024 * 1024)).toFixed(1)} MB` +} + +function formatTime(value: number): string { + return new Date(value).toLocaleString() +} + +export function DocumentsPanel() { + const [tree, setTree] = useState([]) + const [roots, setRoots] = useState([]) + const [loadingTree, setLoadingTree] = useState(true) + const [treeError, setTreeError] = useState(null) + const [selectedPath, setSelectedPath] = useState(null) + const [docContent, setDocContent] = useState('') + const [docMeta, setDocMeta] = useState<{ size: number; modified: number } | null>(null) + const [loadingDoc, setLoadingDoc] = useState(false) + const [docError, setDocError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searching, setSearching] = useState(false) + const [searchError, setSearchError] = useState(null) + const [expandedDirs, setExpandedDirs] = useState>(new Set()) + + const loadTree = useCallback(async () => { + setLoadingTree(true) + setTreeError(null) + try { + const res = await fetch('/api/docs/tree') + const data = (await res.json()) as DocsTreeResponse + if (!res.ok) throw new Error(data.error || 'Failed to load documents') + + setTree(data.tree || []) + setRoots(data.roots || []) + const defaultExpanded = new Set((data.roots || []).filter(Boolean)) + setExpandedDirs(defaultExpanded) + } catch (error) { + setTree([]) + setRoots([]) + setTreeError((error as Error).message || 'Failed to load documents') + } finally { + setLoadingTree(false) + } + }, []) + + const loadDoc = useCallback(async (path: string) => { + setLoadingDoc(true) + setDocError(null) + setSelectedPath(path) + try { + const res = await fetch(`/api/docs/content?path=${encodeURIComponent(path)}`) + const data = (await res.json()) as DocsContentResponse + if (!res.ok) throw new Error(data.error || 'Failed to load document') + setDocContent(data.content || '') + setDocMeta({ size: data.size, modified: data.modified }) + } catch (error) { + setDocContent('') + setDocMeta(null) + setDocError((error as Error).message || 'Failed to load document') + } finally { + setLoadingDoc(false) + } + }, []) + + useEffect(() => { + void loadTree() + }, [loadTree]) + + const filePaths = useMemo(() => collectFilePaths(tree), [tree]) + + useEffect(() => { + if (selectedPath) return + if (filePaths.length === 0) return + void loadDoc(filePaths[0]) + }, [filePaths, loadDoc, selectedPath]) + + useEffect(() => { + const query = searchQuery.trim() + if (query.length < 2) { + setSearchResults([]) + setSearchError(null) + setSearching(false) + return + } + + const handle = setTimeout(async () => { + setSearching(true) + setSearchError(null) + try { + const res = await fetch(`/api/docs/search?q=${encodeURIComponent(query)}&limit=100`) + const data = (await res.json()) as DocsSearchResponse + if (!res.ok) throw new Error(data.error || 'Failed to search docs') + setSearchResults(data.results || []) + } catch (error) { + setSearchResults([]) + setSearchError((error as Error).message || 'Failed to search docs') + } finally { + setSearching(false) + } + }, 250) + + return () => clearTimeout(handle) + }, [searchQuery]) + + const isShowingSearch = searchQuery.trim().length >= 2 + + const toggleDir = (path: string) => { + setExpandedDirs((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + } + + const renderNode = (node: DocsTreeNode, depth = 0) => { + if (node.type === 'directory') { + const isOpen = expandedDirs.has(node.path) + return ( + + toggleDir(node.path)} + className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-secondary text-left" + style={{ paddingLeft: `${depth * 16 + 8}px` }} + > + {isOpen ? '▾' : '▸'} + {node.name} + + {isOpen && node.children && ( + + {node.children.map((child) => renderNode(child, depth + 1))} + + )} + + ) + } + + const active = selectedPath === node.path + return ( + void loadDoc(node.path)} + className={`w-full text-left py-1.5 px-2 rounded-md text-sm ${ + active ? 'bg-primary/15 text-primary' : 'text-foreground hover:bg-secondary' + }`} + style={{ paddingLeft: `${depth * 16 + 26}px` }} + > + {node.name} + + ) + } + + return ( + + + + + + + Document Viewer + + Browse and inspect workspace docs from allowed roots. + + + + {!selectedPath && ( + Select a file to view its contents. + )} + + {selectedPath && ( + + + {selectedPath} + {docMeta && ( + + {formatBytes(docMeta.size)} • Updated {formatTime(docMeta.modified)} + + )} + + + {loadingDoc && Loading document...} + {docError && {docError}} + + {!loadingDoc && !docError && ( + + + + )} + + )} + + + + ) +}
+ Browse and inspect workspace docs from allowed roots. +