{rows.map((row) => {
@@ -135,14 +140,10 @@ export function JobsDrawer(props: JobsDrawerProps): ReactNode {
title={t('jobViewOutput')}
onClick={(event) => { onOpenOutput(row, event.currentTarget) }}
>
-
-
-
- {job.kind}
- {job.label}
-
- {secondary}
-
+
+
{job.kind}
+
{job.label}
+
{secondary}
{job.status === 'running' && (
- {armed ? t('jobKillConfirm') : }
+ {armed ? '!' : }
)}
{killFailed &&
{t('jobKillError')} }
diff --git a/src/client/SubagentView.module.css b/src/client/SubagentView.module.css
index ba2a09a..86dc43b 100644
--- a/src/client/SubagentView.module.css
+++ b/src/client/SubagentView.module.css
@@ -9,20 +9,24 @@
flex-direction: column;
}
+/* The page header keeps the mockup's rhythm: a 13px semibold title, a mono
+ count, and a hairline underline that separates chrome from content. */
.subagentHeader {
flex: none;
display: flex;
- align-items: center;
+ align-items: baseline;
gap: 8px;
- height: 36px;
- padding: 0 8px 0 12px;
+ padding: 10px 10px 9px 12px;
+ border-bottom: 1px solid var(--dsw-alias-border-l4);
}
.subagentTitle {
flex: 1;
min-width: 0;
- font: var(--dsw-font-s-14);
- color: var(--dsw-alias-label-secondary);
+ font-size: 13px;
+ line-height: 1.35;
+ font-weight: 600;
+ color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -31,8 +35,9 @@
/* The descendant count badge ("3 · 1 运行中") beside the title. */
.subagentCount {
flex: none;
- font: var(--dsw-font-xxxs-11);
- color: var(--dsw-alias-label-tertiary);
+ font-family: var(--ds-font-family-code);
+ font-size: 10px;
+ color: var(--dsw-alias-label-secondary);
}
.subagentRefresh {
@@ -40,16 +45,18 @@
display: inline-flex;
align-items: center;
justify-content: center;
- width: 24px;
- height: 24px;
+ width: 20px;
+ height: 20px;
+ padding: 0;
border: none;
- border-radius: 6px;
+ border-radius: 3px;
background: transparent;
- color: var(--dsw-alias-label-secondary);
+ color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.subagentRefresh:hover {
+ color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx
index e10c68f..611150f 100644
--- a/src/client/SubagentView.tsx
+++ b/src/client/SubagentView.tsx
@@ -47,7 +47,8 @@ import { TasksGraph } from './TasksGraph.tsx'
import { TasksTree } from './TasksTree.tsx'
import { JobsDrawer, JobOutputPopoverContent } from './JobsDrawer.tsx'
import { AnchoredPopover } from './AnchoredPopover.tsx'
-import { AgentNodePopover, TeamBoardPopover, WorkflowNodePopover } from './TasksPopovers.tsx'
+import { AgentNodePopover, WorkflowNodePopover } from './TasksPopovers.tsx'
+import { TeamBoard } from './TeamBoard.tsx'
import type { SidebarStore } from './state.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import legacy from './SubagentView.module.css'
@@ -130,7 +131,6 @@ type PagePopover =
| { kind: 'node'; nodeId: string; anchor: HTMLElement }
| { kind: 'workflow'; nodeId: string; anchor: HTMLElement }
| { kind: 'job'; jobId: string; anchor: HTMLElement }
- | { kind: 'team'; anchor: HTMLElement }
/**
* The sidebar's Tasks page.
@@ -182,6 +182,7 @@ export function SubagentView(props: {
const mode = modeOverride ?? prefsMode
const [folded, setFolded] = useState(true)
+ const [teamBoardCollapsed, setTeamBoardCollapsed] = useState(false)
const [popover, setPopover] = useState
(null)
const model = useMemo(
@@ -347,22 +348,11 @@ export function SubagentView(props: {
/** The current popover's resolved content (subjects re-resolve live). */
const popoverContent = ((): ReactNode => {
if (popover === null) return null
- if (popover.kind === 'team' && teamView?.available === true && teamView.team !== null && rootId !== undefined) {
- return (
-
- )
- }
if (popover.kind === 'job') {
const row = jobRows.find(candidate => candidate.job.id === popover.jobId)
if (row === undefined) return null
return
}
- if (popover.kind === 'team') return null
const node = model.find(candidate => candidate.id === popover.nodeId)
if (node === undefined) return null
if (popover.kind === 'workflow' && node.kind === 'workflow') {
@@ -398,17 +388,6 @@ export function SubagentView(props: {
: ''}
{countLabel !== undefined && {countLabel} }
- {teamView?.available === true && teamView.team !== null && (
- {
- setPopover(popover?.kind === 'team' ? null : { kind: 'team', anchor: event.currentTarget })
- }}
- >
- {t('teamChip', { members: teamView.team.members.length, tasks: teamView.team.tasks.length })}
-
- )}
+ {rootId !== undefined && teamView?.available === true && teamView.team !== null && (
+ { setTeamBoardCollapsed(current => !current) }}
+ />
+ )}
{failedParents.length > 0 && (
{t('catalogLoadFailed', { count: failedParents.length })}
diff --git a/src/client/TasksGraph.tsx b/src/client/TasksGraph.tsx
index 79b318d..fe01352 100644
--- a/src/client/TasksGraph.tsx
+++ b/src/client/TasksGraph.tsx
@@ -1,18 +1,21 @@
/**
- * The workflow-graph mode of the Tasks page: the approved Variant D canvas —
- * layered agent/workflow nodes over bezier edges, pointer-drag pan,
- * wheel-zoom-to-cursor, per-phase dashed frames, fold aggregate nodes, and a
- * bottom-right control cluster (view toggle / zoom out / percentage / zoom
- * in / fit / fold toggle). The cluster lives OUTSIDE the scroll surface so
- * the view toggle stays reachable in both modes (a lesson of the mockup).
+ * The workflow-graph mode of the Tasks page (the approved Variant-D canvas):
+ * layered agent/workflow nodes over bezier edges on a dotted grid, drag-pan
+ * from the BACKGROUND only (never from a node — the mockup's rule; capturing
+ * pointers on the container is what silently swallowed node clicks), wheel
+ * zoom to the cursor, dashed phase frames, fold aggregate nodes, and the
+ * bottom-right horizontal control cluster (view toggle / zoom out / level /
+ * zoom in / fit) that stays reachable in BOTH modes.
*
- * The layout comes from the pure `layoutTasksGraph`; this component owns
- * only the transform state and DOM wiring.
+ * Fitting: the canvas auto-fits while the reader has not touched the view,
+ * re-running on container resize and layout growth (the sidebar mounts
+ * hidden at zero size, so a one-shot fit on mount is not enough), centering
+ * on both axes and scaling UP to {@link FIT_MAX_SCALE} so a small tree fills
+ * the narrow panel instead of hugging the top-left corner.
*/
import {
useCallback,
useEffect,
- useLayoutEffect,
useMemo,
useRef,
useState,
@@ -20,43 +23,25 @@ import {
type ReactNode,
} from 'react'
import clsx from 'clsx'
-import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { StateDot } 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_NODE_H, GRAPH_NODE_W, layoutTasksGraph, type GraphBox } from './tasks-graph-layout.ts'
-import { agentSecondary, foldPreviews, LiveLine, nodeDotState } from './tasks-shared.tsx'
-import { t, type CopyKey } from './locales.ts'
+import { GRAPH_NODE_W, layoutTasksGraphForWidth, type GraphBox } from './tasks-graph-layout.ts'
+import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
+import { t } from './locales.ts'
import css from './tasks-graph.module.css'
/** The zoom bounds of the canvas. */
-const ZOOM_MIN = 0.15
-const ZOOM_MAX = 2.5
+const ZOOM_MIN = 0.3
+const ZOOM_MAX = 1.5
+/** Upper bound of the auto-fit scale (never balloon a two-node tree). */
+const FIT_MAX_SCALE = 1.15
+/** Lower bound of the auto-fit scale: below this the cards stop being
+ * readable, so the canvas overflows and pans instead of shrinking further. */
+const FIT_MIN_SCALE = 0.78
/** Drag-vs-click separation: pointer travel below this stays a click. */
const CLICK_TOLERANCE_PX = 4
-/** The workflow run status label key. */
-function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey {
- switch (status) {
- case 'running': return 'workflowRunning'
- case 'completed': return 'workflowCompleted'
- case 'cancelled': return 'workflowCancelled'
- case 'failed': return 'workflowFailed'
- }
-}
-
-/** The member tally of a run (`done` = every member with an outcome). */
-function memberTally(run: TasksWorkflowNode['run']): { done: number; total: number } {
- let done = 0
- let total = 0
- for (const phase of run.phases) {
- for (const member of phase.members) {
- total += 1
- if (member.outcome !== undefined) done += 1
- }
- }
- return { done, total }
-}
-
/** One phase frame of a run node (the dashed box behind its members). */
interface PhaseFrame {
key: string
@@ -70,7 +55,7 @@ interface PhaseFrame {
export interface TasksGraphProps {
nodes: readonly TasksNode[]
folded: boolean
- /** Re-fit trigger: the topology root id (a tree switch re-centers). */
+ /** Re-fit trigger: the topology root id (a reroot re-centers). */
rootId: string | undefined
onActivate(node: TasksAgentNode): void
onNodeInfo(node: TasksAgentNode, anchor: HTMLElement): void
@@ -80,44 +65,40 @@ export interface TasksGraphProps {
onModeChange(mode: 'graph' | 'tree'): void
}
-/** The shared view-mode toggle button (also used by the tree mode). */
+/** The shared view-mode toggle (glyph + label, per the mockup). */
export function ViewModeToggle(props: {
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
}): ReactNode {
const { mode, onModeChange } = props
const toTree = mode === 'graph'
+ const label = toTree ? `▤ ${t('tasksViewTree')}` : `⌗ ${t('tasksViewGraph')}`
return (
{ onModeChange(toTree ? 'tree' : 'graph') }}
>
- {toTree ? '☰' : '⌗'}
+ {label}
)
}
-/** The fold toggle button (shared by both modes). */
+/** The fold toggle (expand / re-collapse the settled aggregates). */
export function FoldToggleButton(props: { folded: boolean; onToggleFold(): void }): ReactNode {
const { folded, onToggleFold } = props
return (
-
-
-
+ {folded ? '⇪' : '⇩'}
)
}
@@ -129,25 +110,61 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const [dragging, setDragging] = useState(false)
/** Pointer travel of the in-flight gesture (click suppression). */
const travelRef = useRef(0)
+ /** The reader moved/zoomed: stop auto-fitting over their view. */
+ const touchedRef = useRef(false)
const tfRef = useRef(tf)
tfRef.current = tf
- const layout = useMemo(() => layoutTasksGraph(nodes), [nodes])
+ /** Container width drives the sibling-band wrap (see bandColsFor). */
+ const [width, setWidth] = useState(0)
+ const layout = useMemo(
+ () => layoutTasksGraphForWidth(nodes, width, FIT_MIN_SCALE),
+ [nodes, width],
+ )
const edges = useMemo(() => tasksEdges(nodes), [nodes])
const nodeById = useMemo(() => new Map(nodes.map(node => [node.id, node])), [nodes])
- /** Fit the whole canvas into the container (initial mount + ⌂ + reroot). */
+ /** Center the canvas in the container (both axes) and scale to fill. */
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 k = Math.min((cw - 40) / layout.width, (ch - 40) / layout.height, 1)
- setTf({ x: (cw - layout.width * k) / 2, y: 12, k })
+ const k = Math.max(
+ FIT_MIN_SCALE,
+ Math.min((cw - 24) / layout.width, (ch - 24) / layout.height, FIT_MAX_SCALE),
+ )
+ setTf({
+ x: (cw - layout.width * k) / 2,
+ y: Math.max(8, (ch - layout.height * k) / 2),
+ k,
+ })
}, [layout.width, layout.height])
- useLayoutEffect(() => { fit() }, [fit, rootId])
+ /** The ⌂ button: refit AND re-enable auto-fit for later resizes. */
+ const refit = useCallback((): void => {
+ touchedRef.current = false
+ fit()
+ }, [fit])
+
+ // Auto-fit on mount, on layout growth and on container resize — but never
+ // over a view the reader has panned/zoomed themselves.
+ useEffect(() => {
+ if (!touchedRef.current) fit()
+ }, [fit, rootId])
+
+ useEffect(() => {
+ const container = containerRef.current
+ if (container === null || typeof ResizeObserver === 'undefined') return
+ const observer = new ResizeObserver((entries) => {
+ const rect = entries[0]?.contentRect
+ if (rect !== undefined) setWidth(current => (Math.abs(current - rect.width) < 2 ? current : rect.width))
+ if (!touchedRef.current) fit()
+ })
+ observer.observe(container)
+ return () => { observer.disconnect() }
+ }, [fit])
// Wheel-zoom to cursor (non-passive: the page must not scroll).
useEffect(() => {
@@ -161,6 +178,7 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const { x, y, k } = tfRef.current
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * Math.exp(-event.deltaY * 0.0014)))
if (next === k) return
+ touchedRef.current = true
setTf({
x: mx - (mx - x) * (next / k),
y: my - (my - y) * (next / k),
@@ -176,36 +194,46 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const { x, y, k } = tfRef.current
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, k * factor))
if (next === k || container === null) return
+ 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 })
}, [])
- // Drag-pan: pointer capture on the container, any start point pans.
+ /**
+ * Drag-pan. The gesture only ever starts on the BACKGROUND: a pointerdown
+ * on a node (or the control cluster) is left alone, so the node's own click
+ * fires. Listeners live on `window` — never `setPointerCapture` on the
+ * container, which retargets the derived click event and silently kills
+ * every node interaction.
+ */
const onPointerDown = useCallback((event: ReactPointerEvent): void => {
if (event.button !== 0) return
+ const target = event.target as HTMLElement
+ if (target.closest('[data-graph-node]') !== null) return
+ if (target.closest('[data-graph-controls]') !== null) return
travelRef.current = 0
const startX = event.clientX
const startY = event.clientY
const { x, y } = tfRef.current
- const target = event.currentTarget
- target.setPointerCapture(event.pointerId)
const onMove = (move: globalThis.PointerEvent): void => {
const dx = move.clientX - startX
const dy = move.clientY - startY
travelRef.current = Math.max(travelRef.current, Math.abs(dx) + Math.abs(dy))
+ if (travelRef.current <= CLICK_TOLERANCE_PX) return
+ touchedRef.current = true
+ setDragging(true)
setTf(current => ({ ...current, x: x + dx, y: y + dy }))
- if (travelRef.current > CLICK_TOLERANCE_PX) setDragging(true)
}
const onUp = (): void => {
- target.removeEventListener('pointermove', onMove)
- target.removeEventListener('pointerup', onUp)
- target.removeEventListener('pointercancel', onUp)
+ window.removeEventListener('pointermove', onMove)
+ window.removeEventListener('pointerup', onUp)
+ window.removeEventListener('pointercancel', onUp)
setDragging(false)
}
- target.addEventListener('pointermove', onMove)
- target.addEventListener('pointerup', onUp)
- target.addEventListener('pointercancel', onUp)
+ window.addEventListener('pointermove', onMove)
+ window.addEventListener('pointerup', onUp)
+ window.addEventListener('pointercancel', onUp)
}, [])
/** Suppress node clicks that ended a drag gesture. */
@@ -226,14 +254,10 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
}
if (boxes.length === 0) return
const x1 = Math.min(...boxes.map(box => box.x)) - 12
- const y1 = Math.min(...boxes.map(box => box.y)) - 22
+ 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, x: x1, y: y1, w: x2 - x1, h: y2 - y1 })
})
}
return frames
@@ -245,8 +269,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
const y1 = from.y + from.h
const x2 = to.x + to.w / 2
const y2 = to.y
- const midY = (y1 + y2) / 2
- return `M ${x1} ${y1} C ${x1} ${midY} ${x2} ${midY} ${x2} ${y2}`
+ const dy = Math.max(24, (y2 - y1) / 2)
+ return `M ${x1} ${y1} C ${x1} ${y1 + dy} ${x2} ${y2 - dy} ${x2} ${y2}`
}
return (
@@ -277,7 +301,8 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
key={`${edge.from}->${edge.to}`}
className={clsx(
css.edge,
- (edge.kind === 'team' || edge.kind === 'workflow') && css.edgeTeam,
+ edge.kind === 'team' && css.edgeTeam,
+ edge.kind === 'workflow' && css.edgeWorkflow,
target?.kind === 'fold' && css.edgeFold,
)}
d={edgePath(from, to)}
@@ -305,43 +330,44 @@ export function TasksGraph(props: TasksGraphProps): ReactNode {
return renderAgentNode(node, style, onActivate, onNodeInfo, clickAllowed)
})}
-
-
-
-
-
{ zoomBy(1 / 1.25) }}
- >
- −
-
-
- {Math.round(tf.k * 100)}%
-
-
{ zoomBy(1.25) }}
- >
- +
-
+
+
+
+ { zoomBy(1 / 1.2) }}
+ >
+ −
+
+ {Math.round(tf.k * 100)}%
+ { zoomBy(1.2) }}
+ >
+ +
+
+
+ ⌂
+
+
)
}
-/** One agent node card. */
+/** One agent node card: dot + title, one mono meta line, live line when running. */
function renderAgentNode(
node: TasksAgentNode,
style: { left: number; top: number; width: number; minHeight: number },
@@ -352,33 +378,37 @@ function renderAgentNode(
return (
{
+ onClick={() => {
if (!clickAllowed()) return
onActivate(node)
- void event
}}
>
-
- {node.label}
+
+
+ {node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
+
- {agentSecondary(node)}
+ {agentMeta(node)}
{node.state === 'running' && }
{
@@ -386,7 +416,7 @@ function renderAgentNode(
onNodeInfo(node, event.currentTarget)
}}
>
- ⓘ
+ i
)
@@ -399,14 +429,14 @@ function renderWorkflowNode(
onWorkflowInfo: (node: TasksWorkflowNode, anchor: HTMLElement) => void,
clickAllowed: () => boolean,
): ReactNode {
- const tally = memberTally(node.run)
return (
{
if (!clickAllowed()) return
@@ -414,12 +444,10 @@ function renderWorkflowNode(
}}
>
- ▶
- {node.run.name}
-
-
- {`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done: tally.done, total: tally.total })}`}
+
+ {`▶ ${node.run.name}`}
+ {workflowMeta(node)}
)
}
@@ -434,6 +462,7 @@ function renderFoldNode(
return (
- {t('tasksFoldCompleted', { count: node.count })}
- {foldPreviews(node.previews)}
+
+
+
+ {t('tasksFoldCompleted', { count: node.count })}
+
+
+
+ {`⇪ ${t('tasksFoldExpand')} · ${foldPreviews(node.previews)}`}
+
)
}
diff --git a/src/client/TasksPopovers.tsx b/src/client/TasksPopovers.tsx
index 1a25600..2f94a71 100644
--- a/src/client/TasksPopovers.tsx
+++ b/src/client/TasksPopovers.tsx
@@ -1,22 +1,17 @@
/**
* The anchored-popover CONTENTS of the Tasks page (geometry and dismissal
- * live in AnchoredPopover): agent node details with a transcript jump, the
- * workflow run detail with clickable member rows, and the Agent Teams task
- * board (roster + basic CAS operations: complete / reopen / delete /
- * reassign / create / edit). Every mutation passes the task's CURRENT
- * revision; a conflict shows the note and re-syncs via the parent poller.
+ * live in AnchoredPopover): the agent node detail with a transcript jump and
+ * the workflow run detail with clickable member rows. Both follow the mockup
+ * grammar — a letter-spaced uppercase head, a dt/dd key/value grid, and one
+ * full-width jump action. The team board is NOT here: it is always visible as
+ * a strip above the canvas (TeamBoard.tsx).
*/
-import { useState, type ReactNode } from 'react'
+import type { ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
-import type {
- SidebarSubagentAddress,
- SidebarTeamMemberView,
- SidebarTeamTaskView,
-} from '../context-types.ts'
+import type { SidebarSubagentAddress } from '../context-types.ts'
import type { TasksAgentNode, TasksWorkflowNode } from './tasks-model.ts'
-import { agentSecondary, flatten, nodeDotState } from './tasks-shared.tsx'
-import { api } from './api.ts'
+import { flatten, nodeDotState, workflowStatusKey } from './tasks-shared.tsx'
import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
@@ -30,13 +25,18 @@ function stateKey(state: TasksAgentNode['state']): CopyKey {
}
}
+/** The popover's key/value grid. */
+function PopRows(props: { children: ReactNode }): ReactNode {
+ return {props.children}
+}
+
/** One key/value row of a popover. */
-function PopRow(props: { label: string; children: ReactNode }): ReactNode {
+function PopRow(props: { label: string; mono?: boolean; children: ReactNode }): ReactNode {
return (
-
- {props.label}
- {props.children}
-
+ <>
+ {props.label}
+ {props.children}
+ >
)
}
@@ -52,37 +52,39 @@ export function AgentNodePopover(props: {
: undefined
return (
+
+ {t('tasksNodeDetail')}
+
{node.label}
-
- {t(stateKey(node.state))}
-
- {node.mode !== undefined && (
-
- {node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
+
+
+ {t(stateKey(node.state))}
- )}
- {node.team !== undefined && (
- <>
+ {node.mode !== undefined && (
+
+ {node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')}
+
+ )}
+ {node.team !== undefined && (
{node.team.role === 'lead' ? 'lead' : node.team.name}
- {node.team.model !== undefined && (
- {node.team.model}
- )}
- >
+ )}
+ {node.team?.model !== undefined && (
+ {node.team.model}
+ )}
+ {(liveTool !== undefined || liveText !== undefined) && (
+ {liveTool ?? liveText}
+ )}
+
+ {liveText !== undefined && liveTool !== undefined && (
+ {liveText}
)}
- {(liveTool !== undefined || liveText !== undefined) && (
- {liveTool ?? liveText}
- )}
- {node.childAddress !== undefined || node.parentId === undefined ? (
- { onJump(node) }}
- >
+ {(node.childAddress !== undefined || node.parentId === undefined) && (
+ { onJump(node) }}>
{t('tasksNodeJump')}
- ) : null}
+ )}
)
}
@@ -96,26 +98,24 @@ export function WorkflowNodePopover(props: {
const { run } = node
return (
+
+ {t('workflowRun')}
+
▶ {run.name}
-
- {t(run.status === 'running'
- ? 'workflowRunning'
- : run.status === 'completed'
- ? 'workflowCompleted'
- : run.status === 'cancelled' ? 'workflowCancelled' : 'workflowFailed')}
-
+
+ {t(workflowStatusKey(run.status))}
+
{run.phases.map((phase, phaseIndex) => (
-
- {phase.title ?? t('workflowPhaseUnnamed')}
-
+
{phase.title ?? t('workflowPhaseUnnamed')}
{phase.members.map(member => (
{
if (member.childId === '') return
onJumpMember({
@@ -126,13 +126,13 @@ export function WorkflowNodePopover(props: {
}}
>
- {member.label}
+ {member.label}
))}
@@ -142,276 +142,3 @@ export function WorkflowNodePopover(props: {
)
}
-
-/** The task status label key. */
-function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
- switch (status) {
- case 'pending': return 'teamTaskPending'
- case 'in_progress': return 'teamTaskInProgress'
- case 'completed': return 'teamTaskCompleted'
- case 'deleted': return 'teamTaskDeleted'
- }
-}
-
-/**
- * The Agent Teams board popover: roster + shared task list with the basic
- * operations. Poll-driven refresh: a successful mutation (or a conflict)
- * asks the parent to re-pull `teams.view` immediately.
- */
-export function TeamBoardPopover(props: {
- rootId: string
- members: readonly SidebarTeamMemberView[]
- tasks: readonly SidebarTeamTaskView[]
- onChanged(): void
-}): ReactNode {
- const { rootId, members, tasks, onChanged } = props
- const [busy, setBusy] = useState(false)
- const [note, setNote] = useState
(undefined)
- const [editingId, setEditingId] = useState(undefined)
- const [creating, setCreating] = useState(false)
- const [armedDeleteId, setArmedDeleteId] = useState(undefined)
-
- /** Run one mutation with the shared busy/conflict handling. */
- const mutate = async (
- action: () => Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }>,
- ): Promise => {
- if (busy) return
- setBusy(true)
- setNote(undefined)
- try {
- const result = await action()
- if (!result.ok) {
- setNote(result.error.code === 'team-task-conflict' ? t('teamTaskConflict') : t('teamTaskError', { message: result.error.message }))
- }
- onChanged()
- } catch (error) {
- setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
- } finally {
- setBusy(false)
- }
- }
-
- return (
-
-
{t('teamBoard')}
-
- {members.map(member => member.name).join(' · ')}
-
-
-
- {tasks.length === 0 &&
{t('teamTasksEmpty')}
}
- {tasks.filter(task => task.status !== 'deleted').map(task => (
-
{ setEditingId(open ? task.id : undefined) }}
- onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }}
- mutate={mutate}
- />
- ))}
-
- {note !== undefined &&
{note}
}
- {creating
- ? (
-
{ setCreating(false) }}
- onSubmit={(subject, description) => void mutate(async () => {
- const result = await api.teamsTaskCreate(rootId, { subject, description })
- if (result.ok) setCreating(false)
- return result
- })}
- />
- )
- : (
- { setCreating(true) }}
- >
- + {t('teamTaskCreate')}
-
- )}
-
-
- )
-}
-
-/** One task row of the board (status action + owner select + edit/delete). */
-function TeamTaskRow(props: {
- rootId: string
- task: SidebarTeamTaskView
- members: readonly SidebarTeamMemberView[]
- busy: boolean
- editing: boolean
- armedDelete: boolean
- onEdit(open: boolean): void
- onArmDelete(armed: boolean): void
- mutate(action: () => Promise<{ ok: true } | { ok: false; error: { code: string; message: string } }>): Promise
-}): ReactNode {
- const { rootId, task, members, busy, editing, armedDelete, onEdit, onArmDelete, mutate } = props
- if (editing) {
- return (
- { onEdit(false) }}
- onSubmit={(subject, description) => void mutate(async () => {
- const result = await api.teamsTaskUpdate(rootId, {
- taskId: task.id,
- expectedRevision: task.revision,
- action: 'edit',
- subject,
- description,
- })
- if (result.ok) onEdit(false)
- return result
- })}
- />
- )
- }
- return (
-
-
-
- {task.subject}
- {!task.ready && ` · ${t('teamTaskBlocked')}`}
-
- {
- const owner = event.target.value
- void mutate(() => api.teamsTaskUpdate(rootId, {
- taskId: task.id,
- expectedRevision: task.revision,
- action: 'reassign',
- ...(owner === '' ? {} : { owner }),
- }))
- }}
- >
- {t('teamTaskUnowned')}
- {members.filter(member => member.role === 'teammate').map(member => (
- {member.name}
- ))}
-
-
- {task.status !== 'completed' && (
- void mutate(() => api.teamsTaskUpdate(rootId, {
- taskId: task.id, expectedRevision: task.revision, action: 'complete',
- }))}
- >
- ✓
-
- )}
- {task.status === 'completed' && (
- void mutate(() => api.teamsTaskUpdate(rootId, {
- taskId: task.id, expectedRevision: task.revision, action: 'reopen',
- }))}
- >
- ↺
-
- )}
- { onEdit(true) }}
- >
- ✎
-
- {
- if (!armedDelete) { onArmDelete(true); return }
- onArmDelete(false)
- void mutate(() => api.teamsTaskUpdate(rootId, {
- taskId: task.id, expectedRevision: task.revision, action: 'delete',
- }))
- }}
- >
- {armedDelete ? t('teamTaskDeleteConfirm') : '🗑'}
-
-
- {t(taskStatusKey(task.status))}
-
- )
-}
-
-/** The create/edit task form (subject + description). */
-function TeamTaskForm(props: {
- busy: boolean
- initialSubject?: string
- initialDescription?: string
- submitLabel: string
- onCancel(): void
- onSubmit(subject: string, description: string): void
-}): ReactNode {
- const [subject, setSubject] = useState(props.initialSubject ?? '')
- const [description, setDescription] = useState(props.initialDescription ?? '')
- return (
-
- )
-}
diff --git a/src/client/TasksTree.tsx b/src/client/TasksTree.tsx
index 2b30092..5c96156 100644
--- a/src/client/TasksTree.tsx
+++ b/src/client/TasksTree.tsx
@@ -1,32 +1,18 @@
/**
- * The tree mode of the Tasks page: the SAME unified model rendered as a
- * classic indentation tree (keyboard navigable, the official catalog's
- * arrow-key recipe). Fold aggregates are rows; workflow runs are group rows
- * whose members indent one level deeper. The control cluster (view toggle +
- * fold toggle) stays visible bottom-right — the mockup's lesson.
+ * The tree mode of the Tasks page: the SAME unified model rendered as the
+ * classic indentation tree — nested children with a hairline connector, the
+ * mockup's row metrics (12px title / 9.5px mono meta), left accent bar on the
+ * current session, dashed fold rows, and the shared bottom-right control
+ * cluster (view toggle + fold toggle) so switching back is always possible.
*/
import { useCallback, useMemo, useRef, type KeyboardEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TasksAgentNode, TasksNode, TasksWorkflowNode } from './tasks-model.ts'
-import { agentSecondary, foldPreviews, LiveLine, nodeDotState } from './tasks-shared.tsx'
+import { agentMeta, foldPreviews, LiveLine, nodeDotState, workflowMeta } from './tasks-shared.tsx'
import { FoldToggleButton, ViewModeToggle } from './TasksGraph.tsx'
-import { t, type CopyKey } from './locales.ts'
+import { t } from './locales.ts'
import css from './tasks-graph.module.css'
-import legacy from './SubagentView.module.css'
-
-/** Pixels of indentation per depth level (the classic tree's rhythm). */
-const INDENT_PX = 18
-
-/** The workflow run status label key. */
-function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey {
- switch (status) {
- case 'running': return 'workflowRunning'
- case 'completed': return 'workflowCompleted'
- case 'cancelled': return 'workflowCancelled'
- case 'failed': return 'workflowFailed'
- }
-}
export interface TasksTreeProps {
nodes: readonly TasksNode[]
@@ -37,7 +23,7 @@ export interface TasksTreeProps {
onToggleFold(): void
mode: 'graph' | 'tree'
onModeChange(mode: 'graph' | 'tree'): void
- /** Fallback loading rows while the root catalog hydrates. */
+ /** Fallback loading row while the root catalog hydrates. */
loading?: boolean
}
@@ -47,24 +33,17 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
} = props
const bodyRef = useRef(null)
- /** Depth of every node (roots at 0), derived from the parent chain. */
- const depthById = useMemo(() => {
- const byId = new Map(nodes.map(node => [node.id, node]))
- const depths = new Map()
- const depthOf = (node: TasksNode, guard: number): number => {
- const known = depths.get(node.id)
- if (known !== undefined) return known
- if (node.parentId === undefined || guard > 64) {
- depths.set(node.id, 0)
- return 0
- }
- const parent = byId.get(node.parentId)
- const depth = parent === undefined ? 0 : depthOf(parent, guard + 1) + 1
- depths.set(node.id, depth)
- return depth
+ /** children by parent id (model order preserved). */
+ const childrenOf = useMemo(() => {
+ const map = new Map()
+ const ids = new Set(nodes.map(node => node.id))
+ for (const node of nodes) {
+ const parent = node.parentId !== undefined && ids.has(node.parentId) ? node.parentId : ''
+ const list = map.get(parent)
+ if (list === undefined) map.set(parent, [node])
+ else list.push(node)
}
- for (const node of nodes) depthOf(node, 0)
- return depths
+ return map
}, [nodes])
/** Arrow-key navigation over the visible rows (official catalog recipe). */
@@ -99,6 +78,109 @@ export function TasksTree(props: TasksTreeProps): ReactNode {
}
}
+ /** One row plus its nested children (connector line per the mockup). */
+ const renderNode = (node: TasksNode, depth: number): ReactNode => {
+ const children = childrenOf.get(node.id) ?? []
+ const row = node.kind === 'fold'
+ ? (
+ { activateOnKey(event, onToggleFold) }}
+ >
+
+
+
+ {t('tasksFoldCompleted', { count: node.count })}
+
+ {`${folded ? '⇪' : '⇩'} ${t(folded ? 'tasksFoldExpand' : 'tasksFoldCollapse')}`}
+
+
+ {foldPreviews(node.previews)}
+
+
+ )
+ : node.kind === 'workflow'
+ ? (
+ { onWorkflowInfo(node, event.currentTarget) }}
+ onKeyDown={(event) => {
+ activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
+ }}
+ >
+
+
+ {`▶ ${node.run.name}`}
+ {workflowMeta(node)}
+
+
+ )
+ : (
+ { onActivate(node) }}
+ onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
+ >
+
+
+
+ {node.team?.role === 'teammate' ? `◇ ${node.label}` : node.label}
+
+ {agentMeta(node)}
+ {node.state === 'running' && }
+
+ {
+ event.stopPropagation()
+ onNodeInfo(node, event.currentTarget)
+ }}
+ >
+ i
+
+
+ )
+ if (children.length === 0) return {row}
+ return (
+
+ {row}
+
+ {children.map(child => renderNode(child, depth + 1))}
+
+
+ )
+ }
+
+ const roots = childrenOf.get('') ?? []
+
return (
- {loading === true && (
-
{t('loading')}
+ {loading === true && roots.length === 0 && (
+
{t('loading')}
)}
- {nodes.map((node) => {
- const depth = depthById.get(node.id) ?? 0
- const indent = { marginLeft: depth * INDENT_PX }
- if (node.kind === 'fold') {
- return (
-
{ activateOnKey(event, onToggleFold) }}
- >
-
- {t('tasksFoldCompleted', { count: node.count })}
- {foldPreviews(node.previews)}
-
-
- )
- }
- if (node.kind === 'workflow') {
- const done = node.run.phases.reduce(
- (sum, phase) => sum + phase.members.filter(member => member.outcome !== undefined).length,
- 0,
- )
- const total = node.run.phases.reduce((sum, phase) => sum + phase.members.length, 0)
- return (
-
{ onWorkflowInfo(node, event.currentTarget) }}
- onKeyDown={(event) => {
- activateOnKey(event, () => { onWorkflowInfo(node, event.currentTarget as HTMLElement) })
- }}
- >
- ▶
-
- {node.run.name}
-
- {`${t(workflowStatusKey(node.run.status))} · ${t('workflowMembers', { done, total })}`}
-
-
-
- )
- }
- return (
-
{ onActivate(node) }}
- onKeyDown={(event) => { activateOnKey(event, () => { onActivate(node) }) }}
- >
-
-
- {node.label}
- {agentSecondary(node)}
- {node.state === 'running' && }
-
- {
- event.stopPropagation()
- onNodeInfo(node, event.currentTarget)
- }}
- >
- ⓘ
-
-
- )
- })}
+ {roots.map(root => renderNode(root, 0))}
-
+
diff --git a/src/client/TeamBoard.tsx b/src/client/TeamBoard.tsx
new file mode 100644
index 0000000..232d27c
--- /dev/null
+++ b/src/client/TeamBoard.tsx
@@ -0,0 +1,333 @@
+/**
+ * The Agent Teams board — ALWAYS VISIBLE under the page header whenever the
+ * tree's root leads a team (the explicit review ask: the board must not hide
+ * behind a chip). It is a full-width strip, not a side rail, because the
+ * native sidebar is narrow (~360px): members wrap as chips and the task list
+ * scrolls inside a bounded height, so the canvas keeps its room.
+ *
+ * Mutations are CAS: every action sends the task's CURRENT revision; a
+ * conflict surfaces as a note and re-syncs through the parent's poller.
+ */
+import { useState, type ReactNode } from 'react'
+import clsx from 'clsx'
+import { IconChevronUpOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { SidebarTeamMemberView, SidebarTeamTaskView } from '../context-types.ts'
+import { api } from './api.ts'
+import { t, type CopyKey } from './locales.ts'
+import css from './tasks-graph.module.css'
+
+/** The task status label key. */
+function taskStatusKey(status: SidebarTeamTaskView['status']): CopyKey {
+ switch (status) {
+ case 'pending': return 'teamTaskPending'
+ case 'in_progress': return 'teamTaskInProgress'
+ case 'completed': return 'teamTaskCompleted'
+ case 'deleted': return 'teamTaskDeleted'
+ }
+}
+
+/** One mutation outcome (the host route's own union). */
+type MutationResult = { ok: true } | { ok: false; error: { code: string; message: string } }
+
+export interface TeamBoardProps {
+ rootId: string
+ members: readonly SidebarTeamMemberView[]
+ tasks: readonly SidebarTeamTaskView[]
+ /** Re-pull `teams.view` now (after every mutation / conflict). */
+ onChanged(): void
+ /** The strip's own collapse state (the reader's choice, remembered while mounted). */
+ collapsed: boolean
+ onToggleCollapsed(): void
+}
+
+export function TeamBoard(props: TeamBoardProps): ReactNode {
+ const { rootId, members, tasks, onChanged, collapsed, onToggleCollapsed } = props
+ const [busy, setBusy] = useState(false)
+ const [note, setNote] = useState
(undefined)
+ const [editingId, setEditingId] = useState(undefined)
+ const [creating, setCreating] = useState(false)
+ const [armedDeleteId, setArmedDeleteId] = useState(undefined)
+
+ const open = tasks.filter(task => task.status !== 'deleted')
+
+ /** Run one mutation with the shared busy/conflict handling. */
+ const mutate = async (action: () => Promise): Promise => {
+ if (busy) return
+ setBusy(true)
+ setNote(undefined)
+ try {
+ const result = await action()
+ if (!result.ok) {
+ setNote(result.error.code === 'team-task-conflict'
+ ? t('teamTaskConflict')
+ : t('teamTaskError', { message: result.error.message }))
+ }
+ onChanged()
+ } catch (error) {
+ setNote(t('teamTaskError', { message: error instanceof Error ? error.message : String(error) }))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const teammates = members.filter(member => member.role === 'teammate')
+
+ return (
+
+
+ {t('teamBoard')}
+
+ {t('teamChip', { members: members.length, tasks: open.length })}
+
+
+
+
+
+ {!collapsed && (
+ <>
+
+ {members.map(member => (
+
+
+ {member.name}
+
+ ))}
+
+ {open.length === 0 && !creating && {t('teamTasksEmpty')}
}
+ {open.length > 0 && (
+
+ {open.map(task => (
+ { setEditingId(edit ? task.id : undefined) }}
+ onArmDelete={(armed) => { setArmedDeleteId(armed ? task.id : undefined) }}
+ mutate={mutate}
+ />
+ ))}
+
+ )}
+ {note !== undefined && {note}
}
+ {creating
+ ? (
+ { setCreating(false) }}
+ onSubmit={(subject, description) => void mutate(async () => {
+ const result = await api.teamsTaskCreate(rootId, { subject, description })
+ if (result.ok) setCreating(false)
+ return result
+ })}
+ />
+ )
+ : (
+
+ { setCreating(true) }}
+ >
+ {`+ ${t('teamTaskCreate')}`}
+
+
+ )}
+ >
+ )}
+
+ )
+}
+
+/** One task row: status dot, subject, owner select, status chip, actions. */
+function TeamTaskRow(props: {
+ rootId: string
+ task: SidebarTeamTaskView
+ teammates: readonly SidebarTeamMemberView[]
+ busy: boolean
+ editing: boolean
+ armedDelete: boolean
+ onEdit(edit: boolean): void
+ onArmDelete(armed: boolean): void
+ mutate(action: () => Promise): Promise
+}): ReactNode {
+ const { rootId, task, teammates, busy, editing, armedDelete, onEdit, onArmDelete, mutate } = props
+ if (editing) {
+ return (
+ { onEdit(false) }}
+ onSubmit={(subject, description) => void mutate(async () => {
+ const result = await api.teamsTaskUpdate(rootId, {
+ taskId: task.id,
+ expectedRevision: task.revision,
+ action: 'edit',
+ subject,
+ description,
+ })
+ if (result.ok) onEdit(false)
+ return result
+ })}
+ />
+ )
+ }
+ return (
+
+
+
+ {task.subject}
+
+ {
+ const owner = event.target.value
+ void mutate(() => api.teamsTaskUpdate(rootId, {
+ taskId: task.id,
+ expectedRevision: task.revision,
+ action: 'reassign',
+ ...(owner === '' ? {} : { owner }),
+ }))
+ }}
+ >
+ {t('teamTaskUnowned')}
+ {teammates.map(member => (
+ {member.name}
+ ))}
+
+
+ {t(task.ready ? taskStatusKey(task.status) : 'teamTaskBlocked')}
+
+
+ {task.status !== 'completed'
+ ? (
+ void mutate(() => api.teamsTaskUpdate(rootId, {
+ taskId: task.id, expectedRevision: task.revision, action: 'complete',
+ }))}
+ >
+ ✓
+
+ )
+ : (
+ void mutate(() => api.teamsTaskUpdate(rootId, {
+ taskId: task.id, expectedRevision: task.revision, action: 'reopen',
+ }))}
+ >
+ ↺
+
+ )}
+ { onEdit(true) }}
+ >
+ ✎
+
+ {
+ if (!armedDelete) { onArmDelete(true); return }
+ onArmDelete(false)
+ void mutate(() => api.teamsTaskUpdate(rootId, {
+ taskId: task.id, expectedRevision: task.revision, action: 'delete',
+ }))
+ }}
+ >
+ {armedDelete ? '!' : '✕'}
+
+
+
+ )
+}
+
+/** The create/edit task form (subject + description). */
+function TeamTaskForm(props: {
+ busy: boolean
+ initialSubject?: string
+ initialDescription?: string
+ submitLabel: string
+ onCancel(): void
+ onSubmit(subject: string, description: string): void
+}): ReactNode {
+ const [subject, setSubject] = useState(props.initialSubject ?? '')
+ const [description, setDescription] = useState(props.initialDescription ?? '')
+ return (
+
+ )
+}
diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts
index 0bfed7f..7a4a8b5 100644
--- a/src/client/locales-ar.ts
+++ b/src/client/locales-ar.ts
@@ -409,6 +409,8 @@ export const ar: Record = {
tasksZoomOut: 'تصغير',
tasksZoomFit: 'ملاءمة اللوحة',
tasksNodeJump: 'فتح النسخة',
+ tasksNodeDetail: 'تفاصيل العقدة',
+ workflowRun: 'سير العمل',
tasksNodeState: 'الحالة',
tasksNodeMode: 'الوضع',
tasksNodeModel: 'الطراز',
diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts
index c5551ef..8eeb8c6 100644
--- a/src/client/locales-de.ts
+++ b/src/client/locales-de.ts
@@ -394,6 +394,8 @@ export const de: Record = {
tasksZoomOut: 'Verkleinern',
tasksZoomFit: 'An die Canvas anpassen',
tasksNodeJump: 'Transkript öffnen',
+ tasksNodeDetail: 'Knotendetails',
+ workflowRun: 'Workflow',
tasksNodeState: 'Status',
tasksNodeMode: 'Modus',
tasksNodeModel: 'Modell',
diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts
index 5651144..fde88e4 100644
--- a/src/client/locales-fr.ts
+++ b/src/client/locales-fr.ts
@@ -401,6 +401,8 @@ export const fr: Record = {
tasksZoomOut: 'Zoom arrière',
tasksZoomFit: 'Ajuster au canevas',
tasksNodeJump: 'Ouvrir la transcription',
+ tasksNodeDetail: 'Détails du nœud',
+ workflowRun: 'Workflow',
tasksNodeState: 'État',
tasksNodeMode: 'Mode',
tasksNodeModel: 'Modèle',
diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts
index 81eccd8..4be6326 100644
--- a/src/client/locales-hi.ts
+++ b/src/client/locales-hi.ts
@@ -408,6 +408,8 @@ export const hi: Record = {
tasksZoomOut: 'ज़ूम आउट',
tasksZoomFit: 'कैनवास में फ़िट करें',
tasksNodeJump: 'ट्रांसक्रिप्ट खोलें',
+ tasksNodeDetail: 'नोड विवरण',
+ workflowRun: 'वर्कफ़्लो',
tasksNodeState: 'स्थिति',
tasksNodeMode: 'मोड',
tasksNodeModel: 'मॉडल',
diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts
index 907df5a..fef9e0c 100644
--- a/src/client/locales-id.ts
+++ b/src/client/locales-id.ts
@@ -406,6 +406,8 @@ export const id: Record = {
tasksZoomOut: 'Perkecil',
tasksZoomFit: 'Sesuaikan ke kanvas',
tasksNodeJump: 'Buka transkrip',
+ tasksNodeDetail: 'Detail node',
+ workflowRun: 'Alur kerja',
tasksNodeState: 'Status',
tasksNodeMode: 'Mode',
tasksNodeModel: 'Model',
diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts
index 1ec67ff..7c60605 100644
--- a/src/client/locales-it.ts
+++ b/src/client/locales-it.ts
@@ -399,6 +399,8 @@ export const it: Record = {
tasksZoomOut: 'Riduci',
tasksZoomFit: 'Adatta all’area di disegno',
tasksNodeJump: 'Apri trascrizione',
+ tasksNodeDetail: 'Dettagli del nodo',
+ workflowRun: 'Workflow',
tasksNodeState: 'Stato',
tasksNodeMode: 'Modalità',
tasksNodeModel: 'Modello',
diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts
index b995edc..0e1dc22 100644
--- a/src/client/locales-ja.ts
+++ b/src/client/locales-ja.ts
@@ -408,6 +408,8 @@ export const ja: Record = {
tasksZoomOut: '縮小',
tasksZoomFit: 'キャンバスに合わせる',
tasksNodeJump: 'トランスクリプトを開く',
+ tasksNodeDetail: 'ノード詳細',
+ workflowRun: 'ワークフロー',
tasksNodeState: '状態',
tasksNodeMode: 'モード',
tasksNodeModel: 'モデル',
diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts
index 9b8f686..27fff20 100644
--- a/src/client/locales-ko.ts
+++ b/src/client/locales-ko.ts
@@ -400,6 +400,8 @@ export const ko: Record = {
tasksZoomOut: '축소',
tasksZoomFit: '캔버스에 맞추기',
tasksNodeJump: '대화 기록 보기',
+ tasksNodeDetail: '노드 상세',
+ workflowRun: '워크플로',
tasksNodeState: '상태',
tasksNodeMode: '모드',
tasksNodeModel: '모델',
diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts
index 4f6cf74..de66c3a 100644
--- a/src/client/locales-nl.ts
+++ b/src/client/locales-nl.ts
@@ -406,6 +406,8 @@ export const nl: Record = {
tasksZoomOut: 'Uitzoomen',
tasksZoomFit: 'Passend maken',
tasksNodeJump: 'Transcript openen',
+ tasksNodeDetail: 'Knooppuntdetails',
+ workflowRun: 'Workflow',
tasksNodeState: 'Status',
tasksNodeMode: 'Modus',
tasksNodeModel: 'Model',
diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts
index 02342ba..884a017 100644
--- a/src/client/locales-pl.ts
+++ b/src/client/locales-pl.ts
@@ -410,6 +410,8 @@ export const pl: Record = {
tasksZoomOut: 'Oddal',
tasksZoomFit: 'Dopasuj do kanwy',
tasksNodeJump: 'Otwórz transkrypcję',
+ tasksNodeDetail: 'Szczegóły węzła',
+ workflowRun: 'Przepływ pracy',
tasksNodeState: 'Stan',
tasksNodeMode: 'Tryb',
tasksNodeModel: 'Model',
diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts
index 429b35c..d3e473a 100644
--- a/src/client/locales-pt.ts
+++ b/src/client/locales-pt.ts
@@ -391,6 +391,8 @@ export const pt: Record = {
tasksZoomOut: 'Afastar',
tasksZoomFit: 'Ajustar à tela',
tasksNodeJump: 'Abrir transcrição',
+ tasksNodeDetail: 'Detalhes do nó',
+ workflowRun: 'Workflow',
tasksNodeState: 'Estado',
tasksNodeMode: 'Modo',
tasksNodeModel: 'Modelo',
diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts
index 0a72d48..c272026 100644
--- a/src/client/locales-ru.ts
+++ b/src/client/locales-ru.ts
@@ -406,6 +406,8 @@ export const ru: Record = {
tasksZoomOut: 'Уменьшить',
tasksZoomFit: 'По размеру холста',
tasksNodeJump: 'Открыть транскрипт',
+ tasksNodeDetail: 'Сведения об узле',
+ workflowRun: 'Рабочий процесс',
tasksNodeState: 'Состояние',
tasksNodeMode: 'Режим',
tasksNodeModel: 'Модель',
diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts
index 3eacc99..cd1ed3e 100644
--- a/src/client/locales-sv.ts
+++ b/src/client/locales-sv.ts
@@ -391,6 +391,8 @@ export const sv: Record = {
tasksZoomOut: 'Zooma ut',
tasksZoomFit: 'Anpassa till ytan',
tasksNodeJump: 'Öppna transkription',
+ tasksNodeDetail: 'Noddetaljer',
+ workflowRun: 'Arbetsflöde',
tasksNodeState: 'Status',
tasksNodeMode: 'Läge',
tasksNodeModel: 'Modell',
diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts
index 028f5aa..0c548e5 100644
--- a/src/client/locales-th.ts
+++ b/src/client/locales-th.ts
@@ -408,6 +408,8 @@ export const th: Record = {
tasksZoomOut: 'ซูมออก',
tasksZoomFit: 'พอดีผืนผ้าใบ',
tasksNodeJump: 'เปิดบันทึกถอดความ',
+ tasksNodeDetail: 'รายละเอียดโหนด',
+ workflowRun: 'เวิร์กโฟลว์',
tasksNodeState: 'สถานะ',
tasksNodeMode: 'โหมด',
tasksNodeModel: 'โมเดล',
diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts
index ccbb3f9..824c864 100644
--- a/src/client/locales-tr.ts
+++ b/src/client/locales-tr.ts
@@ -408,6 +408,8 @@ export const tr: Record = {
tasksZoomOut: 'Uzaklaştır',
tasksZoomFit: 'Tuvale sığdır',
tasksNodeJump: 'Transkripti aç',
+ tasksNodeDetail: 'Düğüm ayrıntıları',
+ workflowRun: 'İş akışı',
tasksNodeState: 'Durum',
tasksNodeMode: 'Mod',
tasksNodeModel: 'Model',
diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts
index 49df667..b67901a 100644
--- a/src/client/locales-vi.ts
+++ b/src/client/locales-vi.ts
@@ -408,6 +408,8 @@ export const vi: Record = {
tasksZoomOut: 'Thu nhỏ',
tasksZoomFit: 'Vừa khung vẽ',
tasksNodeJump: 'Mở bản ghi hội thoại',
+ tasksNodeDetail: 'Chi tiết nút',
+ workflowRun: 'Quy trình',
tasksNodeState: 'Trạng thái',
tasksNodeMode: 'Chế độ',
tasksNodeModel: 'Mô hình',
diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts
index 209f5cc..d871c59 100644
--- a/src/client/locales-zh-HK.ts
+++ b/src/client/locales-zh-HK.ts
@@ -423,6 +423,8 @@ export const zhHK: Record = {
tasksZoomOut: '縮小',
tasksZoomFit: '適應畫布',
tasksNodeJump: '檢視逐字稿',
+ tasksNodeDetail: '節點詳情',
+ workflowRun: '工作流',
tasksNodeState: '狀態',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts
index 64a16b4..f273356 100644
--- a/src/client/locales-zh-MO.ts
+++ b/src/client/locales-zh-MO.ts
@@ -423,6 +423,8 @@ export const zhMO: Record = {
tasksZoomOut: '縮小',
tasksZoomFit: '適應畫布',
tasksNodeJump: '檢視逐字稿',
+ tasksNodeDetail: '節點詳情',
+ workflowRun: '工作流',
tasksNodeState: '狀態',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts
index b1fc1a5..fa83b93 100644
--- a/src/client/locales-zh-TW.ts
+++ b/src/client/locales-zh-TW.ts
@@ -423,6 +423,8 @@ export const zhTW: Record = {
tasksZoomOut: '縮小',
tasksZoomFit: '適應畫布',
tasksNodeJump: '檢視逐字稿',
+ tasksNodeDetail: '節點詳情',
+ workflowRun: '工作流',
tasksNodeState: '狀態',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
diff --git a/src/client/locales.ts b/src/client/locales.ts
index 4c2df74..df19e5a 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -420,6 +420,8 @@ export const zh = {
tasksZoomOut: '缩小',
tasksZoomFit: '适应画布',
tasksNodeJump: '查看转录',
+ tasksNodeDetail: '节点详情',
+ workflowRun: '工作流',
tasksNodeState: '状态',
tasksNodeMode: '模式',
tasksNodeModel: '模型',
@@ -913,6 +915,8 @@ export const en: Record = {
tasksZoomOut: 'Zoom out',
tasksZoomFit: 'Fit to canvas',
tasksNodeJump: 'Open transcript',
+ tasksNodeDetail: 'Node detail',
+ workflowRun: 'Workflow',
tasksNodeState: 'State',
tasksNodeMode: 'Mode',
tasksNodeModel: 'Model',
diff --git a/src/client/tasks-graph-layout.ts b/src/client/tasks-graph-layout.ts
index 6418ebf..a911b61 100644
--- a/src/client/tasks-graph-layout.ts
+++ b/src/client/tasks-graph-layout.ts
@@ -1,22 +1,29 @@
/**
- * The layered top-down layout of the Tasks graph canvas: a tidy-tree
- * simplification — every node sits one row below its parent, siblings share
- * a row in model order, and each parent is horizontally centered over its
- * children's band. Pure and dependency-free (the bundle-purity rule forbids
- * pulling a graph library into the core bundle; <100-node trees need no
- * virtualisation), unit-testable in the node environment.
+ * The layered top-down layout of the Tasks graph canvas: a tidy-tree pass
+ * with a NARROW-PANEL rule — a parent's children wrap into bands of at most
+ * {@link LayoutOptions.maxBandCols} columns, stacking extra rows below it.
+ * Without wrapping a five-child level is ~770px wide and the whole graph
+ * shrinks to ~40% inside the 360px native sidebar, which is unreadable; with
+ * wrapping the canvas keeps a ~1:1 scale and the reader pans far less.
+ *
+ * Pure and dependency-free (the bundle-purity rule forbids a graph library in
+ * the core bundle; <100-node trees need no virtualisation).
*/
import type { TasksNode } from './tasks-model.ts'
import { tasksEdges } from './tasks-model.ts'
-/** Geometry constants of the canvas (px, pre-scale). */
-export const GRAPH_NODE_W = 140
-export const GRAPH_NODE_H = 48
+/** Geometry constants of the canvas (px, pre-scale). Metrics follow the
+ * approved Variant-D mockup, whose design target is the NARROW native right
+ * sidebar (~360px): a 132px card fits two per row plus the gutter. */
+export const GRAPH_NODE_W = 132
+export const GRAPH_NODE_H = 46
/** Extra height of an agent node carrying a live line. */
-export const GRAPH_LIVE_H = 16
+export const GRAPH_LIVE_H = 13
export const GRAPH_GAP_X = 28
-export const GRAPH_GAP_Y = 72
+export const GRAPH_GAP_Y = 53
export const GRAPH_PAD = 16
+/** The row stride (one row of cards plus the vertical gutter). */
+export const GRAPH_ROW_STRIDE = GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
/** One laid-out node. */
export interface GraphBox {
@@ -34,22 +41,46 @@ export interface GraphLayout {
height: number
}
-/** The display height of one node (live lines grow agent rows). */
+/** Layout knobs. */
+export interface LayoutOptions {
+ /** Columns per sibling band before it wraps to the next row (default 4). */
+ maxBandCols?: number
+}
+
+/**
+ * The display height of one node. Every RUNNING agent reserves the live-line
+ * row: a running node without activity yet still renders the "thinking" line
+ * (LiveLine's fallback), so reserving only when data arrived would clip it.
+ */
function nodeHeight(node: TasksNode): number {
- if (node.kind === 'agent' && node.live !== undefined) return GRAPH_NODE_H + GRAPH_LIVE_H
+ if (node.kind === 'agent' && node.state === 'running') return GRAPH_NODE_H + GRAPH_LIVE_H
return GRAPH_NODE_H
}
/**
- * Lay out the model top-down. Nodes whose parent is missing (a fold node
- * whose parent scrolled out of the model — defensive) attach to the first
- * root. The algorithm is the classic two-walk tidy pass: a post-order walk
- * accumulates each subtree's width, a pre-order walk assigns x so every
- * parent centers over its children.
- * @param nodes - the model's pre-order node list (roots = parentless rows).
+ * Chunk one sibling band into rows of at most `cols` children. Counting (not
+ * width-packing) keeps the graph SHAPE: a level stays a row of siblings, and
+ * a nested subtree never forces its aunts into a ladder of one-child rows.
+ * Wide subtrees are handled by the fit scale instead.
*/
-export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
- const roots = nodes.filter(node => node.parentId === undefined || !nodes.some(candidate => candidate.id === node.parentId))
+function bands(children: readonly TasksNode[], cols: number): TasksNode[][] {
+ if (children.length <= cols) return [children.slice()]
+ const out: TasksNode[][] = []
+ for (let index = 0; index < children.length; index += cols) {
+ out.push(children.slice(index, index + cols))
+ }
+ return out
+}
+
+/**
+ * Lay out the model top-down, wrapping wide sibling bands.
+ * @param nodes - the model's pre-order node list (roots = parentless rows).
+ * @param options - see {@link LayoutOptions}.
+ */
+export function layoutTasksGraph(nodes: readonly TasksNode[], options: LayoutOptions = {}): GraphLayout {
+ const cols = Math.max(1, options.maxBandCols ?? 4)
+ const ids = new Set(nodes.map(node => node.id))
+ const roots = nodes.filter(node => node.parentId === undefined || !ids.has(node.parentId))
const childrenOf = new Map()
for (const edge of tasksEdges(nodes)) {
const child = nodes.find(node => node.id === edge.to)
@@ -63,33 +94,48 @@ export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const subtreeWidth = (node: TasksNode): number => {
const children = childrenOf.get(node.id) ?? []
if (children.length === 0) return GRAPH_NODE_W
- const band = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
- + GRAPH_GAP_X * (children.length - 1)
- return Math.max(GRAPH_NODE_W, band)
+ let widest = GRAPH_NODE_W
+ for (const band of bands(children, cols)) {
+ const width = band.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ + GRAPH_GAP_X * (band.length - 1)
+ widest = Math.max(widest, width)
+ }
+ return widest
}
const boxes = new Map()
- /** Pre-order: place `node` so its band centers on `centerX`; `depth` is
- * the row (the tallest ancestor row so far offsets uniformly). */
- const place = (node: TasksNode, centerX: number, depth: number): void => {
- const h = nodeHeight(node)
+ /**
+ * Place `node` centered on `centerX` at row `row`; returns the LAST row its
+ * subtree occupies. Wrapped bands advance to the row after the deepest row
+ * of the previous band, so a sibling's children can never land on top of
+ * the next band (the overlap a naive +1 counter produced).
+ */
+ const place = (node: TasksNode, centerX: number, row: number): number => {
boxes.set(node.id, {
id: node.id,
x: Math.round(centerX - GRAPH_NODE_W / 2),
- y: GRAPH_PAD + depth * (GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y),
+ y: GRAPH_PAD + row * GRAPH_ROW_STRIDE,
w: GRAPH_NODE_W,
- h,
+ h: nodeHeight(node),
})
const children = childrenOf.get(node.id) ?? []
- if (children.length === 0) return
- const total = children.reduce((sum, child) => sum + subtreeWidth(child), 0)
- + GRAPH_GAP_X * (children.length - 1)
- let cursor = centerX - total / 2
- for (const child of children) {
- const width = subtreeWidth(child)
- place(child, cursor + width / 2, depth + 1)
- cursor += width + GRAPH_GAP_X
+ if (children.length === 0) return row
+ let deepest = row
+ let bandRow = row + 1
+ for (const band of bands(children, cols)) {
+ const bandWidth = band.reduce((sum, child) => sum + subtreeWidth(child), 0)
+ + GRAPH_GAP_X * (band.length - 1)
+ let cursor = centerX - bandWidth / 2
+ let bandDeepest = bandRow
+ for (const child of band) {
+ const width = subtreeWidth(child)
+ bandDeepest = Math.max(bandDeepest, place(child, cursor + width / 2, bandRow))
+ cursor += width + GRAPH_GAP_X
+ }
+ deepest = Math.max(deepest, bandDeepest)
+ bandRow = bandDeepest + 1
}
+ return deepest
}
// Single-root forest in practice (the topology root); the loop stays so a
@@ -106,3 +152,48 @@ export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout {
const height = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_H, ...allBoxes.map(box => box.y + box.h + GRAPH_PAD))
return { boxes, width, height }
}
+
+/**
+ * How many sibling columns fit the given container width at ~1:1 scale. The
+ * canvas keeps its readability floor instead of shrinking: at 360px two cards
+ * per row, at 720px four.
+ */
+export function bandColsFor(containerWidth: number): number {
+ const usable = containerWidth - GRAPH_PAD * 2
+ const cols = Math.floor((usable + GRAPH_GAP_X) / (GRAPH_NODE_W + GRAPH_GAP_X))
+ return Math.min(4, Math.max(1, cols))
+}
+
+/**
+ * The layout for one container width: the WIDEST sibling arrangement whose
+ * canvas still fits `containerWidth / minScale` — i.e. the least-tall layout
+ * the reader can see without zooming below the readability floor. Wrapping
+ * more than needed turns the graph into a ladder; wrapping too little forces
+ * an unreadable scale, so the loop tries the widest first and narrows.
+ *
+ * @param nodes - the model's node list.
+ * @param containerWidth - the canvas viewport width in px.
+ * @param minScale - the readability floor the fit honours (default 0.78).
+ */
+export function layoutTasksGraphForWidth(
+ nodes: readonly TasksNode[],
+ containerWidth: number,
+ minScale = 0.78,
+): GraphLayout {
+ const budget = containerWidth <= 0 ? Number.POSITIVE_INFINITY : (containerWidth - 24) / minScale
+ const widest = bandColsFor(containerWidth <= 0 ? 360 : containerWidth)
+ // Pass 1: the least-tall arrangement that FITS the readability budget at
+ // two or more columns (below two the graph degenerates into a ladder).
+ for (let cols = widest; cols >= 2; cols -= 1) {
+ const layout = layoutTasksGraph(nodes, { maxBandCols: cols })
+ if (layout.width <= budget) return layout
+ }
+ // Pass 2: nothing flat fits — accept a slight horizontal overflow (the
+ // reader pans a little) rather than stacking every child vertically.
+ const tolerant = budget * 1.2
+ for (let cols = widest; cols >= 2; cols -= 1) {
+ const layout = layoutTasksGraph(nodes, { maxBandCols: cols })
+ if (layout.width <= tolerant) return layout
+ }
+ return layoutTasksGraph(nodes, { maxBandCols: 1 })
+}
diff --git a/src/client/tasks-graph.module.css b/src/client/tasks-graph.module.css
index 5bdfe2a..e2b97ba 100644
--- a/src/client/tasks-graph.module.css
+++ b/src/client/tasks-graph.module.css
@@ -1,8 +1,14 @@
/**
- * Styles of the Tasks page's graph canvas, tree rows, anchored popovers, and
- * the bottom jobs drawer. Skin contract: every color resolves to a
- * --dsw-alias-* / --dsw-font-* token (guarded by tests/theme.spec.ts); the
- * hard-projection shadows mix a border token so they follow the theme.
+ * Tasks page chrome — the approved Variant-D language: flat panels, 1px ink
+ * borders, hard 3px offset shadows, monospace micro-type with letter-spaced
+ * uppercase section labels, a dotted graph-paper canvas, and one accent used
+ * sparingly (current node, team/workflow relations, fold CTA).
+ *
+ * Design target: the native right sidebar at its NARROW width (~360px). Every
+ * block must be readable there — nothing may rely on horizontal room.
+ *
+ * Skin contract: every color resolves to a --dsw-* token (tests/theme.spec.ts
+ * guards `color:`); hard shadows mix a token so they follow the theme.
*/
/* ── Graph canvas ──────────────────────────────────────────────────────── */
@@ -11,6 +17,8 @@
position: relative;
flex: 1;
min-height: 0;
+ display: flex;
+ flex-direction: column;
overflow: hidden;
}
@@ -19,6 +27,12 @@
inset: 0;
cursor: grab;
touch-action: none;
+ /* Panning must never turn into a text selection. */
+ user-select: none;
+ /* Graph paper: the dotted grid is part of the language. */
+ background-image: radial-gradient(circle, var(--dsw-alias-border-l1) 0.6px, transparent 0.6px);
+ background-size: 16px 16px;
+ background-position: 0 0;
}
.canvasDragging {
@@ -42,68 +56,87 @@
.edge {
fill: none;
- stroke: var(--dsw-alias-border-l2);
- stroke-width: 1.5;
+ stroke: var(--dsw-alias-label-tertiary);
+ stroke-width: 1;
+ opacity: 0.55;
+}
+
+.edgeTeam {
+ stroke: var(--dsw-alias-state-business-primary);
+ stroke-dasharray: 3 3;
+ opacity: 0.85;
}
-.edgeTeam,
.edgeWorkflow {
- stroke: var(--dsw-alias-accent);
- stroke-opacity: 0.65;
+ stroke: var(--dsw-alias-state-business-primary);
+ opacity: 0.8;
}
.edgeFold {
- stroke-dasharray: 4 3;
+ stroke-dasharray: 1 4;
+ opacity: 0.4;
}
.phaseFrame {
position: absolute;
- border: 1px dashed var(--dsw-alias-border-l2);
- border-radius: 12px;
+ border: 1px dashed var(--dsw-alias-label-tertiary);
+ border-radius: 3px;
pointer-events: none;
}
.phaseLabel {
position: absolute;
- top: -9px;
- left: 10px;
- padding: 0 6px;
- background: var(--dsw-alias-bg-base);
- font: var(--dsw-font-xxxs-11);
+ top: -7px;
+ left: 8px;
+ padding: 0 5px;
+ background: var(--dsw-alias-bg-layer-1);
+ font-family: var(--ds-font-family-code);
+ font-size: 8.5px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
color: var(--dsw-alias-label-tertiary);
- border-radius: 6px;
}
-/* ── Graph nodes ───────────────────────────────────────────────────────── */
+/* ── Nodes ─────────────────────────────────────────────────────────────── */
.node {
position: absolute;
display: flex;
flex-direction: column;
- gap: 2px;
- padding: 7px 9px;
+ padding: 6px 8px;
background: var(--dsw-alias-bg-layer-1);
- border: 1px solid var(--dsw-alias-border-l1);
- border-radius: 10px;
- box-shadow: 3px 3px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 55%, transparent);
+ border: 1px solid var(--dsw-alias-label-primary);
+ border-radius: 3px;
cursor: pointer;
overflow: hidden;
+ transition: box-shadow 0.12s ease;
}
.node:hover {
- background: var(--dsw-alias-interactive-bg-hover);
-}
-
-.nodeRunning {
- border-color: var(--dsw-alias-accent);
+ box-shadow: 3px 3px 0 color-mix(in srgb, var(--dsw-alias-label-primary) 90%, transparent);
}
.nodeCurrent {
- box-shadow: 0 0 0 2px var(--dsw-alias-accent), 3px 3px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 55%, transparent);
+ box-shadow: 3px 3px 0 var(--dsw-alias-state-business-primary);
}
-.nodeDone {
- opacity: 0.68;
+.nodeLead {
+ border-width: 2px;
+}
+
+.nodeTeam {
+ border-color: var(--dsw-alias-state-business-primary);
+}
+
+.nodeWorkflow {
+ border-style: double;
+ border-width: 3px;
+ border-color: var(--dsw-alias-state-business-primary);
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 6%, var(--dsw-alias-bg-layer-1));
+}
+
+.nodeSettled {
+ opacity: 0.62;
}
.nodeError {
@@ -112,118 +145,99 @@
.nodeFold {
border-style: dashed;
- border-color: var(--dsw-alias-border-l2);
+ border-color: var(--dsw-alias-label-tertiary);
background: var(--dsw-alias-bg-base);
- box-shadow: none;
- align-items: flex-start;
- justify-content: center;
}
-.nodeWorkflow {
- border-color: var(--dsw-alias-accent);
+.nodeFold:hover {
+ border-color: var(--dsw-alias-label-primary);
+ box-shadow: none;
}
.nodeHeader {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 5px;
min-width: 0;
}
.nodeDot {
- flex-shrink: 0;
+ flex: none;
}
.nodeTitle {
- font: var(--dsw-font-xxxs-strong-11);
+ font-size: 11px;
+ line-height: 1.35;
+ font-weight: 600;
color: var(--dsw-alias-label-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-.nodeSecondary {
- font-size: var(--dsw-font-xxxs-11-font-size);
- line-height: var(--dsw-font-xxxs-11-line-height);
- color: var(--dsw-alias-label-tertiary);
+.nodeTitlePlain {
+ font-weight: 500;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.nodeMeta {
+ margin-top: 2px;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ line-height: 1.3;
+ color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-.nodeInfo {
- position: absolute;
- top: 4px;
- right: 4px;
- width: 16px;
- height: 16px;
- display: flex;
- align-items: center;
- justify-content: center;
- border: none;
- border-radius: 5px;
- background: transparent;
- color: var(--dsw-alias-label-tertiary);
- font-size: 10px;
- line-height: 1;
- cursor: pointer;
- opacity: 0;
- transition: opacity 0.12s ease;
-}
+/* ── Live activity row (icon + tool + args; shared by both modes) ──────── */
-.node:hover .nodeInfo,
-.nodeInfo:focus-visible {
- opacity: 1;
-}
-
-.nodeInfo:hover {
- background: var(--dsw-alias-interactive-bg-active);
- color: var(--dsw-alias-label-primary);
-}
-
-/* ── Live activity lines (icon + tool + args) ──────────────────────────── */
-
-.liveLine {
+.live {
display: flex;
align-items: center;
gap: 4px;
+ margin-top: 3px;
min-width: 0;
- font-size: var(--dsw-font-xxxs-11-font-size);
- line-height: var(--dsw-font-xxxs-11-line-height);
- color: var(--dsw-alias-label-secondary);
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ line-height: 1.3;
+ color: var(--dsw-alias-state-business-primary);
white-space: nowrap;
overflow: hidden;
}
.liveGlyph {
+ flex: none;
display: inline-flex;
- flex-shrink: 0;
- color: var(--dsw-alias-accent);
+ align-items: center;
+ justify-content: center;
+ width: 11px;
+ height: 11px;
+ border: 1px solid currentColor;
+ border-radius: 2px;
}
.liveTool {
- flex-shrink: 0;
- font-weight: 600;
- color: var(--dsw-alias-label-primary);
+ flex: none;
}
.liveArgs {
+ color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
- color: var(--dsw-alias-label-tertiary);
}
.liveText {
- display: block;
- font-size: var(--dsw-font-xxxs-11-font-size);
- line-height: var(--dsw-font-xxxs-11-line-height);
+ margin-top: 2px;
+ font-size: 9.5px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-/* ── Control cluster (bottom-right; visible in BOTH modes) ─────────────── */
+/* ── Control cluster: bottom-right, horizontal, visible in BOTH modes ──── */
.controls {
position: absolute;
@@ -231,40 +245,54 @@
bottom: 10px;
z-index: 6;
display: flex;
- flex-direction: column;
- gap: 1px;
- padding: 3px;
+ flex-direction: row;
+ align-items: center;
background: var(--dsw-alias-bg-layer-1);
- border: 1px solid var(--dsw-alias-border-l1);
- border-radius: 10px;
- box-shadow: 2px 2px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 45%, transparent);
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 3px;
}
.controlBtn {
- width: 26px;
- height: 26px;
- display: flex;
+ min-width: 24px;
+ height: 24px;
+ display: inline-flex;
align-items: center;
justify-content: center;
+ padding: 0 4px;
border: none;
- border-radius: 7px;
- background: transparent;
+ background: none;
color: var(--dsw-alias-label-secondary);
- font: var(--dsw-font-xxxs-strong-11);
+ font-family: var(--ds-font-family-code);
+ font-size: 12px;
+ line-height: 1;
cursor: pointer;
}
+.controlBtn + .controlBtn,
+.controlZoomLevel + .controlBtn,
+.controlBtn + .controlZoomLevel {
+ border-left: 1px solid var(--dsw-alias-border-l2);
+}
+
.controlBtn:hover {
- background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.controlBtnActive {
- color: var(--dsw-alias-accent);
+ color: var(--dsw-alias-state-business-primary);
}
-.controlPct {
+.controlMode {
font-size: 9px;
+ letter-spacing: 0.06em;
+ color: var(--dsw-alias-label-primary);
+}
+
+.controlZoomLevel {
+ padding: 0 5px;
+ font-family: var(--ds-font-family-code);
+ font-size: 8.5px;
+ color: var(--dsw-alias-label-tertiary);
}
/* ── Tree mode ─────────────────────────────────────────────────────────── */
@@ -273,17 +301,18 @@
flex: 1;
min-height: 0;
overflow-y: auto;
- padding: 4px 6px 12px;
+ padding: 8px 10px 44px;
+ scrollbar-width: thin;
+ user-select: none;
}
.treeRow {
position: relative;
display: flex;
align-items: flex-start;
- gap: 6px;
- padding: 5px 8px;
- margin-top: 2px;
- border-radius: 8px;
+ gap: 7px;
+ padding: 4px 8px;
+ border-radius: 3px;
cursor: pointer;
}
@@ -292,16 +321,23 @@
}
.treeRow:focus-visible {
- outline: 2px solid var(--dsw-alias-accent);
+ outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: -2px;
}
.treeRowActive {
- background: var(--dsw-alias-interactive-bg-active);
+ background: var(--dsw-alias-interactive-bg-hover);
+ box-shadow: inset 2px 0 0 var(--dsw-alias-state-business-primary);
}
-.treeRowDone {
- opacity: 0.68;
+.treeRowSettled {
+ opacity: 0.62;
+}
+
+.treeKids {
+ margin-left: 15px;
+ padding-left: 10px;
+ border-left: 1px solid var(--dsw-alias-border-l4);
}
.treeContent {
@@ -312,155 +348,205 @@
gap: 1px;
}
-.treeFold {
- border: 1px dashed var(--dsw-alias-border-l2);
- color: var(--dsw-alias-label-secondary);
- font: var(--dsw-font-xxxs-11);
+.treeTitle {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ line-height: 1.35;
+ font-weight: 500;
+ color: var(--dsw-alias-label-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-.treeFold:hover {
- color: var(--dsw-alias-label-primary);
+.treeMeta {
+ font-family: var(--ds-font-family-code);
+ font-size: 9.5px;
+ line-height: 1.3;
+ color: var(--dsw-alias-label-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.treeFoldRow {
+ border: 1px dashed var(--dsw-alias-label-tertiary);
+ border-radius: 3px;
+ margin: 2px 0;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.treeFoldRow:hover {
+ border-color: var(--dsw-alias-label-primary);
}
.treeInfo {
- flex-shrink: 0;
- width: 18px;
- height: 18px;
+ flex: none;
+ width: 16px;
+ height: 16px;
display: flex;
align-items: center;
justify-content: center;
- border: none;
- border-radius: 5px;
- background: transparent;
+ border: 1px solid var(--dsw-alias-border-l1);
+ border-radius: 3px;
+ background: none;
color: var(--dsw-alias-label-tertiary);
- font-size: 10px;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
line-height: 1;
cursor: pointer;
- opacity: 0;
- transition: opacity 0.12s ease;
+ opacity: 0.7;
}
.treeRow:hover .treeInfo,
+.node:hover .treeInfo,
.treeInfo:focus-visible {
opacity: 1;
-}
-
-.treeInfo:hover {
- background: var(--dsw-alias-interactive-bg-active);
color: var(--dsw-alias-label-primary);
}
-/* ── Anchored popover card ─────────────────────────────────────────────── */
+.treeInfo:hover {
+ border-color: var(--dsw-alias-label-primary);
+ color: var(--dsw-alias-label-primary);
+}
+
+/* ── Popover card ──────────────────────────────────────────────────────── */
.popCard {
display: flex;
flex-direction: column;
- gap: 6px;
- padding: 10px;
- background: var(--dsw-alias-bg-layer-2);
- border: 1px solid var(--dsw-alias-border-l1);
- border-radius: 12px;
- box-shadow: 4px 4px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 55%, transparent);
- font: var(--dsw-font-xxxs-11);
+ gap: 4px;
+ padding: 10px 12px;
+ background: var(--dsw-alias-bg-layer-1);
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 4px;
+ box-shadow:
+ 3px 3px 0 color-mix(in srgb, var(--dsw-alias-label-primary) 16%, transparent),
+ 0 16px 40px -12px color-mix(in srgb, var(--dsw-alias-label-primary) 25%, transparent);
+ font-size: 11px;
color: var(--dsw-alias-label-primary);
+ box-sizing: border-box;
+}
+
+.popHead {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--dsw-alias-label-tertiary);
}
.popTitle {
- font: var(--dsw-font-xxxs-strong-11);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-transform: none;
+ letter-spacing: 0;
+ font-size: 11px;
+ font-weight: 600;
color: var(--dsw-alias-label-primary);
+}
+
+.popRows {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 2px 10px;
+ align-items: baseline;
+}
+
+.popKey {
+ font-size: 10px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.popValue {
+ text-align: right;
+ font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.popRow {
- display: flex;
- gap: 8px;
- align-items: baseline;
- min-width: 0;
-}
-
-.popKey {
- flex-shrink: 0;
- color: var(--dsw-alias-label-tertiary);
-}
-
-.popValue {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- color: var(--dsw-alias-label-primary);
-}
-
-.popSection {
- margin-top: 2px;
- padding-top: 6px;
- border-top: 1px solid var(--dsw-alias-border-l1);
+.popValueMono {
+ font-family: var(--ds-font-family-code);
+ font-size: 10px;
}
.popPrimary {
- align-self: stretch;
- padding: 5px 8px;
- border: none;
- border-radius: 8px;
- background: var(--dsw-alias-button-primary-fill);
- color: var(--dsw-alias-accent-ink);
- font: var(--dsw-font-xxxs-strong-11);
+ width: 100%;
+ margin-top: 8px;
+ padding: 5px;
+ border: 1px solid var(--dsw-alias-label-primary);
+ border-radius: 2px;
+ background: var(--dsw-alias-label-primary);
+ color: var(--dsw-alias-bg-layer-1);
+ font-family: var(--ds-font-family-code);
+ font-size: 10px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
cursor: pointer;
}
.popPrimary:hover {
- background: var(--dsw-alias-button-primary-hover);
+ background: var(--dsw-alias-state-business-primary);
+ border-color: var(--dsw-alias-state-business-primary);
+ color: var(--dsw-alias-label-primary-foreground);
}
-.popGhost {
- padding: 3px 8px;
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 7px;
- background: transparent;
+.popPre {
+ margin: 6px 0 0;
+ padding: 8px;
+ max-height: 140px;
+ overflow: auto;
+ background: var(--dsw-alias-bg-base);
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 3px;
+ font-family: var(--ds-font-family-code);
+ font-size: 10px;
+ line-height: 1.55;
color: var(--dsw-alias-label-secondary);
- font: var(--dsw-font-xxxs-11);
- cursor: pointer;
+ white-space: pre-wrap;
+ word-break: break-word;
}
-.popGhost:hover {
- background: var(--dsw-alias-interactive-bg-hover);
- color: var(--dsw-alias-label-primary);
-}
-
-.popGhostDanger {
- color: var(--dsw-alias-state-error-primary);
- border-color: var(--dsw-alias-state-error-primary);
-}
-
-.popActions {
- display: flex;
- gap: 6px;
- flex-wrap: wrap;
-}
-
-.popError {
- color: var(--dsw-alias-state-error-primary);
-}
-
-.popNote {
- color: var(--dsw-alias-state-warn-label);
+/* The workflow popover's phase/member list. */
+.popSection {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid var(--dsw-alias-border-l4);
}
.popList {
display: flex;
flex-direction: column;
- gap: 2px;
- max-height: 220px;
+ gap: 1px;
+ max-height: 180px;
overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+.popGroup {
+ padding: 4px 2px 2px;
+ font-family: var(--ds-font-family-code);
+ font-size: 8.5px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--dsw-alias-label-tertiary);
}
.popListRow {
display: flex;
align-items: center;
gap: 6px;
- padding: 4px 6px;
- border-radius: 7px;
+ padding: 3px 4px;
+ border-radius: 2px;
cursor: pointer;
}
@@ -468,90 +554,264 @@
background: var(--dsw-alias-interactive-bg-hover);
}
-.popInput,
-.popSelect {
- width: 100%;
- padding: 4px 7px;
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 7px;
- background: var(--dsw-alias-bg-base);
+.popListLabel {
+ flex: 1;
+ min-width: 0;
+ font-size: 11px;
color: var(--dsw-alias-label-primary);
- font: var(--dsw-font-xxxs-11);
- box-sizing: border-box;
-}
-
-.popInput:focus,
-.popSelect:focus {
- outline: none;
- border-color: var(--dsw-alias-accent);
-}
-
-.popPre {
- margin: 0;
- max-height: 180px;
- overflow: auto;
- padding: 6px 8px;
- background: var(--dsw-alias-bg-base);
- border: 1px solid var(--dsw-alias-border-l1);
- border-radius: 8px;
- font-family: var(--dsw-font-s-14);
- font-size: var(--dsw-font-xxxs-11-font-size);
- line-height: var(--dsw-font-xxxs-11-line-height);
- color: var(--dsw-alias-label-secondary);
- white-space: pre-wrap;
- word-break: break-all;
-}
-
-.popHint {
- color: var(--dsw-alias-label-tertiary);
-}
-
-/* ── Team chip in the header ───────────────────────────────────────────── */
-
-.teamChip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 8px;
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 999px;
- background: var(--dsw-alias-bg-layer-1);
- color: var(--dsw-alias-label-secondary);
- font: var(--dsw-font-xxxs-11);
- cursor: pointer;
+ overflow: hidden;
+ text-overflow: ellipsis;
white-space: nowrap;
}
-.teamChip:hover {
- background: var(--dsw-alias-interactive-bg-hover);
+.popHint {
+ font-size: 10px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.popError {
+ font-size: 10px;
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.popNote {
+ font-size: 10px;
+ color: var(--dsw-alias-state-warn-primary);
+}
+
+/* ── Team board (always-visible strip under the header) ────────────────── */
+
+.teamBoard {
+ flex: none;
+ border-bottom: 1px solid var(--dsw-alias-border-l4);
+ background: var(--dsw-alias-bg-layer-1);
+}
+
+.teamBoardBar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 12px 4px;
+ border: none;
+ background: none;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--dsw-alias-label-secondary);
+ cursor: pointer;
+ text-align: left;
+}
+
+.teamBoardBar:hover {
color: var(--dsw-alias-label-primary);
}
+.teamBoardCount {
+ letter-spacing: 0.02em;
+ text-transform: none;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.teamBoardChev {
+ margin-left: auto;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.teamMembers {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 0 12px 6px;
+}
+
+.teamMember {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 1px 6px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 2px;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ color: var(--dsw-alias-label-secondary);
+ max-width: 100%;
+ overflow: hidden;
+}
+
+.teamMemberLead {
+ border-color: var(--dsw-alias-state-business-primary);
+ color: var(--dsw-alias-label-primary);
+}
+
+.teamMemberName {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.teamTasks {
+ max-height: 84px;
+ overflow-y: auto;
+ padding: 0 8px 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ scrollbar-width: thin;
+}
+
+.teamTask {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ padding: 3px 5px;
+ border-radius: 3px;
+}
+
+.teamTask:hover {
+ background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.teamTaskSubject {
+ flex: 1;
+ min-width: 0;
+ font-size: 10px;
+ color: var(--dsw-alias-label-primary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.teamTaskOwner {
+ flex: none;
+ max-width: 72px;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ color: var(--dsw-alias-label-tertiary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.teamTaskStatus {
+ flex: none;
+ padding: 0 4px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 2px;
+ font-family: var(--ds-font-family-code);
+ font-size: 8.5px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.teamTaskBlocked {
+ border-color: var(--dsw-alias-state-warn-primary);
+ color: var(--dsw-alias-state-warn-primary);
+}
+
+.teamActions {
+ flex: none;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.teamBtn {
+ min-width: 18px;
+ height: 18px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 3px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 2px;
+ background: none;
+ color: var(--dsw-alias-label-tertiary);
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.teamBtn:hover {
+ border-color: var(--dsw-alias-label-primary);
+ color: var(--dsw-alias-label-primary);
+}
+
+.teamBtnDanger {
+ border-color: var(--dsw-alias-state-error-primary);
+ color: var(--dsw-alias-state-error-primary);
+}
+
+.teamOwnerSelect {
+ flex: none;
+ max-width: 76px;
+ padding: 1px 2px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 2px;
+ background: var(--dsw-alias-bg-base);
+ color: var(--dsw-alias-label-secondary);
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+}
+
+.teamForm {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 2px 12px 6px;
+}
+
+.teamInput {
+ width: 100%;
+ padding: 4px 6px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 2px;
+ background: var(--dsw-alias-bg-base);
+ color: var(--dsw-alias-label-primary);
+ font-size: 11px;
+ box-sizing: border-box;
+}
+
+.teamInput:focus {
+ outline: none;
+ border-color: var(--dsw-alias-state-business-primary);
+}
+
+.teamFormActions {
+ display: flex;
+ gap: 6px;
+}
+
+.teamEmpty {
+ padding: 2px 12px 6px;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
/* ── Jobs drawer ───────────────────────────────────────────────────────── */
.jobsDrawer {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 5;
- display: flex;
- flex-direction: column;
+ flex: none;
+ border-top: 1px solid var(--dsw-alias-border-l4);
background: var(--dsw-alias-bg-layer-1);
- border-top: 1px solid var(--dsw-alias-border-l1);
- box-shadow: 0 -3px 0 color-mix(in srgb, var(--dsw-alias-border-l1) 35%, transparent);
+ z-index: 5;
}
.jobsDrawerBar {
display: flex;
align-items: center;
- gap: 6px;
+ gap: 8px;
width: 100%;
- padding: 7px 10px;
+ padding: 7px 12px;
border: none;
- background: transparent;
+ background: none;
+ font-family: var(--ds-font-family-code);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
color: var(--dsw-alias-label-secondary);
- font: var(--dsw-font-xxxs-strong-11);
cursor: pointer;
text-align: left;
}
@@ -560,125 +820,155 @@
color: var(--dsw-alias-label-primary);
}
+.jobsBigNum {
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--dsw-alias-state-business-primary);
+}
+
.jobsDrawerCount {
+ letter-spacing: 0.02em;
+ text-transform: none;
color: var(--dsw-alias-label-tertiary);
- font: var(--dsw-font-xxxs-11);
}
-.jobsDrawerAuto {
- margin-left: auto;
- color: var(--dsw-alias-label-tertiary);
- font: var(--dsw-font-xxxs-11);
-}
-
-.jobsDrawerChevron {
+.jobsDrawerChev {
margin-left: auto;
display: inline-flex;
- transition: transform 0.15s ease;
+ color: var(--dsw-alias-label-tertiary);
+ transition: transform 0.18s ease;
}
-.jobsDrawerChevronOpen {
+.jobsDrawerChevOpen {
transform: rotate(180deg);
}
+.jobsAutoNote {
+ padding: 0 12px 8px;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.jobsAutoNoteAccent {
+ color: var(--dsw-alias-state-business-primary);
+}
+
.jobsDrawerBody {
- max-height: 42%;
+ max-height: 156px;
overflow-y: auto;
- padding: 0 6px 8px;
+ border-top: 1px dashed var(--dsw-alias-border-l4);
+ padding: 4px 8px 8px;
display: flex;
flex-direction: column;
gap: 2px;
+ scrollbar-width: thin;
}
.jobsRow {
display: flex;
- align-items: flex-start;
- gap: 4px;
+ align-items: center;
+ gap: 7px;
+ padding: 4px 6px;
+ border-radius: 3px;
+}
+
+.jobsRowSettled {
+ opacity: 0.62;
}
.jobsRowMain {
flex: 1;
min-width: 0;
display: flex;
- align-items: flex-start;
- gap: 6px;
- padding: 5px 6px;
+ align-items: center;
+ gap: 7px;
+ padding: 0;
border: none;
- border-radius: 8px;
- background: transparent;
- color: var(--dsw-alias-label-primary);
- font: var(--dsw-font-xxxs-11);
+ background: none;
+ color: inherit;
cursor: pointer;
text-align: left;
}
-.jobsRowMain:hover {
+.jobsRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
-.jobsRowSettled {
- opacity: 0.72;
-}
-
-.jobsContent {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 1px;
-}
-
-.jobsLabelLine {
- display: flex;
- align-items: baseline;
- gap: 6px;
- min-width: 0;
-}
-
.jobsKind {
- flex-shrink: 0;
+ flex: none;
+ padding: 0 3px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 2px;
+ font-family: var(--ds-font-family-code);
+ font-size: 8.5px;
color: var(--dsw-alias-label-tertiary);
}
.jobsLabel {
+ flex: 1;
+ min-width: 0;
+ font-family: var(--ds-font-family-code);
+ font-size: 11px;
+ color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- color: var(--dsw-alias-label-primary);
}
-.jobsSecondary {
- color: var(--dsw-alias-label-tertiary);
- overflow: hidden;
- text-overflow: ellipsis;
+.jobsMeta {
+ flex: none;
+ font-size: 9.5px;
+ color: var(--dsw-alias-label-secondary);
white-space: nowrap;
}
.jobsKill {
- flex-shrink: 0;
- margin-top: 4px;
- padding: 2px 6px;
- border: 1px solid var(--dsw-alias-border-l2);
- border-radius: 6px;
- background: transparent;
+ flex: none;
+ padding: 1px 5px;
+ border: 1px solid var(--dsw-alias-border-l4);
+ border-radius: 2px;
+ background: none;
color: var(--dsw-alias-label-tertiary);
- font: var(--dsw-font-xxxs-11);
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
cursor: pointer;
}
.jobsKill:hover {
- color: var(--dsw-alias-state-error-primary);
- border-color: var(--dsw-alias-state-error-primary);
+ border-color: var(--dsw-alias-label-primary);
+ color: var(--dsw-alias-label-primary);
}
.jobsKillArmed {
- color: var(--dsw-alias-state-error-primary);
border-color: var(--dsw-alias-state-error-primary);
+ color: var(--dsw-alias-state-error-primary);
}
.jobsKillError {
- flex-shrink: 0;
- margin-top: 4px;
+ flex: none;
+ font-family: var(--ds-font-family-code);
+ font-size: 9px;
color: var(--dsw-alias-state-error-primary);
- font: var(--dsw-font-xxxs-11);
+}
+
+/* ── Page-level empty / loading hints ──────────────────────────────────── */
+
+.viewEmpty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--dsw-alias-label-secondary);
+}
+
+.viewEmptyHint {
+ font-size: 11px;
+ color: var(--dsw-alias-label-tertiary);
+ text-align: center;
+ padding: 0 24px;
}
diff --git a/src/client/tasks-shared.tsx b/src/client/tasks-shared.tsx
index 8a2e5d8..f62b2bf 100644
--- a/src/client/tasks-shared.tsx
+++ b/src/client/tasks-shared.tsx
@@ -1,20 +1,29 @@
/**
- * Shared presentation bits of the Tasks page's two modes (graph + tree):
- * the state-dot mapping, the secondary card line, the iconified live
- * activity row ("glyph + tool + args" — the user-approved format replacing
- * the old `>read[file]` text), and the fold aggregate caption. Kept in one
- * module so graph and tree can never drift apart visually.
+ * Shared presentation bits of the Tasks page's two modes (graph + tree): the
+ * state-dot mapping, the SHORT mono meta line (one or two tokens — the card
+ * is 132px wide in the narrow native sidebar, so anything longer moves into
+ * the ⓘ popover), the iconified live activity row ("glyph + tool + args",
+ * the approved format), and the fold aggregate caption.
+ *
+ * Card content contract (the page's answer to "everything is ellipsized"):
+ * line 1 dot + name (11px, semibold, one line)
+ * line 2 mode/model · activity (9px mono, one line)
+ * line 3 live line (running nodes only)
+ * Everything else — display title, full model id, team role, phase names,
+ * latest output text, jump action — lives in the anchored popover.
*/
import type { ReactNode } from 'react'
import { StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
import type { LastActivity } from '../subagent-activity.ts'
-import type { TasksAgentNode, TasksNodeState } from './tasks-model.ts'
+import type { TasksAgentNode, TasksNodeState, TasksWorkflowNode } from './tasks-model.ts'
import { toolGlyph } from './tool-icons.tsx'
-import { t } from './locales.ts'
+import { t, type CopyKey } from './locales.ts'
import css from './tasks-graph.module.css'
/** Preview cap of one tool-call argument line. */
-const ARGS_PREVIEW = 60
+const ARGS_PREVIEW = 42
+/** Preview cap of one text line. */
+const TEXT_PREVIEW = 72
/** The host StateDot semantic of a node display state. */
export function nodeDotState(state: TasksNodeState): StateDotState {
@@ -26,6 +35,16 @@ export function nodeDotState(state: TasksNodeState): StateDotState {
}
}
+/** The short state word of a node (the meta line's second token). */
+export function stateLabel(state: TasksNodeState): string {
+ const key: CopyKey = state === 'running'
+ ? 'tasksStateRunning'
+ : state === 'idle'
+ ? 'subagentInactive'
+ : state === 'error' ? 'tasksStateError' : 'tasksStateDone'
+ return t(key)
+}
+
/** First `limit` characters with an ellipsis when truncated. */
export function preview(text: string, limit: number = ARGS_PREVIEW): string {
return text.length > limit ? `${text.slice(0, limit)}…` : text
@@ -37,46 +56,68 @@ export function flatten(text: string): string {
}
/**
- * The secondary line of an agent node: display title (when it differs from
- * the durable label) · mode · activity · teammate model. Empty parts drop
- * out. Synthesized workflow members show their origin instead of a mode.
+ * The mono meta line of an agent card — at most two short tokens:
+ * - the topology root: 主代理 · 状态
+ * - a teammate with a known model: 模型 · 状态
+ * - any other agent: 模式 · 状态
*/
-export function agentSecondary(node: TasksAgentNode): string {
- const parts: string[] = []
- if (node.title !== undefined && node.title !== node.label) parts.push(node.title)
+export function agentMeta(node: TasksAgentNode): string {
+ const state = stateLabel(node.state)
+ if (node.parentId === undefined) return `${t('subagentMainAgent')} · ${state}`
+ if (node.team?.role === 'teammate' && node.team.model !== undefined && node.team.model !== '') {
+ return `${node.team.model} · ${state}`
+ }
if (node.mode !== undefined) {
- parts.push(node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'))
- } else if (node.synthesized === true) {
- parts.push(t('tasksWorkflowMember'))
+ return `${node.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable')} · ${state}`
+ }
+ return state
+}
+
+/** The mono meta line of a workflow run card: status · member tally. */
+export function workflowMeta(node: TasksWorkflowNode): string {
+ const { run } = node
+ let done = 0
+ let total = 0
+ for (const phase of run.phases) {
+ for (const member of phase.members) {
+ total += 1
+ if (member.outcome !== undefined) done += 1
+ }
+ }
+ return `${t(workflowStatusKey(run.status))} · ${t('workflowMembers', { done, total })}`
+}
+
+/** The workflow run status label key. */
+export function workflowStatusKey(status: TasksWorkflowNode['run']['status']): CopyKey {
+ switch (status) {
+ case 'running': return 'workflowRunning'
+ case 'completed': return 'workflowCompleted'
+ case 'cancelled': return 'workflowCancelled'
+ case 'failed': return 'workflowFailed'
}
- parts.push(node.activity === 'running' ? t('subagentRunning') : t('subagentInactive'))
- if (node.team?.model !== undefined) parts.push(node.team.model)
- return parts.join(' · ')
}
/**
- * The live activity row of a RUNNING agent node: the tool line renders as
- * "glyph + tool + args" (the approved live-line format), plus the flattened
- * last text line underneath. A running node with neither reads "thinking…".
+ * The live activity row of a RUNNING agent: "glyph + tool + args" (the
+ * approved format), plus the flattened last text line underneath. A running
+ * node with neither reads as thinking.
*/
export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
const { live } = props
if (live?.text === undefined && live?.tool === undefined) {
- return {t('subagentThinking')}
+ return {t('subagentThinking')}
}
return (
<>
{live.tool !== undefined && (
-
- {toolGlyph(live.tool.name)(10)}
+
+ {toolGlyph(live.tool.name)(8)}
{live.tool.name}
- {live.tool.args !== '' && (
- {preview(live.tool.args)}
- )}
+ {live.tool.args !== '' && {preview(live.tool.args)} }
)}
{live.text !== undefined && (
- {flatten(live.text)}
+ {preview(flatten(live.text), TEXT_PREVIEW)}
)}
>
)
@@ -84,5 +125,5 @@ export function LiveLine(props: { live: LastActivity | undefined }): ReactNode {
/** The fold aggregate's subtitle: up to two label previews joined by ·. */
export function foldPreviews(previews: readonly string[]): string {
- return previews.join(' · ')
+ return previews.join(' / ')
}
diff --git a/tests/tasks-graph-layout.spec.ts b/tests/tasks-graph-layout.spec.ts
index b05953a..f79f20f 100644
--- a/tests/tasks-graph-layout.spec.ts
+++ b/tests/tasks-graph-layout.spec.ts
@@ -1,6 +1,8 @@
/**
- * Unit tests for the layered graph layout (layoutTasksGraph): parent
- * centering over children bands, depth rows, and live-line heights.
+ * Unit tests for the layered graph layout: parent centering, depth rows, the
+ * sibling-band WRAP that keeps a narrow sidebar readable (and the row
+ * reservation that stops a wrapped band from landing on a sibling's
+ * children), the live-line height, and the width-aware solver.
*/
import { describe, expect, it } from 'vitest'
import {
@@ -9,7 +11,10 @@ import {
GRAPH_NODE_H,
GRAPH_NODE_W,
GRAPH_PAD,
+ GRAPH_ROW_STRIDE,
+ bandColsFor,
layoutTasksGraph,
+ layoutTasksGraphForWidth,
} from '../src/client/tasks-graph-layout.ts'
import type { TasksAgentNode, TasksNode } from '../src/client/tasks-model.ts'
@@ -22,14 +27,21 @@ function agent(id: string, parentId?: string, live = false): TasksAgentNode {
}
}
+/** The row index of one laid-out node. */
+function layoutRow(layout: ReturnType, id: string): number {
+ const box = layout.boxes.get(id)
+ if (box === undefined) throw new Error(`no box for ${id}`)
+ return (box.y - GRAPH_PAD) / GRAPH_ROW_STRIDE
+}
+
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)
- expect(layout.boxes.get('root')).toMatchObject({ y: GRAPH_PAD, h: GRAPH_NODE_H })
- const rowY = GRAPH_PAD + GRAPH_NODE_H + GRAPH_LIVE_H + GRAPH_GAP_Y
- expect(layout.boxes.get('a')?.y).toBe(rowY)
- expect(layout.boxes.get('b')?.y).toBe(rowY)
+ // 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', () => {
@@ -39,11 +51,11 @@ describe('layoutTasksGraph', () => {
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)
+ const bandCenter = (a!.x + b!.x + GRAPH_NODE_W) / 2
expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5)
})
- it('widens the canvas for deep/wide forests and grows live rows', () => {
+ 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'),
@@ -60,4 +72,53 @@ describe('layoutTasksGraph', () => {
expect(layout.boxes.get('root')).toBeDefined()
expect(layout.boxes.get('ghost')).toBeDefined()
})
+
+ it('wraps a wide sibling band into rows of maxBandCols', () => {
+ const nodes: TasksNode[] = [agent('root')]
+ for (let index = 0; index < 5; index += 1) nodes.push(agent(`c${index}`, 'root'))
+ const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
+ // Three wrapped rows of 2/2/1, never five on one line.
+ expect([layoutRow(layout, 'c0'), layoutRow(layout, 'c1')]).toEqual([1, 1])
+ expect([layoutRow(layout, 'c2'), layoutRow(layout, 'c3')]).toEqual([2, 2])
+ expect(layoutRow(layout, 'c4')).toBe(3)
+ // The canvas stays as narrow as one wrapped band.
+ expect(layout.width).toBeLessThanOrEqual(2 * GRAPH_NODE_W + 28 + 2 * GRAPH_PAD)
+ })
+
+ it('never lets a wrapped band land on a sibling subtree row', () => {
+ // root → [a (with a child), b, c, d] wrapped at 2 columns: the second
+ // band must start BELOW a's child, not on the same row as it.
+ const nodes: TasksNode[] = [
+ agent('root'), agent('a', 'root'), agent('b', 'root'),
+ agent('c', 'root'), agent('d', 'root'), agent('a1', 'a'),
+ ]
+ const layout = layoutTasksGraph(nodes, { maxBandCols: 2 })
+ expect(layoutRow(layout, 'c')).toBeGreaterThan(layoutRow(layout, 'a1'))
+ expect(layoutRow(layout, 'd')).toBe(layoutRow(layout, 'c'))
+ })
+})
+
+describe('bandColsFor / layoutTasksGraphForWidth', () => {
+ it('derives the column count from the container width', () => {
+ expect(bandColsFor(360)).toBe(2)
+ expect(bandColsFor(720)).toBe(4)
+ expect(bandColsFor(120)).toBe(1)
+ })
+
+ it('prefers a flat band when the container is wide', () => {
+ const nodes: TasksNode[] = [agent('root')]
+ for (let index = 0; index < 4; index += 1) nodes.push(agent(`c${index}`, 'root'))
+ const wide = layoutTasksGraphForWidth(nodes, 720)
+ expect(['c0', 'c1', 'c2', 'c3'].map(id => layoutRow(wide, id))).toEqual([1, 1, 1, 1])
+ })
+
+ it('wraps instead of overflowing wildly when the container is narrow', () => {
+ const nodes: TasksNode[] = [agent('root')]
+ for (let index = 0; index < 6; index += 1) nodes.push(agent(`c${index}`, 'root'))
+ const narrow = layoutTasksGraphForWidth(nodes, 360)
+ // Never wider than the tolerant budget (a small pan is allowed, a 3x
+ // overflow is not), and never a one-child-per-row ladder.
+ expect(narrow.width).toBeLessThanOrEqual(((360 - 24) / 0.78) * 1.2)
+ expect(layoutRow(narrow, 'c1')).toBe(layoutRow(narrow, 'c0'))
+ })
})
diff --git a/tests/tasks-page.spec.tsx b/tests/tasks-page.spec.tsx
index fe54794..42ff0d1 100644
--- a/tests/tasks-page.spec.tsx
+++ b/tests/tasks-page.spec.tsx
@@ -53,12 +53,15 @@ function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
+let teamPayload: unknown = { available: false }
+
beforeEach(() => {
+ teamPayload = { available: false }
vi.stubGlobal('fetch', async (url: string | URL | Request) => {
const method = String(url).split('/').pop()
if (method === 'subagents.live') return jsonResponse({ ok: true, value: { live: {} } })
if (method === 'workflows.list') return jsonResponse({ ok: true, value: { runs: [] } })
- if (method === 'teams.view') return jsonResponse({ ok: true, value: { available: false } })
+ if (method === 'teams.view') return jsonResponse({ ok: true, value: teamPayload })
if (method === 'jobs.output') return jsonResponse({ ok: true, value: { text: 'out', truncated: false, read: true } })
throw new Error(`unexpected fetch ${String(url)}`)
})
@@ -183,3 +186,74 @@ describe('Tasks page interactions', () => {
unmount()
})
})
+
+describe('Tasks page graph interactions and team board', () => {
+ it('activates a graph node after a background pointerdown (no click theft)', async () => {
+ const opened: unknown[] = []
+ const store = makeStore(snapshotWithChildren(1))
+ const ctx = makeCtx(store, { openSubagent: (address) => { opened.push(address) } })
+ const { container, unmount } = renderRoot(
+ createElement(SubagentView, { sessionId: 'root', active: true, ctx }),
+ )
+ // Unfold so the settled child is visible, then simulate the real gesture
+ // order on the canvas background before clicking the node.
+ const foldToggle = container.querySelector('[data-graph-controls] button:nth-child(2)') as HTMLButtonElement
+ await act(async () => { foldToggle.click() })
+ const canvas = container.querySelector('[role="group"]') as HTMLElement
+ await act(async () => {
+ canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 5, clientY: 5 }))
+ window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
+ })
+ const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
+ expect(node).not.toBeNull()
+ await act(async () => { node.click() })
+ expect(opened).toEqual([{ parentSessionId: 'root', childSessionId: 'child-0', mode: 'one-shot' }])
+ unmount()
+ })
+
+ it('never starts a pan from a node (the gesture belongs to the node)', async () => {
+ const store = makeStore(snapshotWithChildren(1))
+ const { container, unmount } = renderRoot(
+ createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
+ )
+ const foldToggle = container.querySelector('[data-graph-controls] button:nth-child(2)') as HTMLButtonElement
+ await act(async () => { foldToggle.click() })
+ const node = container.querySelector('[data-graph-node="child-0"]') as HTMLElement
+ const inner = node.parentElement as HTMLElement
+ const before = inner.style.transform
+ await act(async () => {
+ node.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 10, clientY: 10 }))
+ window.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 80, clientY: 90 }))
+ window.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
+ })
+ expect(inner.style.transform).toBe(before)
+ unmount()
+ })
+
+ it('shows the team board without any click when the root leads a team', async () => {
+ teamPayload = {
+ available: true,
+ team: {
+ members: [
+ { id: 'root', name: 'lead', role: 'lead', status: 'running', diagnostics: [] },
+ { id: 'child-0', name: 'writer', role: 'teammate', status: 'idle', model: 'glm-5.3', diagnostics: [] },
+ ],
+ tasks: [{
+ id: 't1', revision: 1, subject: '收窄卡片', description: '', status: 'in_progress',
+ blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [],
+ }],
+ },
+ }
+ const store = makeStore(snapshotWithChildren(1))
+ const { container, unmount } = renderRoot(
+ createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
+ )
+ await act(async () => { await Promise.resolve() })
+ // Visible by default: header, members and the task row (no chip to click).
+ expect(container.textContent).toContain('团队任务板')
+ expect(container.textContent).toContain('lead')
+ expect(container.textContent).toContain('writer')
+ expect(container.textContent).toContain('收窄卡片')
+ unmount()
+ })
+})