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:
+48
-1
@@ -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<string, LastActivity> }
|
||||
|
||||
/** 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<SubagentLiveResult>('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<WorkflowsListResult>('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<TeamsViewResult>('teams.view', { rootSessionId }, signal),
|
||||
/** Create one shared task on the root-led team. */
|
||||
teamsTaskCreate: (rootSessionId: string, req: TeamsTaskCreateRequest) =>
|
||||
call<TeamsTaskMutationResult>('teams.taskCreate', { rootSessionId, ...req }),
|
||||
/** CAS-mutate one shared task; conflicts ride the returned union. */
|
||||
teamsTaskUpdate: (rootSessionId: string, req: TeamsTaskUpdateRequest) =>
|
||||
call<TeamsTaskMutationResult>('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. */
|
||||
|
||||
@@ -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<string, GraphBox>
|
||||
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<string, TasksNode[]>()
|
||||
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<string, GraphBox>()
|
||||
/** 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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> = {}): SidebarSessionSummary {
|
||||
return { id, displayTitle: id, ...over }
|
||||
}
|
||||
|
||||
/** A catalog child row. */
|
||||
function child(id: string, over: Partial<SidebarSubagentChildEntry> = {}): 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> = {}): 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> = {}): SidebarTeamMemberView {
|
||||
return { id: 'x', name: 'n', role: 'teammate', status: 'running', diagnostics: [], ...over }
|
||||
}
|
||||
|
||||
/** A base input; override per test. */
|
||||
function input(over: Partial<TasksModelInput> = {}): 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user