Merge pull request #227 from builderz-labs/fix/issue-218-documents-nav

fix: add Documents nav + panel (closes #218)
This commit is contained in:
nyk 2026-03-05 23:25:02 +07:00 committed by GitHub
commit 4549045a84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 336 additions and 0 deletions

View File

@ -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 <GitHubSyncPanel />
case 'office':
return <OfficePanel />
case 'documents':
return <DocumentsPanel />
case 'super-admin':
return <SuperAdminPanel />
case 'workspaces':

View File

@ -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',

View File

@ -27,6 +27,7 @@ const navGroups: NavGroup[] = [
{ id: 'tasks', label: 'Tasks', icon: <TasksIcon />, priority: true },
{ id: 'sessions', label: 'Sessions', icon: <SessionsIcon />, priority: false },
{ id: 'office', label: 'Office', icon: <OfficeIcon />, priority: false },
{ id: 'documents', label: 'Documents', icon: <DocumentsIcon />, priority: false },
],
},
{
@ -637,3 +638,13 @@ function OfficeIcon() {
</svg>
)
}
function DocumentsIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 1.5h7l3 3V14a1 1 0 01-1 1H3a1 1 0 01-1-1V2.5a1 1 0 011-1z" />
<path d="M10 1.5V5h3" />
<path d="M5 8h6M5 10.5h6M5 13h4" />
</svg>
)
}

View File

@ -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<DocsTreeNode[]>([])
const [roots, setRoots] = useState<string[]>([])
const [loadingTree, setLoadingTree] = useState(true)
const [treeError, setTreeError] = useState<string | null>(null)
const [selectedPath, setSelectedPath] = useState<string | null>(null)
const [docContent, setDocContent] = useState<string>('')
const [docMeta, setDocMeta] = useState<{ size: number; modified: number } | null>(null)
const [loadingDoc, setLoadingDoc] = useState(false)
const [docError, setDocError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<DocsSearchResult[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(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<string>((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 (
<div key={node.path}>
<button
onClick={() => 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` }}
>
<span className="text-xs text-muted-foreground">{isOpen ? '▾' : '▸'}</span>
<span className="text-sm text-foreground">{node.name}</span>
</button>
{isOpen && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
)
}
const active = selectedPath === node.path
return (
<button
key={node.path}
onClick={() => 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}
</button>
)
}
return (
<div className="h-full p-4 md:p-6">
<div className="h-full min-h-[600px] rounded-xl border border-border bg-card overflow-hidden grid grid-cols-1 lg:grid-cols-[340px_1fr]">
<aside className="border-r border-border p-4 space-y-3 overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-foreground">Documents</h2>
<button
onClick={() => void loadTree()}
className="text-xs px-2 py-1 rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-secondary"
>
Refresh
</button>
</div>
<div className="space-y-1">
<label htmlFor="docs-search" className="text-xs text-muted-foreground">Search docs</label>
<input
id="docs-search"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Type at least 2 characters..."
className="w-full h-9 px-3 rounded-md bg-background border border-border text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
{roots.length > 0 && (
<div className="text-xs text-muted-foreground">
Roots: {roots.join(', ')}
</div>
)}
{loadingTree && (
<div className="text-sm text-muted-foreground">Loading documents...</div>
)}
{treeError && (
<div className="text-sm text-red-400">{treeError}</div>
)}
{!loadingTree && !treeError && isShowingSearch && (
<div className="space-y-1">
{searching && <div className="text-sm text-muted-foreground">Searching...</div>}
{searchError && <div className="text-sm text-red-400">{searchError}</div>}
{!searching && !searchError && searchResults.length === 0 && (
<div className="text-sm text-muted-foreground">No matches.</div>
)}
{!searching && !searchError && searchResults.map((result) => (
<button
key={result.path}
onClick={() => void loadDoc(result.path)}
className={`w-full text-left p-2 rounded-md border ${
selectedPath === result.path
? 'border-primary/40 bg-primary/10'
: 'border-border hover:bg-secondary'
}`}
>
<div className="text-sm text-foreground truncate">{result.name}</div>
<div className="text-xs text-muted-foreground truncate">{result.path}</div>
<div className="text-2xs text-muted-foreground mt-0.5">{result.matches} matches</div>
</button>
))}
</div>
)}
{!loadingTree && !treeError && !isShowingSearch && (
<div className="space-y-1">
{tree.length === 0 && (
<div className="text-sm text-muted-foreground">
No supported docs roots found. Add one of: <code className="font-mono">docs</code>, <code className="font-mono">knowledge-base</code>, <code className="font-mono">knowledge</code>, <code className="font-mono">memory</code>.
</div>
)}
{tree.map((node) => renderNode(node))}
</div>
)}
</aside>
<section className="p-4 md:p-6 overflow-y-auto">
<div className="mb-4">
<h3 className="text-base md:text-lg font-semibold text-foreground">Document Viewer</h3>
<p className="text-xs text-muted-foreground mt-1">
Browse and inspect workspace docs from allowed roots.
</p>
</div>
{!selectedPath && (
<div className="text-sm text-muted-foreground">Select a file to view its contents.</div>
)}
{selectedPath && (
<div className="space-y-3">
<div className="rounded-md border border-border bg-secondary/30 px-3 py-2">
<div className="text-sm text-foreground font-medium break-all">{selectedPath}</div>
{docMeta && (
<div className="mt-1 text-xs text-muted-foreground">
{formatBytes(docMeta.size)} Updated {formatTime(docMeta.modified)}
</div>
)}
</div>
{loadingDoc && <div className="text-sm text-muted-foreground">Loading document...</div>}
{docError && <div className="text-sm text-red-400">{docError}</div>}
{!loadingDoc && !docError && (
<div className="rounded-md border border-border bg-background p-4">
<MarkdownRenderer content={docContent} />
</div>
)}
</div>
)}
</section>
</div>
</div>
)
}