Add reliable execution reporting and coordinated dashboard views
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { Blocks, Circle } from 'lucide-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'
|
||||
@@ -10,13 +11,23 @@ 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 } from '@/lib/types'
|
||||
import type { State, AttentionItem } from '@/lib/types'
|
||||
import { projectTitle, date } from '@/lib/types'
|
||||
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()
|
||||
@@ -28,7 +39,14 @@ function Shell({ token, initial, logout }: { token: string; initial: State; logo
|
||||
return () => { clearInterval(timer); controller.abort() }
|
||||
}, [token])
|
||||
useEffect(() => { document.title = `${navigation.find(n => n.url === location.pathname)?.title || '总览'} · ${title}` }, [title, location.pathname])
|
||||
return <Workbench.Provider value={{ state, token, refresh }}><SidebarProvider><AppSidebar title={title} onLogout={logout} /><SidebarInset><header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-4 md:px-6"><div className="flex items-center gap-3"><SidebarTrigger aria-label="切换导航" /><Separator orientation="vertical" className="h-4" /><Breadcrumb><BreadcrumbList><BreadcrumbItem><BreadcrumbPage>{navigation.find(n => n.url === location.pathname)?.title || '总览'}</BreadcrumbPage></BreadcrumbItem></BreadcrumbList></Breadcrumb></div><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="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>}<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="*" element={<Navigate to="/overview" replace />} /></Routes></main><footer className="border-t px-6 py-4 text-xs text-muted-foreground">Agent Workbench · 每 3 秒同步 · AI 接入状态以会话显示为准</footer></SidebarInset></SidebarProvider></Workbench.Provider>
|
||||
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>}<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)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Adapted from official shadcn/ui sidebar-07 (MIT); demo identities removed.
|
||||
import type { ComponentProps } from 'react'
|
||||
import { Blocks, LayoutDashboard, ListTodo, Server, MessagesSquare, GitBranch, LogOut } from 'lucide-react'
|
||||
import { Blocks, LayoutDashboard, ListTodo, Server, MessagesSquare, GitBranch, ScrollText, LogOut } from 'lucide-react'
|
||||
import { NavMain } from '@/components/nav-main'
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail, SidebarMenu, SidebarMenuItem, SidebarMenuButton } from '@/components/ui/sidebar'
|
||||
export const navigation = [{ title: '总览', url: '/overview', icon: LayoutDashboard }, { title: '任务', url: '/tasks', icon: ListTodo }, { title: '集群', url: '/cluster', icon: Server }, { title: '沟通', url: '/communication', icon: MessagesSquare }, { title: '决策', url: '/decisions', icon: GitBranch }]
|
||||
export const navigation = [{ title: '总览', url: '/overview', icon: LayoutDashboard }, { title: '任务', url: '/tasks', icon: ListTodo }, { title: '集群', url: '/cluster', icon: Server }, { title: '沟通', url: '/communication', icon: MessagesSquare }, { title: '决策', url: '/decisions', icon: GitBranch }, { title: '日志', url: '/logs', icon: ScrollText }]
|
||||
export function AppSidebar({ title, onLogout, ...props }: ComponentProps<typeof Sidebar> & { title: string; onLogout: () => void }) {
|
||||
return <Sidebar collapsible="icon" {...props}><SidebarHeader><SidebarMenu><SidebarMenuItem><SidebarMenuButton size="lg" asChild><a href="#/overview"><div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"><Blocks className="size-4" /></div><div className="grid flex-1 text-left text-sm leading-tight"><span className="truncate font-semibold">{title}</span><span className="truncate text-xs text-muted-foreground">Agent Workbench</span></div></a></SidebarMenuButton></SidebarMenuItem></SidebarMenu></SidebarHeader><SidebarContent><NavMain items={navigation} /></SidebarContent><SidebarFooter><SidebarMenu><SidebarMenuItem><SidebarMenuButton onClick={onLogout} tooltip="退出登录"><LogOut /><span>退出登录</span></SidebarMenuButton></SidebarMenuItem></SidebarMenu></SidebarFooter><SidebarRail /></Sidebar>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Adapted from official shadcn/ui sidebar-07 (MIT); see THIRD_PARTY_NOTICES.md.
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useWorkbench } from '@/lib/context'
|
||||
import { SidebarGroup, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar'
|
||||
export function NavMain({ items }: { items: { title: string; url: string; icon: LucideIcon }[] }) {
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
return <SidebarGroup><SidebarGroupLabel>项目工作台</SidebarGroupLabel><SidebarMenu>{items.map(item => <SidebarMenuItem key={item.url}><NavLink to={item.url} onClick={() => { if (isMobile) setOpenMobile(false) }}>{({ isActive }) => <SidebarMenuButton asChild tooltip={item.title} isActive={isActive} className="data-[active=true]:bg-sidebar-primary/10 data-[active=true]:text-sidebar-primary"><span><item.icon /><span>{item.title}</span></span></SidebarMenuButton>}</NavLink></SidebarMenuItem>)}</SidebarMenu></SidebarGroup>
|
||||
const { state } = useWorkbench()
|
||||
return <SidebarGroup><SidebarGroupLabel>项目工作台</SidebarGroupLabel><SidebarMenu>{items.map(item => {
|
||||
const attention = (state.attention?.items ?? []).filter(entry => entry.required && `/${entry.page}` === item.url)
|
||||
const description = attention.length ? `${item.title}:${attention.map(entry => entry.reason).join(';')}` : item.title
|
||||
return <SidebarMenuItem key={item.url}><NavLink to={item.url} aria-label={description} title={description} onClick={() => { if (isMobile) setOpenMobile(false) }}>{({ isActive }) => <SidebarMenuButton asChild tooltip={description} isActive={isActive} className="relative data-[active=true]:bg-sidebar-primary/10 data-[active=true]:text-sidebar-primary"><span><item.icon /><span>{item.title}</span>{attention.length > 0 && <span aria-hidden="true" className="absolute top-1 right-1 size-2 rounded-full bg-destructive" />}</span></SidebarMenuButton>}</NavLink></SidebarMenuItem>
|
||||
})}</SidebarMenu></SidebarGroup>
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ export function Status({ value }: { value: string }) {
|
||||
return <Badge variant="outline" className={tone}>{statusLabels[value] || value}</Badge>
|
||||
}
|
||||
export function Empty({ children }: { children: ReactNode }) { return <p className="py-6 text-sm leading-7 text-muted-foreground">{children}</p> }
|
||||
export function Panel({ title, description, children, className }: { title: string; description?: string; children: ReactNode; className?: string }) {
|
||||
return <Card className={className}><CardHeader><CardTitle>{title}</CardTitle>{description && <CardDescription>{description}</CardDescription>}</CardHeader><CardContent>{children}</CardContent></Card>
|
||||
export function Panel({ title, description, children, className, id }: { id?: string; title: string; description?: string; children: ReactNode; className?: string }) {
|
||||
return <Card id={id} className={className}><CardHeader><CardTitle>{title}</CardTitle>{description && <CardDescription>{description}</CardDescription>}</CardHeader><CardContent>{children}</CardContent></Card>
|
||||
}
|
||||
export function Reports({ events, compact = false }: { events: Event[]; compact?: boolean }) {
|
||||
if (!events.length) return <Empty>暂无记录。新的汇报会同步到这里。</Empty>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
--status-success-bg: oklch(0.962 0.044 156.743);
|
||||
--status-danger: oklch(0.444 0.177 26.899);
|
||||
--status-danger-bg: oklch(0.971 0.013 17.38);
|
||||
--status-warning: oklch(0.555 0.163 48.998);
|
||||
--status-warning-bg: oklch(0.987 0.022 95.277);
|
||||
--status-active: var(--primary);
|
||||
--status-active-bg: oklch(0.97 0.014 254.604);
|
||||
}
|
||||
@@ -126,6 +128,7 @@
|
||||
@layer utilities {
|
||||
.status-success { color: var(--status-success); background: var(--status-success-bg); border-color: transparent; }
|
||||
.status-danger { color: var(--status-danger); background: var(--status-danger-bg); border-color: transparent; }
|
||||
.status-warning { color: var(--status-warning); background: var(--status-warning-bg); border-color: transparent; }
|
||||
.status-active { color: var(--status-active); background: var(--status-active-bg); border-color: transparent; }
|
||||
.status-neutral { color: var(--muted-foreground); background: var(--muted); border-color: transparent; }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
export type Task = { id: string; name: string; status: string; phase: string; eta_min_minutes?: number | null; eta_max_minutes?: number | null; eta_basis?: string; decision?: string; updated?: number }
|
||||
export type Task = {
|
||||
session_id?: string;
|
||||
execution_status?: 'pending' | 'running' | 'completed' | 'blocked' | 'failed'; last_report_at?: number | null; last_report_id?: string | null
|
||||
goal?: string; scope?: string; coupling_assessment?: string; owner?: string; integration_owner?: string; version?: string
|
||||
parent_id?: string | null; merged_into_id?: string | null; depends_on?: string[]; parallel_with?: string[]; merge_sources?: string[]; parallel_scope?: string; acceptance_criteria?: string[]
|
||||
sync_points?: { id: string; label: string; task_ids: string[]; status: 'waiting' | 'ready' | 'completed' }[]
|
||||
integration_status?: 'not_started' | 'waiting' | 'integrating' | 'integrated' | 'verified'; id: string; name: string; status: string; phase: string; eta_min_minutes?: number | null; eta_max_minutes?: number | null; eta_basis?: string; eta_estimated_at?: number | null; eta_revision?: number; eta_change_reason?: string; eta_last_reviewed_at?: number | null; eta_review_status?: string; current_progress?: string; progress_updated?: number | null; decision?: string; updated?: number }
|
||||
export type Session = { id: string; name: string; wp?: string; role?: string; thread_id?: string; thread_url?: string; connection?: string; history_synced_at?: number }
|
||||
export type Event = { id: number; kind: string; text: string; created: number; session_id?: string }
|
||||
export type Message = { id: string; session_id: string; text: string; source: 'human' | 'agent'; status: string; created: number; origin?: string; reply_to?: string }
|
||||
export type Message = { log_id?: string | null; id: string; session_id: string; text: string; source: 'human' | 'agent'; status: string; created: number; origin?: string; reply_to?: string }
|
||||
export type Sample<T> = { host?: string; status?: string; error?: string; collected_at?: string; data?: T | null }
|
||||
export type NodeMetric = { memory_used_percent?: number; memory_total_bytes?: number; memory_available_bytes?: number; load_1m?: number; load_5m?: number; load_15m?: number }
|
||||
export type Mount = { mount_target: string; used_percent?: number; total_bytes?: number; available_bytes?: number }
|
||||
export type SchedulerData = { count?: number; by_state?: Record<string, number>; queues?: { name: string; state: string; pend?: number; run?: number }[] }
|
||||
export type Metrics = { sampled_at?: number; status?: string; message?: string; data?: { status?: string; message?: string; collected_at?: string; nodes?: Sample<NodeMetric>[]; storage?: Sample<Mount[]>[]; schedulers?: Record<string, Record<string, Sample<SchedulerData>>> } }
|
||||
export type State = { hooks?: { pending_notifications: number; active_firing: number; recent: { source: string; summary: string; status: string; severity: string }[] }; config?: { title?: string }; title?: string; primary_session_id?: string; tasks: Task[]; sessions: Session[]; events: Event[]; requests: { id: string; name: string; status: string }[]; metrics?: Metrics; updated?: number }
|
||||
export type State = { attention?: Attention; decisions?: Decision[]; hooks?: { pending_notifications: number; active_firing: number; recent: { source: string; summary: string; status: string; severity: string }[] }; config?: { title?: string }; title?: string; primary_session_id?: string; tasks: Task[]; sessions: Session[]; events: Event[]; requests: { id: string; name: string; status: string }[]; metrics?: Metrics; updated?: number }
|
||||
export const statusLabels: Record<string, string> = { queued: '排队', running: '进行中', waiting: '等待', blocked: '受阻', completed: '已完成', failed: '失败', cancelled: '已取消', claimed: '处理中', delivered: '已送达', uncertain: '待人工核查' }
|
||||
export const phaseLabels: Record<string, string> = { planning: '规划', implementation: '实现', validation: '验证', integration: '集成', done: '完成' }
|
||||
export function date(value?: string | number) { return value == null ? '暂无' : new Date(typeof value === 'number' ? value * 1000 : value).toLocaleString('zh-CN') }
|
||||
export function projectTitle(state: State) { return state.config?.title || state.title || 'Agent Workbench' }
|
||||
|
||||
export type DecisionOption = { id: string; label: string; description: string }
|
||||
export type DecisionResponse = { id: string; option_id: string | null; comment: string; created: string | number; authorization: false }
|
||||
export type Decision = {
|
||||
id: string; title: string; question: string; impact: string
|
||||
priority: 'normal' | 'high' | 'urgent'
|
||||
status: 'open' | 'decided' | 'pending_verification'
|
||||
details: string; recommendation: string; options: DecisionOption[]
|
||||
deadline: string | null; requires_authorization: boolean; task_id: string | null
|
||||
response: DecisionResponse | null
|
||||
}
|
||||
|
||||
|
||||
export type Incident = {
|
||||
id: string; source: string; event_id: string
|
||||
level: 'info' | 'warning' | 'error' | 'critical'; status: 'active' | 'resolved'
|
||||
summary: string; impact: string; action: string; next_step: string; evidence: string[]
|
||||
first_seen: number; last_seen: number; resolved_at: number | null; repeat_count: number
|
||||
}
|
||||
|
||||
export type AttentionPage = 'overview' | 'tasks' | 'cluster' | 'communication' | 'decisions' | 'logs'
|
||||
export type AttentionItem = { id: string; page: AttentionPage; required: boolean; reason: string; href: string; updated: number }
|
||||
export type Attention = { items: AttentionItem[]; pages: Partial<Record<AttentionPage, boolean>> }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Send, RefreshCw } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Send } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { PageHeading } from '@/components/shared'
|
||||
import { useWorkbench } from '@/lib/context'
|
||||
import { request, errorText, messageKey } from '@/lib/api'
|
||||
import type { Message } from '@/lib/types'
|
||||
@@ -40,6 +40,11 @@ export default function Communication() {
|
||||
const timer = setInterval(() => void poll(), 3000)
|
||||
return () => { controller.abort(); clearInterval(timer) }
|
||||
}, [selected, token, known, refreshVersion])
|
||||
useEffect(() => {
|
||||
const reload = () => { setRefreshVersion(n => n + 1); void refresh().catch(e => setError(errorText(e))) }
|
||||
window.addEventListener('workbench-refresh-conversation', reload)
|
||||
return () => window.removeEventListener('workbench-refresh-conversation', reload)
|
||||
}, [refresh])
|
||||
const items = messages.session === selected ? messages.items : []
|
||||
const lastId = items.at(-1)?.id
|
||||
useEffect(() => {
|
||||
@@ -48,6 +53,7 @@ export default function Communication() {
|
||||
if (loaded.current !== selected || box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight
|
||||
loaded.current = selected
|
||||
}, [lastId, selected, messages.session])
|
||||
const decisions = (state as { decisions?: { id: string; status: string }[] }).decisions?.filter(d => d.status === 'open') || []
|
||||
const text = drafts[selected] || ''
|
||||
async function send(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
@@ -64,22 +70,24 @@ export default function Communication() {
|
||||
} catch (e) { setError(errorText(e)) }
|
||||
finally { setSending(false) }
|
||||
}
|
||||
return <div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||
<PageHeading title="项目沟通" description="询问进度、讨论方案,或告诉我你的新想法。" action={<Button variant="ghost" size="sm" onClick={() => { setRefreshVersion(n => n + 1); void refresh() }}><RefreshCw />刷新</Button>} />
|
||||
return <div className="flex min-h-0 w-full flex-1 flex-col">
|
||||
|
||||
{decisions.length > 0 && <p className="shrink-0 border-b px-4 py-3 text-sm"><Link className="text-primary underline underline-offset-4" to={`/decisions?decision=${encodeURIComponent(decisions[0].id)}`}>有 {decisions.length} 项需要你决定,查看决策</Link></p>}
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
<section className="overflow-hidden rounded-xl border bg-card">
|
||||
<div ref={scroll} role="log" aria-label="对话" aria-live="polite" className="h-[min(60vh,660px)] min-h-72 space-y-6 overflow-y-auto p-5 sm:p-7">
|
||||
<section className="flex min-h-0 flex-1 flex-col bg-card">
|
||||
<div ref={scroll} role="log" aria-label="对话" aria-live="polite" className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 py-5 sm:px-8">
|
||||
{items.length ? items.map(m => <article key={m.id} className={`flex ${m.source === 'human' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[92%] rounded-2xl px-4 py-3 sm:max-w-[85%] ${m.source === 'human' ? 'bg-primary text-primary-foreground' : 'bg-muted/60'}`}>
|
||||
<p className="mb-1 text-xs opacity-65">{m.source === 'human' ? '你' : '项目助手'}</p>
|
||||
<p className="whitespace-pre-wrap wrap-anywhere text-sm leading-7">{m.text}</p>
|
||||
{m.log_id && <Link className="mt-2 inline-block text-sm text-primary underline underline-offset-4" to={`/logs?incident=${encodeURIComponent(m.log_id)}`}>查看日志</Link>}
|
||||
{m.source === 'human' && ['queued', 'claimed'].includes(m.status) && <p className="mt-2 text-xs opacity-70">正在等待回复…</p>}
|
||||
{m.source === 'human' && ['failed', 'uncertain'].includes(m.status) && <p className="mt-2 text-xs opacity-70">暂未确认回复,请稍后查看;无需重复发送。</p>}
|
||||
</div>
|
||||
</article>) : <div className="flex h-full items-center justify-center text-sm text-muted-foreground">{known ? '有什么想了解的?从这里开始交流。' : '会话准备好后,即可在这里交流。'}</div>}
|
||||
</div>
|
||||
<form onSubmit={send} className="border-t p-4">
|
||||
<Textarea id="message" aria-label="输入消息" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="输入你的问题或想法…" maxLength={32000} rows={3} required className="resize-none border-0 shadow-none focus-visible:ring-0" />
|
||||
<form onSubmit={send} className="shrink-0 border-t p-3 sm:px-8">
|
||||
<Textarea id="message" aria-label="输入消息" value={text} onChange={e => setDrafts(old => ({ ...old, [selected]: e.target.value }))} placeholder="输入你的问题或想法…" maxLength={32000} rows={2} required className="resize-none border-0 shadow-none focus-visible:ring-0" />
|
||||
<div className="mt-2 flex items-center justify-between gap-3"><p className="text-xs text-muted-foreground">{!known ? '正在准备会话' : session?.connection === 'connected' ? '' : '暂时离线,消息会保存并等待回复。'}</p><Button type="submit" disabled={!known || sending || !text.trim()}><Send />{sending ? '发送中…' : '发送'}</Button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -1,14 +1,113 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { PageHeading, Panel, Empty, Reports } from '@/components/shared'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from '@/components/ui/sheet'
|
||||
import { PageHeading, Panel, Empty } from '@/components/shared'
|
||||
import { useWorkbench } from '@/lib/context'
|
||||
import { date } from '@/lib/types'
|
||||
import { ApiError, errorText, messageKey, request } from '@/lib/api'
|
||||
import { date, type Decision } from '@/lib/types'
|
||||
|
||||
const statuses = { open: '等待决定', decided: '已决定', pending_verification: '待核实' }
|
||||
const priorities = { normal: '普通', high: '重要', urgent: '紧急' }
|
||||
type Draft = { optionId: string | null; comment: string }
|
||||
const emptyDraft: Draft = { optionId: null, comment: '' }
|
||||
function Status({ decision }: { decision: Decision }) {
|
||||
return <Badge variant="outline" className={decision.status === 'decided' ? 'status-success' : 'status-neutral'}>{statuses[decision.status]}</Badge>
|
||||
}
|
||||
export default function Decisions() {
|
||||
const { state } = useWorkbench()
|
||||
const pending = state.tasks.filter(t => t.decision)
|
||||
return <div className="space-y-6"><PageHeading title="方案与决策" description="将待决定事项与方案记录集中管理。" action={<Button asChild><Link to="/communication">进入专门会话讨论 <ArrowRight /></Link></Button>} />
|
||||
<Panel title="等待决定" description="以下内容由工作包登记;聊天中的来源字段不代表人类批准。">{pending.length ? <div className="divide-y">{pending.map(t => <article key={t.id} className="py-5 first:pt-0 last:pb-0"><h3 className="font-medium">{t.id} · {t.name}</h3><p className="mt-3 whitespace-pre-wrap text-sm leading-7">{t.decision}</p><p className="mt-3 text-xs text-muted-foreground">更新于 {date(t.updated)}</p></article>)}</div> : <Empty>当前没有登记待决策事项。</Empty>}</Panel>
|
||||
<Panel title="方案与决策记录"><Reports events={state.events.filter(e => /decision|proposal|方案|决策/.test(e.kind))} /></Panel>
|
||||
const { state, token, refresh } = useWorkbench()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [receipts, setReceipts] = useState<Record<string, Decision>>({})
|
||||
const decisions = (state.decisions ?? []).map(decision => decision.status === 'open' ? receipts[decision.id] ?? decision : decision)
|
||||
const selectedId = params.get('decision')
|
||||
const selected = decisions.find(d => d.id === selectedId)
|
||||
const [drafts, setDrafts] = useState<Record<string, Draft>>({})
|
||||
const [notices, setNotices] = useState<Record<string, string>>({})
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
const [submitted, setSubmitted] = useState<Record<string, boolean>>({})
|
||||
const inFlight = useRef(new Set<string>())
|
||||
const attempts = useRef(new Map<string, { payload: string; key: string }>())
|
||||
const acknowledged = useRef(new Set<string>())
|
||||
const draft = selected ? drafts[selected.id] ?? emptyDraft : emptyDraft
|
||||
const editable = selected?.status === 'open' && !busy[selected.id] && !submitted[selected.id]
|
||||
function open(id: string | null) {
|
||||
setParams(previous => { const next = new URLSearchParams(previous); if (id) next.set('decision', id); else next.delete('decision'); return next })
|
||||
}
|
||||
function updateDraft(value: Partial<Draft>) {
|
||||
if (!selected || !editable) return
|
||||
setDrafts(previous => ({ ...previous, [selected.id]: { ...(previous[selected.id] ?? emptyDraft), ...value } }))
|
||||
}
|
||||
async function sync(id: string) {
|
||||
try { await refresh(); setNotices(previous => ({ ...previous, [id]: '' })) }
|
||||
catch (error) { setNotices(previous => ({ ...previous, [id]: `状态同步未完成:${errorText(error)} 请刷新状态。` })) }
|
||||
}
|
||||
async function submit(decision: Decision) {
|
||||
const id = decision.id
|
||||
const current = drafts[id] ?? emptyDraft
|
||||
if (decision.status !== 'open' || inFlight.current.has(id) || acknowledged.current.has(id)) return
|
||||
if (decision.options.length && !decision.options.some(option => option.id === current.optionId)) return
|
||||
const body = { option_id: current.optionId, comment: current.comment }
|
||||
const payload = JSON.stringify(body)
|
||||
let attempt = attempts.current.get(id)
|
||||
if (!attempt || attempt.payload !== payload) { attempt = { payload, key: messageKey() }; attempts.current.set(id, attempt) }
|
||||
inFlight.current.add(id)
|
||||
setBusy(previous => ({ ...previous, [id]: true }))
|
||||
setNotices(previous => ({ ...previous, [id]: '' }))
|
||||
try {
|
||||
const result = await request<Decision>(token, `/api/decisions/${encodeURIComponent(id)}/respond`, { ...body, idempotency_key: attempt.key })
|
||||
setReceipts(previous => ({ ...previous, [id]: result }))
|
||||
acknowledged.current.add(id)
|
||||
setSubmitted(previous => ({ ...previous, [id]: true }))
|
||||
setNotices(previous => ({ ...previous, [id]: '' }))
|
||||
await sync(id)
|
||||
} catch (error) {
|
||||
setNotices(previous => ({ ...previous, [id]: `${errorText(error)} 草稿已保留;重试相同内容不会重复提交。` }))
|
||||
if (error instanceof ApiError && error.status === 409) await sync(id)
|
||||
} finally {
|
||||
inFlight.current.delete(id)
|
||||
setBusy(previous => ({ ...previous, [id]: false }))
|
||||
}
|
||||
}
|
||||
const legacy = state.tasks.filter(task => task.decision)
|
||||
return <div className="space-y-6">
|
||||
<PageHeading title="方案与决策" description="查看影响与方案,再明确提交你的选择。" action={<Button asChild variant="outline"><Link to="/communication">讨论方案 <ArrowRight /></Link></Button>} />
|
||||
{decisions.length ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{decisions.map(decision => <Card key={decision.id} className="gap-0 p-0">
|
||||
<button type="button" onClick={() => open(decision.id)} className="flex h-full w-full flex-col gap-4 rounded-xl p-6 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`查看决策:${decision.title}`}>
|
||||
<div className="flex flex-wrap gap-2"><Badge variant="outline" className={decision.priority === 'urgent' ? 'status-danger' : 'status-neutral'}>{priorities[decision.priority]}</Badge><Status decision={decision} /></div>
|
||||
<h2 className="font-semibold break-words">{decision.title}</h2>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6">{decision.question}</p>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">影响:{decision.impact || '尚未补充'}</p>
|
||||
<span className="mt-auto flex items-center gap-2 text-sm text-primary">查看方案与详情 <ArrowRight className="size-4" /></span>
|
||||
</button>
|
||||
</Card>)}</div> : <Panel title="决策展板"><Empty>当前没有结构化决策事项。可在沟通中请总控补充需要决定的问题与方案。</Empty></Panel>}
|
||||
{legacy.length > 0 && <Panel title="待完善条目(只读)" description="以下是工作包原有的文字记录,尚不能在此提交选择。"><div className="space-y-4">{legacy.map(task => <article key={task.id} className="rounded-lg border p-4"><h3 className="font-medium">{task.id} · {task.name}</h3><p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6">{task.decision}</p><p className="mt-2 text-xs text-muted-foreground">更新于 {date(task.updated)}</p><Button asChild variant="link" className="mt-2 h-auto p-0"><Link to="/communication">请总控补充结构化选项 <ArrowRight /></Link></Button></article>)}</div></Panel>}
|
||||
<Sheet open={selectedId !== null} onOpenChange={value => { if (!value) open(null) }}>
|
||||
<SheetContent className="w-full sm:max-w-xl">
|
||||
<SheetHeader className="pr-12"><SheetTitle>{selected?.title ?? '决策暂不可用'}</SheetTitle><SheetDescription>{selected?.question ?? '该条目尚未同步或已移除,请刷新状态后重试。'}</SheetDescription></SheetHeader>
|
||||
{selected ? <>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 pb-4 break-words">
|
||||
<div className="flex flex-wrap gap-2"><Badge variant="outline">{priorities[selected.priority]}</Badge><Status decision={selected} /></div>
|
||||
<section><h3 className="font-medium">影响</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.impact || '尚未补充'}</p></section>
|
||||
<section><h3 className="font-medium">背景与依据</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.details || '尚未补充'}</p></section>
|
||||
{selected.recommendation && <section className="rounded-lg border bg-muted/50 p-4"><h3 className="font-medium">推荐与理由</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.recommendation}</p><p className="mt-2 text-xs text-muted-foreground">推荐不会替你选择。</p></section>}
|
||||
{selected.deadline && Number.isFinite(Date.parse(selected.deadline)) && <p className="text-sm">截止时间:{date(selected.deadline)}</p>}
|
||||
{selected.requires_authorization && <p className="rounded-lg border p-4 text-sm">此事项涉及权限。提交选择后仍需核实,不代表已授权。</p>}
|
||||
{selected.options.length > 0 && <fieldset disabled={!editable} className="space-y-3"><legend className="mb-3 font-medium">方案与取舍{selected.status === 'open' ? '(请选择一项)' : ''}</legend>{selected.options.map(option => <label key={option.id} className={`flex gap-3 rounded-lg border p-4 ${editable ? 'cursor-pointer hover:bg-muted/50' : ''}`}><input type="radio" name={`decision-${selected.id}`} value={option.id} checked={selected.status === 'open' ? draft.optionId === option.id : selected.response?.option_id === option.id} onChange={() => updateDraft({ optionId: option.id })} className="mt-1 size-4 shrink-0 accent-primary" /><span><span className="font-medium">{option.label}</span><span className="mt-1 block whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{option.description}</span></span></label>)}</fieldset>}
|
||||
{selected.status === 'open' && <div className="space-y-2"><label htmlFor="decision-comment" className="text-sm font-medium">补充备注</label><Textarea id="decision-comment" value={draft.comment} onChange={event => updateDraft({ comment: event.target.value })} disabled={!editable} placeholder="补充你的考虑或要求" /></div>}
|
||||
{selected.response && <section className="space-y-2 rounded-lg border p-4"><h3 className="font-medium">已记录的回复</h3><p className="text-sm">选择:{selected.options.find(option => option.id === selected.response?.option_id)?.label ?? selected.response.option_id ?? '仅备注'}</p><p className="whitespace-pre-wrap text-sm">{selected.response.comment || '无补充备注'}</p><p className="text-xs text-muted-foreground">{date(selected.response.created)}</p></section>}
|
||||
{selected.status === 'pending_verification' && <p role="status" className="text-sm">选择已记录,等待核实;尚未授权。</p>}
|
||||
{notices[selected.id] && <p role="status" className="text-sm">{notices[selected.id]}</p>}
|
||||
</div>
|
||||
<SheetFooter className="border-t">
|
||||
{selected.status === 'open' && !submitted[selected.id] && <Button disabled={!editable || (selected.options.length > 0 && !selected.options.some(option => option.id === draft.optionId))} onClick={() => void submit(selected)}>{busy[selected.id] ? '正在提交…' : '提交选择'}</Button>}
|
||||
<Button variant="outline" disabled={busy[selected.id]} onClick={() => void sync(selected.id)}>刷新状态</Button>
|
||||
</SheetFooter>
|
||||
</> : <SheetFooter><Button variant="outline" onClick={() => void sync(selectedId ?? '')}>刷新状态</Button></SheetFooter>}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||
import { PageHeading, Panel, Empty } from '@/components/shared'
|
||||
import { useWorkbench } from '@/lib/context'
|
||||
import { request, errorText } from '@/lib/api'
|
||||
import { date, type Incident } from '@/lib/types'
|
||||
|
||||
const levels = { info: '信息', warning: '警告', error: '错误', critical: '严重' }
|
||||
const levelColors = { info: 'status-active', warning: 'status-warning', error: 'status-danger', critical: 'bg-destructive text-white' }
|
||||
function Labels({ incident }: { incident: Incident }) {
|
||||
return <div className="flex flex-wrap gap-2"><Badge variant="outline" className={levelColors[incident.level]}>{levels[incident.level]}</Badge><Badge variant="outline" className={incident.status === 'resolved' ? 'status-success' : 'status-neutral'}>{incident.status === 'resolved' ? '已恢复' : '处理中'}</Badge></div>
|
||||
}
|
||||
function Times({ incident }: { incident: Incident }) {
|
||||
return <div className="space-y-1 text-xs text-muted-foreground"><p>首次发生:{date(incident.first_seen)}</p><p>最近发生:{date(incident.last_seen)}</p>{incident.resolved_at != null && <p>恢复时间:{date(incident.resolved_at)}</p>}<p>重复上报 {Math.max(0, incident.repeat_count - 1)} 次</p></div>
|
||||
}
|
||||
export default function Logs() {
|
||||
const { token } = useWorkbench()
|
||||
const [logs, setLogs] = useState<Incident[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [level, setLevel] = useState('all')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [params, setParams] = useSearchParams()
|
||||
const refreshRef = useRef<() => Promise<void>>(async () => {})
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let pending = false
|
||||
const refresh = async () => {
|
||||
if (pending || controller.signal.aborted) return
|
||||
pending = true
|
||||
setBusy(true)
|
||||
try {
|
||||
const result = await request<{ logs: Incident[] }>(token, '/api/logs', undefined, controller.signal)
|
||||
if (!controller.signal.aborted) { setLogs(result.logs); setLoaded(true); setError('') }
|
||||
} catch (cause) {
|
||||
if (!controller.signal.aborted) setError(errorText(cause))
|
||||
} finally {
|
||||
pending = false
|
||||
if (!controller.signal.aborted) setBusy(false)
|
||||
}
|
||||
}
|
||||
refreshRef.current = refresh
|
||||
void refresh()
|
||||
const timer = setInterval(() => void refresh(), 3000)
|
||||
return () => { clearInterval(timer); controller.abort() }
|
||||
}, [token])
|
||||
const selectedId = params.get('incident')
|
||||
const selected = logs.find(incident => incident.id === selectedId)
|
||||
const visible = logs.filter(incident => (level === 'all' || incident.level === level) && (status === 'all' || incident.status === status))
|
||||
function open(id: string | null) {
|
||||
setParams(previous => { const next = new URLSearchParams(previous); if (id) next.set('incident', id); else next.delete('incident'); return next })
|
||||
}
|
||||
return <div className="space-y-6">
|
||||
<PageHeading title="运行日志" description="查看事件影响、处理进展与恢复情况。每 3 秒同步,最多显示最近 100 项。" action={<Button variant="outline" disabled={busy} onClick={() => void refreshRef.current()}><RefreshCw className={busy ? 'animate-spin' : ''} />刷新</Button>} />
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error} {loaded ? '当前保留上次成功读取的数据。' : '尚未成功读取事件,请稍后重试。'}</AlertDescription></Alert>}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">等级<select value={level} onChange={event => setLevel(event.target.value)} className="h-9 rounded-md border bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><option value="all">全部等级</option>{Object.entries(levels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label className="flex items-center gap-2 text-sm">恢复状态<select value={status} onChange={event => setStatus(event.target.value)} className="h-9 rounded-md border bg-background px-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><option value="all">全部状态</option><option value="active">处理中</option><option value="resolved">已恢复</option></select></label>
|
||||
</div>
|
||||
{visible.length > 0 ? <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{visible.map(incident => <Card key={incident.id} className="gap-0 p-0"><button type="button" onClick={() => open(incident.id)} className="flex h-full w-full flex-col gap-4 rounded-xl p-6 text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label={`查看事件:${incident.summary}`}><Labels incident={incident} /><h2 className="whitespace-pre-wrap break-words font-medium">{incident.summary}</h2><p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">影响:{incident.impact || '尚未补充'}</p><Times incident={incident} /><span className="mt-auto text-sm text-primary">查看处理详情</span></button></Card>)}</div> : <Panel title="事件"><Empty>{!loaded ? (error ? '事件暂不可用。' : '正在读取事件…') : logs.length ? '没有符合当前筛选条件的事件。' : '暂无事件。'}</Empty></Panel>}
|
||||
<Sheet open={selectedId !== null} onOpenChange={value => { if (!value) open(null) }}><SheetContent className="w-full sm:max-w-xl">
|
||||
<SheetHeader className="pr-12"><SheetTitle>{selected?.summary ?? '事件详情'}</SheetTitle><SheetDescription>{selected ? '事件摘要与处理进展' : loaded ? '该事件不在最近记录中,或已移除。' : '正在读取事件;若读取失败,请关闭详情后重试。'}</SheetDescription></SheetHeader>
|
||||
{selected && <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 pb-6 break-words"><Labels incident={selected} /><Times incident={selected} />
|
||||
<section><h3 className="font-medium">影响</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.impact || '尚未补充'}</p></section>
|
||||
<section><h3 className="font-medium">已采取的处理</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.action || '尚未登记处理措施'}</p></section>
|
||||
<section><h3 className="font-medium">下一步</h3><p className="mt-2 whitespace-pre-wrap text-sm leading-6">{selected.next_step || '尚未补充'}</p></section>
|
||||
<section><h3 className="font-medium">相关证据摘要</h3>{selected.evidence.length ? <ul className="mt-2 list-disc space-y-2 pl-5 text-sm leading-6">{selected.evidence.map((evidence, index) => <li key={index} className="whitespace-pre-wrap">{evidence}</li>)}</ul> : <p className="mt-2 text-sm text-muted-foreground">暂无证据摘要。</p>}</section>
|
||||
{error && <p role="status" className="text-sm text-destructive">{error} 当前显示上次读取的详情。</p>}
|
||||
</div>}
|
||||
</SheetContent></Sheet>
|
||||
</div>
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { useWorkbench } from '@/lib/context'
|
||||
import { phaseLabels } from '@/lib/types'
|
||||
export default function Overview() {
|
||||
const { state } = useWorkbench()
|
||||
const pending = state.tasks.filter(t => t.decision)
|
||||
const pending = [...state.tasks.filter(t => t.decision), ...((state as { decisions?: { id: string; title: string; question: string; status: string }[] }).decisions || []).filter(d => d.status === 'open').map(d => ({ id: d.id, name: d.title, decision: d.question }))]
|
||||
const blocked = state.tasks.filter(t => ['blocked', 'failed'].includes(t.status))
|
||||
const lead = state.tasks.find(t => t.id === 'WP0')
|
||||
const stats = [{ label: '工作包', value: state.tasks.length, icon: Layers3 }, { label: '进行中', value: state.tasks.filter(t => t.status === 'running').length, icon: Activity }, { label: '已完成', value: state.tasks.filter(t => t.status === 'completed').length, icon: CheckCircle2 }, { label: '待决定', value: pending.length, icon: CircleHelp }]
|
||||
@@ -15,7 +15,7 @@ export default function Overview() {
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">{stats.map(s => <Card key={s.label}><CardContent className="flex items-start justify-between pt-6"><div><p className="text-sm text-muted-foreground">{s.label}</p><p className="mt-2 text-3xl font-semibold tabular-nums">{s.value}</p></div><s.icon className="size-5 text-primary" /></CardContent></Card>)}</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2"><Panel title="当前阶段与阻塞" description="总控记录与需要关注的任务。">{lead ? <div className="mb-5 flex items-center gap-3"><span className="font-medium">{phaseLabels[lead.phase] || lead.phase}</span><Status value={lead.status} /></div> : <Empty>尚未登记总控阶段。</Empty>}{blocked.length ? <div className="space-y-3">{blocked.slice(0, 3).map(t => <p key={t.id} className="text-sm">{t.id} · {t.name}</p>)}</div> : <p className="text-sm text-muted-foreground">当前没有登记受阻任务。</p>}<Button variant="link" asChild className="mt-4 px-0"><Link to="/tasks">查看全部任务 <ArrowRight /></Link></Button></Panel>
|
||||
<Panel title="等待你决定" description="待决策事项摘要。">{pending.length ? <div className="space-y-4">{pending.slice(0, 3).map(t => <div key={t.id}><p className="text-xs font-medium text-muted-foreground">{t.id} · {t.name}</p><p className="mt-1 line-clamp-3 whitespace-pre-wrap text-sm leading-6">{t.decision}</p></div>)}</div> : <Empty>当前没有登记待决策事项。</Empty>}<Button variant="link" asChild className="mt-4 px-0"><Link to="/decisions">查看方案与决策 <ArrowRight /></Link></Button></Panel></div>
|
||||
{state.hooks && <Panel title="故障与恢复" description="重大事件触发汇报;重复状态不打扰。"><p className="text-sm text-muted-foreground">活动故障 {state.hooks.active_firing} · 待投递通知 {state.hooks.pending_notifications}</p>{state.hooks.recent.slice(0, 3).map((h, i) => <p className="mt-3 text-sm" key={i}>{h.status === 'resolved' ? '已恢复' : '故障'} · {h.summary}</p>)}</Panel>}
|
||||
{state.hooks && <Panel title="故障与恢复" description="重大事件触发汇报;重复状态不打扰。"><p className="text-sm text-muted-foreground">活动故障 {state.hooks.active_firing} · 待投递通知 {state.hooks.pending_notifications}</p><Button variant="link" asChild className="mt-3 px-0"><Link to="/logs">查看运行日志 <ArrowRight /></Link></Button></Panel>}
|
||||
<Panel title="最近重要汇报" description="保留关键结论,详细讨论进入专门会话。"><Reports events={state.events.filter(e => /report|summary|handoff/.test(e.kind)).slice(0, 3)} compact /></Panel>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Panel, Empty, Status } from '@/components/shared'
|
||||
import type { Task } from '@/lib/types'
|
||||
export function TaskLinks({ ids, tasks, onOpen }: { ids: string[]; tasks: Task[]; onOpen: (id: string) => void }) {
|
||||
return <div className="flex flex-wrap gap-2">{[...new Set(ids)].map(id => <Button key={id} type="button" variant="outline" size="sm" className="h-auto max-w-full whitespace-normal text-left" onClick={() => onOpen(id)}>{id} · {tasks.find(task => task.id === id)?.name ?? '未同步任务'}</Button>)}</div>
|
||||
}
|
||||
export default function TaskRelations({ tasks, onOpen }: { tasks: Task[]; onOpen: (id: string) => void }) {
|
||||
const groups = [
|
||||
{ title: '父子拆分', description: '父任务包含子任务,不表示先后执行顺序。', label: '包含', edges: tasks.flatMap(task => task.parent_id ? [[task.parent_id, task.id]] : []) },
|
||||
{ title: '前置依赖', description: '箭头从前置任务指向依赖它的任务;具体等待点见详情。', label: '前置于', edges: tasks.flatMap(task => (task.depends_on ?? []).map(id => [id, task.id])) },
|
||||
{ title: '归并', description: '箭头从来源任务指向接收归并的任务,不代表已经完成集成。', label: '归并到', edges: tasks.flatMap(task => [...(task.merged_into_id ? [[task.id, task.merged_into_id]] : []), ...(task.merge_sources ?? []).map(id => [id, task.id])]) },
|
||||
]
|
||||
return <div className="space-y-5"><p className="text-sm text-muted-foreground">这是任务关系图,不是 Git 分支。不同关系分别展示;可并行范围与同步点在任务详情中查看。</p>
|
||||
<Panel title="全部任务"><div className="flex flex-wrap gap-3">{tasks.map(task => <div key={task.id} className="space-y-2 rounded-lg border p-3"><TaskLinks ids={[task.id]} tasks={tasks} onOpen={onOpen} /><Status value={task.status} />{Boolean(task.parallel_with?.length) && <p className="text-xs text-muted-foreground">有可并行任务,查看详情确认范围</p>}</div>)}</div></Panel>
|
||||
{groups.map(group => <Panel key={group.title} title={group.title} description={group.description}>{group.edges.length ? <div className="max-h-96 overflow-auto" tabIndex={0} aria-label={`${group.title}关系,可滚动`}><ul className="min-w-[36rem] space-y-3">{[...new Map(group.edges.map(edge => [JSON.stringify(edge), edge])).values()].map(([from, to]) => <li key={JSON.stringify([from, to])} className="grid grid-cols-[1fr_auto_1fr] items-center gap-4 rounded-lg border p-3"><TaskLinks ids={[from]} tasks={tasks} onOpen={onOpen} /><span className="text-xs text-muted-foreground">{group.label} →</span><TaskLinks ids={[to]} tasks={tasks} onOpen={onOpen} /></li>)}</ul></div> : <Empty>尚未登记此类关系。</Empty>}</Panel>)}
|
||||
</div>
|
||||
}
|
||||
@@ -1,10 +1,66 @@
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { PageHeading, Panel, Status, Empty, Reports } from '@/components/shared'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet'
|
||||
import { useWorkbench } from '@/lib/context'
|
||||
import { phaseLabels, date } from '@/lib/types'
|
||||
import { phaseLabels, date, type Task } from '@/lib/types'
|
||||
import TaskRelations, { TaskLinks } from './task-relations'
|
||||
|
||||
const activeStatuses = new Set(['queued', 'running', 'waiting', 'blocked', 'claimed'])
|
||||
const reviewLabels: Record<string, string> = { pending: '正在重新核查', overdue: '已超过原估计,待核查', unknown: '暂无法确认剩余时间', needs_decision: '预计时间需重新确认' }
|
||||
const integrationLabels = { not_started: '尚未开始', waiting: '等待集成', integrating: '正在集成', integrated: '已集成', verified: '已验证' }
|
||||
const syncLabels = { waiting: '等待', ready: '已就绪', completed: '已完成' }
|
||||
function TaskStatus({ task }: { task: Task }) {
|
||||
return <div className="flex flex-wrap items-center gap-2">{task.status === 'completed' ? <Badge variant="outline" className="status-success">已验收</Badge> : <Status value={task.status} />}{task.execution_status === 'completed' && task.status === 'waiting' && task.phase === 'integration' && <span className="text-xs text-muted-foreground">执行已完成,等待集成验收</span>}</div>
|
||||
}
|
||||
function remaining(task: Task) {
|
||||
if (task.status === 'completed') return '已完成 · 0 分钟'
|
||||
if (task.eta_review_status === 'unknown') return '暂无法确认剩余时间'
|
||||
if (task.eta_min_minutes == null || (activeStatuses.has(task.status) && (task.eta_max_minutes ?? task.eta_min_minutes) <= 0)) return '尚无可靠估计'
|
||||
if (task.eta_max_minutes == null || task.eta_max_minutes === task.eta_min_minutes) return `${task.eta_min_minutes} 分钟`
|
||||
return `${task.eta_min_minutes}–${task.eta_max_minutes} 分钟`
|
||||
}
|
||||
function Estimate({ task }: { task: Task }) {
|
||||
return <div className="space-y-1"><p title={task.eta_change_reason || undefined}>{remaining(task)}</p>{typeof task.eta_estimated_at === 'number' && Number.isFinite(task.eta_estimated_at) && task.eta_estimated_at > 0 && <p className="text-xs text-muted-foreground">估算于 {date(task.eta_estimated_at)}</p>}{task.status !== 'completed' && task.eta_review_status !== 'unknown' && reviewLabels[task.eta_review_status ?? ''] && <p className="text-xs text-muted-foreground">{reviewLabels[task.eta_review_status ?? '']}</p>}</div>
|
||||
}
|
||||
function Updated({ task }: { task: Task }) {
|
||||
const updated = task.progress_updated ?? task.updated
|
||||
const valid = typeof updated === 'number' && Number.isFinite(updated) && updated > 0
|
||||
return <div className="space-y-1 text-xs text-muted-foreground"><p>更新于 {valid ? date(updated) : '暂无'}</p>{activeStatuses.has(task.status) && (!valid || Date.now() / 1000 - updated > 600) && <p>进度待更新,需核查</p>}</div>
|
||||
}
|
||||
function Text({ title, value }: { title: string; value?: string }) {
|
||||
return <section><h3 className="font-medium">{title}</h3><p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6">{value?.trim() || '尚未登记'}</p></section>
|
||||
}
|
||||
export default function Tasks() {
|
||||
const { state } = useWorkbench()
|
||||
return <div className="space-y-6"><PageHeading title="任务与工作包" description="每项工作的当前阶段、预计剩余时间和判断依据。" />
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{state.tasks.map(t => <Panel key={t.id} title={`${t.id} · ${t.name}`}><div className="flex items-center justify-between gap-3"><Status value={t.status} /><span className="text-sm text-muted-foreground">{phaseLabels[t.phase] || t.phase}</span></div><dl className="mt-5 space-y-4 text-sm"><div><dt className="text-muted-foreground">预计剩余</dt><dd className="mt-1 font-medium">{t.eta_min_minutes == null ? '尚无可靠估计' : `${t.eta_min_minutes}–${t.eta_max_minutes} 分钟`}</dd></div>{t.eta_basis && <div><dt className="text-muted-foreground">估计依据</dt><dd className="mt-1 whitespace-pre-wrap leading-6">{t.eta_basis}</dd></div>}{t.decision && <div><dt className="text-muted-foreground">待决定</dt><dd className="mt-1 whitespace-pre-wrap leading-6">{t.decision}</dd></div>}</dl><p className="mt-5 text-xs text-muted-foreground">更新于 {date(t.updated)}</p></Panel>)}</div>{!state.tasks.length && <Panel title="工作包"><Empty>尚未登记工作包。</Empty></Panel>}
|
||||
const [params, setParams] = useSearchParams()
|
||||
const view = ['list', 'branches'].includes(params.get('view') ?? '') ? params.get('view')! : 'board'
|
||||
const focused = params.get('task')
|
||||
const selected = state.tasks.find(task => task.id === focused)
|
||||
function open(id: string | null) { setParams(previous => { const next = new URLSearchParams(previous); if (id) next.set('task', id); else next.delete('task'); return next }) }
|
||||
function changeView(value: string) { setParams(previous => { const next = new URLSearchParams(previous); next.set('view', value); return next }) }
|
||||
const sessions = selected ? state.sessions.filter(session => session.id === selected.session_id || session.wp === selected.id || Boolean(selected.owner && (session.id === selected.owner || session.thread_id === selected.owner))) : []
|
||||
function relations(title: string, ids: string[]) { return <section><h3 className="mb-2 font-medium">{title}</h3>{ids.length ? <TaskLinks ids={ids} tasks={state.tasks} onOpen={open} /> : <p className="text-sm text-muted-foreground">尚未登记</p>}</section> }
|
||||
return <div className="space-y-6">
|
||||
<PageHeading title="任务与工作包" description="每项工作正在做什么,以及当前阶段和预计剩余时间" />
|
||||
<div role="group" aria-label="任务视图" className="flex flex-wrap gap-2">{[['board', '展板'], ['list', '列表'], ['branches', '分支关系']].map(([value, label]) => <Button key={value} variant={view === value ? 'default' : 'outline'} aria-pressed={view === value} onClick={() => changeView(value)}>{label}</Button>)}</div>
|
||||
{!state.tasks.length ? <Panel title="工作包"><Empty>尚未登记工作包。</Empty></Panel> : view === 'branches' ? <TaskRelations tasks={state.tasks} onOpen={open} /> : view === 'list' ? <div className="overflow-x-auto rounded-xl border bg-card"><table className="w-full min-w-[64rem] text-left text-sm"><caption className="sr-only">任务列表,点击任务名称查看详情</caption><thead className="border-b bg-muted/50"><tr>{['任务名称', '目前进度', '状态', '阶段', '预计剩余', '负责人'].map(label => <th key={label} className="p-4 font-medium">{label}</th>)}</tr></thead><tbody>{state.tasks.map(task => <tr key={task.id} className="border-b last:border-0"><td className="p-4"><Button variant="link" className="h-auto whitespace-normal p-0 text-left" onClick={() => open(task.id)}>{task.id} · {task.name}</Button></td><td className="max-w-sm space-y-2 p-4"><p className="whitespace-pre-wrap break-words">{task.current_progress?.trim() || '尚未汇报'}</p><Updated task={task} /></td><td className="p-4"><TaskStatus task={task} /></td><td className="p-4">{phaseLabels[task.phase] || task.phase}</td><td className="p-4"><Estimate task={task} /></td><td className="p-4">{task.owner || '尚未登记'}</td></tr>)}</tbody></table></div> : <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">{state.tasks.map(task => <Card id={`task-${task.id}`} key={task.id} className="p-0"><button type="button" onClick={() => open(task.id)} className="flex h-full w-full flex-col gap-4 rounded-xl p-6 text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><h2 className="font-semibold break-words">{task.id} · {task.name}</h2><div className="flex w-full items-center justify-between gap-3"><TaskStatus task={task} /><span className="text-sm text-muted-foreground">{phaseLabels[task.phase] || task.phase}</span></div><div className="space-y-1 text-sm"><p className="text-muted-foreground">目前进度</p><p className="whitespace-pre-wrap break-words leading-6">{task.current_progress?.trim() || '尚未汇报'}</p></div><div className="space-y-1 text-sm"><p className="text-muted-foreground">预计剩余</p><Estimate task={task} /></div>{task.decision && <p className="whitespace-pre-wrap break-words text-sm">待决定:{task.decision}</p>}<Updated task={task} /><span className="mt-auto text-sm text-primary">查看任务详情</span></button></Card>)}</div>}
|
||||
<details className="rounded-xl border bg-card p-6"><summary className="cursor-pointer text-sm font-medium">Agent 活动记录</summary><div className="mt-5"><Reports events={state.events} /></div></details>
|
||||
<Sheet open={focused !== null} onOpenChange={value => { if (!value) open(null) }}><SheetContent className="w-full sm:max-w-2xl"><SheetHeader className="pr-12"><SheetTitle>{selected ? `${selected.id} · ${selected.name}` : '任务暂不可用'}</SheetTitle><SheetDescription>{selected ? '任务目标、协作关系与验收进展' : '该任务尚未同步或已移除。'}</SheetDescription></SheetHeader>{selected && <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-4 pb-6 break-words">
|
||||
<div className="flex items-center gap-3"><TaskStatus task={selected} /><span className="text-sm">{phaseLabels[selected.phase] || selected.phase}</span></div>
|
||||
<Text title="目标" value={selected.goal} /><Text title="工作范围" value={selected.scope} /><Text title="目前进度" value={selected.current_progress || '尚未汇报'} /><Updated task={selected} />
|
||||
<section className="text-sm"><h3 className="mb-2 font-medium">预计剩余</h3><Estimate task={selected} /></section>
|
||||
<Text title="负责人" value={selected.owner} /><Text title="集成负责人" value={selected.integration_owner} />
|
||||
<section><h3 className="mb-2 font-medium">关联会话</h3>{sessions.length ? <ul className="space-y-2 text-sm">{sessions.map(session => <li key={session.id}>{session.name}{session.role ? ` · ${session.role}` : ''}{session.thread_id && <span className="block text-xs text-muted-foreground">会话标识:{session.thread_id}</span>}</li>)}</ul> : <p className="text-sm text-muted-foreground">尚未登记会话映射</p>}</section>
|
||||
<Text title="任务间的关联与影响" value={selected.coupling_assessment} />
|
||||
{relations('所属父任务(包含关系)', selected.parent_id ? [selected.parent_id] : [])}{relations('拆分出的子任务', state.tasks.filter(task => task.parent_id === selected.id).map(task => task.id))}{relations('前置依赖', selected.depends_on ?? [])}{relations('可并行的任务', selected.parallel_with ?? [])}
|
||||
<Text title="可并行部分与等待点" value={selected.parallel_scope} />
|
||||
<section><h3 className="mb-2 font-medium">同步点</h3>{selected.sync_points?.length ? <ul className="space-y-3">{selected.sync_points.map(point => <li key={point.id} className="space-y-2 rounded-lg border p-3"><p className="text-sm">{point.label} · {syncLabels[point.status]}</p><TaskLinks ids={point.task_ids} tasks={state.tasks} onOpen={open} /></li>)}</ul> : <p className="text-sm text-muted-foreground">尚未登记</p>}</section>
|
||||
{relations('归并来源', [...(selected.merge_sources ?? []), ...state.tasks.filter(task => task.merged_into_id === selected.id).map(task => task.id)])}{relations('归并目标', [...(selected.merged_into_id ? [selected.merged_into_id] : []), ...state.tasks.filter(task => task.merge_sources?.includes(selected.id)).map(task => task.id)])}
|
||||
<section><h3 className="mb-2 font-medium">验收条件</h3>{selected.acceptance_criteria?.length ? <ul className="list-disc space-y-2 pl-5 text-sm">{selected.acceptance_criteria.map((criterion, index) => <li key={index} className="whitespace-pre-wrap">{criterion}</li>)}</ul> : <p className="text-sm text-muted-foreground">尚未登记</p>}</section>
|
||||
<Text title="版本" value={selected.version} /><Text title="集成状态" value={selected.integration_status ? integrationLabels[selected.integration_status] : undefined} />{selected.decision && <Text title="待决定" value={selected.decision} />}
|
||||
</div>}</SheetContent></Sheet>
|
||||
</div>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user