From f584b67813a60215a9cd5beac3c6d4865d70937d Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 27 Sep 2026 01:13:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E5=9B=BE=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=8C=E6=AE=B5=E5=BC=8F=E5=8D=A1=E7=89=87?= =?UTF-8?q?=20+=20=E6=8A=98=E5=8F=A0=E8=81=9A=E5=90=88=20+=20=E7=AA=84?= =?UTF-8?q?=E6=A0=8F=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图模式(主显示模式)换成新的卡片语言,树模式保持不变: - 卡片 = 上段(类型徽章 + 相位徽标 + 名称两行 + mono 元信息 + 团队任务行) + 下段小条(状态点 + 状态词 + 主 Agent 同款合并活动 + 完成后的折叠 chevron)。 类型徽章用页面自己的词汇(主代理 / 子代理 / 成员 / 工作流 / ✓N 已完成)。 - 分组色(层级):按树深度取 4 档令牌色阶(color-mix 混 --dsw-alias-state-business-primary), 作用于左描边与上段极淡底色;相位徽标走 ink 色族,两套分组不撞色。 相位虚线框保留但改用同色极淡描边,卡片徽标承担「这是哪个阶段」。 - 执行态:小条 `data-running` + 从左到右扫光(1.5s,末 1/3 停顿, prefers-reduced-motion 关闭);合并活动行来自宿主 chat 词条,缺失时回退插件文案。 - 折叠:已完成节点的 chevron 把该节点「收进已完成聚合」(模型新增 foldedIds, 与全局 folded 走同一组守卫:只在已结算叶子、非当前会话、非团队成员时生效); 聚合节点点击 = 全部展开(清空手动集 + 关闭全局折叠);控制条仍只管全局规则。 - 布局与窄栏(用户反馈 a–f):卡片高度预算按新行重算(徽章 + 两行标题 + 元信息 + 小条 = 103px,任务行 +18),宽度 176 → 190;fit 按内容包围盒双轴居中、 FIT_MAX_SCALE 1.15 → 1.30;缩放范围 0.4–2.0 并归一化 wheel deltaMode; 平移加边界夹取(至少 72px 内容留在视口内);空画布双击重新适配并恢复自动 fit; 连线改「垂直出线 stub + 贝塞尔」并加粗到 1.5px。 验证:typecheck / lint 通过;pnpm test 1253 passed / 9 skipped(含新增双段结构、 data-running、chevron → 聚合、聚合 → 全部展开、工作流相位徽标 5 例)。 (cherry picked from commit aee571e50beb5dc979995f0841cffd0a575f2173) --- src/client/SubagentView.tsx | 26 ++- src/client/TasksGraph.tsx | 273 ++++++++++++++++++++------- src/client/locales-ar.ts | 4 + src/client/locales-de.ts | 4 + src/client/locales-fr.ts | 4 + src/client/locales-hi.ts | 4 + src/client/locales-id.ts | 4 + src/client/locales-it.ts | 4 + src/client/locales-ja.ts | 4 + src/client/locales-ko.ts | 4 + src/client/locales-nl.ts | 4 + src/client/locales-pl.ts | 4 + src/client/locales-pt.ts | 4 + src/client/locales-ru.ts | 4 + src/client/locales-sv.ts | 4 + src/client/locales-th.ts | 4 + src/client/locales-tr.ts | 4 + src/client/locales-vi.ts | 4 + src/client/locales-zh-HK.ts | 4 + src/client/locales-zh-MO.ts | 4 + src/client/locales-zh-TW.ts | 4 + src/client/locales.ts | 8 + src/client/tasks-card.tsx | 158 ++++++++++++++++ src/client/tasks-graph-layout.ts | 103 +++++----- src/client/tasks-graph.module.css | 234 ++++++++++++++++++++++- src/client/tasks-model.ts | 56 +++++- tests/subagent-live-polling.spec.tsx | 25 ++- tests/tasks-graph-layout.spec.ts | 85 ++------- tests/tasks-page.spec.tsx | 167 +++++++++++++++- 29 files changed, 999 insertions(+), 212 deletions(-) create mode 100644 src/client/tasks-card.tsx diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 4f125f9..90ace72 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -256,6 +256,12 @@ export function SubagentView(props: { const mode = modeOverride ?? prefsMode const [folded, setFolded] = useState(true) + /** + * Nodes the reader folded by hand from their own card bar. Kept apart from + * `folded` (the global rule) so the control cluster and a single card never + * fight: the aggregate's click clears BOTH, which is what "expand" means. + */ + const [foldedIds, setFoldedIds] = useState>(() => new Set()) const [teamBoardCollapsed, setTeamBoardCollapsed] = useState(false) const [popover, setPopover] = useState(null) @@ -272,8 +278,9 @@ export function SubagentView(props: { teamMembers, teamTasks: teamView?.available === true ? teamView.team?.tasks ?? [] : [], folded, + foldedIds, })), - [byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded], + [byId, catalogs, rootId, sessionId, live, runs, teamMembers, teamView, folded, foldedIds], ) /** @@ -337,6 +344,21 @@ export function SubagentView(props: { } }, [openChild, openMain]) + /** Fold one settled node into its parent's aggregate (its card's chevron). */ + const foldNode = useCallback((node: TasksAgentNode): void => { + setFoldedIds(current => { + const next = new Set(current) + next.add(node.id) + return next + }) + }, []) + + /** Expand every fold: the global rule off AND the manual folds cleared. */ + const expandFold = useCallback((): void => { + setFolded(false) + setFoldedIds(current => (current.size === 0 ? current : new Set())) + }, []) + /** Open the shared task window from a board row (undefined = create). */ const openTaskFromBoard = useCallback(( task: { id: string } | undefined, @@ -542,6 +564,8 @@ export function SubagentView(props: { onWorkflowInfo={(node, anchor) => { setPopover({ kind: 'workflow', nodeId: node.id, anchor }) }} onOpenTask={openTaskById} onToggleFold={() => { setFolded(current => !current) }} + onExpandFold={expandFold} + onFoldNode={foldNode} mode={mode} onModeChange={setModeOverride} /> diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx index 88148c9..c4eebbf 100644 --- a/src/client/TasksGraph.tsx +++ b/src/client/TasksGraph.tsx @@ -30,49 +30,87 @@ import { } from 'react' import clsx from 'clsx' import { - Button, IconChecklistOutlineRegular, IconFullscreenOutlineRegular, IconLoadingOutlineRegular, IconTreeCornerRegular, - StateDot, + Button, IconChecklistOutlineRegular, IconChevronDownOutlineRegular, IconFullscreenOutlineRegular, + IconLoadingOutlineRegular, IconTreeCornerRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { TasksAgentNode, TasksFoldNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts' import { tasksEdges } from './tasks-model.ts' import { - GRAPH_FIT_MIN_SCALE, GRAPH_NODE_W, layoutTasksGraphForWidth, type GraphBox, + GRAPH_FIT_MIN_SCALE, GRAPH_NODE_H, GRAPH_NODE_W, GRAPH_PAD, layoutTasksGraphForWidth, + type GraphBox, } from './tasks-graph-layout.ts' import { - AgentGlyph, agentMeta, FoldGlyph, foldPreviews, LiveLine, nodeDotState, TaskLine, - WorkflowGlyph, workflowMeta, + agentMeta, foldPreviews, TaskLine, WorkflowGlyph, workflowMeta, } from './tasks-shared.tsx' +import { CardBar, CardTop, phaseClass, type CardKind } from './tasks-card.tsx' +import { doneActivityTitle, liveActivityLabel } from './process-labels.ts' import { t } from './locales.ts' import css from './tasks-graph.module.css' import canvasCss from './tasks-canvas.module.css' -/** The zoom bounds of the canvas. */ -const ZOOM_MIN = 0.3 -const ZOOM_MAX = 1.5 +/** The zoom bounds of the canvas. The upper bound is deliberately above 1: on + * a narrow panel the reader zooms IN to read a card, and the fit's own cap + * (FIT_MAX_SCALE) is what keeps a two-node tree from ballooning. */ +const ZOOM_MIN = 0.4 +const ZOOM_MAX = 2 /** Upper bound of the auto-fit scale (never balloon a two-node tree). */ -const FIT_MAX_SCALE = 1.15 +const FIT_MAX_SCALE = 1.3 +/** Breathing room the fit leaves around the CONTENT box, per axis. */ +const FIT_MARGIN = 24 +/** How much of the canvas must stay on screen while panning (px). */ +const PAN_KEEP = 72 /* The readability floor the fit honours is `GRAPH_FIT_MIN_SCALE` (imported): * the layout budgets its columns from the same number, so scaling and wrapping * can never disagree. */ +/** The perpendicular stub each connector leaves a card with (px, pre-scale). */ +const EDGE_STUB = 8 /** Drag-vs-click separation: pointer travel below this stays a click. */ const CLICK_TOLERANCE_PX = 4 /** - * Card line 1 of an agent / run node: the canvas node title with the - * truncation swapped for a TWO-line clamp — a single ellipsized line left too - * few CJK characters to tell two siblings apart, and `nodeHeight` in - * tasks-graph-layout.ts reserves the card height for that second line (plus - * one row per live / task line below it). The untruncated name stays in the - * `title` attribute. + * The card kind of one agent node: the node's own role decides the badge, so a + * teammate never reads as a plain subagent and the root never reads as a child. */ -const NODE_TITLE = canvasCss.nodeTitle -/** The fold aggregate's title: the same clamp on the receding plain ink. */ -const NODE_TITLE_PLAIN = clsx(NODE_TITLE, canvasCss.nodeTitlePlain) +function agentCardKind(node: TasksAgentNode): CardKind { + if (node.team?.role === 'teammate') return 'teammate' + return node.parentId === undefined ? 'main' : 'subagent' +} + +/** + * The bar's activity line. A running node shows the LIVE merged activity; a + * settled one shows the same merged wording in its completed form (the host's + * own `done.*` phrasing) — a settled card whose bar read only "已完成" would + * throw away the one thing the fold is for. + */ +function barActivity(node: TasksAgentNode): string | undefined { + const summary = node.live?.summary + if (node.state === 'running') return liveActivityLabel(summary) + return summary === undefined ? undefined : doneActivityTitle(summary) +} + +/** Members of one run that already reported an outcome. */ +function doneMembers(node: TasksWorkflowNode): number { + let done = 0 + for (const phase of node.run.phases) { + for (const member of phase.members) if (member.outcome !== undefined) done += 1 + } + return done +} + +/** Members of one run across every phase. */ +function totalMembers(node: TasksWorkflowNode): number { + let total = 0 + for (const phase of node.run.phases) total += phase.members.length + return total +} /** One phase frame of a run node (the dashed box behind its members). */ interface PhaseFrame { key: string title: string | undefined + /** The phase's index within its run — drives the frame's colour, which the + * members' own phase badges repeat (badge + colour, no lines to decode). */ + index: number x: number y: number w: number @@ -88,7 +126,12 @@ export interface TasksGraphProps { onWorkflowInfo(node: TasksWorkflowNode, anchor: HTMLElement): void /** Open the shared task window for one task id. */ onOpenTask(taskId: string, anchor: HTMLElement): void + /** The control cluster's global fold switch (settled leaves). */ onToggleFold(): void + /** The fold AGGREGATE's click: expand everything (global + manual folds). */ + onExpandFold(): void + /** Fold ONE settled node into its parent's aggregate (the bar's chevron). */ + onFoldNode(node: TasksAgentNode): void mode: 'graph' | 'tree' onModeChange(mode: 'graph' | 'tree'): void /** Fallback hint while the root catalog hydrates (the graph twin of @@ -138,7 +181,10 @@ export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void } export function TasksGraph(props: TasksGraphProps): ReactNode { - const { nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, mode, onModeChange, loading } = props + const { + nodes, folded, rootId, onNodeInfo, onWorkflowInfo, onOpenTask, onToggleFold, onExpandFold, + onFoldNode, mode, onModeChange, loading, + } = props const containerRef = useRef(null) const [tf, setTf] = useState({ x: 0, y: 0, k: 1 }) const [dragging, setDragging] = useState(false) @@ -158,24 +204,58 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { const edges = useMemo(() => tasksEdges(nodes), [nodes]) const nodeById = useMemo(() => new Map(nodes.map(node => [node.id, node])), [nodes]) - /** Center the canvas in the container (both axes) and scale to fill. */ + /** + * Center the canvas and scale it to fill. The fit measures the CONTENT box + * (the layout's padding is breathing room, not content): centering the padded + * box leaves the graph visibly off-center in a narrow panel, which is exactly + * how "the graph looks small and sits in a corner" happens. + */ const fit = useCallback((): void => { const container = containerRef.current if (container === null) return const cw = container.clientWidth const ch = container.clientHeight if (cw === 0 || ch === 0) return + const contentW = Math.max(GRAPH_NODE_W, layout.width - GRAPH_PAD * 2) + const contentH = Math.max(GRAPH_NODE_H, layout.height - GRAPH_PAD * 2) const k = Math.max( GRAPH_FIT_MIN_SCALE, - Math.min((cw - 24) / layout.width, (ch - 24) / layout.height, FIT_MAX_SCALE), + Math.min( + (cw - FIT_MARGIN * 2) / contentW, + (ch - FIT_MARGIN * 2) / contentH, + FIT_MAX_SCALE, + ), ) setTf({ - x: (cw - layout.width * k) / 2, - y: Math.max(8, (ch - layout.height * k) / 2), + x: (cw - contentW * k) / 2 - GRAPH_PAD * k, + y: Math.max(FIT_MARGIN / 2, (ch - contentH * k) / 2) - GRAPH_PAD * k, k, }) }, [layout.width, layout.height]) + /** + * Keep the canvas reachable: a pan or a zoom-out may never push the whole + * graph off screen (the classic "I dragged it away and cannot find it"). + * `PAN_KEEP` px of content stay inside the container on every axis. + */ + const clampTf = useCallback((next: { x: number; y: number; k: number }) => { + const container = containerRef.current + if (container === null) return next + const cw = container.clientWidth + const ch = container.clientHeight + const w = layout.width * next.k + const h = layout.height * next.k + const minX = Math.min(PAN_KEEP, cw - PAN_KEEP) - w + const maxX = Math.max(PAN_KEEP, cw - PAN_KEEP) + const minY = Math.min(PAN_KEEP, ch - PAN_KEEP) - h + const maxY = Math.max(PAN_KEEP, ch - PAN_KEEP) + return { + x: Math.min(maxX, Math.max(minX, next.x)), + y: Math.min(maxY, Math.max(minY, next.y)), + k: next.k, + } + }, [layout.width, layout.height]) + /** The ⌂ button: refit AND re-enable auto-fit for later resizes. */ const refit = useCallback((): void => { touchedRef.current = false @@ -210,18 +290,22 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { const mx = event.clientX - rect.left const my = event.clientY - rect.top const { x, y, k } = tfRef.current - const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * 0.0014))) + // Wheel deltas arrive in pixels, lines or pages depending on the device; + // normalise first, or a line-scrolling mouse zooms 30× slower than a + // trackpad (and a trackpad pinch, which reports ctrlKey, feels stuck). + const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1 + const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * unit * 0.0014))) if (next === k) return touchedRef.current = true - setTf({ + setTf(clampTf({ x: mx - (mx - x) * (next / k), y: my - (my - y) * (next / k), k: next, - }) + })) } container.addEventListener('wheel', onWheel, { passive: false }) return () => { container.removeEventListener('wheel', onWheel) } - }, []) + }, [clampTf]) const zoomBy = useCallback((factor: number): void => { const container = containerRef.current @@ -231,8 +315,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { touchedRef.current = true const mx = container.clientWidth / 2 const my = container.clientHeight / 2 - setTf({ x: mx - (mx - x) * (next / k), y: my - (my - y) * (next / k), k: next }) - }, []) + setTf(clampTf({ x: mx - (mx - x) * (next / k), y: my - (my - y) * (next / k), k: next })) + }, [clampTf]) /** * Drag-pan. The gesture only ever starts on the BACKGROUND: a pointerdown @@ -241,6 +325,18 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { * container, which retargets the derived click event and silently kills * every node interaction. */ + /** + * Double-clicking the BACKGROUND re-fits and hands the view back to auto-fit + * (the gesture a pan/zoom away from the graph needs); a double-click on a + * card stays the card's own. + */ + const onDoubleClick = useCallback((event: React.MouseEvent): void => { + const target = event.target as HTMLElement + if (target.closest('[data-graph-node]') !== null) return + if (target.closest('[data-graph-controls]') !== null) return + refit() + }, [refit]) + const onPointerDown = useCallback((event: ReactPointerEvent): void => { if (event.button !== 0) return const target = event.target as HTMLElement @@ -257,7 +353,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { if (travelRef.current <= CLICK_TOLERANCE_PX) return touchedRef.current = true setDragging(true) - setTf(current => ({ ...current, x: x + dx, y: y + dy })) + setTf(clampTf({ x: x + dx, y: y + dy, k: tfRef.current.k })) } const onUp = (): void => { window.removeEventListener('pointermove', onMove) @@ -268,7 +364,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onUp) - }, []) + }, [clampTf]) /** Suppress node clicks that ended a drag gesture. */ const clickAllowed = useCallback((): boolean => travelRef.current <= CLICK_TOLERANCE_PX, []) @@ -291,20 +387,38 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { const y1 = Math.min(...boxes.map(box => box.y)) - 20 const x2 = Math.max(...boxes.map(box => box.x + box.w)) + 12 const y2 = Math.max(...boxes.map(box => box.y + box.h)) + 12 - frames.push({ key: `${runNode.id}:${phaseIndex}`, title: phase.title, x: x1, y: y1, w: x2 - x1, h: y2 - y1 }) + frames.push({ + key: `${runNode.id}:${phaseIndex}`, + title: phase.title, + index: phaseIndex, + x: x1, + y: y1, + w: x2 - x1, + h: y2 - y1, + }) }) } return frames }, [nodes, layout]) - /** The bezier edge path between two boxes (top-down). */ + /** + * The connector between two boxes: a straight PERPENDICULAR stub out of the + * parent's bottom and into the child's top, joined by one bezier. The stubs + * are what make the hierarchy legible where several lines leave one card — + * a pure bottom-center-to-top-center bezier leaves at an angle, so a ranked + * tree reads as a fan of diagonals over the cards between the rows. + */ const edgePath = (from: GraphBox, to: GraphBox): string => { const x1 = from.x + from.w / 2 const y1 = from.y + from.h const x2 = to.x + to.w / 2 const y2 = to.y - const dy = Math.max(24, (y2 - y1) / 2) - return `M ${x1} ${y1} C ${x1} ${y1 + dy} ${x2} ${y2 - dy} ${x2} ${y2}` + const span = Math.max(0, y2 - y1) + const stub = Math.min(EDGE_STUB, span / 3) + const dy = Math.max(16, (y2 - y1 - stub * 2) / 2) + return `M ${x1} ${y1} L ${x1} ${y1 + stub} ` + + `C ${x1} ${y1 + stub + dy} ${x2} ${y2 - stub - dy} ${x2} ${y2 - stub} ` + + `L ${x2} ${y2}` } return ( @@ -315,6 +429,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { role="group" aria-label={t('tasksViewGraph')} onPointerDown={onPointerDown} + onDoubleClick={onDoubleClick} > {/* The graph twin of TasksTree's loading row: the same copy key and the same "nothing to show yet" condition (an empty canvas would render @@ -357,7 +472,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { {phaseFrames.map(frame => (
{frame.title ?? t('workflowPhaseUnnamed')} @@ -376,11 +491,11 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { const box = layout.boxes.get(node.id) if (box === undefined) return null const style = { left: box.x, top: box.y, width: GRAPH_NODE_W, minHeight: box.h } - if (node.kind === 'fold') return renderFoldNode(node, style, onToggleFold, clickAllowed) + if (node.kind === 'fold') return renderFoldNode(node, style, onExpandFold, clickAllowed) if (node.kind === 'workflow') { return renderWorkflowNode(node, style, onWorkflowInfo, clickAllowed) } - return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed) + return renderAgentNode(node, style, onNodeInfo, onOpenTask, clickAllowed, onFoldNode) })}
@@ -422,18 +537,21 @@ export function TasksGraph(props: TasksGraphProps): ReactNode { ) } -/** One agent node card: dot + title, one mono meta line, live line when running. */ +/** One agent node card: identity on top, the state bar at the bottom. */ function renderAgentNode( node: TasksAgentNode, style: { left: number; top: number; width: number; minHeight: number }, onNodeInfo: (node: TasksAgentNode, anchor: HTMLElement) => void, onOpenTask: (taskId: string, anchor: HTMLElement) => void, clickAllowed: () => boolean, + onFold?: (node: TasksAgentNode) => void, ): ReactNode { + const settled = node.state === 'done' || node.state === 'error' return (
- - - - {node.label} - - {agentMeta(node)} - {node.state === 'running' && } - + + + + { onFold(node) } } + : {})} + />
) } -/** One workflow run node card. */ +/** One workflow run node card: same frame, its own badges and state. */ function renderWorkflowNode( node: TasksWorkflowNode, style: { left: number; top: number; width: number; minHeight: number }, onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void, clickAllowed: () => boolean, ): ReactNode { + const running = node.run.status === 'running' return (
{ if (!clickAllowed()) return onWorkflowInfo(node, event.currentTarget) }} > - - - - {node.run.name} - - {workflowMeta(node)} + +
) } -/** One fold aggregate node. */ +/** + * One fold aggregate node: the completed leaves of one parent, collapsed. Its + * bar is the action row (expand), so the whole card stays one click target. + */ function renderFoldNode( node: TasksFoldNode, style: { left: number; top: number; width: number; minHeight: number }, @@ -505,6 +643,7 @@ function renderFoldNode(
- - - - {t('tasksFoldCompleted', { count: node.count })} + + + {t('tasksFoldExpand')} + - - - {`${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`} - +
) } diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 14fe5ad..4b715ca 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -367,6 +367,7 @@ export const ar: Record = { settingsViewModeTree: 'شجرة', settingsViewModeTreeDesc: 'شجرة كلاسيكية بمسافات بادئة، قابلة للتنقل بلوحة المفاتيح', tasksFoldCompleted: '✓ {count} مكتمل', + tasksFoldOne: 'الطي مع المكتملة', tasksFoldExpand: 'توسيع العُقد المكتملة', tasksFoldCollapse: 'طي العُقد المكتملة', tasksZoomIn: 'تكبير', @@ -384,6 +385,9 @@ export const ar: Record = { tasksStateIdle: 'خامل', tasksStateDone: 'مكتمل', tasksStateError: 'خطأ', + tasksKindSubagent: 'وكيل فرعي', + tasksKindTeammate: 'عضو فريق', + tasksKindWorkflow: 'سير عمل', teamBoard: 'لوحة مهام الفريق', teamChip: 'فريق {members} أعضاء · {tasks} مهام', teamTasksEmpty: 'لا مهام مشتركة بعد', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index e0754a6..1b3ae4a 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -352,6 +352,7 @@ export const de: Record = { settingsViewModeTree: 'Baum', settingsViewModeTreeDesc: 'Klassischer Einrückungsbaum, per Tastatur navigierbar', tasksFoldCompleted: '✓ {count} abgeschlossen', + tasksFoldOne: 'Zu Erledigten falten', tasksFoldExpand: 'Abgeschlossene Knoten ausklappen', tasksFoldCollapse: 'Abgeschlossene Knoten einklappen', tasksZoomIn: 'Vergrößern', @@ -369,6 +370,9 @@ export const de: Record = { tasksStateIdle: 'Bereit', tasksStateDone: 'Abgeschlossen', tasksStateError: 'Fehler', + tasksKindSubagent: 'Subagent', + tasksKindTeammate: 'Teammitglied', + tasksKindWorkflow: 'Workflow', teamBoard: 'Team-Aufgabenboard', teamChip: 'Team {members} Mitglieder · {tasks} Aufgaben', teamTasksEmpty: 'Noch keine geteilten Aufgaben', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 7a91c45..16f7064 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -359,6 +359,7 @@ export const fr: Record = { settingsViewModeTree: 'Arbre', settingsViewModeTreeDesc: 'Arbre à indentation classique, navigable au clavier', tasksFoldCompleted: '✓ {count} terminés', + tasksFoldOne: 'Replier avec les terminés', tasksFoldExpand: 'Déplier les nœuds terminés', tasksFoldCollapse: 'Replier les nœuds terminés', tasksZoomIn: 'Zoom avant', @@ -376,6 +377,9 @@ export const fr: Record = { tasksStateIdle: 'Inactif', tasksStateDone: 'Terminé', tasksStateError: 'Erreur', + tasksKindSubagent: 'Sous-agent', + tasksKindTeammate: 'Membre', + tasksKindWorkflow: 'Workflow', teamBoard: 'Tableau des tâches d’équipe', teamChip: 'Équipe {members} membres · {tasks} tâches', teamTasksEmpty: 'Aucune tâche partagée pour l’instant', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index b21bd38..a822cb5 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -366,6 +366,7 @@ export const hi: Record = { settingsViewModeTree: 'ट्री', settingsViewModeTreeDesc: 'क्लासिक इंडेंटेड ट्री, कीबोर्ड से नेविगेट करने योग्य', tasksFoldCompleted: '✓ {count} पूर्ण', + tasksFoldOne: 'पूर्ण में समेटें', tasksFoldExpand: 'पूर्ण नोड्स विस्तारित करें', tasksFoldCollapse: 'पूर्ण नोड्स संकुचित करें', tasksZoomIn: 'ज़ूम इन', @@ -383,6 +384,9 @@ export const hi: Record = { tasksStateIdle: 'निष्क्रिय', tasksStateDone: 'पूर्ण', tasksStateError: 'त्रुटि', + tasksKindSubagent: 'उप-एजेंट', + tasksKindTeammate: 'टीम सदस्य', + tasksKindWorkflow: 'वर्कफ़्लो', teamBoard: 'टीम कार्य बोर्ड', teamChip: 'टीम {members} सदस्य · {tasks} कार्य', teamTasksEmpty: 'अभी कोई साझा कार्य नहीं', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 499ce5e..3e67b1b 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -364,6 +364,7 @@ export const id: Record = { settingsViewModeTree: 'Pohon', settingsViewModeTreeDesc: 'Pohon indentasi klasik, dapat dinavigasi dengan keyboard', tasksFoldCompleted: '✓ {count} selesai', + tasksFoldOne: 'Lipat ke selesai', tasksFoldExpand: 'Perluas node yang selesai', tasksFoldCollapse: 'Lipat node yang selesai', tasksZoomIn: 'Perbesar', @@ -381,6 +382,9 @@ export const id: Record = { tasksStateIdle: 'Siaga', tasksStateDone: 'Selesai', tasksStateError: 'Error', + tasksKindSubagent: 'Subagen', + tasksKindTeammate: 'Anggota tim', + tasksKindWorkflow: 'Alur kerja', teamBoard: 'Papan tugas tim', teamChip: 'Tim {members} anggota · {tasks} tugas', teamTasksEmpty: 'Belum ada tugas bersama', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index 7201a55..cb6115e 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -357,6 +357,7 @@ export const it: Record = { settingsViewModeTree: 'Albero', settingsViewModeTreeDesc: 'Albero a rientri classico, navigabile da tastiera', tasksFoldCompleted: '✓ {count} completati', + tasksFoldOne: 'Raggruppa nei completati', tasksFoldExpand: 'Espandi i nodi completati', tasksFoldCollapse: 'Comprimi i nodi completati', tasksZoomIn: 'Ingrandisci', @@ -374,6 +375,9 @@ export const it: Record = { tasksStateIdle: 'In attesa', tasksStateDone: 'Completato', tasksStateError: 'Errore', + tasksKindSubagent: 'Subagent', + tasksKindTeammate: 'Membro del team', + tasksKindWorkflow: 'Workflow', teamBoard: 'Bacheca delle attività del team', teamChip: 'Team {members} membri · {tasks} attività', teamTasksEmpty: 'Ancora nessuna attività condivisa', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 25dd042..3994b11 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -366,6 +366,7 @@ export const ja: Record = { settingsViewModeTree: 'ツリー', settingsViewModeTreeDesc: '従来のインデントツリー(キーボード操作可)', tasksFoldCompleted: '✓ {count} 件完了', + tasksFoldOne: '完了済みにまとめる', tasksFoldExpand: '完了ノードを展開', tasksFoldCollapse: '完了ノードを折りたたむ', tasksZoomIn: '拡大', @@ -383,6 +384,9 @@ export const ja: Record = { tasksStateIdle: '待機', tasksStateDone: '完了', tasksStateError: 'エラー', + tasksKindSubagent: 'サブエージェント', + tasksKindTeammate: 'チームメンバー', + tasksKindWorkflow: 'ワークフロー', teamBoard: 'チームタスクボード', teamChip: 'チーム {members} 名 · {tasks} タスク', teamTasksEmpty: '共有タスクはまだありません', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 0d86480..995c494 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -358,6 +358,7 @@ export const ko: Record = { settingsViewModeTree: '트리', settingsViewModeTreeDesc: '클래식 들여쓰기 트리, 키보드 탐색 지원', tasksFoldCompleted: '✓ {count} 완료됨', + tasksFoldOne: '완료 항목으로 접기', tasksFoldExpand: '완료된 노드 펼치기', tasksFoldCollapse: '완료된 노드 접기', tasksZoomIn: '확대', @@ -375,6 +376,9 @@ export const ko: Record = { tasksStateIdle: '대기 중', tasksStateDone: '완료됨', tasksStateError: '오류', + tasksKindSubagent: '하위 에이전트', + tasksKindTeammate: '팀 멤버', + tasksKindWorkflow: '워크플로', teamBoard: '팀 작업 보드', teamChip: '팀 멤버 {members}명 · 작업 {tasks}개', teamTasksEmpty: '공유 작업이 없습니다', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index c1f65c0..7d55318 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -364,6 +364,7 @@ export const nl: Record = { settingsViewModeTree: 'Boom', settingsViewModeTreeDesc: 'Klassieke inspringboom, navigeerbaar met het toetsenbord', tasksFoldCompleted: '✓ {count} voltooid', + tasksFoldOne: 'Vouwen bij afgerond', tasksFoldExpand: 'Voltooide knopen uitklappen', tasksFoldCollapse: 'Voltooide knopen inklappen', tasksZoomIn: 'Inzoomen', @@ -381,6 +382,9 @@ export const nl: Record = { tasksStateIdle: 'Inactief', tasksStateDone: 'Voltooid', tasksStateError: 'Fout', + tasksKindSubagent: 'Subagent', + tasksKindTeammate: 'Teamlid', + tasksKindWorkflow: 'Workflow', teamBoard: 'Teamtakenbord', teamChip: 'Team {members} leden · {tasks} taken', teamTasksEmpty: 'Nog geen gedeelde taken', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 73a4387..78a7b88 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -368,6 +368,7 @@ export const pl: Record = { settingsViewModeTree: 'Drzewo', settingsViewModeTreeDesc: 'Klasyczne drzewo wcięć, nawigowalne z klawiatury', tasksFoldCompleted: '✓ {count} ukończonych', + tasksFoldOne: 'Zwiń do ukończonych', tasksFoldExpand: 'Rozwiń ukończone węzły', tasksFoldCollapse: 'Zwiń ukończone węzły', tasksZoomIn: 'Przybliż', @@ -385,6 +386,9 @@ export const pl: Record = { tasksStateIdle: 'Gotowe', tasksStateDone: 'Ukończone', tasksStateError: 'Błąd', + tasksKindSubagent: 'Podagent', + tasksKindTeammate: 'Członek zespołu', + tasksKindWorkflow: 'Przepływ pracy', teamBoard: 'Tablica zadań zespołu', teamChip: 'Zespół {members} członków · {tasks} zadań', teamTasksEmpty: 'Brak wspólnych zadań', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 262d277..14aa27c 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -349,6 +349,7 @@ export const pt: Record = { settingsViewModeTree: 'Árvore', settingsViewModeTreeDesc: 'Árvore com indentação clássica, navegável pelo teclado', tasksFoldCompleted: '✓ {count} concluídos', + tasksFoldOne: 'Dobrar nos concluídos', tasksFoldExpand: 'Expandir os nós concluídos', tasksFoldCollapse: 'Recolher os nós concluídos', tasksZoomIn: 'Aproximar', @@ -366,6 +367,9 @@ export const pt: Record = { tasksStateIdle: 'Em espera', tasksStateDone: 'Concluído', tasksStateError: 'Erro', + tasksKindSubagent: 'Subagente', + tasksKindTeammate: 'Membro da equipe', + tasksKindWorkflow: 'Fluxo de trabalho', teamBoard: 'Quadro de tarefas da equipe', teamChip: 'Equipe {members} membros · {tasks} tarefas', teamTasksEmpty: 'Ainda não há tarefas compartilhadas', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 8cf6b70..3a5b07a 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -364,6 +364,7 @@ export const ru: Record = { settingsViewModeTree: 'Дерево', settingsViewModeTreeDesc: 'Классическое дерево с отступами, навигация с клавиатуры', tasksFoldCompleted: '✓ {count} завершено', + tasksFoldOne: 'Свернуть в завершённые', tasksFoldExpand: 'Развернуть завершённые узлы', tasksFoldCollapse: 'Свернуть завершённые узлы', tasksZoomIn: 'Увеличить', @@ -381,6 +382,9 @@ export const ru: Record = { tasksStateIdle: 'Ожидает', tasksStateDone: 'Завершено', tasksStateError: 'Ошибка', + tasksKindSubagent: 'Субагент', + tasksKindTeammate: 'Участник команды', + tasksKindWorkflow: 'Рабочий процесс', teamBoard: 'Доска задач команды', teamChip: 'Команда: {members} участников · {tasks} задач', teamTasksEmpty: 'Общих задач пока нет', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 8111969..9dac67a 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -349,6 +349,7 @@ export const sv: Record = { settingsViewModeTree: 'Träd', settingsViewModeTreeDesc: 'Klassiskt indragsträd, navigerbart med tangentbord', tasksFoldCompleted: '✓ {count} klara', + tasksFoldOne: 'Vik ihop med klara', tasksFoldExpand: 'Visa färdiga noder', tasksFoldCollapse: 'Dölj färdiga noder', tasksZoomIn: 'Zooma in', @@ -366,6 +367,9 @@ export const sv: Record = { tasksStateIdle: 'Vilar', tasksStateDone: 'Klar', tasksStateError: 'Fel', + tasksKindSubagent: 'Subagent', + tasksKindTeammate: 'Teammedlem', + tasksKindWorkflow: 'Arbetsflöde', teamBoard: 'Teamuppgiftstavla', teamChip: 'Team {members} medlemmar · {tasks} uppgifter', teamTasksEmpty: 'Inga delade uppgifter än', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 61f4553..5a5b195 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -366,6 +366,7 @@ export const th: Record = { settingsViewModeTree: 'มุมมองต้นไม้', settingsViewModeTreeDesc: 'ต้นไม้แบบเยื้องคลาสสิก นำทางด้วยคีย์บอร์ดได้', tasksFoldCompleted: '✓ เสร็จแล้ว {count}', + tasksFoldOne: 'พับรวมกับที่เสร็จแล้ว', tasksFoldExpand: 'ขยายโหนดที่เสร็จแล้ว', tasksFoldCollapse: 'ย่อโหนดที่เสร็จแล้ว', tasksZoomIn: 'ซูมเข้า', @@ -383,6 +384,9 @@ export const th: Record = { tasksStateIdle: 'พร้อม', tasksStateDone: 'เสร็จแล้ว', tasksStateError: 'ข้อผิดพลาด', + tasksKindSubagent: 'เอเจนต์ย่อย', + tasksKindTeammate: 'สมาชิกทีม', + tasksKindWorkflow: 'เวิร์กโฟลว์', teamBoard: 'บอร์ดงานของทีม', teamChip: 'ทีม {members} สมาชิก · {tasks} งาน', teamTasksEmpty: 'ยังไม่มีงานที่แชร์', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index cd92405..548bd95 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -366,6 +366,7 @@ export const tr: Record = { settingsViewModeTree: 'Ağaç', settingsViewModeTreeDesc: 'Klasik girintili ağaç, klavye ile gezilebilir', tasksFoldCompleted: '✓ {count} tamamlandı', + tasksFoldOne: 'Tamamlananlara katla', tasksFoldExpand: 'Tamamlanan düğümleri genişlet', tasksFoldCollapse: 'Tamamlanan düğümleri daralt', tasksZoomIn: 'Yakınlaştır', @@ -383,6 +384,9 @@ export const tr: Record = { tasksStateIdle: 'Beklemede', tasksStateDone: 'Tamamlandı', tasksStateError: 'Hata', + tasksKindSubagent: 'Alt ajan', + tasksKindTeammate: 'Ekip üyesi', + tasksKindWorkflow: 'İş akışı', teamBoard: 'Takım görev panosu', teamChip: 'Takım {members} üye · {tasks} görev', teamTasksEmpty: 'Henüz paylaşılan görev yok', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index f6bf0c5..c795f5a 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -366,6 +366,7 @@ export const vi: Record = { settingsViewModeTree: 'Cây', settingsViewModeTreeDesc: 'Cây thụt lề cổ điển, điều hướng được bằng bàn phím', tasksFoldCompleted: '✓ {count} hoàn thành', + tasksFoldOne: 'Gộp vào đã xong', tasksFoldExpand: 'Mở rộng các nút đã hoàn thành', tasksFoldCollapse: 'Thu gọn các nút đã hoàn thành', tasksZoomIn: 'Phóng to', @@ -383,6 +384,9 @@ export const vi: Record = { tasksStateIdle: 'Nhàn rỗi', tasksStateDone: 'Hoàn thành', tasksStateError: 'Lỗi', + tasksKindSubagent: 'Tác nhân con', + tasksKindTeammate: 'Thành viên nhóm', + tasksKindWorkflow: 'Quy trình', teamBoard: 'Bảng tác vụ nhóm', teamChip: 'Nhóm {members} thành viên · {tasks} tác vụ', teamTasksEmpty: 'Chưa có tác vụ dùng chung', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index f73f1dd..62d2594 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -381,6 +381,7 @@ export const zhHK: Record = { settingsViewModeTree: '樹狀圖', settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航', tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldOne: '收進已完成聚合', tasksFoldExpand: '展開已完成的節點', tasksFoldCollapse: '收起已完成的節點', tasksZoomIn: '放大', @@ -398,6 +399,9 @@ export const zhHK: Record = { tasksStateIdle: '閒置', tasksStateDone: '已完成', tasksStateError: '出錯', + tasksKindSubagent: '子代理', + tasksKindTeammate: '成員', + tasksKindWorkflow: '工作流', teamBoard: '團隊任務板', teamChip: '團隊 {members} 成員 · {tasks} 任務', teamTasksEmpty: '暫無共用任務', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 5b74383..84ee904 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -381,6 +381,7 @@ export const zhMO: Record = { settingsViewModeTree: '樹狀圖', settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航', tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldOne: '收進已完成聚合', tasksFoldExpand: '展開已完成的節點', tasksFoldCollapse: '收起已完成的節點', tasksZoomIn: '放大', @@ -398,6 +399,9 @@ export const zhMO: Record = { tasksStateIdle: '閒置', tasksStateDone: '已完成', tasksStateError: '出錯', + tasksKindSubagent: '子代理', + tasksKindTeammate: '成員', + tasksKindWorkflow: '工作流', teamBoard: '團隊任務板', teamChip: '團隊 {members} 成員 · {tasks} 任務', teamTasksEmpty: '暫無共用任務', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index a0e7e57..49b1980 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -381,6 +381,7 @@ export const zhTW: Record = { settingsViewModeTree: '樹狀圖', settingsViewModeTreeDesc: '經典縮排樹,可用鍵盤導航', tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldOne: '收進已完成聚合', tasksFoldExpand: '展開已完成的節點', tasksFoldCollapse: '收起已完成的節點', tasksZoomIn: '放大', @@ -398,6 +399,9 @@ export const zhTW: Record = { tasksStateIdle: '閒置', tasksStateDone: '已完成', tasksStateError: '出錯', + tasksKindSubagent: '子代理', + tasksKindTeammate: '成員', + tasksKindWorkflow: '工作流', teamBoard: '團隊任務板', teamChip: '團隊 {members} 成員 · {tasks} 任務', teamTasksEmpty: '暫無共用任務', diff --git a/src/client/locales.ts b/src/client/locales.ts index 5c13904..c359c3d 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -378,6 +378,7 @@ export const zh = { settingsViewModeTree: '树状图', settingsViewModeTreeDesc: '经典缩进树,键盘可导航', tasksFoldCompleted: '✓ {count} 已完成', + tasksFoldOne: '收进已完成聚合', tasksFoldExpand: '展开已完成的节点', tasksFoldCollapse: '折叠已完成的节点', tasksZoomIn: '放大', @@ -395,6 +396,9 @@ export const zh = { tasksStateIdle: '待命', tasksStateDone: '已完成', tasksStateError: '出错', + tasksKindSubagent: '子代理', + tasksKindTeammate: '成员', + tasksKindWorkflow: '工作流', teamBoard: '团队任务板', teamChip: '团队 {members} 成员 · {tasks} 任务', teamTasksEmpty: '暂无共享任务', @@ -848,6 +852,7 @@ export const en: Record = { settingsViewModeTree: 'Tree', settingsViewModeTreeDesc: 'Classic indentation tree, keyboard navigable', tasksFoldCompleted: '✓ {count} completed', + tasksFoldOne: 'Fold into completed', tasksFoldExpand: 'Expand the completed nodes', tasksFoldCollapse: 'Collapse the completed nodes', tasksZoomIn: 'Zoom in', @@ -865,6 +870,9 @@ export const en: Record = { tasksStateIdle: 'Idle', tasksStateDone: 'Done', tasksStateError: 'Error', + tasksKindSubagent: 'Subagent', + tasksKindTeammate: 'Teammate', + tasksKindWorkflow: 'Workflow', teamBoard: 'Team task board', teamChip: 'Team {members} members · {tasks} tasks', teamTasksEmpty: 'No shared tasks yet', diff --git a/src/client/tasks-card.tsx b/src/client/tasks-card.tsx new file mode 100644 index 0000000..45bdb5c --- /dev/null +++ b/src/client/tasks-card.tsx @@ -0,0 +1,158 @@ +/** + * The two-segment node card of the Tasks graph: a TOP segment that says WHAT + * the node is (kind badge, name, mono meta, optional task line) and a BOTTOM + * bar that says WHAT IT IS DOING (state dot + state word + the merged process + * activity, plus the fold chevron once the node has settled). + * + * Both modes share the model but NOT the card: the TREE keeps its compact + * indent row (its own disclosure, its own keyboard recipe), so this module is + * graph-only. Its two pieces are separate components because every node kind + * (agent / workflow run / fold aggregate) composes them differently while the + * geometry stays identical — the layout reserves the height of exactly these + * rows (`tasks-graph-layout.ts`). + * + * Colour: the group colour of a node is its tree DEPTH, from the plugin's own + * token-only ramp (`.depth0…3` in tasks-graph.module.css). The host ships no + * chart palette and the skin contract forbids literal colours, so the ramp is + * mixed from `--dsw-alias-state-business-primary`; a workflow member's PHASE + * badge uses the ink family instead, so the two groupings never share a hue. + */ +import type { ReactNode } from 'react' +import clsx from 'clsx' +import { IconChevronDownOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TasksNodeState } from './tasks-model.ts' +import { nodeDotState, stateLabel } from './tasks-shared.tsx' +import { t } from './locales.ts' +import css from './tasks-graph.module.css' + +/** The five kinds of card the graph draws. */ +export type CardKind = 'main' | 'subagent' | 'teammate' | 'workflow' | 'fold' + +/** + * The kind badge's word: the SAME vocabulary the rest of the page uses (the + * header already calls the root "主代理"), so no sixth word is invented for it. + * @param kind - card kind. + * @param count - folded member count (the fold aggregate's badge only). + */ +export function cardKindLabel(kind: CardKind, count = 0): string { + switch (kind) { + case 'main': return t('subagentMainAgent') + case 'subagent': return t('tasksKindSubagent') + case 'teammate': return t('tasksKindTeammate') + case 'workflow': return t('tasksKindWorkflow') + case 'fold': return t('tasksFoldCompleted', { count }) + } +} + +/** + * The depth class of a node's group colour (levels past 3 share the last one). + * Typed `string | undefined` because the CSS-module map is an open record: a + * typo'd class name would otherwise pass `tsc` as a `string`. + */ +export function depthClass(depth: number): string | undefined { + if (depth <= 0) return css.depth0 + if (depth === 1) return css.depth1 + if (depth === 2) return css.depth2 + return css.depth3 +} + +/** The phase class of a workflow member's badge (phases past 3 share the last one). */ +export function phaseClass(index: number): string | undefined { + if (index <= 0) return css.phase0 + if (index === 1) return css.phase1 + if (index === 2) return css.phase2 + return css.phase3 +} + +/** The top segment: the node's identity. */ +export function CardTop(props: { + kind: CardKind + /** Folded member count, for the aggregate's badge. */ + count?: number + /** Tree depth → the group colour of the badge and the card's left edge. */ + depth: number + /** Workflow phase (a member's badge). */ + phase?: { index: number; title?: string } + /** The node's name (already resolved by the model). */ + name: string + /** The mono meta line under the name. */ + meta?: string + /** Extra class for the name (the aggregate recedes one rung). */ + extraClass?: string + /** Extra rows the caller appends (the team task line). */ + children?: ReactNode +}): ReactNode { + const { kind, count, depth, phase } = props + return ( + + + + {cardKindLabel(kind, count ?? 0)} + + {phase !== undefined && ( + + {phase.title ?? t('workflowPhaseUnnamed')} + + )} + + {props.name} + {props.meta !== undefined && {props.meta}} + {props.children} + + ) +} + +/** The bottom bar: the node's state, its merged activity, and its fold chevron. */ +export function CardBar(props: { + state: TasksNodeState + /** The merged process activity line (running nodes and settled summaries). */ + activity?: string + /** Whether the node is executing (drives the sweep). */ + running?: boolean + /** Fold the node into its parent's completed aggregate (settled nodes only). */ + onFold?: (event: { stopPropagation(): void }) => void + /** The chevron's accessible name (defaults to the per-node fold word). */ + foldLabel?: string + /** Render the state WORD next to the dot (off for the aggregate's action bar). */ + stateWord?: boolean + /** Replace the whole bar's content (the aggregate's action row). */ + children?: ReactNode +}): ReactNode { + const { state, activity, running } = props + return ( + + {props.children ?? ( + <> + + {props.stateWord !== false && {stateLabel(state)}} + {activity !== undefined && activity !== '' && ( + {activity} + )} + {props.onFold !== undefined && ( + + )} + + )} + + ) +} diff --git a/src/client/tasks-graph-layout.ts b/src/client/tasks-graph-layout.ts index b39be96..4908987 100644 --- a/src/client/tasks-graph-layout.ts +++ b/src/client/tasks-graph-layout.ts @@ -10,59 +10,62 @@ * the core bundle; <100-node trees need no virtualisation). */ import type { TasksNode } from './tasks-model.ts' -import type { SidebarChildLiveView } from '../context-types.ts' import { tasksEdges } from './tasks-model.ts' /** Geometry constants of the canvas (px, pre-scale). The design target is the - * NARROW native right sidebar (~360px): a 176px card fits two per row plus - * the gutter. */ -export const GRAPH_NODE_W = 176 + * NARROW native right sidebar (~360px): a 190px card fits two per row plus + * the gutter, and the card's own rows stay readable at the fit's floor. */ +export const GRAPH_NODE_W = 190 /** * The card's heights are the SUM OF THE ROWS THE CARD RENDERS, in the real * metrics of the page's type scale (the same numbers the rules in - * tasks-graph.module.css / tasks-canvas.module.css are written with). They are - * derived rather than hand-tuned because the card is a fixed-geometry box: its - * height is `auto` with `min-height: box.h`, so an under-reserved card does not - * stay inside the box the layout handed it — it RENDERS TALLER, and everything - * the layout derives from `box.h` (the row pitch, the phase frames, the canvas - * extent) then disagrees with the pixels on screen. + * tasks-graph.module.css are written with). They are derived rather than + * hand-tuned because the card is a fixed-geometry box: its height is `auto` + * with `min-height: box.h`, so an under-reserved card does not stay inside the + * box the layout handed it — it RENDERS TALLER, and everything the layout + * derives from `box.h` (the row pitch, the phase frames, the canvas extent) + * then disagrees with the pixels on screen. * - * Measured on the rendered page: a running teammate card carrying a two-line - * title, its meta, a live row and a task line renders 100px while the base - * reserve handed it 68 — `min-height` cannot hold it down, so the card ate - * 32px of the 53px gutter to the next row and poked out of its phase frame. + * The card is TWO SEGMENTS (tasks-card.tsx): a top segment carrying the kind + * badge, the clamped name, the mono meta (and, for a team member, the shared + * task line) and a bottom state bar carrying the state dot, the state word and + * the merged process activity. Every node kind reserves the SAME base height — + * a settled node shows its activity summary in that bar just like a running + * one, so the two can never disagree with the layout. + * + * Measured on the rendered page before this structure existed: a running + * teammate card carrying a two-line title, its meta, a live row and a task line + * rendered 100px while the base reserve handed it 68 — `min-height` cannot hold + * it down, so the card ate 32px of the 53px gutter to the next row and poked + * out of its phase frame. */ const NODE_EDGE_H = 1 -const NODE_PAD_Y = 6 -/** Both edges' 1px outline plus the vertical padding (`.node` box-sizing). */ -const NODE_CHROME_H = 2 * (NODE_PAD_Y + NODE_EDGE_H) +/** The card's own border only: the two segments carry their own padding. */ +const NODE_CHROME_H = 2 * NODE_EDGE_H +/** `.cardTop`'s vertical padding (6px top / 5px bottom). */ +const NODE_PAD_TOP = 6 +const NODE_PAD_BOTTOM = 5 +/** One badge row — `--dsw-font-xxxs-11` (11px/14px) plus its 2px lead. */ +const NODE_BADGE_H = 14 + 2 /** One title line — `--dsw-font-xxs-strong-12` (12px/18px). */ const NODE_TITLE_LINE_H = 18 -/** Title lines the card reserves: the clamp in tasks-canvas.module.css. */ +/** Title lines the card reserves: the clamp in tasks-graph.module.css. */ const NODE_TITLE_LINES = 2 /** The mono meta line — `--dsw-font-xxxs-11` (11px/14px) plus its 2px lead. */ const NODE_META_H = 14 + 2 -/** One live row: LiveLine's 10px/1.3 tool row (or its meta-styled fallback) - * plus the 3px lead. */ -const NODE_LIVE_H = 13 + 3 -/** LiveLine's flattened text row — a 10px line whose box measures 14px, plus - * its 2px lead — which a tail carrying both a tool call and text renders - * UNDER the tool row. */ -const NODE_LIVE_TAIL_H = 14 + 2 +/** The state bar — `.cardBar`'s fixed height (its top border sits inside it). */ +const NODE_BAR_H = 22 /** The shared-task row: its 10px/1.3 line plus the 3px lead, sized for the * `+N` chip's own 1px outline (the tallest shape the row can take). */ const NODE_TASK_H = 15 + 3 -/** TWO title lines plus the mono meta line: long agent names are clamped to - * two lines instead of ellipsizing to a couple of characters (see NODE_TITLE - * in TasksGraph.tsx), so the reserve covers that second line. */ -export const GRAPH_NODE_H = NODE_CHROME_H + NODE_TITLE_LINES * NODE_TITLE_LINE_H + NODE_META_H -/** Extra height of a RUNNING agent node: the live row it always shows (a - * running node without activity yet renders LiveLine's "thinking" fallback). */ -export const GRAPH_LIVE_H = NODE_LIVE_H -/** Extra height of a running node whose tail carries text as well as a tool. */ -export const GRAPH_LIVE_TAIL_H = NODE_LIVE_TAIL_H +/** Badges + TWO title lines + the mono meta line + the state bar: long agent + * names are clamped to two lines instead of ellipsizing to a couple of + * characters (see `.cardName` in tasks-graph.module.css), so the reserve + * covers that second line. */ +export const GRAPH_NODE_H = NODE_CHROME_H + NODE_PAD_TOP + NODE_BADGE_H + + NODE_TITLE_LINES * NODE_TITLE_LINE_H + NODE_META_H + NODE_PAD_BOTTOM + NODE_BAR_H /** Extra height of an agent node carrying a shared-task line (team members). */ export const GRAPH_TASK_H = NODE_TASK_H @@ -77,7 +80,7 @@ export const GRAPH_GAP_Y = 53 export const GRAPH_PAD = 16 /** The row stride (one row of cards plus the vertical gutter). Every card * shape must fit inside it — see {@link nodeHeight} for the tallest one. */ -export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y +export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_GAP_Y /** One laid-out node. */ export interface GraphBox { @@ -102,35 +105,13 @@ export interface LayoutOptions { } /** - * The display height of one node: the base card plus one reserved row per - * line the card will actually render, so the pixels and the box agree (see - * the constants above for what a mismatch costs). - * - * - a RUNNING agent always reserves the live row: without activity yet it - * still renders the "thinking" line (LiveLine's fallback), and a tail that - * arrived with a tool call reserves its text row too; - * - an agent owning shared tasks (a team member) reserves the task row — - * without it a two-line title, the meta and the task row render 16px below - * the reserved box. - * - * The tallest shape therefore is base + live + live-tail + task, which stays - * inside {@link GRAPH_ROW_STRIDE} (rows can never overlap). + * The display height of one node: the base card plus the shared-task row a + * team member renders. Nothing else varies — the merged activity lives in the + * state bar that every card renders, whatever its state. */ -/** Whether a node's live view carries a merged-activity row to render. */ -function hasMergedActivity(live: SidebarChildLiveView | undefined): boolean { - if (live === undefined) return false - return (live.summary?.counts.length ?? 0) > 0 || live.summary?.running !== undefined -} - function nodeHeight(node: TasksNode): number { if (node.kind !== 'agent') return GRAPH_NODE_H - let height = GRAPH_NODE_H - if (node.state === 'running') { - height += GRAPH_LIVE_H - if (hasMergedActivity(node.live) && node.live?.text !== undefined) height += GRAPH_LIVE_TAIL_H - } - if ((node.tasks?.length ?? 0) > 0) height += GRAPH_TASK_H - return height + return (node.tasks?.length ?? 0) > 0 ? GRAPH_NODE_H + GRAPH_TASK_H : GRAPH_NODE_H } /** diff --git a/src/client/tasks-graph.module.css b/src/client/tasks-graph.module.css index 801c6eb..71ac0c5 100644 --- a/src/client/tasks-graph.module.css +++ b/src/client/tasks-graph.module.css @@ -66,7 +66,11 @@ .edge { fill: none; stroke: var(--dsw-alias-label-tertiary); - stroke-width: 1; + /* 1.5px, not 1: at the narrow panel's fit scale (0.78–0.86) a 1px stroke + lands under a device pixel and the whole hierarchy fades out — measured on + the rendered page by swapping the token and the width. */ + stroke-width: 1.5; + stroke-linecap: round; } .edgeTeam { @@ -90,6 +94,10 @@ groups its members without competing with the solid cards and the ink connectors drawn over it. Its label is the part a reader actually reads, so the label — and only the label — climbs onto a text ink. */ +/* The phase frame is the SPATIAL grouping of one workflow phase; the phase + badges on its member cards carry the same colour, so the reader can match + frame ↔ member without reading the frame's own label. It stays a hairline: + the frame must not compete with the cards it encloses. */ .phaseFrame { position: absolute; border: 1px dashed var(--dsw-alias-border-l2); @@ -97,6 +105,11 @@ pointer-events: none; } +.phaseFrame.phase0 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 42%, transparent); } +.phaseFrame.phase1 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 34%, transparent); } +.phaseFrame.phase2 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 27%, transparent); } +.phaseFrame.phase3 { border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 21%, transparent); } + .phaseLabel { position: absolute; top: -7px; @@ -122,13 +135,17 @@ centering the column turns that leftover into even padding around the block instead of a hole under it (the box size itself is fixed — the edges and the phase frames are placed from it, so a card can never be content-sized). */ +/* The two-segment node card. The segments own their padding because each one + paints its own surface: the TOP segment carries the identity (kind badge, + name, meta, task line) over the level's faint tint, the BOTTOM bar carries + the state and the merged activity. The layout in tasks-graph-layout.ts + reserves both rows, so the box handed to a card is exactly its height. */ .node { position: absolute; box-sizing: border-box; display: flex; flex-direction: column; - justify-content: center; - padding: 6px 8px; + padding: 0; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; @@ -172,6 +189,217 @@ border-color: var(--dsw-alias-state-error-primary); } +/* ── The two-segment card: identity on top, state bar at the bottom ─────── */ + +/* The group colour of a node is its DEPTH. The host ships no chart palette and + the skin contract forbids literal colours, so the ramp mixes the one accent + token: level 0 is the accent itself, deeper levels recede. The level also + tints the top segment faintly, so a wide graph still reads as bands. */ +.depth0 { --card-edge: 100%; --card-tint: 7%; } +.depth1 { --card-edge: 72%; --card-tint: 5%; } +.depth2 { --card-edge: 48%; --card-tint: 3.5%; } +.depth3 { --card-edge: 28%; --card-tint: 2.5%; } + +.cardTop { + position: relative; + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; + padding: 6px 8px 5px 10px; + background: color-mix( + in srgb, var(--dsw-alias-state-business-primary) var(--card-tint, 6%), var(--dsw-alias-bg-layer-1) + ); +} + +/* The level edge: 2px, full height of the segment, never a second border. */ +.cardTop::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 2px; + background: color-mix( + in srgb, var(--dsw-alias-state-business-primary) var(--card-edge, 100%), transparent + ); +} + +.cardBadges { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +/* The kind badge names WHAT the node is (main agent / subagent / teammate / + workflow / completed aggregate) — the vocabulary the page header already + uses, never a glyph the reader has to decode. */ +.kindBadge { + flex: none; + padding: 0 4px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 2px; + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-secondary); +} + +/* The workflow phase badge: the SECOND grouping axis. It takes the ink family + (the level edge owns the accent family) so the two never read as one. */ +.phaseBadge { + flex: none; + max-width: 62%; + padding: 0 4px; + border: 1px solid; + border-radius: 2px; + font: var(--dsw-font-xxxs-11); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.phase0 { + color: var(--dsw-alias-label-primary); + border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 45%, transparent); +} + +.phase1 { + color: color-mix(in srgb, var(--dsw-alias-label-primary) 78%, transparent); + border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 38%, transparent); +} + +.phase2 { + color: color-mix(in srgb, var(--dsw-alias-label-primary) 58%, transparent); + border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 30%, transparent); +} + +.phase3 { + color: color-mix(in srgb, var(--dsw-alias-label-primary) 42%, transparent); + border-color: color-mix(in srgb, var(--dsw-alias-label-primary) 24%, transparent); +} + +.cardName { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + overflow-wrap: anywhere; + font: var(--dsw-font-xxs-strong-12); + color: var(--dsw-alias-label-primary); +} + +/* The aggregate carries a count instead of a name: one rung down in weight and + ink, so a settled summary never competes with a live node. */ +.cardNamePlain { + -webkit-line-clamp: 1; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-label-secondary); +} + +.cardMeta { + font: var(--dsw-font-xxxs-11); + font-family: var(--ds-font-family-code, monospace); + color: var(--dsw-alias-label-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── The state bar ──────────────────────────────────────────────────────── */ + +.cardBar { + position: relative; + display: flex; + align-items: center; + gap: 5px; + flex: none; + height: 22px; + padding: 0 6px 0 8px; + border-top: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-base); + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-secondary); + overflow: hidden; +} + +/* Executing: the bar's background SWEEPS left → right (the same motion the + main conversation's shimmer uses, applied to a strip instead of text). The + band is 45% of the bar and travels the full width; the last third of the + cycle rests, so a fast glance still catches it at rest. */ +.cardBar[data-running='true']::after { + content: ''; + position: absolute; + inset: 0; + background-image: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-state-business-primary) 14%, transparent) 50%, + transparent 100% + ); + background-size: 45% 100%; + background-repeat: no-repeat; + background-position: 0% center; + animation: dsh-tasks-bar-sweep 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite; + pointer-events: none; +} + +@keyframes dsh-tasks-bar-sweep { + 0% { background-position: 0% center; } + 66.6667%, 100% { background-position: 100% center; } +} + +/* Motion is decoration here: the state word and the activity text carry the + same information without it. */ +@media (prefers-reduced-motion: reduce) { + .cardBar[data-running='true']::after { + animation: none; + opacity: 0; + } +} + +.barDot { + flex: none; +} + +.barState { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +/* The merged activity: the main agent's own wording for the node's newest + range, in the mono voice the page uses for machine text. */ +.barActivity { + flex: 1; + min-width: 0; + font-family: var(--ds-font-family-code, monospace); + color: var(--dsw-alias-state-business-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.barFold { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-left: auto; + padding: 0; + border: none; + border-radius: 2px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.barFold:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + /* The fold aggregate mirrors the tree's dashed fold row (`treeFoldRow`). */ .nodeFold { border: 1px dashed var(--dsw-alias-border-l2); diff --git a/src/client/tasks-model.ts b/src/client/tasks-model.ts index 004d27d..e0e89c8 100644 --- a/src/client/tasks-model.ts +++ b/src/client/tasks-model.ts @@ -69,6 +69,13 @@ export interface TasksAgentNode { status: SidebarTeamMemberView['status'] diagnostics: string[] } + /** + * Tree depth (the root is 0). Drives the card's GROUP COLOUR: every node of + * one level wears the same left edge, so a wide graph still reads as bands. + */ + depth: number + /** Workflow phase this node is a member of (workflow runs only). */ + phase?: { key: string; index: number; title?: string } /** The catalog's durable children signal (fold candidacy's leaf test). */ hasChildren?: boolean /** Shared tasks owned by this agent (team boards only; empty otherwise). */ @@ -84,6 +91,8 @@ export interface TasksWorkflowNode { kind: 'workflow' id: string parentId: string + /** Tree depth (see {@link TasksAgentNode.depth}). */ + depth: number run: WorkflowRunView } @@ -92,6 +101,8 @@ export interface TasksFoldNode { kind: 'fold' id: string parentId: string + /** Tree depth (see {@link TasksAgentNode.depth}). */ + depth: number count: number /** The folded session ids, in original order (expansion restores them). */ memberIds: string[] @@ -115,6 +126,12 @@ export interface TasksModelInput { teamTasks?: readonly SidebarTeamTaskView[] /** Whether settled leaves collapse into fold nodes. */ folded: boolean + /** + * Nodes the READER folded by hand (a completed card's bar chevron). They fold + * under the same guards as the global rule, so a manual fold can never hide a + * running or branching node — it only adds a per-node trigger. + */ + foldedIds?: ReadonlySet } /** Human label of one catalog child (the classic rule). */ @@ -153,7 +170,9 @@ function memberOutcomeState(outcome: 'completed' | 'failed' | 'cancelled' | unde * trails its parent's remaining children. */ export function buildTasksModel(input: TasksModelInput): TasksNode[] { - const { byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded } = input + const { + byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded, foldedIds, + } = input const teamTasks = input.teamTasks ?? [] const teamById = new Map(teamMembers.map(member => [member.id, member])) /** Owner display name → node id (the board assigns by member name). */ @@ -205,7 +224,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { /** The agent children of one parent, in catalog order (side threads * excluded), with workflow runs appended in start order. */ - const appendChildren = (parentId: string): void => { + const appendChildren = (parentId: string, depth: number): void => { const catalog = catalogs[parentId] const entries = catalog?.state === 'ready' ? catalog.entries : [] const agentChildren: TasksAgentNode[] = [] @@ -226,6 +245,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { kind: 'agent', id: entry.id, parentId, + depth: depth + 1, label: childLabel(entry, summary), ...(summary?.displayTitle !== undefined && summary.displayTitle !== entry.label ? { title: summary.displayTitle } : {}), @@ -246,16 +266,25 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { const runNodes: TasksWorkflowNode[] = [] const memberNodes: TasksAgentNode[][] = [] for (const run of runsByOrigin.get(parentId) ?? []) { - const runNode: TasksWorkflowNode = { kind: 'workflow', id: `run:${run.runId}`, parentId, run } + const runNode: TasksWorkflowNode = { + kind: 'workflow', id: `run:${run.runId}`, parentId, depth: depth + 1, run, + } runNodes.push(runNode) const members: TasksAgentNode[] = [] - for (const phase of run.phases) { + for (const [phaseIndex, phase] of run.phases.entries()) { for (const member of phase.members) { const existing = member.childId !== '' ? agentChildren.find(candidate => candidate.id === member.childId) : undefined + const phaseRef = { + key: `${run.runId}:${phaseIndex}`, + index: phaseIndex, + ...(phase.title === undefined ? {} : { title: phase.title }), + } if (existing !== undefined) { existing.parentId = runNode.id + existing.depth = runNode.depth + 1 + existing.phase = phaseRef if (tasksByNode.get(existing.id) !== undefined) existing.tasks = tasksByNode.get(existing.id) members.push(existing) } else if (member.childId !== '' && knownAgentIds.has(member.childId)) { @@ -267,6 +296,8 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { kind: 'agent', id: childKnown ? member.childId : `wfmember:${run.runId}:${member.seq}`, parentId: runNode.id, + depth: runNode.depth + 1, + phase: phaseRef, label: member.label, state: memberOutcomeState(member.outcome), activity: member.outcome === undefined ? 'running' : 'inactive', @@ -286,6 +317,8 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { const visibleAgentChildren = agentChildren.filter(child => child.parentId === parentId) // Fold: settled agent leaves of THIS parent collapse into one aggregate. + // `folded` is the page-level rule; `foldedIds` are the nodes the reader + // folded by hand from their own card bar. Both run through the SAME guards. const kept: TasksAgentNode[] = [] const foldCandidates: TasksAgentNode[] = [] for (const child of visibleAgentChildren) { @@ -293,7 +326,8 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { // its own — folding a parent would hide its live branch. const isLeaf = child.hasChildren !== true && !runsByOrigin.has(child.id) if ( - folded && isLeaf && !child.current && child.team === undefined + (folded || (foldedIds?.has(child.id) ?? false)) && isLeaf && !child.current + && child.team === undefined && (child.state === 'done' || child.state === 'error') ) { foldCandidates.push(child) @@ -304,13 +338,14 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { for (const child of kept) { out.push(child) - appendChildren(child.id) + appendChildren(child.id, child.depth) } if (foldCandidates.length > 0) { out.push({ kind: 'fold', id: `fold:${parentId}`, parentId, + depth: depth + 1, count: foldCandidates.length, memberIds: foldCandidates.map(child => child.id), previews: foldCandidates.slice(0, 2).map(child => child.label), @@ -325,7 +360,10 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { const keptMembers: TasksAgentNode[] = [] const foldedMembers: TasksAgentNode[] = [] for (const member of members) { - if (folded && !member.current && (member.state === 'done' || member.state === 'error')) { + if ( + (folded || (foldedIds?.has(member.id) ?? false)) && !member.current + && (member.state === 'done' || member.state === 'error') + ) { foldedMembers.push(member) } else { keptMembers.push(member) @@ -337,6 +375,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { kind: 'fold', id: `fold:${runNode.id}`, parentId: runNode.id, + depth: runNode.depth + 1, count: foldedMembers.length, memberIds: foldedMembers.map(member => member.id), previews: foldedMembers.slice(0, 2).map(member => member.label), @@ -350,6 +389,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { out.push({ kind: 'agent', id: rootId, + depth: 0, label: rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== '' ? rootSummary.displayTitle : rootId, @@ -359,7 +399,7 @@ export function buildTasksModel(input: TasksModelInput): TasksNode[] { ...(rootTeam !== undefined ? { team: rootTeam } : {}), ...(tasksByNode.get(rootId) !== undefined ? { tasks: tasksByNode.get(rootId) } : {}), }) - appendChildren(rootId) + appendChildren(rootId, 0) // Last-resort guard: one node per session id, first occurrence wins. Real // catalogs list a child under exactly one parent, so this only fires on diff --git a/tests/subagent-live-polling.spec.tsx b/tests/subagent-live-polling.spec.tsx index 6113d27..c364a4e 100644 --- a/tests/subagent-live-polling.spec.tsx +++ b/tests/subagent-live-polling.spec.tsx @@ -125,7 +125,15 @@ describe('SubagentView live polling', () => { if (method === 'subagents.live') { const body = JSON.parse(String(init?.body)) as { rootSessionId?: string } liveCalls.push(body.rootSessionId ?? '') - return jsonResponse({ ok: true, value: { live: { a: { running: true }, b: { running: true } } } }) + return jsonResponse({ + ok: true, + value: { + live: { + a: { running: true, summary: { counts: [{ kind: 'read', count: 1 }], runningDetail: '' } }, + b: { running: true }, + }, + }, + }) } if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } }) throw new Error(`unexpected fetch ${String(url)}`) @@ -139,7 +147,11 @@ describe('SubagentView live polling', () => { await act(async () => { await Promise.resolve() }) expect(liveCalls).toEqual(['root']) expect(historySpy).not.toHaveBeenCalled() - expect(container.textContent).toContain('思考中…') + // The live map reached the cards: a running node's bar carries the sweep + // hook, and its merged activity line renders on the bar. + const bar = container.querySelector('[data-graph-node="a"] [data-running="true"]') + expect(bar).not.toBeNull() + expect(bar?.textContent).toBeTruthy() await act(async () => { await vi.advanceTimersByTimeAsync(3_000) }) expect(liveCalls).toEqual(['root', 'root']) @@ -235,7 +247,11 @@ describe('SubagentView live polling', () => { if (method === 'subagents.live') { const body = JSON.parse(String(init?.body)) as { rootSessionId?: string } liveCalls.push(body.rootSessionId ?? '') - const live = body.rootSessionId === 'root' ? { a: { running: true, text: 'hello' } } : {} + // `text` is the child's newest assistant line, which the card shows in + // its detail POPOVER; the bar shows the merged activity summary. + const live = body.rootSessionId === 'root' + ? { a: { running: true, summary: { counts: [{ kind: 'read', count: 1 }], runningDetail: 'hello' } } } + : {} return jsonResponse({ ok: true, value: { live } }) } if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } }) @@ -248,7 +264,8 @@ describe('SubagentView live polling', () => { ) await act(async () => { await Promise.resolve() }) expect(liveCalls).toEqual(['root']) - expect(container.textContent).toContain('hello') + const bar = container.querySelector('[data-graph-node="a"] [data-running="true"]') + expect(bar?.textContent).toContain('hello') // The tree is re-rooted under a new ancestor: old rows must not leak. store.set(reRootedSnapshot()) diff --git a/tests/tasks-graph-layout.spec.ts b/tests/tasks-graph-layout.spec.ts index f7d6e07..c2c07bd 100644 --- a/tests/tasks-graph-layout.spec.ts +++ b/tests/tasks-graph-layout.spec.ts @@ -7,11 +7,7 @@ * the width-aware solver. */ import { describe, expect, it } from 'vitest' -import type { SidebarChildLiveView } from '../src/context-types.ts' import { - GRAPH_GAP_Y, - GRAPH_LIVE_H, - GRAPH_LIVE_TAIL_H, GRAPH_NODE_H, GRAPH_NODE_W, GRAPH_PAD, @@ -38,12 +34,19 @@ function agent( ): TasksAgentNode { return { kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }), + depth: parentId === undefined ? 0 : 1, label: id, state: 'running', activity: 'running', current: false, - ...(live ? { live: { text: 'x' } } : {}), + ...(live ? { live: { running: true, text: 'x' } } : {}), ...extra, } } +/** One running node's live view: a merged activity row (the bar's text). */ +const ACTIVITY = { + running: true, + summary: { counts: [{ kind: 'read' as const, count: 1 }], running: 'read' as const, runningDetail: 'x' }, +} + /** The row index of one laid-out node. */ function layoutRow(layout: ReturnType, id: string): number { const box = layout.boxes.get(id) @@ -51,77 +54,25 @@ function layoutRow(layout: ReturnType, id: string): num return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE } -/** One running node's live view: a merged activity row, no text tail. */ -const ACTIVITY: SidebarChildLiveView = { - running: true, - summary: { counts: [{ kind: 'read', count: 1 }], running: 'read', runningDetail: 'x' }, -} - -describe('layoutTasksGraph', () => { - it('places the root on row 0 and children one row below', () => { - const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')] - const layout = layoutTasksGraph(nodes) - // A running agent reserves the live-line row (its "thinking" fallback). - expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H + GRAPH_LIVE_H }) - expect(layout.boxes.get('a')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE) - expect(layout.boxes.get('b')?.y).toBe(GRAPH_PAD + GRAPH_ROW_STRIDE) - }) - - it('centers a parent over its children band', () => { - const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')] - const layout = layoutTasksGraph(nodes) - const a = layout.boxes.get('a') - const b = layout.boxes.get('b') - const root = layout.boxes.get('root') - expect(a).toBeDefined(); expect(b).toBeDefined(); expect(root).toBeDefined() - const bandCenter = (a!.x + b!.x + GRAPH_NODE_W) / 2 - expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5) - }) - - it('grows live rows and widens the canvas for deep/wide forests', () => { - const nodes: TasksNode[] = [ - agent('root'), agent('a', 'root', true), - agent('a1', 'a'), agent('a2', 'a'), agent('a3', 'a'), - ] - const layout = layoutTasksGraph(nodes) - expect(layout.boxes.get('a')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H) - expect(layout.width).toBeGreaterThan(3 * GRAPH_NODE_W) - expect(layout.height).toBeGreaterThan(2 * (GRAPH_NODE_H + GRAPH_GAP_Y)) - }) - - it('reserves the shared-task row a teammate card renders', () => { - // A non-running teammate with a task line renders two title lines + the - // meta line + the task row; the base card alone reserves 16px less, and - // the card (height auto) renders that much outside its own box — which is - // what the edges and the phase frames are placed from. +describe('layout: card heights', () => { + it('reserves the state bar for every node kind, and the task row only for team members', () => { const nodes: TasksNode[] = [ agent('root'), - agent('mate', 'root', false, { state: 'idle', tasks: [task('t1')] }), + agent('busy', 'root', false, { live: ACTIVITY }), + agent('owner', 'root', false, { tasks: [task('t1')] }), ] const layout = layoutTasksGraph(nodes) - expect(layout.boxes.get('mate')?.h).toBe(GRAPH_NODE_H + GRAPH_TASK_H) - // Without tasks the card keeps the base shape. - const plain = layoutTasksGraph([agent('root'), agent('mate', 'root', false, { state: 'idle' })]) - expect(plain.boxes.get('mate')?.h).toBe(GRAPH_NODE_H) - }) - - it('reserves the live tail row only when the tail carries activity AND text', () => { - const nodes: TasksNode[] = [ - agent('root'), - agent('tool-only', 'root', false, { live: ACTIVITY }), - agent('both', 'root', false, { live: { ...ACTIVITY, text: 'y' } }), - ] - const layout = layoutTasksGraph(nodes) - // One live row either way (the tool row, or the meta-styled thinking - // fallback); the flattened text row is the second one. - expect(layout.boxes.get('tool-only')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H) - expect(layout.boxes.get('both')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_LIVE_TAIL_H) + // The merged activity lives in the bar, which every card renders: a + // running node and a settled one reserve exactly the same base height. + expect(layout.boxes.get('root')?.h).toBe(GRAPH_NODE_H) + expect(layout.boxes.get('busy')?.h).toBe(GRAPH_NODE_H) + expect(layout.boxes.get('owner')?.h).toBe(GRAPH_NODE_H + GRAPH_TASK_H) }) it('keeps every card shape inside one row stride', () => { // The stride is the row pitch: a card taller than it would overlap the // next row (and its phase frame). - const tallest = GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_LIVE_TAIL_H + GRAPH_TASK_H + const tallest = GRAPH_NODE_H + GRAPH_TASK_H expect(GRAPH_ROW_STRIDE).toBeGreaterThanOrEqual(tallest) const nodes: TasksNode[] = [ agent('root'), diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx index 9ae75a9..b6af347 100644 --- a/tests/tasks-page.spec.tsx +++ b/tests/tasks-page.spec.tsx @@ -66,9 +66,15 @@ const teamMutations: Array<{ method: string; body: Record }> = const fetchedMethods: string[] = [] /** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */ let jobsByOwner: Record = {} +/** The `subagents.live` payload the stub answers with (session id → live view). */ +let livePayload: Record = {} +/** The folded workflow runs the stub answers `workflows.list` with. */ +let runsPayload: unknown[] = [] beforeEach(() => { teamPayload = { available: false } + livePayload = {} + runsPayload = [] teamMutations.length = 0 fetchedMethods.length = 0 jobsByOwner = { @@ -78,8 +84,8 @@ beforeEach(() => { const method = String(url).split('/').pop() fetchedMethods.push(method ?? '') const body = JSON.parse(String(init?.body ?? '{}')) as Record - if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } }) - if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } }) + if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: livePayload } }) + if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: runsPayload } }) if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload }) if (method === 'jobs.list') { return jsonResponse({ ok: true, value: { jobs: jobsByOwner[String(body.sessionId ?? '')] ?? [] } }) @@ -130,7 +136,29 @@ function snapshotWithChildren(count: number): SidebarSessionList { return { byId, projectionsBySession } } -/** Flush the mount-time job reads (request → envelope → json → Promise.all). */ +/** + * A snapshot whose root ran one workflow with TWO phases (the experiments the + * status badge and the phase badge read). The members are ordinary catalog + * children, which the model re-parents under the run node. + */ +function snapshotWithRun(): SidebarSessionList { + const byId: Record = { + root: { id: 'root', displayTitle: '主会话', running: true }, + } + const projectionsBySession: Record = {} + const entries: SidebarSubagentCatalogEntry[] = [ + { id: 'm1', createdAt: 1, mode: 'one-shot', label: '审计 A' }, + { id: 'm2', createdAt: 2, mode: 'one-shot', label: '审计 B' }, + ] + for (const entry of entries) { + byId[entry.id] = { id: entry.id, displayTitle: entry.id, origin: 'subagent', parentId: 'root', running: false } + projectionsBySession[entry.id] = { values: { subagentCatalog: [] }, state: 'ready', error: null } + } + projectionsBySession.root = { values: { subagentCatalog: entries }, state: 'ready', error: null } + return { byId, projectionsBySession } +} + + async function flushJobs(): Promise { for (let tick = 0; tick < 4; tick++) { await act(async () => { await Promise.resolve() }) @@ -512,4 +540,137 @@ describe('Tasks page: the shared task window', () => { expect(update?.body).toMatchObject({ action: 'reassign', owner: 'reviewer', expectedRevision: 7 }) unmount() }) + + it('draws the two-segment card: kind badge + name on top, state bar below', async () => { + const store = makeStore(snapshotWithChildren(2)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + await flushJobs() + // The tree is folded by default: unfold so the settled children are cards. + const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement + await act(async () => { foldToggle.click() }) + + const root = container.querySelector('[data-graph-node="root"]') as HTMLElement + expect(root.getAttribute('data-depth')).toBe('0') + // The root is the main agent, a child is a subagent — the page's own words. + expect(root.textContent).toContain('主代理') + const child = container.querySelector('[data-graph-node="child-0"]') as HTMLElement + expect(child.getAttribute('data-depth')).toBe('1') + expect(child.textContent).toContain('子代理') + // Both segments are present: the identity block and the state bar. + const bar = child.querySelector('[data-state]') as HTMLElement + expect(bar).not.toBeNull() + expect(bar.textContent).toContain('已完成') + unmount() + }) + + it('marks a running card with data-running (the sweep hook) and never a settled one', async () => { + livePayload = { + 'child-0': { + running: true, + summary: { counts: [{ kind: 'read', count: 2 }], runningDetail: '' }, + }, + } + const store = makeStore(snapshotWithChildren(2)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + await flushJobs() + // The global fold is ON by default: unfold so the settled sibling is a card + // too (the running one is never a fold candidate). + const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement + await act(async () => { foldToggle.click() }) + const child = container.querySelector('[data-graph-node="child-0"]') as HTMLElement + const bar = child.querySelector('[data-state]') as HTMLElement + expect(bar.getAttribute('data-running')).toBe('true') + // The merged activity wording comes from the plugin's own copy when the + // host's chat namespace is absent (this harness attaches no locale). + expect(bar.textContent).toBeTruthy() + const settled = container.querySelector('[data-graph-node="child-1"]') as HTMLElement + expect(settled.querySelector('[data-state]')?.getAttribute('data-running')).toBeNull() + unmount() + }) + + it('folds one settled card into the aggregate from its bar, and expands it back', async () => { + const store = makeStore(snapshotWithChildren(3)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + await flushJobs() + // The global fold is ON by default, so the settled children are already + // aggregated; unfolding reveals them as individual cards again. + const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement + await act(async () => { foldToggle.click() }) + expect(container.querySelector('[data-graph-node="child-0"]')).not.toBeNull() + + const chevron = container.querySelector( + '[data-graph-node="child-0"] button[aria-label="收进已完成聚合"]', + ) as HTMLButtonElement + expect(chevron).not.toBeNull() + await act(async () => { chevron.click() }) + // The node left the canvas… + expect(container.querySelector('[data-graph-node="child-0"]')).toBeNull() + // …and the aggregate says how many it holds. + const aggregate = container.querySelector('[data-graph-node="fold:root"]') as HTMLElement + expect(aggregate).not.toBeNull() + expect(aggregate.textContent).toContain('1') + + // Clicking the aggregate expands EVERYTHING (manual + global folds). + await act(async () => { aggregate.click() }) + expect(container.querySelector('[data-graph-node="child-0"]')).not.toBeNull() + expect(container.querySelector('[data-graph-node="fold:root"]')).toBeNull() + unmount() + }) + + it('offers no fold chevron on a running card or on the current session', async () => { + livePayload = { 'child-0': { running: true, summary: { counts: [], runningDetail: '' } } } + const store = makeStore(snapshotWithChildren(2)) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + await flushJobs() + const running = container.querySelector('[data-graph-node="child-0"]') as HTMLElement + expect(running.querySelector('button[aria-label="收进已完成聚合"]')).toBeNull() + // The root IS the current session: never foldable. + const root = container.querySelector('[data-graph-node="root"]') as HTMLElement + expect(root.querySelector('button[aria-label="收进已完成聚合"]')).toBeNull() + unmount() + }) + + it('badges a workflow run and its members with the phase they belong to', async () => { + runsPayload = [{ + runId: 'run-1', + name: 'audit', + originSessionId: 'root', + status: 'running', + startedSeq: 1, + startedAt: 0, + phases: [ + { title: '扫描', members: [{ childId: 'm1', label: '审计 A', seq: 2, phase: '扫描' }] }, + { title: '复核', members: [{ childId: 'm2', label: '审计 B', seq: 3, phase: '复核' }] }, + ], + }] + const store = makeStore(snapshotWithRun()) + const { container, unmount } = renderRoot( + createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }), + ) + await flushJobs() + // The run's members are settled, so the global fold has already gathered + // them: unfold to see the member cards themselves. + const foldToggle = container.querySelector('button[aria-label="展开已完成的节点"]') as HTMLButtonElement + await act(async () => { foldToggle.click() }) + // The run node is a card of its own: kind badge 工作流 + its status bar. + const runNode = container.querySelector('[data-graph-node="run:run-1"]') as HTMLElement + expect(runNode).not.toBeNull() + expect(runNode.textContent).toContain('工作流') + expect(runNode.querySelector('[data-state]')).not.toBeNull() + // Each member card carries ITS phase badge (the second grouping axis). + const first = container.querySelector('[data-graph-node="m1"]') as HTMLElement + const second = container.querySelector('[data-graph-node="m2"]') as HTMLElement + expect(first.textContent).toContain('扫描') + expect(second.textContent).toContain('复核') + expect(first.getAttribute('data-depth')).toBe('2') + unmount() + }) })