Visualize version deliveries and task dependency branches

This commit is contained in:
aiagent
2026-10-19 21:58:17 -07:00
parent 9ae803de23
commit bf53f011c9
10 changed files with 275 additions and 15 deletions
+1
View File
@@ -1,4 +1,5 @@
export type Task = {
kind?: 'work' | 'delivery'; delivery_id?: string | null; created_at?: number | null;
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
+50
View File
@@ -0,0 +1,50 @@
import type { Task } from '../lib/types.ts'
export type GraphEdge = { from: string; to: string; kind: 'dependency' | 'membership' | 'merge' }
export function walk(seeds: Iterable<string>, adjacency: Map<string, string[]>) {
const seen = new Set<string>(), pending = [...seeds]
while (pending.length) { const id = pending.pop()!; if (seen.has(id)) continue; seen.add(id); for (const next of adjacency.get(id) ?? []) if (!seen.has(next)) pending.push(next) }
return seen
}
export function buildGraph(tasks: Task[], all = false) {
const byId = new Map(tasks.map(task => [task.id, task]))
const parents = new Map(tasks.map(task => [task.id, (task.depends_on ?? []).filter(id => byId.has(id))]))
const visible = all ? new Set(byId.keys()) : walk(tasks.filter(task => !['completed', 'cancelled', 'failed'].includes(task.status)).map(task => task.id), parents)
for (const id of [...visible]) { const delivery = byId.get(byId.get(id)?.delivery_id ?? ''); if (delivery?.kind === 'delivery') visible.add(delivery.id) }
const nodes = tasks.filter(task => visible.has(task.id))
const edges: GraphEdge[] = [], unique = new Set<string>()
function add(from: string, to: string, kind: GraphEdge['kind']) { const key = JSON.stringify([from, to, kind]); if (visible.has(from) && visible.has(to) && !unique.has(key)) { unique.add(key); edges.push({ from, to, kind }) } }
const mergeTargets = new Map<string, string[]>()
for (const target of tasks) for (const source of target.merge_sources ?? []) mergeTargets.set(source, [...(mergeTargets.get(source) ?? []), target.id])
for (const task of nodes) {
for (const id of task.depends_on ?? []) add(id, task.id, 'dependency')
if (task.kind !== 'delivery' && byId.get(task.delivery_id ?? '')?.kind === 'delivery') add(task.delivery_id!, task.id, 'membership')
const targets = [...(task.merged_into_id ? [task.merged_into_id] : []), ...(mergeTargets.get(task.id) ?? [])]
for (const targetId of targets) { const target = byId.get(targetId); if (target?.kind === 'delivery' && target.integration_status === 'verified' && task.integration_status === 'verified') add(task.id, target.id, 'merge') }
}
return { nodes, edges, deliveries: nodes.filter(task => task.kind === 'delivery').sort((a, b) => (validCreated(a) ?? Number.MAX_SAFE_INTEGER) - (validCreated(b) ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id)) }
}
export function related(id: string, edges: GraphEdge[]) {
const forward = new Map<string, string[]>(), backward = new Map<string, string[]>()
for (const edge of edges) { if (edge.kind === 'membership') continue; forward.set(edge.from, [...(forward.get(edge.from) ?? []), edge.to]); backward.set(edge.to, [...(backward.get(edge.to) ?? []), edge.from]) }
return new Set([...walk([id], forward), ...walk([id], backward)])
}
export function validCreated(task: Task) { return typeof task.created_at === 'number' && Number.isFinite(task.created_at) && task.created_at > 0 ? task.created_at : null }
export function compactLayout(graph: ReturnType<typeof buildGraph>) {
const rows: { task: Task; y: number; group: string | null }[] = []
const positions = new Map<string, { x: number; y: number }>()
const starts = new Map<string, number>()
const groups = new Map<string, Task[]>()
const deliveryIds = new Set(graph.deliveries.map(task => task.id))
const independent: Task[] = []
for (const task of graph.nodes) {
if (task.kind === 'delivery') continue
if (task.delivery_id && deliveryIds.has(task.delivery_id)) { const items = groups.get(task.delivery_id) ?? []; items.push(task); groups.set(task.delivery_id, items) }
else independent.push(task)
}
const add = (task: Task, group: string | null) => { const y = 50 + rows.length * 100; rows.push({ task, y, group }); positions.set(task.id, { x: task.kind === 'delivery' ? 32 : 100, y }) }
for (const delivery of graph.deliveries) { starts.set(delivery.id, 15 + rows.length * 100); for (const task of groups.get(delivery.id) ?? []) add(task, delivery.id); add(delivery, delivery.id) }
for (const task of independent) add(task, null)
return { rows, positions, starts, height: Math.max(100, rows.length * 100) }
}
+35 -9
View File
@@ -1,17 +1,43 @@
import { useId, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Panel, Empty, Status } from '@/components/shared'
import { Empty, Status } from '@/components/shared'
import type { Task } from '@/lib/types'
import { phaseLabels } from '@/lib/types'
import { buildGraph, related, validCreated, compactLayout } from './task-graph'
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>)}
const [all, setAll] = useState(false), [hover, setHover] = useState<string | null>(null), [focus, setFocus] = useState<string | null>(null), [picked, setPicked] = useState<string | null>(null)
const graph = useMemo(() => buildGraph(tasks, all), [tasks, all])
const active = hover ?? focus ?? picked
const highlighted = useMemo(() => active ? related(active, graph.edges) : null, [active, graph.edges])
const marker = useId().replace(/:/g, '')
const layout = useMemo(() => compactLayout(graph), [graph])
const labels = new Map(graph.nodes.map(task => [task.id, task.name]))
const dim = (id: string) => highlighted && !highlighted.has(id) ? 'opacity-50' : ''
return <div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-3"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={all} onChange={event => { setAll(event.target.checked); setPicked(null); setHover(null); setFocus(null) }} />显示全部(含历史)</label><Button variant="outline" size="sm" onClick={() => { setPicked(null); setHover(null); setFocus(null) }}>清除高亮</Button></div>
<p className="text-xs text-muted-foreground">交付按登记时间分组,交付节点位于组末。主线排列不是因果或 Git 历史;默认显示未结束任务与递归前置。悬停或聚焦查看上下游,点击名称或圆节点打开详情。</p>
<div aria-label="关系图图例" className="flex flex-wrap gap-4 text-xs"><span>│ 交付主线</span><span className="text-muted-foreground">细线:明确归属</span><span className="text-primary">粗线箭头:已验证汇合</span><span>虚线箭头:前置依赖</span><span>○ 登记同步点</span></div>
{!graph.nodes.length ? <Empty>当前没有未结束任务或前置任务。可选择显示全部查看历史。</Empty> : <div className="w-full min-w-0 max-w-full overflow-hidden rounded-xl border bg-card"><div className="max-h-[70vh] overflow-auto" tabIndex={0} aria-label="交付与任务关系图,可横向和纵向滚动" onMouseLeave={() => setHover(null)}>
<div className="relative" style={{ width: 960, height: layout.height }}>
<svg width="220" height={layout.height} className="pointer-events-none absolute inset-0 text-primary" aria-hidden="true"><defs><marker id={marker} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" /></marker></defs>
{graph.deliveries.length > 0 && <path d={`M 32 15 V ${layout.positions.get(graph.deliveries.at(-1)!.id)!.y}`} stroke="currentColor" opacity="0.35" strokeWidth="3" fill="none" />}
{graph.edges.map((edge, index) => { const from = layout.positions.get(edge.from)!, to = layout.positions.get(edge.to)!; const membership = edge.kind === 'membership', merge = edge.kind === 'merge'; const lane = 145 + index % 5 * 12; const start = layout.starts.get(edge.from) ?? from.y; const path = membership ? `M 32 ${start} C 64 ${start}, 64 ${to.y}, 100 ${to.y}` : merge ? `M ${from.x} ${from.y + 8} C 100 ${to.y - 18}, 72 ${to.y}, ${to.x + 8} ${to.y}` : `M ${from.x + 8} ${from.y} C ${lane} ${from.y}, ${lane} ${to.y}, ${to.x + 8} ${to.y}`; return <path key={`${edge.kind}:${edge.from}:${edge.to}`} d={path} fill="none" className={membership ? 'text-muted-foreground' : ''} stroke="currentColor" strokeWidth={merge ? 3 : 1.25} strokeDasharray={edge.kind === 'dependency' ? '4 4' : undefined} markerEnd={membership ? undefined : `url(#${marker})`} opacity={highlighted && !(highlighted.has(edge.from) && highlighted.has(edge.to)) ? 0.2 : membership ? 0.45 : 0.85} /> })}
</svg>
{layout.rows.map(({task, y, group}) => <article key={task.id} className={`absolute left-0 h-[100px] w-full border-b last:border-0 ${dim(task.id)}`} style={{ top: y - 50 }} onMouseEnter={() => setHover(task.id)} onMouseLeave={() => setHover(null)} onFocusCapture={() => setFocus(task.id)} onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocus(null) }}>
<button type="button" aria-label={`查看${task.name}详情`} className={`absolute top-[42px] size-4 rounded-full border-2 border-primary bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${active === task.id ? 'ring-2 ring-ring' : ''}`} style={{ left: layout.positions.get(task.id)!.x - 8 }} onClick={() => onOpen(task.id)} />
<div className="absolute inset-y-0 left-[220px] right-3 flex items-center gap-3">
<div className="min-w-0 flex-1"><button type="button" className="block max-w-full truncate rounded-sm text-left text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" title={task.name} onClick={() => onOpen(task.id)}>{task.name}</button>
<p className="mt-1 truncate text-xs text-muted-foreground" title={group ? labels.get(group) : '无明确交付归属'}>{task.kind === 'delivery' ? '交付' : group ? `归属 ${labels.get(group)}` : '无明确交付归属'} · {task.id}{task.version ? ` · ${task.version}` : ''}{task.kind === 'delivery' && !validCreated(task) ? ' · 时间未登记' : ''}</p>
{!!task.sync_points?.length && <div className="mt-1 flex max-w-full gap-3 overflow-x-auto text-xs">{task.sync_points.map(point => <span key={point.id} className="shrink-0" title={point.label}>○ {point.label} · {{waiting:'等待',ready:'已就绪',completed:'已完成'}[point.status]}</span>)}</div>}
</div>
<div className="w-28 shrink-0 space-y-1"><Status value={task.status} /><p className="text-xs text-muted-foreground">{task.integration_status === 'verified' ? '集成已验证' : phaseLabels[task.phase] || task.phase}</p></div>
<Button variant={picked === task.id ? 'secondary' : 'ghost'} size="sm" aria-pressed={picked === task.id} aria-label={`高亮${task.name}上下游`} onClick={() => setPicked(picked === task.id ? null : task.id)}>高亮</Button><Button variant="outline" size="sm" onClick={() => onOpen(task.id)}>详情</Button>
</div>
</article>)}
</div>
</div></div>}
</div>
}