diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index d7f7a00e..dcddfd04 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -8,6 +8,7 @@ import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/co import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants'; import { truncateText } from './draw-misc'; import { hexWithAlpha } from './render-cache'; +import type { KanbanZoneInfo } from '../layout/kanbanLayout'; /** * Draw all task nodes as pill-shaped cards. @@ -176,3 +177,30 @@ function drawTaskPill( ctx.restore(); } + +/** + * Draw kanban column headers above task columns. + */ +export function drawColumnHeaders( + ctx: CanvasRenderingContext2D, + zones: KanbanZoneInfo[], +): void { + for (const zone of zones) { + for (const header of zone.headers) { + ctx.font = 'bold 8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillStyle = hexWithAlpha(header.color, 0.6); + ctx.fillText(header.label, header.x, header.y - 2); + + // Subtle underline + const labelWidth = ctx.measureText(header.label).width; + ctx.beginPath(); + ctx.moveTo(header.x - labelWidth / 2, header.y); + ctx.lineTo(header.x + labelWidth / 2, header.y); + ctx.strokeStyle = hexWithAlpha(header.color, 0.2); + ctx.lineWidth = 0.5; + ctx.stroke(); + } + } +} diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index b49b8b85..e822afcc 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -1,8 +1,8 @@ /** * KanbanLayoutEngine — positions task nodes in kanban columns relative to their owner. * - * Each member/lead gets a zone below them with 4 columns: todo → wip → review → done. - * Tasks are pinned (fx/fy) — no d3-force drift. Deterministic layout. + * Each member/lead gets a zone below them with columns for non-empty statuses only. + * Empty columns are skipped — no wasted space. Each column has a header label. * * Class with ES #private methods, single source of truth from KANBAN_ZONE constants. */ @@ -10,6 +10,31 @@ import type { GraphNode } from '../ports/types'; import { KANBAN_ZONE } from '../constants/canvas-constants'; +/** Column header info for rendering */ +export interface KanbanColumnHeader { + label: string; + x: number; + y: number; + color: string; +} + +/** Zone info per owner for rendering headers */ +export interface KanbanZoneInfo { + ownerId: string; + ownerX: number; + ownerY: number; + headers: KanbanColumnHeader[]; +} + +// Column display config +const COLUMN_LABELS: Record = { + todo: { label: 'Todo', color: '#6b7280' }, + wip: { label: 'In Progress', color: '#3b82f6' }, + done: { label: 'Done', color: '#22c55e' }, + review: { label: 'Review', color: '#f59e0b' }, + approved: { label: 'Approved', color: '#22c55e' }, +}; + export class KanbanLayoutEngine { // Reusable collections (cleared each call, never GC'd) static readonly #nodeMap = new Map(); @@ -17,6 +42,9 @@ export class KanbanLayoutEngine { static readonly #unassigned: GraphNode[] = []; static readonly #colTasks = new Map(); + /** Zone info for rendering column headers — updated each layout() call */ + static zones: KanbanZoneInfo[] = []; + /** * Position all task nodes in kanban columns relative to their owner. * Call AFTER d3-force settles member positions, BEFORE drawing. @@ -26,7 +54,6 @@ export class KanbanLayoutEngine { nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); - // Group tasks by owner — reuse maps const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); const unassigned = this.#unassigned; @@ -46,26 +73,27 @@ export class KanbanLayoutEngine { } } - // Layout each owner's tasks in kanban columns + // Reset zones + this.zones = []; + for (const [ownerId, tasks] of tasksByOwner) { const owner = nodeMap.get(ownerId); if (!owner || owner.x == null || owner.y == null) continue; - KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y); + const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner.x, owner.y, ownerId); + if (zoneInfo) this.zones.push(zoneInfo); } - // Unassigned tasks: separate zone KanbanLayoutEngine.#layoutUnassigned(unassigned); } // ─── Private ────────────────────────────────────────────────────────────── - static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number): void { + static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null { const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE; - const totalWidth = columns.length * columnWidth; - const baseX = ownerX - totalWidth / 2; + const headerHeight = 20; // space for column header label const baseY = ownerY + offsetY; - // Classify each task into a column — reuse shared Map + // Classify tasks into columns const colTasks = KanbanLayoutEngine.#colTasks; colTasks.clear(); for (const col of columns) colTasks.set(col, []); @@ -75,21 +103,47 @@ export class KanbanLayoutEngine { colTasks.get(col)?.push(task); } - // Position each task in its column + row - for (const [colIdx, colName] of columns.entries()) { - const colNodes = colTasks.get(colName) ?? []; - for (const [rowIdx, task] of colNodes.entries()) { + // Collect only NON-EMPTY columns (skip empty — no wasted space) + const activeColumns: { name: string; tasks: GraphNode[] }[] = []; + for (const colName of columns) { + const nodes = colTasks.get(colName) ?? []; + if (nodes.length > 0) { + activeColumns.push({ name: colName, tasks: nodes }); + } + } + + if (activeColumns.length === 0) return null; + + // Center active columns under owner + const totalWidth = activeColumns.length * columnWidth; + const baseX = ownerX - totalWidth / 2; + + // Build headers + position tasks + const headers: KanbanColumnHeader[] = []; + + for (const [colIdx, col] of activeColumns.entries()) { + const colX = baseX + colIdx * columnWidth; + const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; + + // Column header + headers.push({ + label: config.label, + x: colX + columnWidth / 2, // centered in column + y: baseY, + color: config.color, + }); + + // Position tasks below header + for (const [rowIdx, task] of col.tasks.entries()) { if (rowIdx >= maxVisibleRows) { - // Hide overflow tasks off-screen task.x = -99999; task.y = -99999; task.fx = task.x; task.fy = task.y; continue; } - const targetX = baseX + colIdx * columnWidth; - const targetY = baseY + rowIdx * rowHeight; - // Smooth slide: LERP toward target; instant on first appearance + const targetX = colX; + const targetY = baseY + headerHeight + rowIdx * rowHeight; task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; @@ -98,17 +152,12 @@ export class KanbanLayoutEngine { task.vy = 0; } } + + return { ownerId, ownerX, ownerY, headers }; } - /** - * Determine which kanban column a task belongs to. - * Columns: todo → wip → done → review → approved - * approved is separate from review — approved goes after review. - */ static #resolveColumn(task: GraphNode): string { - // Approved = separate column (after review) if (task.reviewState === 'approved') return 'approved'; - // Active review/needsFix = review column (next to done) if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review'; switch (task.taskStatus) { case 'in_progress': diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 2b6fa632..8fd1ba85 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -12,10 +12,11 @@ import { drawBackground, createDepthParticles, updateDepthParticles, type DepthP import { drawEdges } from '../canvas/draw-edges'; import { drawParticles } from '../canvas/draw-particles'; import { drawAgents } from '../canvas/draw-agents'; -import { drawTasks } from '../canvas/draw-tasks'; +import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; import { BloomRenderer } from '../canvas/bloom-renderer'; +import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import type { CameraTransform } from '../hooks/useGraphCamera'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── @@ -208,6 +209,7 @@ export const GraphCanvas = forwardRef(funct // 2c. Visible nodes only (back to front: process → task → member/lead) drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawColumnHeaders(ctx, KanbanLayoutEngine.zones); drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);