From fdc4520080a5d499804da25c4c37fbeae9977c87 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 14 Sep 2026 22:20:44 +0800 Subject: [PATCH] feat(client): unified tasks view model + graph layout + api methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/client/api.ts | 49 ++++- src/client/tasks-graph-layout.ts | 108 ++++++++++ src/client/tasks-model.ts | 329 +++++++++++++++++++++++++++++++ tests/tasks-graph-layout.spec.ts | 63 ++++++ tests/tasks-model.spec.ts | 193 ++++++++++++++++++ 5 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 src/client/tasks-graph-layout.ts create mode 100644 src/client/tasks-model.ts create mode 100644 tests/tasks-graph-layout.spec.ts create mode 100644 tests/tasks-model.spec.ts diff --git a/src/client/api.ts b/src/client/api.ts index c067eb3..1e918e7 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -9,7 +9,16 @@ import { encodeHtmlUrl } from '../html-route.ts' import type { LastActivity } from '../subagent-activity.ts' import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' -import type { SidebarJobView, SidebarSessionEvent } from '../context-types.ts' +import type { + SidebarCreateTeamTaskRequest, + SidebarJobView, + SidebarSessionEvent, + SidebarTeamMemberView, + SidebarTeamTaskMutationResult, + SidebarTeamTaskView, + SidebarUpdateTeamTaskRequest, +} from '../context-types.ts' +import type { WorkflowRunView } from '../workflow-runs.ts' /** One wire failure. */ export class SidebarApiError extends Error { @@ -113,6 +122,23 @@ export interface JobOutputResult { /** The `subagents.live` response: running child id → latest activity. */ export type SubagentLiveResult = { live: Record } +/** The `workflows.list` response: the tree's folded workflow runs. */ +export type WorkflowsListResult = { runs: WorkflowRunView[] } + +/** The `teams.view` response (structural degradation contract). */ +export type TeamsViewResult = + | { available: false } + | { available: true; team: { members: SidebarTeamMemberView[]; tasks: SidebarTeamTaskView[] } | null } + +/** The `teams.taskCreate` request payload (minus the rootSessionId). */ +export type TeamsTaskCreateRequest = SidebarCreateTeamTaskRequest + +/** The `teams.taskUpdate` request payload (minus the rootSessionId). */ +export type TeamsTaskUpdateRequest = SidebarUpdateTeamTaskRequest + +/** The task-mutation result union (CAS conflict stays distinct). */ +export type TeamsTaskMutationResult = SidebarTeamTaskMutationResult + /** * Parse one `/sidebar` JSON response envelope into its value. A non-ok * status, an unparseable body, or any shape other than `{ok: true, value}` @@ -353,6 +379,27 @@ export const api = { */ subagentsLive: (rootSessionId: string, signal?: AbortSignal) => call('subagents.live', { rootSessionId }, signal), + /** + * The workflow runs of the whole tree, folded host-side from the + * `tool-workflow/*` session events (the same four types the official + * workflow-run panel folds). Empty when the tree never ran a workflow — + * absence is the normal case, never an error. + */ + workflowsList: (rootSessionId: string, signal?: AbortSignal) => + call('workflows.list', { rootSessionId }, signal), + /** + * The team led by the tree's root agent (experimental Agent Teams layer): + * `{available:false}` hides the whole block; `{team:null}` means the root + * leads no team. Poll-driven (the official panel is manual-refresh too). + */ + teamsView: (rootSessionId: string, signal?: AbortSignal) => + call('teams.view', { rootSessionId }, signal), + /** Create one shared task on the root-led team. */ + teamsTaskCreate: (rootSessionId: string, req: TeamsTaskCreateRequest) => + call('teams.taskCreate', { rootSessionId, ...req }), + /** CAS-mutate one shared task; conflicts ride the returned union. */ + teamsTaskUpdate: (rootSessionId: string, req: TeamsTaskUpdateRequest) => + call('teams.taskUpdate', { rootSessionId, ...req }), /** Create a Side Chat thread: a child session seeded with the parent's * full log up to now. Empty question = immediate create (Codex-style): * the thread opens empty, the first prompt carries the boundary. */ diff --git a/src/client/tasks-graph-layout.ts b/src/client/tasks-graph-layout.ts new file mode 100644 index 0000000..6418ebf --- /dev/null +++ b/src/client/tasks-graph-layout.ts @@ -0,0 +1,108 @@ +/** + * 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. + */ +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 +/** Extra height of an agent node carrying a live line. */ +export const GRAPH_LIVE_H = 16 +export const GRAPH_GAP_X = 28 +export const GRAPH_GAP_Y = 72 +export const GRAPH_PAD = 16 + +/** One laid-out node. */ +export interface GraphBox { + id: string + x: number + y: number + w: number + h: number +} + +/** The layout result: one box per node plus the canvas extent. */ +export interface GraphLayout { + boxes: ReadonlyMap + width: number + height: number +} + +/** The display height of one node (live lines grow agent rows). */ +function nodeHeight(node: TasksNode): number { + if (node.kind === 'agent' && node.live !== undefined) 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). + */ +export function layoutTasksGraph(nodes: readonly TasksNode[]): GraphLayout { + const roots = nodes.filter(node => node.parentId === undefined || !nodes.some(candidate => candidate.id === node.parentId)) + const childrenOf = new Map() + for (const edge of tasksEdges(nodes)) { + const child = nodes.find(node => node.id === edge.to) + if (child === undefined) continue + const list = childrenOf.get(edge.from) + if (list === undefined) childrenOf.set(edge.from, [child]) + else list.push(child) + } + + /** Post-order: the width of the subtree rooted at `node`. */ + 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) + } + + 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) + 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), + w: GRAPH_NODE_W, + h, + }) + 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 + } + } + + // Single-root forest in practice (the topology root); the loop stays so a + // defensive orphan still lands inside the canvas extent. + let offset = GRAPH_PAD + for (const root of roots) { + const width = subtreeWidth(root) + place(root, offset + width / 2, 0) + offset += width + GRAPH_GAP_X + } + + const allBoxes = [...boxes.values()] + const width = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_W, ...allBoxes.map(box => box.x + box.w + GRAPH_PAD)) + const height = Math.max(GRAPH_PAD * 2 + GRAPH_NODE_H, ...allBoxes.map(box => box.y + box.h + GRAPH_PAD)) + return { boxes, width, height } +} diff --git a/src/client/tasks-model.ts b/src/client/tasks-model.ts new file mode 100644 index 0000000..77db3c4 --- /dev/null +++ b/src/client/tasks-model.ts @@ -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> + catalogs: Readonly> + rootId: string + currentSessionId: string + live: Readonly> + 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() + 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 +} diff --git a/tests/tasks-graph-layout.spec.ts b/tests/tasks-graph-layout.spec.ts new file mode 100644 index 0000000..b05953a --- /dev/null +++ b/tests/tasks-graph-layout.spec.ts @@ -0,0 +1,63 @@ +/** + * Unit tests for the layered graph layout (layoutTasksGraph): parent + * centering over children bands, depth rows, and live-line heights. + */ +import { describe, expect, it } from 'vitest' +import { + GRAPH_GAP_Y, + GRAPH_LIVE_H, + GRAPH_NODE_H, + GRAPH_NODE_W, + GRAPH_PAD, + layoutTasksGraph, +} from '../src/client/tasks-graph-layout.ts' +import type { TasksAgentNode, TasksNode } from '../src/client/tasks-model.ts' + +/** An agent node. */ +function agent(id: string, parentId?: string, live = false): TasksAgentNode { + return { + kind: 'agent', id, ...(parentId === undefined ? {} : { parentId }), + label: id, state: 'running', activity: 'running', current: false, + ...(live ? { live: { text: 'x' } } : {}), + } +} + +describe('layoutTasksGraph', () => { + it('places the root on row 0 and children one row below', () => { + const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')] + const layout = layoutTasksGraph(nodes) + 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) + }) + + it('centers a parent over its children band', () => { + const nodes: TasksNode[] = [agent('root'), agent('a', 'root'), agent('b', 'root')] + const layout = layoutTasksGraph(nodes) + const a = layout.boxes.get('a') + const b = layout.boxes.get('b') + const root = layout.boxes.get('root') + expect(a).toBeDefined(); expect(b).toBeDefined(); expect(root).toBeDefined() + const bandCenter = ((a!.x + b!.x + GRAPH_NODE_W) / 2) + expect(root!.x + GRAPH_NODE_W / 2).toBeCloseTo(bandCenter, 5) + }) + + it('widens the canvas for deep/wide forests and grows live rows', () => { + const nodes: TasksNode[] = [ + agent('root'), agent('a', 'root', true), + agent('a1', 'a'), agent('a2', 'a'), agent('a3', 'a'), + ] + const layout = layoutTasksGraph(nodes) + expect(layout.boxes.get('a')?.h).toBe(GRAPH_NODE_H + GRAPH_LIVE_H) + expect(layout.width).toBeGreaterThan(3 * GRAPH_NODE_W) + expect(layout.height).toBeGreaterThan(2 * (GRAPH_NODE_H + GRAPH_GAP_Y)) + }) + + it('gives every node a box even with a defensive orphan', () => { + const nodes: TasksNode[] = [agent('root'), agent('ghost', 'missing')] + const layout = layoutTasksGraph(nodes) + expect(layout.boxes.get('root')).toBeDefined() + expect(layout.boxes.get('ghost')).toBeDefined() + }) +}) diff --git a/tests/tasks-model.spec.ts b/tests/tasks-model.spec.ts new file mode 100644 index 0000000..e9da0ea --- /dev/null +++ b/tests/tasks-model.spec.ts @@ -0,0 +1,193 @@ +/** + * Unit tests for the unified Tasks view model (buildTasksModel / tasksEdges): + * catalog walking, workflow run attachment + member re-parenting, team + * enrichment, and the per-parent settled-leaf fold. + */ +import { describe, expect, it } from 'vitest' +import { + buildTasksModel, + tasksEdges, + type TasksAgentNode, + type TasksModelInput, + type TasksNode, +} from '../src/client/tasks-model.ts' +import type { + SidebarSessionSummary, + SidebarSubagentCatalog, + SidebarSubagentChildEntry, + SidebarTeamMemberView, +} from '../src/context-types.ts' +import type { WorkflowRunView } from '../src/workflow-runs.ts' + +/** A summary row. */ +function summary(id: string, over: Partial = {}): SidebarSessionSummary { + return { id, displayTitle: id, ...over } +} + +/** A catalog child row. */ +function child(id: string, over: Partial = {}): SidebarSubagentChildEntry { + return { kind: 'child', id, activity: 'running', hasChildren: false, mode: 'one-shot', ...over } +} + +/** A ready catalog. */ +function catalog(entries: SidebarSubagentCatalog['entries']): SidebarSubagentCatalog { + return { entries, parentAvailable: true, state: 'ready', error: null } +} + +/** A workflow run view. */ +function run(over: Partial = {}): WorkflowRunView { + return { + runId: 'run-1', name: 'audit', originSessionId: 'root', status: 'running', + phases: [], startedSeq: 1, startedAt: 0, ...over, + } +} + +/** A team member view. */ +function member(over: Partial = {}): SidebarTeamMemberView { + return { id: 'x', name: 'n', role: 'teammate', status: 'running', diagnostics: [], ...over } +} + +/** A base input; override per test. */ +function input(over: Partial = {}): TasksModelInput { + return { + byId: { root: summary('root', { displayTitle: 'Root', running: true }) }, + catalogs: {}, + rootId: 'root', + currentSessionId: 'root', + live: {}, + runs: [], + teamMembers: [], + folded: true, + ...over, + } +} + +/** All agent nodes of a model. */ +function agents(nodes: readonly TasksNode[]): TasksAgentNode[] { + return nodes.filter((node): node is TasksAgentNode => node.kind === 'agent') +} + +describe('buildTasksModel', () => { + it('walks the catalogs in pre-order and marks the current session', () => { + const model = buildTasksModel(input({ + catalogs: { + root: catalog([child('a'), child('b', { activity: 'inactive' })]), + a: catalog([child('a1')]), + }, + byId: { + root: summary('root', { displayTitle: 'Root', running: true }), + a: summary('a'), b: summary('b'), a1: summary('a1'), + }, + currentSessionId: 'a1', + folded: false, + })) + expect(model.map(node => node.id)).toEqual(['root', 'a', 'a1', 'b']) + expect(agents(model).find(node => node.id === 'a1')?.current).toBe(true) + expect(agents(model).find(node => node.id === 'a1')?.childAddress).toEqual({ + parentSessionId: 'a', childSessionId: 'a1', mode: 'one-shot', + }) + }) + + it('excludes Side Chat threads from the model', () => { + const model = buildTasksModel(input({ + catalogs: { root: catalog([child('a'), child('side', { label: 'Side: 闲聊' })]) }, + byId: { root: summary('root'), a: summary('a'), side: summary('side') }, + })) + expect(model.map(node => node.id)).toEqual(['root', 'a']) + }) + + it('re-parents run members under the run node and synthesizes missing ones', () => { + const audit = run({ + originSessionId: 'root', + phases: [{ + title: '侦察', + members: [ + { seq: 1, label: 'existing', phase: '侦察', childId: 'm1', outcome: 'completed' }, + { seq: 2, label: 'synthesized', phase: '侦察', childId: 'm2' }, + ], + }], + }) + const model = buildTasksModel(input({ + catalogs: { root: catalog([child('m1', { activity: 'inactive' })]) }, + byId: { root: summary('root'), m1: summary('m1', { displayTitle: 'M1' }) }, + runs: [audit], + folded: false, + })) + expect(model.map(node => node.id)).toEqual(['root', 'run:run-1', 'm1', 'm2']) + const synthesized = agents(model).find(node => node.id === 'm2') + expect(synthesized).toMatchObject({ parentId: 'run:run-1', label: 'synthesized', synthesized: true, state: 'running' }) + const existing = agents(model).find(node => node.id === 'm1') + expect(existing?.parentId).toBe('run:run-1') + }) + + it('enriches teammates and the lead from the team view', () => { + const model = buildTasksModel(input({ + catalogs: { root: catalog([child('w', { label: 'writer' })]) }, + byId: { root: summary('root'), w: summary('w') }, + teamMembers: [ + member({ id: 'root', name: 'lead', role: 'lead' }), + member({ id: 'w', name: 'writer', model: 'glm-5.3', status: 'idle' }), + ], + })) + expect(agents(model).find(node => node.id === 'root')?.team?.role).toBe('lead') + const w = agents(model).find(node => node.id === 'w') + expect(w?.team).toMatchObject({ role: 'teammate', name: 'writer', model: 'glm-5.3' }) + }) + + it('folds settled leaves per parent and keeps running/teammate/current rows', () => { + const model = buildTasksModel(input({ + catalogs: { + root: catalog([ + child('done-1', { activity: 'inactive' }), + child('done-2', { activity: 'inactive' }), + child('live', { activity: 'running' }), + child('mate', { activity: 'inactive' }), + child('parent', { activity: 'inactive', hasChildren: true }), + ]), + parent: catalog([child('grandchild')]), + }, + byId: { + root: summary('root'), + 'done-1': summary('done-1'), 'done-2': summary('done-2'), + live: summary('live'), mate: summary('mate'), + parent: summary('parent'), grandchild: summary('grandchild'), + }, + teamMembers: [member({ id: 'mate', status: 'idle' })], + })) + expect(model.map(node => node.id)).toEqual([ + 'root', 'live', 'mate', 'parent', 'grandchild', 'fold:root', + ]) + const fold = model.find(node => node.kind === 'fold') + expect(fold).toMatchObject({ parentId: 'root', count: 2, memberIds: ['done-1', 'done-2'] }) + }) + + it('unfolds everything when folded is false', () => { + const base = input({ + catalogs: { root: catalog([child('done-1', { activity: 'inactive' })]) }, + byId: { root: summary('root'), 'done-1': summary('done-1') }, + folded: false, + }) + expect(buildTasksModel(base).map(node => node.id)).toEqual(['root', 'done-1']) + }) +}) + +describe('tasksEdges', () => { + it('classifies team/workflow/agent edges', () => { + const audit = run({ + originSessionId: 'root', + phases: [{ members: [{ seq: 1, label: 'm', childId: 'm1' }] }], + }) + const model = buildTasksModel(input({ + catalogs: { root: catalog([child('w'), child('m1')]) }, + byId: { root: summary('root'), w: summary('w'), m1: summary('m1') }, + runs: [audit], + teamMembers: [member({ id: 'w' })], + folded: false, + })) + const edges = tasksEdges(model) + const kindOf = (to: string) => edges.find(edge => edge.to === to)?.kind + expect(kindOf('w')).toBe('team') + expect(kindOf('run:run-1')).toBe('workflow') + expect(kindOf('m1')).toBe('workflow') + }) +})