feat(client): unified tasks view model + graph layout + api methods

- tasks-model.ts: one pre-order node list for graph AND tree modes —
  catalog walking, workflow-run attachment with member re-parenting and
  synthesis, team enrichment, per-parent settled-leaf fold aggregates.
- tasks-graph-layout.ts: dependency-free tidy-tree layered layout
  (bundle-purity: no graph library in the core bundle).
- api.ts: workflowsList/teamsView/teamsTaskCreate/teamsTaskUpdate with
  the wire result types mirrored from the host routes.
- 11 unit tests across model derivation, edge classification, fold, and
  layout geometry.
This commit is contained in:
Menghuan1918
2026-09-27 22:18:14 +08:00
parent c445495f8b
commit fdc4520080
5 changed files with 741 additions and 1 deletions
+329
View File
@@ -0,0 +1,329 @@
/**
* The unified view model of the Tasks page: one ordered node list the graph
* canvas AND the tree mode both render (same data, same fold state). Pure
* derivation over the sessions list feed (byId lineage + per-parent
* catalogs), the live activity map, the folded workflow runs, and the
* optional team view — kept framework-free for node-environment unit tests.
*
* Shape rules:
* - agent rows walk the lazy per-parent catalogs in pre-order (the same
* recursion the classic tree used); Side Chat threads and diagnostic rows
* never become nodes;
* - a workflow run hangs under its ORIGIN agent; member agents that already
* exist as the origin's catalog children are RE-PARENTED under the run
* node, members without a catalog row are synthesized from the run's own
* member data (so a finished run still shows its members);
* - team members enrich matching agent nodes in place (role/model/status);
* the lead's membership lands on the root node;
* - fold: per parent, settled agent LEAVES (state done/error, never the
* current session, never a workflow member's run node) collapse into one
* trailing `fold` node carrying the member ids and label previews.
*/
import type {
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarTeamMemberView,
} from '../context-types.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
import { isSideThreadSummary } from './subagent-detect.ts'
/** Display state of one agent node (drives the dot + fold candidacy). */
export type TasksNodeState = 'running' | 'idle' | 'done' | 'error'
/** One agent node (root, subagent, teammate, or synthesized workflow member). */
export interface TasksAgentNode {
kind: 'agent'
id: string
parentId?: string
/** Card line 1: durable label, else summary title, else the id. */
label: string
/** The summary's display title when it differs from the label. */
title?: string
mode?: 'one-shot' | 'continuable'
state: TasksNodeState
/** The catalog's raw activity word (the secondary line localizes it). */
activity: 'running' | 'inactive'
/** The on-screen session (the "you are here" marker). */
current: boolean
/** Live tail of a running child (the icon + tool + args line). */
live?: LastActivity
/** Team enrichment (membership view matched by session id). */
team?: {
role: 'lead' | 'teammate'
name: string
model?: string
status: SidebarTeamMemberView['status']
diagnostics: string[]
}
/** The catalog's durable children signal (fold candidacy's leaf test). */
hasChildren?: boolean
/** Synthesized from a workflow run's member row (no catalog entry). */
synthesized?: boolean
/** Jump target of the row (absent on the root node). */
childAddress?: SidebarSubagentAddress
}
/** One workflow run node (its members hang below as agent children). */
export interface TasksWorkflowNode {
kind: 'workflow'
id: string
parentId: string
run: WorkflowRunView
}
/** One fold aggregate node (settled leaves of one parent, collapsed). */
export interface TasksFoldNode {
kind: 'fold'
id: string
parentId: string
count: number
/** The folded session ids, in original order (expansion restores them). */
memberIds: string[]
/** Up to two label previews for the collapsed subtitle. */
previews: string[]
}
export type TasksNode = TasksAgentNode | TasksWorkflowNode | TasksFoldNode
/** Inputs of the model build (all already-resolved client mirrors). */
export interface TasksModelInput {
byId: Readonly<Record<string, SidebarSessionSummary>>
catalogs: Readonly<Record<string, SidebarSubagentCatalog | undefined>>
rootId: string
currentSessionId: string
live: Readonly<Record<string, LastActivity | undefined>>
runs: readonly WorkflowRunView[]
teamMembers: readonly SidebarTeamMemberView[]
/** Whether settled leaves collapse into fold nodes. */
folded: boolean
}
/** Human label of one catalog child (the classic rule). */
function childLabel(
entry: SidebarSubagentChildEntry,
summary: SidebarSessionSummary | undefined,
): string {
return entry.label ?? summary?.displayTitle ?? entry.id
}
/** Map a team member's runtime status onto the node display state. */
function teamState(status: SidebarTeamMemberView['status']): TasksNodeState {
switch (status) {
case 'running': return 'running'
case 'provisioning': return 'running'
case 'failed': return 'error'
case 'idle':
case 'inactive': return 'idle'
}
}
/** The workflow member's display state from its outcome (undefined = live). */
function memberOutcomeState(outcome: 'completed' | 'failed' | 'cancelled' | undefined): TasksNodeState {
switch (outcome) {
case undefined: return 'running'
case 'completed': return 'done'
case 'cancelled': return 'done'
case 'failed': return 'error'
}
}
/**
* Build the ordered (pre-order) node list of the Tasks page. The result is
* stable for a stable input set: catalog order is preserved, runs follow
* their origin's agent children in startedSeq order, and each fold node
* trails its parent's remaining children.
*/
export function buildTasksModel(input: TasksModelInput): TasksNode[] {
const { byId, catalogs, rootId, currentSessionId, live, runs, teamMembers, folded } = input
const teamById = new Map(teamMembers.map(member => [member.id, member]))
const runsByOrigin = new Map<string, WorkflowRunView[]>()
for (const run of runs) {
const list = runsByOrigin.get(run.originSessionId)
if (list === undefined) runsByOrigin.set(run.originSessionId, [run])
else list.push(run)
}
const teamOf = (id: string): TasksAgentNode['team'] => {
const member = teamById.get(id)
return member === undefined
? undefined
: { role: member.role, name: member.name, model: member.model, status: member.status, diagnostics: member.diagnostics }
}
const out: TasksNode[] = []
/** The agent children of one parent, in catalog order (side threads and
* diagnostics excluded), with workflow runs appended in start order. */
const appendChildren = (parentId: string): void => {
const catalog = catalogs[parentId]
const entries = catalog?.state === 'ready' ? catalog.entries : []
const agentChildren: TasksAgentNode[] = []
for (const entry of entries) {
if (entry.kind !== 'child') continue
const summary = byId[entry.id]
if (summary !== undefined && isSideThreadSummary(summary)) continue
if (entry.label?.startsWith('Side: ') ?? false) continue
const team = teamOf(entry.id)
const state: TasksNodeState = entry.activity === 'running'
? 'running'
: team !== undefined ? teamState(team.status) : 'done'
agentChildren.push({
kind: 'agent',
id: entry.id,
parentId,
label: childLabel(entry, summary),
...(summary?.displayTitle !== undefined && summary.displayTitle !== entry.label
? { title: summary.displayTitle } : {}),
mode: entry.mode,
state,
activity: entry.activity,
current: entry.id === currentSessionId,
...(live[entry.id] !== undefined ? { live: live[entry.id] } : {}),
...(team !== undefined ? { team } : {}),
hasChildren: entry.hasChildren,
childAddress: { parentSessionId: parentId, childSessionId: entry.id, mode: entry.mode },
})
}
// Workflow runs of this agent: member agents with a catalog row are
// re-parented under the run; the rest are synthesized from run data.
const runNodes: TasksWorkflowNode[] = []
const memberNodes: TasksAgentNode[][] = []
for (const run of runsByOrigin.get(parentId) ?? []) {
const runNode: TasksWorkflowNode = { kind: 'workflow', id: `run:${run.runId}`, parentId, run }
runNodes.push(runNode)
const members: TasksAgentNode[] = []
for (const phase of run.phases) {
for (const member of phase.members) {
const existing = member.childId !== ''
? agentChildren.find(candidate => candidate.id === member.childId)
: undefined
if (existing !== undefined) {
existing.parentId = runNode.id
members.push(existing)
} else {
const childKnown = member.childId !== ''
members.push({
kind: 'agent',
id: childKnown ? member.childId : `wfmember:${run.runId}:${member.seq}`,
parentId: runNode.id,
label: member.label,
state: memberOutcomeState(member.outcome),
activity: member.outcome === undefined ? 'running' : 'inactive',
current: member.childId !== '' && member.childId === currentSessionId,
synthesized: true,
...(childKnown
? { childAddress: { parentSessionId: parentId, childSessionId: member.childId, mode: 'one-shot' as const } }
: {}),
})
}
}
}
memberNodes.push(members)
}
const visibleAgentChildren = agentChildren.filter(child => child.parentId === parentId)
// Fold: settled agent leaves of THIS parent collapse into one aggregate.
const kept: TasksAgentNode[] = []
const foldCandidates: TasksAgentNode[] = []
for (const child of visibleAgentChildren) {
// A leaf: no durable children (catalog signal) and no workflow run of
// its own — folding a parent would hide its live branch.
const isLeaf = child.hasChildren !== true && !runsByOrigin.has(child.id)
if (
folded && isLeaf && !child.current && child.team === undefined
&& (child.state === 'done' || child.state === 'error')
) {
foldCandidates.push(child)
} else {
kept.push(child)
}
}
for (const child of kept) {
out.push(child)
appendChildren(child.id)
}
if (foldCandidates.length > 0) {
out.push({
kind: 'fold',
id: `fold:${parentId}`,
parentId,
count: foldCandidates.length,
memberIds: foldCandidates.map(child => child.id),
previews: foldCandidates.slice(0, 2).map(child => child.label),
})
}
for (let index = 0; index < runNodes.length; index += 1) {
const runNode = runNodes[index]
if (runNode === undefined) continue
out.push(runNode)
const members = memberNodes[index] ?? []
// Fold settled members of the run too (same leaf rule, run as parent).
const keptMembers: TasksAgentNode[] = []
const foldedMembers: TasksAgentNode[] = []
for (const member of members) {
if (folded && !member.current && (member.state === 'done' || member.state === 'error')) {
foldedMembers.push(member)
} else {
keptMembers.push(member)
}
}
out.push(...keptMembers)
if (foldedMembers.length > 0) {
out.push({
kind: 'fold',
id: `fold:${runNode.id}`,
parentId: runNode.id,
count: foldedMembers.length,
memberIds: foldedMembers.map(member => member.id),
previews: foldedMembers.slice(0, 2).map(member => member.label),
})
}
}
}
const rootSummary = byId[rootId]
const rootTeam = teamOf(rootId)
out.push({
kind: 'agent',
id: rootId,
label: rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
? rootSummary.displayTitle
: rootId,
state: rootSummary?.running === true ? 'running' : 'done',
activity: rootSummary?.running === true ? 'running' : 'inactive',
current: rootId === currentSessionId,
...(rootTeam !== undefined ? { team: rootTeam } : {}),
})
appendChildren(rootId)
return out
}
/** The parent→child edges of a model (derived, kept out of the build). */
export interface TasksEdge {
from: string
to: string
/** Visual class: teammate links and workflow relations get the accent. */
kind: 'agent' | 'team' | 'workflow'
}
/** Derive the edge list of a built model (pre-order preserved). */
export function tasksEdges(nodes: readonly TasksNode[]): TasksEdge[] {
const byNodeId = new Map(nodes.map(node => [node.id, node]))
const edges: TasksEdge[] = []
for (const node of nodes) {
if (node.parentId === undefined || !byNodeId.has(node.parentId)) continue
const parent = byNodeId.get(node.parentId)
const kind: TasksEdge['kind'] = node.kind === 'workflow' || parent?.kind === 'workflow'
? 'workflow'
: node.kind === 'agent' && node.team?.role === 'teammate'
? 'team'
: 'agent'
edges.push({ from: node.parentId, to: node.id, kind })
}
return edges
}