56 lines
6.2 KiB
TypeScript
56 lines
6.2 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
|
import { HashRouter, Link, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
|
import { Blocks, Circle, RefreshCw } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { AppSidebar, navigation } from '@/components/app-sidebar'
|
|
import { LoginForm } from '@/components/login-form'
|
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'
|
|
import { Workbench } from '@/lib/context'
|
|
import { request, errorText } from '@/lib/api'
|
|
import type { State, AttentionItem } from '@/lib/types'
|
|
import { projectTitle, date } from '@/lib/types'
|
|
import VersionNotice from '@/components/version-notice'
|
|
import Overview from '@/pages/overview'
|
|
import Tasks from '@/pages/tasks'
|
|
import Cluster from '@/pages/cluster'
|
|
import Communication from '@/pages/communication'
|
|
import Decisions from '@/pages/decisions'
|
|
import Logs from '@/pages/logs'
|
|
function attentionTarget(item: AttentionItem) {
|
|
const target = item.href.startsWith('#/') ? item.href.slice(1) : item.href
|
|
return /^\/(overview|tasks|cluster|communication|decisions|logs)(?:[?#]|$)/.test(target) ? target : `/${item.page}`
|
|
}
|
|
function AttentionNotice({ state, pathname }: { state: State; pathname: string }) {
|
|
const items = (state.attention?.items ?? []).filter(item => item.required && `/${item.page}` === pathname)
|
|
if (!items.length) return null
|
|
return <nav aria-label="当前页面需要关注的事项" className="max-h-32 shrink-0 overflow-y-auto rounded-md border bg-background px-4 py-2 text-sm"><ul className="space-y-1">{items.map(item => <li key={item.id}><Link to={attentionTarget(item)} className="flex items-start gap-2 rounded-sm underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><span aria-hidden="true" className="mt-1.5 size-2 shrink-0 rounded-full bg-destructive" /><span className="break-words">{item.reason}</span></Link></li>)}</ul></nav>
|
|
}
|
|
function Shell({ token, initial, logout }: { token: string; initial: State; logout: () => void }) {
|
|
const [state, setState] = useState(initial), [error, setError] = useState('')
|
|
const title = projectTitle(state), location = useLocation()
|
|
const refresh = useCallback(async () => { const latest = await request<State>(token, '/api/state'); setState(latest); setError('') }, [token])
|
|
useEffect(() => {
|
|
const controller = new AbortController(); let busy = false
|
|
const poll = async () => { if (busy) return; busy = true; try { const latest = await request<State>(token, '/api/state', undefined, controller.signal); if (!controller.signal.aborted) { setState(latest); setError('') } } catch (e) { if (!controller.signal.aborted) setError(errorText(e)) } finally { busy = false } }
|
|
const timer = setInterval(() => void poll(), 3000)
|
|
return () => { clearInterval(timer); controller.abort() }
|
|
}, [token])
|
|
useEffect(() => { document.title = `${navigation.find(n => n.url === location.pathname)?.title || '总览'} · ${title}` }, [title, location.pathname])
|
|
const isChat = location.pathname === '/communication'
|
|
return <Workbench.Provider value={{ state, token, refresh }}><SidebarProvider><AppSidebar title={title} onLogout={logout} /><SidebarInset className={isChat ? 'h-svh min-h-0 overflow-hidden' : undefined}>
|
|
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-4 md:px-6"><div className="flex min-w-0 items-center gap-3"><SidebarTrigger aria-label="切换导航" /><Separator orientation="vertical" className="h-4" />{isChat ? <div className="min-w-0"><h1 className="font-semibold">项目沟通</h1><p className="truncate text-xs text-muted-foreground">询问进度、讨论方案,或告诉我你的新想法。</p></div> : <Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbPage>{navigation.find(n => n.url === location.pathname)?.title || '总览'}</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb>}</div>
|
|
{isChat ? <Button variant="ghost" size="sm" onClick={() => window.dispatchEvent(new Event('workbench-refresh-conversation'))}><RefreshCw />刷新</Button> : <div className="flex items-center gap-2 text-xs text-muted-foreground"><Circle className={`size-2 fill-current ${error ? 'text-destructive' : 'text-primary'}`} /><span>{error ? '连接中断' : '实时同步'}</span><span className="hidden lg:inline">{date(state.updated)}</span></div>}
|
|
</header>
|
|
<main className={isChat ? 'flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background' : 'min-w-0 flex-1 space-y-5 bg-muted/25 p-4 md:p-6 lg:p-8'}>{error && <Alert variant="destructive"><AlertDescription>{error} 当前显示上次同步的数据。</AlertDescription></Alert>}<VersionNotice /><AttentionNotice state={state} pathname={location.pathname} /><Routes><Route path="/overview" element={<Overview />} /><Route path="/tasks" element={<Tasks />} /><Route path="/cluster" element={<Cluster />} /><Route path="/communication" element={<Communication />} /><Route path="/decisions" element={<Decisions />} /><Route path="/logs" element={<Logs />} /><Route path="*" element={<Navigate to="/overview" replace />} /></Routes></main>
|
|
{!isChat && <footer className="border-t px-6 py-4 text-xs text-muted-foreground">Agent Workbench · 每 3 秒同步 · AI 接入状态以会话显示为准</footer>}
|
|
</SidebarInset></SidebarProvider></Workbench.Provider>
|
|
}
|
|
export default function App() {
|
|
const [auth, setAuth] = useState<{ token: string; state: State } | null>(null)
|
|
return <HashRouter><TooltipProvider>{auth ? <Shell token={auth.token} initial={auth.state} logout={() => setAuth(null)} /> : <main className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted/40 p-5"><div className="flex items-center gap-3 text-lg font-semibold"><span className="rounded-lg bg-primary p-2 text-primary-foreground"><Blocks className="size-5" /></span>Agent Workbench</div><div className="w-full max-w-md"><LoginForm onLogin={(token, state) => setAuth({ token, state })} /></div></main>}</TooltipProvider></HashRouter>
|
|
}
|