From 11bb49c53e076f1717dc1f47d8463e955e006589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Sat, 28 Mar 2026 12:03:42 +0200 Subject: [PATCH] feat(graph): force-directed agent graph visualization with kanban-zone task layout Force-directed graph visualization for agent teams. Package: @claude-teams/agent-graph (isolated workspace package) - Space theme: bloom, particles, hex grid, depth stars - Members as hexagonal nodes with breathing glow - Tasks as pill cards in kanban columns (todo/wip/done/review/approved) per owner - Message particles along edges (real-time only) - Deterministic layout, Figma-style pan, scroll/pinch zoom - Clean Architecture: ports/adapters/strategies, ES #private classes Integration: features/agent-graph/ (adapter + overlay + tab) - Full-screen overlay (Cmd+Shift+G) + Pin as Tab - Graph button in Team section header - Frustum culling, zero per-frame allocations, adaptive fps - Performance overlay via ?perf query param Also: CI runs on all PR branches, features/CLAUDE.md architecture guide --- .github/workflows/ci.yml | 12 +- CLAUDE.md | 4 + package.json | 1 + packages/agent-graph/package.json | 24 + .../src/canvas/background-layer.ts | 158 ++++++ .../agent-graph/src/canvas/bloom-renderer.ts | 70 +++ .../agent-graph/src/canvas/draw-agents.ts | 280 ++++++++++ packages/agent-graph/src/canvas/draw-edges.ts | 210 ++++++++ .../agent-graph/src/canvas/draw-effects.ts | 175 +++++++ packages/agent-graph/src/canvas/draw-misc.ts | 65 +++ .../agent-graph/src/canvas/draw-particles.ts | 154 ++++++ .../agent-graph/src/canvas/draw-processes.ts | 65 +++ packages/agent-graph/src/canvas/draw-tasks.ts | 178 +++++++ .../agent-graph/src/canvas/hit-detection.ts | 66 +++ packages/agent-graph/src/canvas/index.ts | 11 + .../agent-graph/src/canvas/render-cache.ts | 140 +++++ .../src/constants/canvas-constants.ts | 247 +++++++++ packages/agent-graph/src/constants/colors.ts | 167 ++++++ packages/agent-graph/src/constants/index.ts | 27 + .../agent-graph/src/hooks/useGraphCamera.ts | 178 +++++++ .../src/hooks/useGraphInteraction.ts | 89 ++++ .../src/hooks/useGraphSimulation.ts | 285 ++++++++++ packages/agent-graph/src/index.ts | 28 + .../agent-graph/src/layout/kanbanLayout.ts | 131 +++++ .../agent-graph/src/ports/GraphConfigPort.ts | 55 ++ .../agent-graph/src/ports/GraphDataPort.ts | 20 + .../agent-graph/src/ports/GraphEventPort.ts | 22 + packages/agent-graph/src/ports/index.ts | 13 + packages/agent-graph/src/ports/types.ts | 117 +++++ packages/agent-graph/src/strategies/index.ts | 27 + .../src/strategies/memberStrategy.ts | 72 +++ .../src/strategies/processStrategy.ts | 39 ++ .../src/strategies/taskStrategy.ts | 38 ++ packages/agent-graph/src/strategies/types.ts | 48 ++ packages/agent-graph/src/ui/GraphCanvas.tsx | 295 +++++++++++ packages/agent-graph/src/ui/GraphControls.tsx | 113 ++++ packages/agent-graph/src/ui/GraphOverlay.tsx | 165 ++++++ packages/agent-graph/src/ui/GraphView.tsx | 342 ++++++++++++ packages/agent-graph/tsconfig.json | 17 + pnpm-lock.yaml | 45 ++ pnpm-workspace.yaml | 1 + src/main/index.ts | 4 +- src/main/ipc/teams.ts | 2 +- .../infrastructure/PtyTerminalService.ts | 3 +- .../services/infrastructure/UpdaterService.ts | 3 +- .../services/team/ChangeExtractorService.ts | 11 +- src/main/services/team/TaskChangeComputer.ts | 4 +- src/main/services/team/TeamDataService.ts | 13 +- .../services/team/TeamLogSourceTracker.ts | 2 +- .../cache/JsonTaskChangePresenceRepository.ts | 2 +- .../cache/taskChangePresenceCacheSchema.ts | 2 +- src/main/services/team/index.ts | 2 +- src/main/workers/task-change-worker.ts | 2 +- src/preload/index.ts | 2 +- .../components/layout/PaneContent.tsx | 6 + .../components/layout/SortableTab.tsx | 2 + .../components/team/TeamDetailView.tsx | 78 ++- .../team/dialogs/TaskDetailDialog.tsx | 2 +- .../components/team/kanban/KanbanBoard.tsx | 3 +- src/renderer/features/CLAUDE.md | 494 ++++++++++++++++++ .../agent-graph/adapters/TeamGraphAdapter.ts | 399 ++++++++++++++ .../adapters/useTeamGraphAdapter.ts | 30 ++ src/renderer/features/agent-graph/index.ts | 9 + .../agent-graph/ui/TeamGraphOverlay.tsx | 57 ++ .../features/agent-graph/ui/TeamGraphTab.tsx | 31 ++ src/renderer/hooks/useKeyboardShortcuts.ts | 12 + src/renderer/store/index.ts | 11 +- src/renderer/store/slices/teamSlice.ts | 2 +- src/renderer/types/tabs.ts | 3 +- src/shared/types/api.ts | 2 +- .../CliInstallerService.test.ts | 8 +- 71 files changed, 5332 insertions(+), 63 deletions(-) create mode 100644 packages/agent-graph/package.json create mode 100644 packages/agent-graph/src/canvas/background-layer.ts create mode 100644 packages/agent-graph/src/canvas/bloom-renderer.ts create mode 100644 packages/agent-graph/src/canvas/draw-agents.ts create mode 100644 packages/agent-graph/src/canvas/draw-edges.ts create mode 100644 packages/agent-graph/src/canvas/draw-effects.ts create mode 100644 packages/agent-graph/src/canvas/draw-misc.ts create mode 100644 packages/agent-graph/src/canvas/draw-particles.ts create mode 100644 packages/agent-graph/src/canvas/draw-processes.ts create mode 100644 packages/agent-graph/src/canvas/draw-tasks.ts create mode 100644 packages/agent-graph/src/canvas/hit-detection.ts create mode 100644 packages/agent-graph/src/canvas/index.ts create mode 100644 packages/agent-graph/src/canvas/render-cache.ts create mode 100644 packages/agent-graph/src/constants/canvas-constants.ts create mode 100644 packages/agent-graph/src/constants/colors.ts create mode 100644 packages/agent-graph/src/constants/index.ts create mode 100644 packages/agent-graph/src/hooks/useGraphCamera.ts create mode 100644 packages/agent-graph/src/hooks/useGraphInteraction.ts create mode 100644 packages/agent-graph/src/hooks/useGraphSimulation.ts create mode 100644 packages/agent-graph/src/index.ts create mode 100644 packages/agent-graph/src/layout/kanbanLayout.ts create mode 100644 packages/agent-graph/src/ports/GraphConfigPort.ts create mode 100644 packages/agent-graph/src/ports/GraphDataPort.ts create mode 100644 packages/agent-graph/src/ports/GraphEventPort.ts create mode 100644 packages/agent-graph/src/ports/index.ts create mode 100644 packages/agent-graph/src/ports/types.ts create mode 100644 packages/agent-graph/src/strategies/index.ts create mode 100644 packages/agent-graph/src/strategies/memberStrategy.ts create mode 100644 packages/agent-graph/src/strategies/processStrategy.ts create mode 100644 packages/agent-graph/src/strategies/taskStrategy.ts create mode 100644 packages/agent-graph/src/strategies/types.ts create mode 100644 packages/agent-graph/src/ui/GraphCanvas.tsx create mode 100644 packages/agent-graph/src/ui/GraphControls.tsx create mode 100644 packages/agent-graph/src/ui/GraphOverlay.tsx create mode 100644 packages/agent-graph/src/ui/GraphView.tsx create mode 100644 packages/agent-graph/tsconfig.json create mode 100644 src/renderer/features/CLAUDE.md create mode 100644 src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts create mode 100644 src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts create mode 100644 src/renderer/features/agent-graph/index.ts create mode 100644 src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx create mode 100644 src/renderer/features/agent-graph/ui/TeamGraphTab.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba3cdbf..ae068fd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,12 @@ name: CI on: push: - branches: [main] + branches: [main, dev] paths: - 'src/**' - 'agent-teams-controller/**' - 'mcp-server/**' + - 'packages/**' - 'test/**' - '.github/workflows/**' - 'pnpm-workspace.yaml' @@ -18,11 +19,11 @@ on: - 'tailwind.config.*' - 'eslint.config.*' pull_request: - branches: [main] paths: - 'src/**' - 'agent-teams-controller/**' - 'mcp-server/**' + - 'packages/**' - 'test/**' - '.github/workflows/**' - 'pnpm-workspace.yaml' @@ -58,9 +59,10 @@ jobs: uses: actions/cache@v4 with: path: .eslintcache - key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*') }} - restore-keys: | - eslint-${{ runner.os }}- + key: eslint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.*', 'src/**/*.ts', 'src/**/*.tsx') }} + + - name: Auto-fix import sort (Node version parity) + run: npx eslint src/ --fix --no-cache || true - name: Validate workspace truth gate run: pnpm check diff --git a/CLAUDE.md b/CLAUDE.md index adfa3ce2..858c0dcf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,10 @@ Use path aliases for imports: - `@shared/*` → `src/shared/*` - `@preload/*` → `src/preload/*` +## Features Architecture +**All new features MUST be created in `src/renderer/features//`.** +See `src/renderer/features/CLAUDE.md` for the full guide on creating features with Clean Architecture, SOLID, and class-based patterns. + ## Data Sources ~/.claude/projects/{encoded-path}/*.jsonl - Session files ~/.claude/todos/{sessionId}.json - Todo data diff --git a/package.json b/package.json index 3ecf56ef..4e9f89b5 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "agent-teams-controller": "workspace:*", + "@claude-teams/agent-graph": "workspace:*", "chokidar": "^4.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/agent-graph/package.json b/packages/agent-graph/package.json new file mode 100644 index 00000000..2d4eb5c6 --- /dev/null +++ b/packages/agent-graph/package.json @@ -0,0 +1,24 @@ +{ + "name": "@claude-teams/agent-graph", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "dependencies": { + "d3-force": "^3.0.0" + }, + "devDependencies": { + "@types/d3-force": "^3.0.10" + } +} diff --git a/packages/agent-graph/src/canvas/background-layer.ts b/packages/agent-graph/src/canvas/background-layer.ts new file mode 100644 index 00000000..ea181392 --- /dev/null +++ b/packages/agent-graph/src/canvas/background-layer.ts @@ -0,0 +1,158 @@ +/** + * Background rendering: depth star field + hex grid. + * Adapted from agent-flow's background-layer.ts (Apache 2.0). + */ + +import { COLORS, alphaHex } from '../constants/colors'; +import { BACKGROUND } from '../constants/canvas-constants'; + +// ─── Depth Particle (star) ────────────────────────────────────────────────── + +export interface DepthParticle { + x: number; + y: number; + size: number; + brightness: number; + speed: number; + depth: number; +} + +export function createDepthParticles(w: number, h: number): DepthParticle[] { + const particles: DepthParticle[] = []; + for (let i = 0; i < BACKGROUND.starCount; i++) { + particles.push({ + x: Math.random() * w, + y: Math.random() * h, + size: 0.3 + Math.random() * 1.2, + brightness: 0.15 + Math.random() * 0.4, + speed: 0.05 + Math.random() * 0.15, + depth: Math.random(), + }); + } + return particles; +} + +export function updateDepthParticles( + particles: DepthParticle[], + w: number, + h: number, + dt: number, +): void { + for (const p of particles) { + p.y += p.speed * dt * 20; + if (p.y > h + 5) { + p.y = -5; + p.x = Math.random() * w; + } + } +} + +// ─── Background Drawing ───────────────────────────────────────────────────── + +/** + * Draw the space background: void fill + depth stars + optional hex grid. + */ +export function drawBackground( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + particles: DepthParticle[], + camera: { x: number; y: number; zoom: number }, + time: number, + options?: { showHexGrid?: boolean; showStarField?: boolean }, +): void { + const showStars = options?.showStarField ?? true; + const showHex = options?.showHexGrid ?? true; + + // Deep void background + ctx.fillStyle = COLORS.void; + ctx.fillRect(0, 0, w, h); + + // Depth star field + if (showStars) { + for (const p of particles) { + const parallax = 1 - p.depth * 0.3; + const sx = p.x + camera.x * parallax * 0.02; + const sy = p.y + camera.y * parallax * 0.02; + const twinkle = 0.7 + 0.3 * Math.sin(time * 2 + p.x * 0.01); + const alpha = p.brightness * twinkle; + + ctx.fillStyle = COLORS.holoBright + alphaHex(alpha); + ctx.beginPath(); + ctx.arc( + ((sx % w) + w) % w, + ((sy % h) + h) % h, + p.size, + 0, + Math.PI * 2, + ); + ctx.fill(); + } + } + + // Hex grid + if (showHex) { + drawHexGrid(ctx, w, h, camera, time); + } +} + +// ─── Hex Grid ─────────────────────────────────────────────────────────────── + +// Pre-computed hex vertex offsets +const HEX_OFFSETS: [number, number][] = []; +for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6; + HEX_OFFSETS.push([Math.cos(angle), Math.sin(angle)]); +} + +function drawHexGrid( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + camera: { x: number; y: number; zoom: number }, + time: number, +): void { + const size = BACKGROUND.hexSize; + const pulse = BACKGROUND.hexAlpha * (0.5 + 0.5 * Math.sin(time * BACKGROUND.hexPulseSpeed)); + + // Visible region in world space (expanded a bit for edge cells) + const worldX0 = -camera.x / camera.zoom - size * 2; + const worldY0 = -camera.y / camera.zoom - size * 2; + const worldX1 = (w - camera.x) / camera.zoom + size * 2; + const worldY1 = (h - camera.y) / camera.zoom + size * 2; + + const rowH = size * 1.5; + const colW = size * Math.sqrt(3); + + const rowStart = Math.floor(worldY0 / rowH); + const rowEnd = Math.ceil(worldY1 / rowH); + const colStart = Math.floor(worldX0 / colW); + const colEnd = Math.ceil(worldX1 / colW); + + ctx.save(); + ctx.translate(camera.x, camera.y); + ctx.scale(camera.zoom, camera.zoom); + + ctx.strokeStyle = COLORS.hexGrid + alphaHex(pulse); + ctx.lineWidth = 0.5 / camera.zoom; + + ctx.beginPath(); + for (let row = rowStart; row <= rowEnd; row++) { + for (let col = colStart; col <= colEnd; col++) { + const cx = col * colW + (row % 2 === 0 ? 0 : colW / 2); + const cy = row * rowH; + + for (let i = 0; i < 6; i++) { + const [ox, oy] = HEX_OFFSETS[i]; + const px = cx + ox * size; + const py = cy + oy * size; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + } + ctx.closePath(); + } + } + ctx.stroke(); + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/bloom-renderer.ts b/packages/agent-graph/src/canvas/bloom-renderer.ts new file mode 100644 index 00000000..efbe85a2 --- /dev/null +++ b/packages/agent-graph/src/canvas/bloom-renderer.ts @@ -0,0 +1,70 @@ +/** + * Post-processing bloom effect. + * Adapted from agent-flow's bloom-renderer.ts (Apache 2.0). + * Zero imports — pure Canvas 2D. + */ + +export class BloomRenderer { + #bloomCanvas: HTMLCanvasElement; + #bloomCtx: CanvasRenderingContext2D; + #tempCanvas: HTMLCanvasElement; + #tempCtx: CanvasRenderingContext2D; + #intensity: number; + #w = 0; + #h = 0; + + constructor(intensity = 0.6) { + this.#intensity = intensity; + this.#bloomCanvas = document.createElement('canvas'); + this.#bloomCtx = this.#bloomCanvas.getContext('2d')!; + this.#tempCanvas = document.createElement('canvas'); + this.#tempCtx = this.#tempCanvas.getContext('2d')!; + } + + resize(w: number, h: number): void { + const hw = Math.ceil(w / 2); + const hh = Math.ceil(h / 2); + if (this.#w === hw && this.#h === hh) return; + this.#w = hw; + this.#h = hh; + this.#bloomCanvas.width = hw; + this.#bloomCanvas.height = hh; + this.#tempCanvas.width = hw; + this.#tempCanvas.height = hh; + } + + setIntensity(v: number): void { + this.#intensity = Math.max(0, Math.min(1, v)); + } + + apply(source: HTMLCanvasElement, targetCtx: CanvasRenderingContext2D): void { + if (this.#intensity <= 0 || this.#w === 0) return; + + this.#bloomCtx.clearRect(0, 0, this.#w, this.#h); + this.#bloomCtx.drawImage(source, 0, 0, this.#w, this.#h); + + const radii = [8, 6, 4]; + for (const r of radii) { + this.#tempCtx.clearRect(0, 0, this.#w, this.#h); + this.#tempCtx.filter = `blur(${r}px)`; + this.#tempCtx.drawImage(this.#bloomCanvas, 0, 0); + this.#tempCtx.filter = 'none'; + + this.#bloomCtx.clearRect(0, 0, this.#w, this.#h); + this.#bloomCtx.drawImage(this.#tempCanvas, 0, 0); + } + + const prevOp = targetCtx.globalCompositeOperation; + const prevAlpha = targetCtx.globalAlpha; + targetCtx.globalCompositeOperation = 'lighter'; + targetCtx.globalAlpha = this.#intensity; + targetCtx.drawImage(this.#bloomCanvas, 0, 0, source.width, source.height); + targetCtx.globalCompositeOperation = prevOp; + targetCtx.globalAlpha = prevAlpha; + } + + [Symbol.dispose](): void { + this.#w = 0; + this.#h = 0; + } +} diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts new file mode 100644 index 00000000..5c97774b --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -0,0 +1,280 @@ +/** + * Agent (member/lead) node drawing with holographic effects. + * Adapted from agent-flow's draw-agents.ts (Apache 2.0). + * Uses our GraphNode port type instead of agent-flow's Agent type. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS, getStateColor, alphaHex } from '../constants/colors'; +import { NODE, AGENT_DRAW, CONTEXT_RING, ANIM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; +import { drawHexagon } from './draw-misc'; +import { getAgentGlowSprite, ensureHex, hexWithAlpha } from './render-cache'; + +/** + * Draw all member/lead nodes on the canvas. + */ +export function drawAgents( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'member' && node.kind !== 'lead') continue; + const opacity = getNodeOpacity(node); + if (opacity < MIN_VISIBLE_OPACITY) continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = node.kind === 'lead' ? NODE.radiusLead : NODE.radiusMember; + const color = node.color ?? getStateColor(node.state); + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = opacity; + + // Depth shadow + drawDepthShadow(ctx, x, y, r); + + // Outer glow + drawGlow(ctx, x, y, r, color); + + // Hexagonal body with interior fill + drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered); + + // Breathing animation + drawBreathing(ctx, x, y, r, node.state, time); + + // Name label + drawLabel(ctx, x, y, r, node.label, color); + + // Role subtitle + if (node.role) { + drawSublabel(ctx, x, y, r, node.role); + } + + // Context ring for lead + if (node.kind === 'lead' && node.contextUsage != null) { + drawContextRing(ctx, x, y, r, node.contextUsage, time); + } + + // Selection ring + if (isSelected) { + drawSelectionRing(ctx, x, y, r, color); + } + + ctx.restore(); + } +} + +// ─── Private Helpers ──────────────────────────────────────────────────────── + +function getNodeOpacity(node: GraphNode): number { + if (node.state === 'terminated' || node.state === 'complete') return 0.3; + if (node.spawnStatus === 'spawning') return 0.6; + if (node.spawnStatus === 'waiting') return 0.4; + if (node.spawnStatus === 'offline') return 0; + return 1; +} + +function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { + ctx.save(); + ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; + ctx.shadowBlur = AGENT_DRAW.shadowBlur; + ctx.shadowOffsetX = AGENT_DRAW.shadowOffsetX; + ctx.shadowOffsetY = AGENT_DRAW.shadowOffsetY; + drawHexagon(ctx, x, y, r); + ctx.fillStyle = 'rgba(0, 0, 0, 0.01)'; + ctx.fill(); + ctx.restore(); +} + +function drawGlow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string): void { + const outerR = r + AGENT_DRAW.glowPadding; + const sprite = getAgentGlowSprite(color, r * 0.5, outerR); + ctx.drawImage(sprite, x - outerR, y - outerR); +} + +function drawHexBody( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, + state: string, + time: number, + isSelected: boolean, + isHovered: boolean, +): void { + // Interior fill + drawHexagon(ctx, x, y, r); + ctx.fillStyle = isSelected + ? 'rgba(100, 200, 255, 0.15)' + : COLORS.nodeInterior; + ctx.fill(); + + // Scanline effect + const scanSpeed = state === 'active' || state === 'thinking' + ? ANIM.scanline.active + : ANIM.scanline.normal; + const scanY = ((time * scanSpeed) % (r * 2)) - r; + ctx.save(); + drawHexagon(ctx, x, y, r); + ctx.clip(); + const grad = ctx.createLinearGradient( + x, + y + scanY - AGENT_DRAW.scanlineHalfH, + x, + y + scanY + AGENT_DRAW.scanlineHalfH, + ); + grad.addColorStop(0, hexWithAlpha(color, 0)); + grad.addColorStop(0.5, hexWithAlpha(color, 0.13)); + grad.addColorStop(1, hexWithAlpha(color, 0)); + ctx.fillStyle = grad; + ctx.fillRect(x - r, y + scanY - AGENT_DRAW.scanlineHalfH, r * 2, AGENT_DRAW.scanlineHalfH * 2); + ctx.restore(); + + // Border + drawHexagon(ctx, x, y, r); + ctx.strokeStyle = hexWithAlpha(color, isHovered ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); +} + +function drawBreathing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + state: string, + time: number, +): void { + const isActive = state === 'active' || state === 'thinking' || state === 'tool_calling'; + const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed; + const amp = isActive ? ANIM.breathe.activeAmp : ANIM.breathe.idleAmp; + const breathe = 1 + amp * Math.sin(time * speed); + + if (isActive) { + // Orbiting particles for active agents + const orbitR = r + AGENT_DRAW.orbitParticleOffset; + const count = 4; + for (let i = 0; i < count; i++) { + const angle = time * ANIM.orbitSpeed + (Math.PI * 2 * i) / count; + const px = x + orbitR * breathe * Math.cos(angle); + const py = y + orbitR * breathe * Math.sin(angle); + ctx.fillStyle = COLORS.holoBright + '80'; + ctx.beginPath(); + ctx.arc(px, py, AGENT_DRAW.orbitParticleSize, 0, Math.PI * 2); + ctx.fill(); + } + } else { + // Subtle pulsing glow ring for idle agents + const pulseAlpha = 0.04 + 0.04 * Math.sin(time * speed); + ctx.beginPath(); + ctx.arc(x, y, r + 2, 0, Math.PI * 2); + ctx.strokeStyle = COLORS.holoBase + alphaHex(pulseAlpha); + ctx.lineWidth = 1; + ctx.stroke(); + } +} + +function drawLabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + label: string, + color: string, +): void { + ctx.font = `bold 10px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = color; + ctx.fillText(label, x, y + r + AGENT_DRAW.labelYOffset); +} + +function drawSublabel( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + sublabel: string, +): void { + ctx.font = '7px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = COLORS.textDim; + ctx.fillText(sublabel, x, y + r + AGENT_DRAW.labelYOffset + 13); +} + +/** + * Draw context usage ring around lead node. + */ +export function drawContextRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + usage: number, + time: number, +): void { + const ringR = r + CONTEXT_RING.ringOffset; + const startAngle = -Math.PI / 2; + const endAngle = startAngle + Math.PI * 2 * Math.min(1, usage); + + // Background ring + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = COLORS.holoBright + '15'; + ctx.lineWidth = CONTEXT_RING.ringWidth; + ctx.stroke(); + + // Usage arc + let ringColor: string = COLORS.complete; + if (usage > CONTEXT_RING.criticalThreshold) { + ringColor = COLORS.error; + } else if (usage > CONTEXT_RING.warningThreshold) { + ringColor = COLORS.waiting; + } + + // Pulsing glow for high usage + if (usage > CONTEXT_RING.warningThreshold) { + const pulse = 0.5 + 0.5 * Math.sin(time * 3); + ctx.beginPath(); + ctx.arc(x, y, ringR, startAngle, endAngle); + ctx.strokeStyle = ringColor + alphaHex(0.3 * pulse); + ctx.lineWidth = CONTEXT_RING.ringWidth + CONTEXT_RING.glowPadding; + ctx.stroke(); + } + + ctx.beginPath(); + ctx.arc(x, y, ringR, startAngle, endAngle); + ctx.strokeStyle = ringColor; + ctx.lineWidth = CONTEXT_RING.ringWidth; + ctx.stroke(); + + // Percentage label + if (usage > CONTEXT_RING.percentLabelThreshold) { + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.fillStyle = ringColor; + ctx.fillText(`${Math.round(usage * 100)}%`, x, y - r - CONTEXT_RING.percentYOffset); + } +} + +function drawSelectionRing( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, +): void { + drawHexagon(ctx, x, y, r + 4); + ctx.strokeStyle = hexWithAlpha(color, 0.67); + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.stroke(); + ctx.setLineDash([]); +} diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts new file mode 100644 index 00000000..7b764797 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -0,0 +1,210 @@ +/** + * Edge drawing with tapered bezier curves and gradients. + * Adapted from agent-flow's draw-edges.ts (Apache 2.0). + */ + +import type { GraphNode, GraphEdge, GraphEdgeType } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants'; + +// ─── Edge Type → Color/Width Mapping ──────────────────────────────────────── + +const EDGE_STYLES: Record = { + 'parent-child': { color: COLORS.edgeParentChild, ...BEAM.parentChild }, + ownership: { color: COLORS.edgeOwnership, ...BEAM.ownership }, + blocking: { color: COLORS.edgeBlocking, ...BEAM.blocking, dash: [8, 4] }, + related: { color: COLORS.edgeRelated, ...BEAM.related, dash: [4, 4] }, + message: { color: COLORS.edgeMessage, ...BEAM.message }, +}; + +// ─── Bezier Utilities ─────────────────────────────────────────────────────── + +export interface ControlPoints { + cp1x: number; + cp1y: number; + cp2x: number; + cp2y: number; +} + +export function computeControlPoints( + x1: number, + y1: number, + x2: number, + y2: number, +): ControlPoints { + const dx = x2 - x1; + const dy = y2 - y1; + const nx = -dy * BEAM.curvature; + const ny = dx * BEAM.curvature; + return { + cp1x: x1 + dx * BEAM.cp1 + nx, + cp1y: y1 + dy * BEAM.cp1 + ny, + cp2x: x1 + dx * BEAM.cp2 + nx, + cp2y: y1 + dy * BEAM.cp2 + ny, + }; +} + +/** + * Evaluate a cubic bezier at parameter t. + */ +export function bezierPoint( + x1: number, + y1: number, + cp: ControlPoints, + x2: number, + y2: number, + t: number, +): { x: number; y: number } { + const u = 1 - t; + const uu = u * u; + const uuu = uu * u; + const tt = t * t; + const ttt = tt * t; + return { + x: uuu * x1 + 3 * uu * t * cp.cp1x + 3 * u * tt * cp.cp2x + ttt * x2, + y: uuu * y1 + 3 * uu * t * cp.cp1y + 3 * u * tt * cp.cp2y + ttt * y2, + }; +} + +// ─── Draw All Edges ───────────────────────────────────────────────────────── + +export function drawEdges( + ctx: CanvasRenderingContext2D, + edges: GraphEdge[], + nodeMap: Map, + _time: number, + hasActiveParticles: Set, +): void { + for (const edge of edges) { + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; + const isActive = hasActiveParticles.has(edge.id); + const alpha = isActive ? BEAM.activeAlpha : BEAM.idleAlpha; + + if (alpha < MIN_VISIBLE_OPACITY) continue; + + const cp = computeControlPoints(source.x, source.y, target.x, target.y); + + ctx.save(); + ctx.globalAlpha = alpha; + + // Draw tapered bezier + drawTaperedBezier( + ctx, + source.x, + source.y, + cp, + target.x, + target.y, + style.startW, + style.endW, + edge.color ?? style.color, + style.dash, + ); + + // Arrow for blocking edges + if (edge.type === 'blocking') { + drawArrowHead(ctx, cp, target.x, target.y, style.color, alpha); + } + + ctx.restore(); + } +} + +// ─── Tapered Bezier ───────────────────────────────────────────────────────── + +function drawTaperedBezier( + ctx: CanvasRenderingContext2D, + x1: number, + y1: number, + cp: ControlPoints, + x2: number, + y2: number, + startW: number, + endW: number, + color: string, + dash?: number[], +): void { + if (dash) { + // Dashed edges use stroke, not fill polygon + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, x2, y2); + ctx.strokeStyle = color; + ctx.lineWidth = (startW + endW) / 2; + ctx.setLineDash(dash); + ctx.stroke(); + ctx.setLineDash([]); + return; + } + + // Build polygon outline for tapered width + const segments = BEAM.segments; + const leftPoints: { x: number; y: number }[] = []; + const rightPoints: { x: number; y: number }[] = []; + + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const pos = bezierPoint(x1, y1, cp, x2, y2, t); + const w = startW + (endW - startW) * t; + + // Normal perpendicular + const dt = 0.01; + const tNext = Math.min(1, t + dt); + const posNext = bezierPoint(x1, y1, cp, x2, y2, tNext); + const dx = posNext.x - pos.x; + const dy = posNext.y - pos.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = -dy / len; + const ny = dx / len; + + leftPoints.push({ x: pos.x + nx * w * 0.5, y: pos.y + ny * w * 0.5 }); + rightPoints.push({ x: pos.x - nx * w * 0.5, y: pos.y - ny * w * 0.5 }); + } + + ctx.beginPath(); + ctx.moveTo(leftPoints[0].x, leftPoints[0].y); + for (let i = 1; i < leftPoints.length; i++) { + ctx.lineTo(leftPoints[i].x, leftPoints[i].y); + } + for (let i = rightPoints.length - 1; i >= 0; i--) { + ctx.lineTo(rightPoints[i].x, rightPoints[i].y); + } + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); +} + +// ─── Arrow Head ───────────────────────────────────────────────────────────── + +function drawArrowHead( + ctx: CanvasRenderingContext2D, + cp: ControlPoints, + x2: number, + y2: number, + color: string, + alpha: number, +): void { + // Compute direction at t=1 + const dx = x2 - cp.cp2x; + const dy = y2 - cp.cp2y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const ux = dx / len; + const uy = dy / len; + const arrowSize = 8; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - ux * arrowSize - uy * arrowSize * 0.5, y2 - uy * arrowSize + ux * arrowSize * 0.5); + ctx.lineTo(x2 - ux * arrowSize + uy * arrowSize * 0.5, y2 - uy * arrowSize - ux * arrowSize * 0.5); + ctx.closePath(); + ctx.fill(); + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/draw-effects.ts b/packages/agent-graph/src/canvas/draw-effects.ts new file mode 100644 index 00000000..b2e4e68e --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-effects.ts @@ -0,0 +1,175 @@ +/** + * Visual effects: spawn animation, completion shatter, spawn ring. + * Adapted from agent-flow's draw-effects.ts (Apache 2.0). + */ + +import { alphaHex } from '../constants/colors'; +import { SPAWN_FX, COMPLETE_FX } from '../constants/canvas-constants'; +import { drawHexagon } from './draw-misc'; +import { hexWithAlpha } from './render-cache'; + +// ─── Effect Type ──────────────────────────────────────────────────────────── + +export interface VisualEffect { + type: 'spawn' | 'complete' | 'shatter'; + x: number; + y: number; + color: string; + age: number; + duration: number; + particles?: ShatterParticle[]; +} + +interface ShatterParticle { + angle: number; + speed: number; + size: number; +} + +/** + * Create a spawn effect at position. + */ +export function createSpawnEffect(x: number, y: number, color: string): VisualEffect { + return { type: 'spawn', x, y, color, age: 0, duration: 0.8 }; +} + +/** + * Create a completion shatter effect at position. + */ +export function createCompleteEffect(x: number, y: number, color: string): VisualEffect { + const particles: ShatterParticle[] = []; + for (let i = 0; i < 12; i++) { + particles.push({ + angle: (Math.PI * 2 * i) / 12 + (Math.random() - 0.5) * 0.3, + speed: 30 + Math.random() * 60, + size: 1 + Math.random() * 2, + }); + } + return { type: 'shatter', x, y, color, age: 0, duration: 0.8, particles }; +} + +// ─── Draw Effects ─────────────────────────────────────────────────────────── + +export function drawEffects( + ctx: CanvasRenderingContext2D, + effects: VisualEffect[], +): void { + for (const fx of effects) { + const progress = fx.age / fx.duration; + if (progress >= 1) continue; + + switch (fx.type) { + case 'spawn': + drawSpawnEffect(ctx, fx, progress); + break; + case 'complete': + drawCompleteEffect(ctx, fx, progress); + break; + case 'shatter': + drawShatterEffect(ctx, fx, progress); + break; + } + } +} + +// ─── Spawn: expanding hex ring + white flash ──────────────────────────────── + +function drawSpawnEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + const alpha = SPAWN_FX.maxAlpha * (1 - progress); + const ringR = SPAWN_FX.ringStart + SPAWN_FX.ringExpand * progress; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Expanding hex ring + drawHexagon(ctx, fx.x, fx.y, ringR); + ctx.strokeStyle = fx.color; + ctx.lineWidth = 2 * (1 - progress); + ctx.stroke(); + + // Flash + if (progress < SPAWN_FX.flashThreshold) { + const flashProgress = progress / SPAWN_FX.flashThreshold; + const flashR = SPAWN_FX.flashBaseRadius * (1 - flashProgress) + SPAWN_FX.flashMinRadius; + ctx.fillStyle = '#ffffff' + alphaHex(SPAWN_FX.flashAlpha * (1 - flashProgress)); + ctx.beginPath(); + ctx.arc(fx.x, fx.y, flashR, 0, Math.PI * 2); + ctx.fill(); + } + + // Scatter particles + for (let i = 0; i < SPAWN_FX.particleCount; i++) { + const angle = (Math.PI * 2 * i) / SPAWN_FX.particleCount; + const dist = ringR * 0.8 * progress; + const px = fx.x + Math.cos(angle) * dist; + const py = fx.y + Math.sin(angle) * dist; + ctx.fillStyle = fx.color + alphaHex(alpha * 0.6); + ctx.beginPath(); + ctx.arc(px, py, SPAWN_FX.particleSize * (1 - progress), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} + +// ─── Complete: white flash + expanding ring ───────────────────────────────── + +function drawCompleteEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + const alpha = COMPLETE_FX.maxAlpha * (1 - progress); + const ringR = COMPLETE_FX.ringStart + COMPLETE_FX.ringExpand * progress; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Expanding ring + ctx.beginPath(); + ctx.arc(fx.x, fx.y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = fx.color; + ctx.lineWidth = COMPLETE_FX.lineWidthMax * (1 - progress); + ctx.stroke(); + + // Flash + if (progress < COMPLETE_FX.flashThreshold) { + const flashAlpha = COMPLETE_FX.flashAlpha * (1 - progress / COMPLETE_FX.flashThreshold); + ctx.fillStyle = '#ffffff' + alphaHex(flashAlpha); + ctx.beginPath(); + ctx.arc(fx.x, fx.y, COMPLETE_FX.flashRadius * (1 - progress), 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} + +// ─── Shatter: particles scatter outward ───────────────────────────────────── + +function drawShatterEffect(ctx: CanvasRenderingContext2D, fx: VisualEffect, progress: number): void { + if (!fx.particles) return; + + const alpha = 1 - progress; + ctx.save(); + ctx.globalAlpha = alpha; + + for (const p of fx.particles) { + const dist = p.speed * progress; + const px = fx.x + Math.cos(p.angle) * dist; + const py = fx.y + Math.sin(p.angle) * dist; + const size = p.size * (1 - progress * 0.5); + + // Glow + const grad = ctx.createRadialGradient(px, py, 0, px, py, size * 3); + grad.addColorStop(0, fx.color + alphaHex(alpha * 0.4)); + grad.addColorStop(1, hexWithAlpha(fx.color, 0)); + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(px, py, size * 3, 0, Math.PI * 2); + ctx.fill(); + + // Core + ctx.fillStyle = fx.color; + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/draw-misc.ts b/packages/agent-graph/src/canvas/draw-misc.ts new file mode 100644 index 00000000..1a60831e --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-misc.ts @@ -0,0 +1,65 @@ +/** + * Utility drawing functions. + * Adapted from agent-flow's draw-misc.ts (Apache 2.0). + */ + +import { measureTextCached } from './render-cache'; + +/** + * Truncate text to fit within maxWidth, appending "..." if needed. + * Uses binary search for efficiency. + */ +export function truncateText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number, + font: string, +): string { + if (measureTextCached(ctx, font, text) <= maxWidth) return text; + + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (measureTextCached(ctx, font, text.slice(0, mid) + '...') <= maxWidth) { + lo = mid; + } else { + hi = mid - 1; + } + } + return lo > 0 ? text.slice(0, lo) + '...' : '...'; +} + +// Pre-computed hex vertex unit offsets (avoids cos/sin per call) +const HEX_COS: number[] = []; +const HEX_SIN: number[] = []; +for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6; + HEX_COS.push(Math.cos(angle)); + HEX_SIN.push(Math.sin(angle)); +} + +/** + * Draw a regular hexagon path centered at (x, y) with given radius. + */ +export function drawHexagon( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, +): void { + ctx.beginPath(); + ctx.moveTo(x + radius * HEX_COS[0], y + radius * HEX_SIN[0]); + ctx.lineTo(x + radius * HEX_COS[1], y + radius * HEX_SIN[1]); + ctx.lineTo(x + radius * HEX_COS[2], y + radius * HEX_SIN[2]); + ctx.lineTo(x + radius * HEX_COS[3], y + radius * HEX_SIN[3]); + ctx.lineTo(x + radius * HEX_COS[4], y + radius * HEX_SIN[4]); + ctx.lineTo(x + radius * HEX_COS[5], y + radius * HEX_SIN[5]); + ctx.closePath(); +} + +/** + * SVG path data for the Claude spark logo (256×256 viewbox). + */ +export const CLAUDE_SPARK_D = + 'M128,8C60.6,8,8,60.6,8,128s52.6,120,120,120s120-52.6,120-120S195.4,8,128,8z M161.6,169.6 c-4.8,8-16,10.8-24,6l-9.6-5.6l-9.6,5.6c-8,4.8-19.2,1.6-24-6c-4.8-8-1.6-19.2,6-24l9.6-5.6v-11.2l-9.6-5.6 c-8-4.8-10.8-16-6-24c4.8-8,16-10.8,24-6l9.6,5.6l9.6-5.6c8-4.8,19.2-1.6,24,6c4.8,8,1.6,19.2-6,24l-9.6,5.6v11.2l9.6,5.6 C163.2,150.4,166.4,161.6,161.6,169.6z'; diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts new file mode 100644 index 00000000..863aa250 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -0,0 +1,154 @@ +/** + * Particle animation along edges. + * Adapted from agent-flow's draw-particles.ts (Apache 2.0). + */ + +import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { PARTICLE_DRAW, BEAM } from '../constants/canvas-constants'; +import { bezierPoint, computeControlPoints, type ControlPoints } from './draw-edges'; +import { getGlowSprite, hexWithAlpha } from './render-cache'; + +/** + * Build a lookup from edge.id → edge for fast particle→edge resolution. + */ +export function buildEdgeMap(edges: GraphEdge[]): Map { + const map = new Map(); + for (const e of edges) map.set(e.id, e); + return map; +} + +/** + * Draw all active particles along their edges. + */ +export function drawParticles( + ctx: CanvasRenderingContext2D, + particles: GraphParticle[], + edgeMap: Map, + nodeMap: Map, + time: number, +): void { + for (const p of particles) { + const edge = edgeMap.get(p.edgeId); + if (!edge) continue; + + const source = nodeMap.get(edge.source); + const target = nodeMap.get(edge.target); + if (!source || !target) continue; + if (source.x == null || source.y == null || target.x == null || target.y == null) continue; + + const cp = computeControlPoints(source.x, source.y, target.x, target.y); + const color = p.color || COLORS.message; + const baseSize = (p.size ?? 1) * 3; + // Differentiate visual by particle kind + const size = p.kind === 'spawn' ? baseSize * 1.5 + : p.kind === 'review_request' || p.kind === 'review_response' ? baseSize * 1.2 + : baseSize; + + // Wobble offset for organic look + const phaseOffset = p.id.charCodeAt(Math.min(5, p.id.length - 1)) * 0.1; + const wobbleAmp = BEAM.wobble.amp; + + drawParticleTrail(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time); + drawParticleCore(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time); + + // Label + if (p.label && p.progress > PARTICLE_DRAW.labelMinT && p.progress < PARTICLE_DRAW.labelMaxT) { + const pos = getWobbledPosition(source, target, cp, p.progress, wobbleAmp, phaseOffset, time); + ctx.font = `${PARTICLE_DRAW.labelFontSize}px monospace`; + ctx.textAlign = 'center'; + ctx.fillStyle = hexWithAlpha(color, 0.56); + ctx.fillText(p.label, pos.x, pos.y + PARTICLE_DRAW.labelYOffset); + } + } +} + +// ─── Private Helpers ──────────────────────────────────────────────────────── + +function getWobbledPosition( + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + t: number, + wobbleAmp: number, + phaseOffset: number, + time: number, +): { x: number; y: number } { + const pos = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, t); + + // Perpendicular wobble + const dt = 0.01; + const tNext = Math.min(1, t + dt); + const posNext = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, tNext); + const dx = posNext.x - pos.x; + const dy = posNext.y - pos.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = -dy / len; + const ny = dx / len; + + const wobble = Math.sin(t * BEAM.wobble.freq + time * BEAM.wobble.timeFreq + phaseOffset) * wobbleAmp; + return { + x: pos.x + nx * wobble, + y: pos.y + ny * wobble, + }; +} + +function drawParticleTrail( + ctx: CanvasRenderingContext2D, + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + progress: number, + color: string, + size: number, + wobbleAmp: number, + phaseOffset: number, + time: number, +): void { + const trailSegments = 6; + const trailStep = BEAM.wobble.trailOffset / trailSegments; + + for (let i = trailSegments; i >= 1; i--) { + const t = Math.max(0, progress - trailStep * i); + const pos = getWobbledPosition(source, target, cp, t, wobbleAmp, phaseOffset, time); + const alpha = (1 - i / trailSegments) * 0.3; + const trailSize = size * (1 - i / trailSegments) * 0.5; + + ctx.fillStyle = hexWithAlpha(color, alpha); + ctx.beginPath(); + ctx.arc(pos.x, pos.y, trailSize, 0, Math.PI * 2); + ctx.fill(); + } +} + +function drawParticleCore( + ctx: CanvasRenderingContext2D, + source: GraphNode, + target: GraphNode, + cp: ControlPoints, + progress: number, + color: string, + size: number, + wobbleAmp: number, + phaseOffset: number, + time: number, +): void { + const pos = getWobbledPosition(source, target, cp, progress, wobbleAmp, phaseOffset, time); + + // Glow sprite + const glowR = PARTICLE_DRAW.glowRadius; + const sprite = getGlowSprite(color, glowR, 0.4, 0); + ctx.drawImage(sprite, pos.x - glowR, pos.y - glowR); + + // Core dot + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, size, 0, Math.PI * 2); + ctx.fill(); + + // Highlight + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, size * PARTICLE_DRAW.coreHighlightScale, 0, Math.PI * 2); + ctx.fill(); +} diff --git a/packages/agent-graph/src/canvas/draw-processes.ts b/packages/agent-graph/src/canvas/draw-processes.ts new file mode 100644 index 00000000..25485126 --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-processes.ts @@ -0,0 +1,65 @@ +/** + * Process node rendering — small circles for running processes. + * NEW — not from agent-flow. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS } from '../constants/colors'; +import { NODE } from '../constants/canvas-constants'; +import { hexWithAlpha, getGlowSprite } from './render-cache'; + +/** + * Draw all process nodes as small circles. + */ +export function drawProcesses( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'process') continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusProcess; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = 0.8; + + // Glow — use cached sprite instead of createRadialGradient per frame + const procColor = node.color ?? COLORS.tool_calling; + const glowSprite = getGlowSprite(procColor, r * 2, 0.19, 0); + ctx.drawImage(glowSprite, x - r * 2, y - r * 2); + + // Body + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = isSelected ? COLORS.cardBgSelected : COLORS.cardBg; + ctx.fill(); + ctx.strokeStyle = hexWithAlpha(procColor, 0.38); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); + + // Spinning ring for active processes + const spinAngle = time * 2; + ctx.beginPath(); + ctx.arc(x, y, r + 3, spinAngle, spinAngle + Math.PI * 0.8); + ctx.strokeStyle = hexWithAlpha(procColor, 0.38); + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Label + ctx.font = '7px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = COLORS.textDim; + const label = node.label.length > 12 ? node.label.slice(0, 12) + '...' : node.label; + ctx.fillText(label, x, y + r + 4); + + ctx.restore(); + } +} diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts new file mode 100644 index 00000000..d7f7a00e --- /dev/null +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -0,0 +1,178 @@ +/** + * Task pill-shaped node rendering. + * NEW — not from agent-flow. Custom renderer for our task nodes. + */ + +import type { GraphNode } from '../ports/types'; +import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors'; +import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants'; +import { truncateText } from './draw-misc'; +import { hexWithAlpha } from './render-cache'; + +/** + * Draw all task nodes as pill-shaped cards. + */ +export function drawTasks( + ctx: CanvasRenderingContext2D, + nodes: GraphNode[], + time: number, + selectedId: string | null, + hoveredId: string | null, +): void { + for (const node of nodes) { + if (node.kind !== 'task') continue; + + const opacity = getTaskOpacity(node); + if (opacity < MIN_VISIBLE_OPACITY) continue; + + const x = node.x ?? 0; + const y = node.y ?? 0; + const isSelected = node.id === selectedId; + const isHovered = node.id === hoveredId; + + ctx.save(); + ctx.globalAlpha = opacity; + + drawTaskPill(ctx, x, y, node, time, isSelected, isHovered); + + ctx.restore(); + } +} + +// ─── Private ──────────────────────────────────────────────────────────────── + +function getTaskOpacity(node: GraphNode): number { + if (node.taskStatus === 'deleted') return 0; + if (node.reviewState === 'approved') return 0.65; + if (node.taskStatus === 'completed') return 0.45; + return 1; +} + +function drawTaskPill( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + node: GraphNode, + time: number, + isSelected: boolean, + isHovered: boolean, +): void { + const w = TASK_PILL.width; + const h = TASK_PILL.height; + const r = TASK_PILL.borderRadius; + const halfW = w / 2; + const halfH = h / 2; + + const statusColor = getTaskStatusColor(node.taskStatus); + const reviewColor = getReviewStateColor(node.reviewState); + + // Pulse only for active work — completed + approved = static + const needsAttention = + (node.taskStatus === 'in_progress' && node.reviewState !== 'approved') || + node.reviewState === 'review' || + node.reviewState === 'needsFix' || + (node.needsClarification != null); + const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved'; + const breathe = needsAttention && !isFinished + ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) + : 1; + const scale = breathe; + + ctx.save(); + ctx.translate(x, y); + ctx.scale(scale, scale); + + // Shadow — stronger for attention tasks + ctx.shadowColor = hexWithAlpha(statusColor, 0.25); + ctx.shadowBlur = needsAttention ? 12 : 4; + + // Background fill + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, w, h, r); + ctx.fillStyle = isSelected + ? COLORS.cardBgSelected + : isHovered + ? 'rgba(15, 20, 40, 0.7)' + : COLORS.cardBg; + ctx.fill(); + ctx.shadowBlur = 0; + + // Border + ctx.beginPath(); + ctx.roundRect(-halfW, -halfH, w, h, r); + ctx.strokeStyle = hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5); + ctx.lineWidth = isSelected ? 2 : 1; + ctx.stroke(); + + // Review state overlay border — pulsing for review/needsFix, STATIC for approved + if (reviewColor !== 'transparent') { + ctx.beginPath(); + ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1); + const reviewAlpha = node.reviewState === 'approved' + ? 0.6 // static — no pulse + : 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix + ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha); + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + // Clarification warning indicator + if (node.needsClarification) { + const pulseAlpha = 0.4 + 0.4 * Math.sin(time * 4); + ctx.beginPath(); + ctx.roundRect(-halfW - 2, -halfH - 2, w + 4, h + 4, r + 2); + ctx.strokeStyle = hexWithAlpha(COLORS.error, pulseAlpha); + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Status dot + ctx.fillStyle = statusColor; + ctx.beginPath(); + ctx.arc( + -halfW + TASK_PILL.statusDotX, + 0, + TASK_PILL.statusDotRadius, + 0, + Math.PI * 2, + ); + ctx.fill(); + + // Display ID + const displayId = node.displayId ?? node.label; + ctx.font = `bold ${TASK_PILL.idFontSize}px monospace`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = isFinished ? COLORS.textDim : COLORS.textPrimary; + ctx.fillText(displayId, -halfW + TASK_PILL.textOffsetX, -4); + + // Subject text + if (node.sublabel) { + ctx.font = `${TASK_PILL.subjectFontSize}px sans-serif`; + ctx.fillStyle = isFinished ? COLORS.textMuted : COLORS.textDim; + const maxW = w - TASK_PILL.textOffsetX - 8; + const subject = truncateText(ctx, node.sublabel, maxW, ctx.font); + ctx.fillText(subject, -halfW + TASK_PILL.textOffsetX, 8); + } + + // Approved badge: checkmark at right side + if (node.reviewState === 'approved') { + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = COLORS.reviewApproved; + ctx.fillText('\u2713', halfW - 8, 0); // ✓ + } + + // Completed: subtle strikethrough line + if (node.taskStatus === 'completed' && node.reviewState !== 'approved') { + ctx.beginPath(); + ctx.moveTo(-halfW + TASK_PILL.textOffsetX, 0); + ctx.lineTo(halfW - 10, 0); + ctx.strokeStyle = COLORS.textMuted; + ctx.lineWidth = 0.5; + ctx.stroke(); + } + + ctx.restore(); +} diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts new file mode 100644 index 00000000..3895b9fd --- /dev/null +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -0,0 +1,66 @@ +/** + * Hit detection — determine what the user clicked/hovered in world space. + * Adapted from agent-flow's hit-detection.ts (Apache 2.0). + */ + +import type { GraphNode } from '../ports/types'; +import { NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; + +/** + * Find the node at the given world-space coordinates. + * Returns node ID or null. + * Priority: lead > member > task > process. + */ +export function findNodeAt( + worldX: number, + worldY: number, + nodes: GraphNode[], +): string | null { + // Check in reverse priority order, return last match (highest priority wins) + let hit: string | null = null; + + for (const node of nodes) { + const x = node.x ?? 0; + const y = node.y ?? 0; + + switch (node.kind) { + case 'lead': + case 'member': { + const r = (node.kind === 'lead' ? NODE.radiusLead : NODE.radiusMember) + HIT_DETECTION.agentPadding; + const dx = worldX - x; + const dy = worldY - y; + if (dx * dx + dy * dy <= r * r) { + hit = node.id; + // Lead has highest priority, return immediately + if (node.kind === 'lead') return hit; + } + break; + } + case 'task': { + const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; + const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; + if ( + worldX >= x - halfW && + worldX <= x + halfW && + worldY >= y - halfH && + worldY <= y + halfH + ) { + hit = node.id; + } + break; + } + case 'process': { + const r = NODE.radiusProcess + HIT_DETECTION.agentPadding; + const dx = worldX - x; + const dy = worldY - y; + if (dx * dx + dy * dy <= r * r) { + // Only override if no member/lead already hit + if (!hit) hit = node.id; + } + break; + } + } + } + + return hit; +} diff --git a/packages/agent-graph/src/canvas/index.ts b/packages/agent-graph/src/canvas/index.ts new file mode 100644 index 00000000..82d8def4 --- /dev/null +++ b/packages/agent-graph/src/canvas/index.ts @@ -0,0 +1,11 @@ +export { drawAgents, drawContextRing } from './draw-agents'; +export { drawEdges, bezierPoint, computeControlPoints, type ControlPoints } from './draw-edges'; +export { drawParticles, buildEdgeMap } from './draw-particles'; +export { drawEffects, createSpawnEffect, createCompleteEffect, type VisualEffect } from './draw-effects'; +export { drawTasks } from './draw-tasks'; +export { drawProcesses } from './draw-processes'; +export { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from './background-layer'; +export { BloomRenderer } from './bloom-renderer'; +export { findNodeAt } from './hit-detection'; +export { truncateText, drawHexagon, CLAUDE_SPARK_D } from './draw-misc'; +export { getGlowSprite, getAgentGlowSprite, measureTextCached } from './render-cache'; diff --git a/packages/agent-graph/src/canvas/render-cache.ts b/packages/agent-graph/src/canvas/render-cache.ts new file mode 100644 index 00000000..9f163e92 --- /dev/null +++ b/packages/agent-graph/src/canvas/render-cache.ts @@ -0,0 +1,140 @@ +/** + * Pre-rendered sprite cache for Canvas 2D glow effects. + * Adapted from agent-flow (Apache 2.0). + */ + +const glowCache = new Map(); +const textCache = new Map(); +const TEXT_CACHE_LIMIT = 2000; + +// ─── Color resolution: named colors → hex ─────────────────────────────────── + +let _resolverCtx: CanvasRenderingContext2D | null = null; +const _hexCache = new Map(); + +/** + * Ensure a color string is in #rrggbb hex format. + * Resolves CSS named colors (purple → #800080), shorthand (#abc → #aabbcc). + */ +function ensureHex(color: string): string { + if (color.startsWith('#') && color.length === 7) return color; + + let hex = _hexCache.get(color); + if (hex) return hex; + + if (color.startsWith('#') && color.length === 4) { + hex = `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`; + } else { + // Resolve named/rgb/hsl colors via canvas + _resolverCtx ??= document.createElement('canvas').getContext('2d')!; + _resolverCtx.fillStyle = '#000000'; + _resolverCtx.fillStyle = color; + hex = _resolverCtx.fillStyle; // always returns #rrggbb + } + + _hexCache.set(color, hex); + return hex; +} + +/** Build a hex color with alpha: "#rrggbbaa" — cached for repeated calls */ +const _hexAlphaCache = new Map(); +function hexWithAlpha(color: string, alpha: number): string { + // Quantize alpha to 1/255 steps for cache hit rate + const a = Math.round(Math.max(0, Math.min(1, alpha)) * 255); + const key = `${color}|${a}`; + let result = _hexAlphaCache.get(key); + if (result) return result; + result = ensureHex(color) + ALPHA_LUT[a]; + _hexAlphaCache.set(key, result); + if (_hexAlphaCache.size > 500) _hexAlphaCache.clear(); // prevent unbounded growth + return result; +} + +// Import-time LUT for alpha hex +const ALPHA_LUT: string[] = []; +for (let i = 0; i < 256; i++) ALPHA_LUT.push(i.toString(16).padStart(2, '0')); + +// ─── Glow Sprite Cache ────────────────────────────────────────────────────── + +/** + * Get or create a pre-rendered radial gradient glow sprite. + */ +export function getGlowSprite( + color: string, + radius: number, + innerAlpha: number, + outerAlpha: number, +): HTMLCanvasElement { + const hex = ensureHex(color); + const key = `${hex}|${radius}|${innerAlpha}|${outerAlpha}`; + let canvas = glowCache.get(key); + if (canvas) return canvas; + + const size = Math.ceil(radius * 2); + canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + const cx = size / 2; + + const grad = ctx.createRadialGradient(cx, cx, 0, cx, cx, radius); + grad.addColorStop(0, hexWithAlpha(hex, innerAlpha)); + grad.addColorStop(1, hexWithAlpha(hex, outerAlpha)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, size, size); + + glowCache.set(key, canvas); + return canvas; +} + +/** + * Get or create a pre-rendered agent glow sprite (inner + outer radius). + */ +export function getAgentGlowSprite( + color: string, + innerRadius: number, + outerRadius: number, +): HTMLCanvasElement { + const hex = ensureHex(color); + const key = `agent|${hex}|${innerRadius}|${outerRadius}`; + let canvas = glowCache.get(key); + if (canvas) return canvas; + + const size = Math.ceil(outerRadius * 2); + canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d')!; + const cx = size / 2; + + const grad = ctx.createRadialGradient(cx, cx, innerRadius, cx, cx, outerRadius); + grad.addColorStop(0, hexWithAlpha(hex, 0.25)); + grad.addColorStop(0.5, hexWithAlpha(hex, 0.08)); + grad.addColorStop(1, hexWithAlpha(hex, 0)); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, size, size); + + glowCache.set(key, canvas); + return canvas; +} + +/** + * Cached text width measurement. + */ +export function measureTextCached(ctx: CanvasRenderingContext2D, font: string, text: string): number { + const key = `${font}|${text}`; + let w = textCache.get(key); + if (w !== undefined) return w; + + if (textCache.size > TEXT_CACHE_LIMIT) textCache.clear(); + + const prevFont = ctx.font; + ctx.font = font; + w = ctx.measureText(text).width; + ctx.font = prevFont; + textCache.set(key, w); + return w; +} + +/** Exported for use by draw functions that need hex+alpha colors */ +export { ensureHex, hexWithAlpha }; diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts new file mode 100644 index 00000000..259ce4f8 --- /dev/null +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -0,0 +1,247 @@ +/** + * Canvas rendering constants for the agent graph visualization. + * Adapted from agent-flow's canvas-constants.ts (Apache 2.0). + * Stripped of unused features (tool cards, discoveries, cost overlays, bubbles). + */ + +// ─── Visibility threshold ─────────────────────────────────────────────────── + +export const MIN_VISIBLE_OPACITY = 0.05; + +// ─── Animation speed multipliers (× deltaTime) ───────────────────────────── + +export const ANIM_SPEED = { + agentFadeIn: 3, + agentScaleIn: 4, + agentFadeOut: 0.4, + agentScaleOut: 0.05, + edgeFadeIn: 4, + particleSpeed: 1.2, + maxDeltaTime: 0.1, + defaultDeltaTime: 0.016, + /** Task pill fade in/out */ + taskFadeIn: 3, + taskFadeOut: 0.6, +} as const; + +// ─── Camera / interaction ─────────────────────────────────────────────────── + +export const CAMERA = { + zoomStepDown: 0.92, + zoomStepUp: 1.08, + minZoom: 0.15, + maxZoom: 5, + velocityScale: 0.016, +} as const; + +// ─── Force simulation ─────────────────────────────────────────────────────── + +export const FORCE = { + chargeStrength: -800, + centerStrength: 0.03, + collideRadius: 100, + linkDistance: { + 'parent-child': 500, + ownership: 150, + blocking: 200, + related: 200, + message: 300, + }, + linkStrength: 0.4, + alphaDecay: 0.02, + velocityDecay: 0.4, +} as const; + +// ─── Node sizes ───────────────────────────────────────────────────────────── + +export const NODE = { + /** Lead agent radius */ + radiusLead: 32, + /** Team member radius */ + radiusMember: 24, + /** Process node radius */ + radiusProcess: 14, +} as const; + +// ─── Task pill dimensions ─────────────────────────────────────────────────── + +export const TASK_PILL = { + width: 120, + height: 36, + borderRadius: 6, + statusDotRadius: 4, + statusDotX: 12, + /** Font size for display ID */ + idFontSize: 9, + /** Font size for subject text */ + subjectFontSize: 7, + /** Max chars for subject before truncation */ + subjectMaxChars: 18, + /** X offset for text content */ + textOffsetX: 20, +} as const; + +// ─── Agent drawing constants ──────────────────────────────────────────────── + +export const AGENT_DRAW = { + glowPadding: 20, + outerRingOffset: 3, + shadowBlur: 15, + shadowOffsetX: 3, + shadowOffsetY: 5, + labelYOffset: 8, + labelWidthMultiplier: 3, + scanlineHalfH: 4, + waitingDashSpeed: 25, + orbitParticleOffset: 12, + orbitParticleSize: 1.5, + rippleInnerOffset: 5, + rippleMaxExpand: 45, + rippleMaxAlpha: 0.4, + waitingOrbitOffset: 14, + waitingOrbitParticleSize: 2, + waitingOrbitSpeed: 0.8, + waitingBreatheSpeed: 1.2, + waitingBreatheAmp: 0.08, + sparkScale: 0.45, + sparkViewBox: 256, + subIconScale: 0.45, +} as const; + +// ─── Context ring (lead node only) ───────────────────────────────────────── + +export const CONTEXT_RING = { + ringOffset: 8, + ringWidth: 4, + warningThreshold: 0.8, + criticalThreshold: 0.9, + percentLabelThreshold: 0.7, + glowPadding: 4, + glowLineWidth: 2, + glowBlur: 12, + percentYOffset: 10, +} as const; + +// ─── Edge/beam drawing ────────────────────────────────────────────────────── + +export const BEAM = { + curvature: 0.15, + cp1: 0.33, + cp2: 0.66, + segments: 16, + parentChild: { startW: 3, endW: 1 }, + ownership: { startW: 2, endW: 0.8 }, + blocking: { startW: 2, endW: 1.5 }, + related: { startW: 1, endW: 0.5 }, + message: { startW: 1.5, endW: 0.5 }, + glowExtra: { startW: 3, endW: 1, alpha: 0.08 }, + idleAlpha: 0.08, + activeAlpha: 0.3, + wobble: { amp: 3, freq: 10, timeFreq: 3, trailOffset: 0.15 }, +} as const; + +// ─── Animation constants ──────────────────────────────────────────────────── + +export const ANIM = { + inertiaDecay: 0.94, + inertiaThreshold: 0.5, + dragLerp: 0.25, + autoFitLerp: 0.06, + dragThresholdPx: 5, + viewportPadding: 120, + breathe: { + activeSpeed: 2, + activeAmp: 0.03, + idleSpeed: 0.7, + idleAmp: 0.015, + }, + scanline: { active: 40, normal: 15 }, + orbitSpeed: 1.5, + pulseSpeed: 4, +} as const; + +// ─── Visual effects ───────────────────────────────────────────────────────── + +export const FX = { + spawnDuration: 0.8, + completeDuration: 1.0, + shatterDuration: 0.8, + shatterCount: 12, + shatterSpeed: { min: 30, range: 60 }, + shatterSize: { min: 1, range: 2 }, + trailSegments: 8, +} as const; + +export const SPAWN_FX = { + ringStart: 10, + ringExpand: 60, + maxAlpha: 0.7, + flashThreshold: 0.3, + flashAlpha: 0.6, + flashBaseRadius: 20, + flashMinRadius: 5, + particleCount: 8, + particleSize: 1.5, +} as const; + +export const COMPLETE_FX = { + ringStart: 20, + ringExpand: 80, + maxAlpha: 0.6, + flashThreshold: 0.2, + flashAlpha: 0.8, + flashRadius: 30, + lineWidthMax: 3, + glowInner: 5, + glowOuter: 10, +} as const; + +// ─── Particle drawing ─────────────────────────────────────────────────────── + +export const PARTICLE_DRAW = { + glowRadius: 15, + coreHighlightScale: 0.4, + labelMinT: 0.2, + labelMaxT: 0.8, + labelFontSize: 8, + labelYOffset: -12, + /** Seconds a particle lives before fading */ + lifetime: 2.0, +} as const; + +// ─── Hit detection ────────────────────────────────────────────────────────── + +export const HIT_DETECTION = { + /** Extra padding around nodes for easier clicking */ + agentPadding: 8, + /** Task pill hit area padding */ + taskPadding: 4, +} as const; + +// ─── Background ───────────────────────────────────────────────────────────── + +export const BACKGROUND = { + /** Number of depth particles (stars) */ + starCount: 80, + /** Hex grid cell size */ + hexSize: 30, + /** Hex grid max alpha */ + hexAlpha: 0.08, + /** Hex grid pulse speed */ + hexPulseSpeed: 0.3, +} as const; + +// ─── Kanban zone layout ───────────────────────────────────────────────────── + +export const KANBAN_ZONE = { + /** Column width: pill (120) + gap (20) */ + columnWidth: 140, + /** Row height: pill (36) + gap (10) */ + rowHeight: 46, + /** Zone starts this far below member node center */ + offsetY: 60, + /** Column order: todo → wip → done → review → approved */ + columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, + /** Max tasks shown per column (overflow hidden) */ + maxVisibleRows: 6, +} as const; diff --git a/packages/agent-graph/src/constants/colors.ts b/packages/agent-graph/src/constants/colors.ts new file mode 100644 index 00000000..8474d1e6 --- /dev/null +++ b/packages/agent-graph/src/constants/colors.ts @@ -0,0 +1,167 @@ +/** + * Color palette for the space-themed graph visualization. + * Adapted from agent-flow's colors.ts (Apache 2.0). + * Uses our GraphNodeState instead of agent-flow's AgentState. + */ + +import type { GraphNodeState } from '../ports/types'; + +// ─── Holographic Color Palette ────────────────────────────────────────────── + +export const COLORS = { + // Background + void: '#050510', + hexGrid: '#0d0d1f', + + // Primary hologram + holoBase: '#66ccff', + holoBright: '#aaeeff', + holoHot: '#ffffff', + + // Node states + idle: '#66ccff', + active: '#66ccff', + thinking: '#66ccff', + tool_calling: '#ffbb44', + complete: '#66ffaa', + error: '#ff5566', + waiting: '#ffaa33', + terminated: '#888899', + + // Edge/Particle colors + dispatch: '#cc88ff', + return: '#66ffaa', + tool: '#ffbb44', + message: '#66ccff', + + // Task status colors + taskPending: '#6b7280', + taskInProgress: '#3b82f6', + taskCompleted: '#22c55e', + taskDeleted: '#ef4444', + + // Review state colors + reviewNone: 'transparent', + reviewPending: '#f59e0b', + reviewNeedsFix: '#ef4444', + reviewApproved: '#22c55e', + + // Edge type colors + edgeParentChild: '#66ccff', + edgeOwnership: '#66ccff', + edgeBlocking: '#ff5566', + edgeRelated: '#888899', + edgeMessage: '#cc88ff', + + // Particle kind colors + particleMessage: '#66ccff', + particleTaskAssign: '#ffbb44', + particleReviewRequest: '#f59e0b', + particleReviewResponse: '#22c55e', + particleSpawn: '#cc88ff', + + // UI Chrome + nodeInterior: 'rgba(10, 15, 40, 0.5)', + textPrimary: '#aaeeff', + textDim: '#66ccff90', + textMuted: '#66ccff50', + + // Glass card (for popovers) + glassBg: 'rgba(10, 15, 30, 0.7)', + glassBorder: 'rgba(100, 200, 255, 0.15)', + glassHighlight: 'rgba(100, 200, 255, 0.08)', + + // Holo background/border opacities + holoBg05: 'rgba(100, 200, 255, 0.05)', + holoBg10: 'rgba(100, 200, 255, 0.1)', + holoBorder10: 'rgba(100, 200, 255, 0.1)', + holoBorder12: 'rgba(100, 200, 255, 0.12)', + + // Card backgrounds + cardBg: 'rgba(10, 15, 30, 0.6)', + cardBgSelected: 'rgba(100, 200, 255, 0.15)', + + // Controls + controlBg: 'rgba(8, 12, 24, 0.85)', + controlBorder: 'rgba(100, 200, 255, 0.1)', + controlActive: 'rgba(100, 200, 255, 0.15)', + controlInactive: 'rgba(100, 200, 255, 0.05)', +} as const; + +// ─── State Color Resolver ─────────────────────────────────────────────────── + +export function getStateColor(state: GraphNodeState): string { + switch (state) { + case 'idle': + return COLORS.idle; + case 'active': + return COLORS.active; + case 'thinking': + return COLORS.thinking; + case 'tool_calling': + return COLORS.tool_calling; + case 'complete': + return COLORS.complete; + case 'error': + return COLORS.error; + case 'waiting': + return COLORS.waiting; + case 'terminated': + return COLORS.terminated; + } +} + +// ─── Task Status Color Resolver ───────────────────────────────────────────── + +export function getTaskStatusColor( + status: 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined, +): string { + switch (status) { + case 'pending': + return COLORS.taskPending; + case 'in_progress': + return COLORS.taskInProgress; + case 'completed': + return COLORS.taskCompleted; + case 'deleted': + return COLORS.taskDeleted; + default: + return COLORS.taskPending; + } +} + +// ─── Review State Color Resolver ──────────────────────────────────────────── + +export function getReviewStateColor( + state: 'none' | 'review' | 'needsFix' | 'approved' | undefined, +): string { + switch (state) { + case 'review': + return COLORS.reviewPending; + case 'needsFix': + return COLORS.reviewNeedsFix; + case 'approved': + return COLORS.reviewApproved; + default: + return COLORS.reviewNone; + } +} + +// ─── Hex Color Alpha Utility ──────────────────────────────────────────────── + +// Pre-built LUT: index 0-255 → '00'-'ff' (avoids toString+padStart per call) +const ALPHA_HEX_LUT: string[] = []; +for (let i = 0; i < 256; i++) ALPHA_HEX_LUT.push(i.toString(16).padStart(2, '0')); + +/** Convert 0..1 alpha to 2-digit hex suffix (via LUT) */ +export function alphaHex(alpha: number): string { + return ALPHA_HEX_LUT[Math.round(Math.max(0, Math.min(1, alpha)) * 255)]; +} + +/** Safely combine a partial rgba base (e.g. "rgba(100, 200, 255,") with an alpha value */ +export function withAlpha(rgbaBase: string, alpha: number): string { + // Handles both "rgba(r,g,b," and "rgba(r, g, b," formats + const trimmed = rgbaBase.trimEnd(); + const separator = trimmed.endsWith(',') ? ' ' : ', '; + return `${trimmed}${separator}${alpha})`; +} diff --git a/packages/agent-graph/src/constants/index.ts b/packages/agent-graph/src/constants/index.ts new file mode 100644 index 00000000..9488d7c5 --- /dev/null +++ b/packages/agent-graph/src/constants/index.ts @@ -0,0 +1,27 @@ +export { + MIN_VISIBLE_OPACITY, + ANIM_SPEED, + CAMERA, + FORCE, + NODE, + TASK_PILL, + AGENT_DRAW, + CONTEXT_RING, + BEAM, + ANIM, + FX, + SPAWN_FX, + COMPLETE_FX, + PARTICLE_DRAW, + HIT_DETECTION, + BACKGROUND, +} from './canvas-constants'; + +export { + COLORS, + getStateColor, + getTaskStatusColor, + getReviewStateColor, + alphaHex, + withAlpha, +} from './colors'; diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts new file mode 100644 index 00000000..75b1ae89 --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -0,0 +1,178 @@ +/** + * Camera hook — pan, zoom, auto-fit. + * Adapted from agent-flow's use-canvas-camera.ts (Apache 2.0). + * All state in refs — no React re-renders. + */ + +import { useRef, useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; + +export interface CameraTransform { + x: number; + y: number; + zoom: number; +} + +export interface UseGraphCameraResult { + transformRef: React.MutableRefObject; + screenToWorld: (sx: number, sy: number) => { x: number; y: number }; + worldToScreen: (wx: number, wy: number) => { x: number; y: number }; + handleWheel: (e: WheelEvent) => void; + handlePanStart: (sx: number, sy: number) => void; + handlePanMove: (sx: number, sy: number) => void; + handlePanEnd: () => void; + zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number) => void; + zoomIn: () => void; + zoomOut: () => void; + updateInertia: () => void; +} + +export function useGraphCamera(): UseGraphCameraResult { + const transformRef = useRef({ x: 0, y: 0, zoom: 1 }) as React.MutableRefObject; + const panStartRef = useRef<{ x: number; y: number; camX: number; camY: number } | null>(null); + const velocityRef = useRef({ vx: 0, vy: 0 }); + + const screenToWorld = useCallback((sx: number, sy: number) => { + const t = transformRef.current; + return { + x: (sx - t.x) / t.zoom, + y: (sy - t.y) / t.zoom, + }; + }, []); + + const worldToScreen = useCallback((wx: number, wy: number) => { + const t = transformRef.current; + return { + x: wx * t.zoom + t.x, + y: wy * t.zoom + t.y, + }; + }, []); + + const handleWheel = useCallback((e: WheelEvent) => { + const t = transformRef.current; + + // Trackpad pinch (ctrlKey=true) sends small deltaY values — use them directly. + // Mouse wheel sends larger discrete deltaY — normalize to smaller steps. + let zoomDelta: number; + if (e.ctrlKey) { + // Pinch-to-zoom: deltaY is typically -2..+2, dampen it + zoomDelta = -e.deltaY * 0.008; + } else { + // Mouse wheel: deltaY is typically ±100-150, use discrete steps + zoomDelta = e.deltaY < 0 ? 0.08 : -0.08; + } + + const newZoom = Math.max(CAMERA.minZoom, Math.min(CAMERA.maxZoom, t.zoom * (1 + zoomDelta))); + + // Zoom toward cursor position + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect?.(); + const cx = rect ? e.clientX - rect.left : e.offsetX; + const cy = rect ? e.clientY - rect.top : e.offsetY; + + t.x = cx - (cx - t.x) * (newZoom / t.zoom); + t.y = cy - (cy - t.y) * (newZoom / t.zoom); + t.zoom = newZoom; + }, []); + + const lastPanPos = useRef({ x: 0, y: 0 }); + + const handlePanStart = useCallback((sx: number, sy: number) => { + const t = transformRef.current; + panStartRef.current = { x: sx, y: sy, camX: t.x, camY: t.y }; + lastPanPos.current = { x: sx, y: sy }; + velocityRef.current = { vx: 0, vy: 0 }; + }, []); + + const handlePanMove = useCallback((sx: number, sy: number) => { + const start = panStartRef.current; + if (!start) return; + const t = transformRef.current; + const dx = sx - start.x; + const dy = sy - start.y; + t.x = start.camX + dx; + t.y = start.camY + dy; + // Per-frame delta for inertia (not total drag distance) + const frameDx = sx - lastPanPos.current.x; + const frameDy = sy - lastPanPos.current.y; + lastPanPos.current = { x: sx, y: sy }; + velocityRef.current = { vx: frameDx * CAMERA.velocityScale, vy: frameDy * CAMERA.velocityScale }; + }, []); + + const handlePanEnd = useCallback(() => { + panStartRef.current = null; + }, []); + + const updateInertia = useCallback(() => { + const v = velocityRef.current; + if (Math.abs(v.vx) < ANIM.inertiaThreshold && Math.abs(v.vy) < ANIM.inertiaThreshold) { + v.vx = 0; + v.vy = 0; + return; + } + const t = transformRef.current; + t.x += v.vx; + t.y += v.vy; + v.vx *= ANIM.inertiaDecay; + v.vy *= ANIM.inertiaDecay; + }, []); + + const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number) => { + if (nodes.length === 0) return; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const n of nodes) { + const x = n.x ?? 0; + const y = n.y ?? 0; + const pad = n.kind === 'task' + ? TASK_PILL.width / 2 + : n.kind === 'lead' + ? NODE.radiusLead + : NODE.radiusMember; + minX = Math.min(minX, x - pad); + minY = Math.min(minY, y - pad); + maxX = Math.max(maxX, x + pad); + maxY = Math.max(maxY, y + pad); + } + + const padding = ANIM.viewportPadding; + const contentW = maxX - minX + padding * 2; + const contentH = maxY - minY + padding * 2; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const zoom = Math.max( + CAMERA.minZoom, + Math.min(CAMERA.maxZoom, Math.min(canvasW / contentW, canvasH / contentH)), + ); + + const t = transformRef.current; + t.zoom = zoom; + t.x = canvasW / 2 - centerX * zoom; + t.y = canvasH / 2 - centerY * zoom; + }, []); + + const zoomIn = useCallback(() => { + const t = transformRef.current; + t.zoom = Math.min(CAMERA.maxZoom, t.zoom * 1.2); + }, []); + + const zoomOut = useCallback(() => { + const t = transformRef.current; + t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); + }, []); + + return { + transformRef, + screenToWorld, + worldToScreen, + handleWheel, + handlePanStart, + handlePanMove, + handlePanEnd, + zoomToFit, + zoomIn, + zoomOut, + updateInertia, + }; +} diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts new file mode 100644 index 00000000..2c76c940 --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -0,0 +1,89 @@ +/** + * Interaction hook — click, hover, drag on canvas. + * Delegates hit testing to strategy pattern. + */ + +import { useRef, useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import { ANIM } from '../constants/canvas-constants'; +import { findNodeAt } from '../canvas/hit-detection'; + +export interface UseGraphInteractionResult { + hoveredNodeId: React.RefObject; + dragNodeId: React.RefObject; + isDragging: React.RefObject; + handleMouseDown: (wx: number, wy: number, nodes: GraphNode[]) => void; + handleMouseMove: (wx: number, wy: number, nodes: GraphNode[]) => void; + handleMouseUp: () => string | null; + handleDoubleClick: (wx: number, wy: number, nodes: GraphNode[]) => string | null; +} + +export function useGraphInteraction( + onDragNode?: (nodeId: string, x: number, y: number) => void, +): UseGraphInteractionResult { + const hoveredNodeId = useRef(null); + const dragNodeId = useRef(null); + const isDragging = useRef(false); + const mouseDownPos = useRef<{ x: number; y: number } | null>(null); + const clickedNodeId = useRef(null); + + const handleMouseDown = useCallback((wx: number, wy: number, nodes: GraphNode[]) => { + mouseDownPos.current = { x: wx, y: wy }; + const hit = findNodeAt(wx, wy, nodes); + clickedNodeId.current = hit; + + if (hit) { + dragNodeId.current = hit; + } + }, []); + + const handleMouseMove = useCallback((wx: number, wy: number, nodes: GraphNode[]) => { + // Check drag threshold + if (mouseDownPos.current && dragNodeId.current) { + const dx = wx - mouseDownPos.current.x; + const dy = wy - mouseDownPos.current.y; + if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) { + isDragging.current = true; + } + } + + // Drag node + if (isDragging.current && dragNodeId.current) { + onDragNode?.(dragNodeId.current, wx, wy); + return; + } + + // Hover detection + hoveredNodeId.current = findNodeAt(wx, wy, nodes); + }, [onDragNode]); + + const handleMouseUp = useCallback((): string | null => { + const wasDragging = isDragging.current; + const nodeId = clickedNodeId.current; + + isDragging.current = false; + dragNodeId.current = null; + mouseDownPos.current = null; + clickedNodeId.current = null; + + // If not dragging, this was a click + if (!wasDragging && nodeId) { + return nodeId; + } + return null; + }, []); + + const handleDoubleClick = useCallback((wx: number, wy: number, nodes: GraphNode[]): string | null => { + return findNodeAt(wx, wy, nodes); + }, []); + + return { + hoveredNodeId, + dragNodeId, + isDragging, + handleMouseDown, + handleMouseMove, + handleMouseUp, + handleDoubleClick, + }; +} diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts new file mode 100644 index 00000000..c7c2e3c2 --- /dev/null +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -0,0 +1,285 @@ +/** + * Graph simulation hook using d3-force for MEMBER/LEAD nodes only. + * Task nodes are positioned by KanbanLayoutEngine (deterministic grid). + * + * CRITICAL: Animation state in useRef, NOT useState — no React re-renders at 60fps. + * This hook does NOT run its own RAF loop — the parent (GraphView) calls tick(). + */ + +import { useRef, useEffect, useCallback } from 'react'; +import { + forceSimulation, + forceCenter, + forceManyBody, + forceCollide, + forceLink, + type Simulation, + type SimulationNodeDatum, + type SimulationLinkDatum, +} from 'd3-force'; +import type { GraphNode, GraphEdge, GraphParticle, GraphNodeKind } from '../ports/types'; +import { FORCE, ANIM_SPEED } from '../constants/canvas-constants'; +import { getNodeStrategy } from '../strategies'; +import { createSpawnEffect, createCompleteEffect, type VisualEffect } from '../canvas/draw-effects'; +import { getStateColor } from '../constants/colors'; +import { KanbanLayoutEngine } from '../layout/kanbanLayout'; + +// ─── Force Node/Link types (properly typed, no loose `string`) ────────────── + +interface ForceNode extends SimulationNodeDatum { + id: string; + kind: GraphNodeKind; +} + +interface ForceLink extends SimulationLinkDatum { + id: string; + edgeType: string; +} + +// ─── Simulation State (in ref, not useState) ──────────────────────────────── + +export interface SimulationState { + nodes: GraphNode[]; + edges: GraphEdge[]; + particles: GraphParticle[]; + effects: VisualEffect[]; + time: number; +} + +export interface UseGraphSimulationResult { + stateRef: React.MutableRefObject; + updateData: (nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => void; + /** Tick one simulation frame — called from parent's RAF loop */ + tick: (dt: number) => void; +} + +// ─── Deterministic hash for stable initial positions ───────────────────────── + +/** Returns a value in [-0.5, 0.5] deterministically from string + seed */ +function deterministicPosition(id: string, seed: number): number { + let hash = seed * 2654435761; + for (let i = 0; i < id.length; i++) { + hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0; + } + return ((hash & 0x7fffffff) % 1000) / 1000 - 0.5; +} + +// ─── Hook ─────────────────────────────────────────────────────────────────── + +export function useGraphSimulation(): UseGraphSimulationResult { + const stateRef = useRef({ + nodes: [], + edges: [], + particles: [], + effects: [], + time: 0, + }); + + const simRef = useRef | null>(null); + + // Initialize d3-force simulation + const initSimulation = useCallback(() => { + if (simRef.current) simRef.current.stop(); + + const sim = forceSimulation([]) + .force('center', forceCenter(0, 0).strength(FORCE.centerStrength)) + .force('charge', forceManyBody().strength((d) => { + return getNodeStrategy(d.kind).getChargeStrength(); + })) + .force('collide', forceCollide().radius((d) => { + return getNodeStrategy(d.kind).getCollisionRadius(); + })) + .force('link', forceLink([]).id((d) => d.id).distance((d) => { + return FORCE.linkDistance[d.edgeType as keyof typeof FORCE.linkDistance] ?? 200; + }).strength(FORCE.linkStrength)) + .alphaDecay(FORCE.alphaDecay) + .velocityDecay(FORCE.velocityDecay) + .stop(); // We tick manually + + simRef.current = sim; + return sim; + }, []); + + // Track node set identity to avoid re-running simulation when data reference changes but content is same + const lastNodeIdsHash = useRef(''); + + // Sync graph data to d3-force — ONLY when node set actually changes + const syncSimulation = useCallback((nodes: GraphNode[], edges: GraphEdge[]) => { + // Hash includes IDs + mutable fields (status, owner, review) to detect real changes + const hash = nodes.map((n) => `${n.id}:${n.state}:${n.ownerId ?? ''}:${n.taskStatus ?? ''}:${n.reviewState ?? ''}`).sort().join(','); + if (hash === lastNodeIdsHash.current) return; // same nodes — skip re-simulation + lastNodeIdsHash.current = hash; + + let sim = simRef.current; + if (!sim) sim = initSimulation(); + + // Tasks excluded from d3-force — positioned by KanbanLayoutEngine + const forceNodes: ForceNode[] = nodes + .filter((n) => n.kind !== 'task') + .map((n) => ({ + id: n.id, + kind: n.kind, + // Deterministic initial positions from node ID hash — same layout every time + x: n.x ?? deterministicPosition(n.id, 0) * 500, + y: n.y ?? deterministicPosition(n.id, 1) * 500, + vx: n.vx ?? 0, + vy: n.vy ?? 0, + fx: n.fx, + fy: n.fy, + })); + + // Links only between non-task nodes (parent-child: lead↔member) + const forceNodeIds = new Set(forceNodes.map((n) => n.id)); + const forceLinks: ForceLink[] = edges + .filter((e) => forceNodeIds.has(e.source) && forceNodeIds.has(e.target)) + .map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + edgeType: e.type, + })); + + sim.nodes(forceNodes); + (sim.force('link') as ReturnType)?.links(forceLinks); + sim.alpha(1); + + // Run simulation to near-completion so nodes are settled on first render + for (let i = 0; i < 120; i++) sim.tick(); + sim.alpha(0); // fully settled — no more movement until new data + + // Copy settled positions BACK to GraphNode objects + const simNodeMap = new Map(); + for (const sn of sim.nodes()) simNodeMap.set(sn.id, sn); + for (const node of nodes) { + const sn = simNodeMap.get(node.id); + if (sn) { + node.x = sn.x; + node.y = sn.y; + node.vx = sn.vx; + node.vy = sn.vy; + } + } + + // Position tasks in kanban zones relative to their owners + KanbanLayoutEngine.layout(nodes); + }, [initSimulation]); + + // Track previous node IDs and states for effect spawning + const prevNodeIdsRef = useRef(new Set()); + const prevNodeStatesRef = useRef(new Map()); + + // Update data from adapter + const updateData = useCallback((nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => { + const state = stateRef.current; + const prevIds = prevNodeIdsRef.current; + const prevStates = prevNodeStatesRef.current; + + // Preserve positions from previous frame + const prevPositions = new Map(); + for (const n of state.nodes) { + if (n.x != null && n.y != null) { + prevPositions.set(n.id, { x: n.x, y: n.y, vx: n.vx ?? 0, vy: n.vy ?? 0 }); + } + } + + for (const n of nodes) { + const prev = prevPositions.get(n.id); + if (prev && n.x == null) { + n.x = prev.x; + n.y = prev.y; + n.vx = prev.vx; + n.vy = prev.vy; + } + } + + // Detect state transitions → spawn visual effects + for (const node of nodes) { + // New node appeared → spawn effect + if (!prevIds.has(node.id) && node.x != null && node.y != null) { + state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + } + + // Task completed → shatter effect + const prevState = prevStates.get(node.id); + if (prevState && prevState !== 'complete' && node.state === 'complete' && node.x != null && node.y != null) { + state.effects.push(createCompleteEffect(node.x, node.y, node.color ?? getStateColor(node.state))); + } + } + + // Update tracking refs + prevNodeIdsRef.current = new Set(nodes.map((n) => n.id)); + prevNodeStatesRef.current = new Map(nodes.map((n) => [n.id, n.state])); + + state.nodes = nodes; + state.edges = edges; + state.particles = particles; + + syncSimulation(nodes, edges); + }, [syncSimulation]); + + // Tick one frame (called by parent's RAF loop) + const tick = useCallback((dt: number) => { + tickFrame(stateRef.current, simRef.current, dt); + }, []); + + // Cleanup + useEffect(() => { + return () => { + simRef.current?.stop(); + }; + }, []); + + return { stateRef, updateData, tick }; +} + +// ─── Frame Tick (pure function) ───────────────────────────────────────────── + +function tickFrame( + state: SimulationState, + sim: Simulation | null, + dt: number, +): void { + state.time += dt; + + // Tick d3-force (only when simulation is still active) + if (sim && sim.alpha() > 0.001) { + sim.tick(1); + + const simNodes = sim.nodes(); + const simNodeMap = new Map(); + for (const sn of simNodes) simNodeMap.set(sn.id, sn); + + for (const node of state.nodes) { + const sn = simNodeMap.get(node.id); + if (sn) { + node.x = sn.x; + node.y = sn.y; + node.vx = sn.vx; + node.vy = sn.vy; + } + } + } + + // Re-layout tasks in kanban zones ONLY when members moved (alpha > 0 or drag) + if (!sim || sim.alpha() > 0.001 || state.particles.length > 0) { + KanbanLayoutEngine.layout(state.nodes); + } + + // Update particle progress — in-place removal (no new array allocation) + let pw = 0; + for (let i = 0; i < state.particles.length; i++) { + const p = state.particles[i]; + p.progress += dt * ANIM_SPEED.particleSpeed * 0.5; + if (p.progress < 1) state.particles[pw++] = p; + } + state.particles.length = pw; + + // Update effects — in-place removal + let ew = 0; + for (let i = 0; i < state.effects.length; i++) { + const fx = state.effects[i]; + fx.age += dt; + if (fx.age < fx.duration) state.effects[ew++] = fx; + } + state.effects.length = ew; +} diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts new file mode 100644 index 00000000..26a1f01c --- /dev/null +++ b/packages/agent-graph/src/index.ts @@ -0,0 +1,28 @@ +/** + * @claude-teams/agent-graph + * + * Force-directed graph visualization for agent teams. + * Isolated package — depends only on React (peer) and d3-force. + * Uses Port/Adapter pattern: host project provides data through port interfaces. + */ + +// ─── Components ────────────────────────────────────────────────────────────── +export { GraphView } from './ui/GraphView'; +export type { GraphViewProps } from './ui/GraphView'; + +// ─── Port Interfaces (for adapters in host project) ───────────────────────── +export type { GraphDataPort } from './ports/GraphDataPort'; +export type { GraphEventPort } from './ports/GraphEventPort'; +export type { GraphConfigPort } from './ports/GraphConfigPort'; + +// ─── Port Types ────────────────────────────────────────────────────────────── +export type { + GraphNode, + GraphEdge, + GraphParticle, + GraphNodeKind, + GraphNodeState, + GraphEdgeType, + GraphParticleKind, + GraphDomainRef, +} from './ports/types'; diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts new file mode 100644 index 00000000..6528c542 --- /dev/null +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -0,0 +1,131 @@ +/** + * 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. + * + * Class with ES #private methods, single source of truth from KANBAN_ZONE constants. + */ + +import type { GraphNode } from '../ports/types'; +import { KANBAN_ZONE } from '../constants/canvas-constants'; + +export class KanbanLayoutEngine { + // Reusable collections (cleared each call, never GC'd) + static readonly #nodeMap = new Map(); + static readonly #tasksByOwner = new Map(); + static readonly #unassigned: GraphNode[] = []; + static readonly #colTasks = new Map(); + + /** + * Position all task nodes in kanban columns relative to their owner. + * Call AFTER d3-force settles member positions, BEFORE drawing. + */ + static layout(nodes: GraphNode[]): void { + const nodeMap = this.#nodeMap; + 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; + unassigned.length = 0; + + for (const n of nodes) { + if (n.kind !== 'task') continue; + if (n.ownerId) { + let group = tasksByOwner.get(n.ownerId); + if (!group) { + group = []; + tasksByOwner.set(n.ownerId, group); + } + group.push(n); + } else { + unassigned.push(n); + } + } + + // Layout each owner's tasks in kanban columns + 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); + } + + // Unassigned tasks: separate zone + KanbanLayoutEngine.#layoutUnassigned(unassigned); + } + + // ─── Private ────────────────────────────────────────────────────────────── + + static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number): void { + const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE; + const totalWidth = columns.length * columnWidth; + const baseX = ownerX - totalWidth / 2; + const baseY = ownerY + offsetY; + + // Classify each task into a column — reuse shared Map + const colTasks = KanbanLayoutEngine.#colTasks; + colTasks.clear(); + for (const col of columns) colTasks.set(col, []); + + for (const task of tasks) { + const col = KanbanLayoutEngine.#resolveColumn(task); + 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()) { + if (rowIdx >= maxVisibleRows) { + // Hide overflow tasks off-screen + task.x = -99999; + task.y = -99999; + task.fx = task.x; + task.fy = task.y; + continue; + } + task.x = baseX + colIdx * columnWidth; + task.y = baseY + rowIdx * rowHeight; + task.fx = task.x; + task.fy = task.y; + task.vx = 0; + task.vy = 0; + } + } + } + + /** + * 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': + return 'wip'; + case 'completed': + return 'done'; + default: + return 'todo'; + } + } + + static #layoutUnassigned(tasks: GraphNode[]): void { + const { columnWidth, rowHeight } = KANBAN_ZONE; + for (const [idx, task] of tasks.entries()) { + task.x = -400 + (idx % 3) * columnWidth; + task.y = 400 + Math.floor(idx / 3) * rowHeight; + task.fx = task.x; + task.fy = task.y; + task.vx = 0; + task.vy = 0; + } + } +} diff --git a/packages/agent-graph/src/ports/GraphConfigPort.ts b/packages/agent-graph/src/ports/GraphConfigPort.ts new file mode 100644 index 00000000..6065bfe2 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphConfigPort.ts @@ -0,0 +1,55 @@ +import type { GraphNodeState } from './types'; + +/** + * Configuration port — visual theme, filters, animation settings. + * All fields optional — package provides sensible defaults. + */ +export interface GraphConfigPort { + // ─── Theme ───────────────────────────────────────────────────────────── + /** Background color (default: space dark #0a0f1a) */ + backgroundColor?: string; + /** Whether to show hex grid on background */ + showHexGrid?: boolean; + /** Whether to show depth star field */ + showStarField?: boolean; + /** Bloom post-processing intensity (0 = off, 1 = default) */ + bloomIntensity?: number; + + // ─── Node Colors (overrides per state) ───────────────────────────────── + nodeStateColors?: Partial>; + /** Task status colors */ + taskStatusColors?: { + pending?: string; + in_progress?: string; + completed?: string; + deleted?: string; + }; + /** Review state colors */ + reviewStateColors?: { + review?: string; + needsFix?: string; + approved?: string; + }; + + // ─── Filters (show/hide node kinds) ──────────────────────────────────── + showTasks?: boolean; + showProcesses?: boolean; + showCompletedTasks?: boolean; + showEdgeLabels?: boolean; + + // ─── Animation ───────────────────────────────────────────────────────── + /** Animation enabled (default: true) */ + animationEnabled?: boolean; + /** Particle speed multiplier (default: 1) */ + particleSpeed?: number; + /** Breathing animation speed (default: 1) */ + breathingSpeed?: number; + + // ─── Force Layout ────────────────────────────────────────────────────── + /** Charge strength (repulsion, default: -800) */ + chargeStrength?: number; + /** Center attraction strength (default: 0.03) */ + centerStrength?: number; + /** Task orbit radius around owner (default: 150) */ + taskOrbitRadius?: number; +} diff --git a/packages/agent-graph/src/ports/GraphDataPort.ts b/packages/agent-graph/src/ports/GraphDataPort.ts new file mode 100644 index 00000000..ec4c5ce0 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphDataPort.ts @@ -0,0 +1,20 @@ +import type { GraphNode, GraphEdge, GraphParticle } from './types'; + +/** + * Data provider port — supplies graph state to the visualization. + * Host project implements this via an adapter (e.g., useTeamGraphAdapter). + */ +export interface GraphDataPort { + /** All nodes to render (members, tasks, processes, lead) */ + nodes: GraphNode[]; + /** All edges (ownership, blocking, related, message, parent-child) */ + edges: GraphEdge[]; + /** Active particles (messages in flight, spawn effects) */ + particles: GraphParticle[]; + /** Team name for display */ + teamName: string; + /** Team brand color */ + teamColor?: string; + /** Whether the team lead process is alive */ + isAlive?: boolean; +} diff --git a/packages/agent-graph/src/ports/GraphEventPort.ts b/packages/agent-graph/src/ports/GraphEventPort.ts new file mode 100644 index 00000000..8ed94ca6 --- /dev/null +++ b/packages/agent-graph/src/ports/GraphEventPort.ts @@ -0,0 +1,22 @@ +import type { GraphDomainRef, GraphEdge } from './types'; + +/** + * Event callback port — graph fires these when user interacts with nodes/edges. + * Host project provides handlers to navigate to domain-specific views. + */ +export interface GraphEventPort { + /** Single click on a node — show popover with details */ + onNodeClick?: (ref: GraphDomainRef) => void; + /** Double click on a node — open full detail dialog */ + onNodeDoubleClick?: (ref: GraphDomainRef) => void; + /** Click on an edge */ + onEdgeClick?: (edge: GraphEdge) => void; + /** Click on empty canvas background */ + onBackgroundClick?: () => void; + /** "Send Message" action from node popover */ + onSendMessage?: (memberName: string, teamName: string) => void; + /** "Open Task Detail" action from task popover */ + onOpenTaskDetail?: (taskId: string, teamName: string) => void; + /** "Open Member Profile" action from member popover */ + onOpenMemberProfile?: (memberName: string, teamName: string) => void; +} diff --git a/packages/agent-graph/src/ports/index.ts b/packages/agent-graph/src/ports/index.ts new file mode 100644 index 00000000..532bc497 --- /dev/null +++ b/packages/agent-graph/src/ports/index.ts @@ -0,0 +1,13 @@ +export type { GraphDataPort } from './GraphDataPort'; +export type { GraphEventPort } from './GraphEventPort'; +export type { GraphConfigPort } from './GraphConfigPort'; +export type { + GraphNode, + GraphEdge, + GraphParticle, + GraphNodeKind, + GraphNodeState, + GraphEdgeType, + GraphParticleKind, + GraphDomainRef, +} from './types'; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts new file mode 100644 index 00000000..2ff862c8 --- /dev/null +++ b/packages/agent-graph/src/ports/types.ts @@ -0,0 +1,117 @@ +/** + * Core types for graph visualization. + * Framework-agnostic — no dependencies on TeamData, Zustand, Electron, or agent-flow internals. + */ + +// ─── Node Kinds ────────────────────────────────────────────────────────────── + +export type GraphNodeKind = 'lead' | 'member' | 'task' | 'process'; + +export type GraphNodeState = + | 'idle' + | 'active' + | 'thinking' + | 'tool_calling' + | 'waiting' + | 'complete' + | 'error' + | 'terminated'; + +// ─── Edge & Particle Types ─────────────────────────────────────────────────── + +export type GraphEdgeType = 'parent-child' | 'ownership' | 'blocking' | 'related' | 'message'; + +export type GraphParticleKind = + | 'message' + | 'task_assign' + | 'review_request' + | 'review_response' + | 'spawn'; + +// ─── Graph Node ────────────────────────────────────────────────────────────── + +export interface GraphNode { + /** Unique node identifier (e.g., "member:alice", "task:abc123") */ + id: string; + kind: GraphNodeKind; + label: string; + state: GraphNodeState; + + /** Node color override (e.g., member.color hex value) */ + color?: string; + + // ─── Member/Lead-specific ────────────────────────────────────────────── + /** Agent role description */ + role?: string; + /** Spawn lifecycle status */ + spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; + /** Context window usage ratio (0..1), available for lead only */ + contextUsage?: number; + + // ─── Task-specific ───────────────────────────────────────────────────── + /** Short display ID (e.g., "#3") */ + displayId?: string; + /** Task subject / description */ + sublabel?: string; + /** Owner member node ID — tasks orbit around this node */ + ownerId?: string | null; + /** Task status for pill coloring */ + taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted'; + /** Review state overlay */ + reviewState?: 'none' | 'review' | 'needsFix' | 'approved'; + /** Requires clarification indicator */ + needsClarification?: 'lead' | 'user' | null; + + // ─── Process-specific ────────────────────────────────────────────────── + /** Clickable URL for process */ + processUrl?: string; + + // ─── Force simulation (managed by the package internally) ────────────── + x?: number; + y?: number; + vx?: number; + vy?: number; + /** Pinned position (user-dragged) */ + fx?: number | null; + fy?: number | null; + + // ─── Domain reference (opaque, for navigation back to host app) ──────── + domainRef: GraphDomainRef; +} + +// ─── Graph Edge ────────────────────────────────────────────────────────────── + +export interface GraphEdge { + id: string; + source: string; + target: string; + type: GraphEdgeType; + /** Label shown on edge (e.g., message summary) */ + label?: string; + /** Edge color override */ + color?: string; +} + +// ─── Graph Particle ────────────────────────────────────────────────────────── + +export interface GraphParticle { + id: string; + /** Edge ID this particle travels along */ + edgeId: string; + /** Progress along edge (0..1) */ + progress: number; + kind: GraphParticleKind; + color: string; + /** Size multiplier (1 = default) */ + size?: number; + /** Short label near particle */ + label?: string; +} + +// ─── Domain Reference (opaque back-pointer) ────────────────────────────────── + +export type GraphDomainRef = + | { kind: 'lead'; teamName: string } + | { kind: 'member'; teamName: string; memberName: string } + | { kind: 'task'; teamName: string; taskId: string } + | { kind: 'process'; teamName: string; processId: string }; diff --git a/packages/agent-graph/src/strategies/index.ts b/packages/agent-graph/src/strategies/index.ts new file mode 100644 index 00000000..8f0fc9f0 --- /dev/null +++ b/packages/agent-graph/src/strategies/index.ts @@ -0,0 +1,27 @@ +/** + * Strategy registry — maps GraphNodeKind to its render strategy. + * Open-Closed: add new node kinds by adding new strategies to the registry. + */ + +import type { GraphNodeKind } from '../ports/types'; +import type { NodeRenderStrategy } from './types'; +import { LeadStrategy, MemberStrategy } from './memberStrategy'; +import { TaskStrategy } from './taskStrategy'; +import { ProcessStrategy } from './processStrategy'; + +const STRATEGIES: Record = { + lead: new LeadStrategy(), + member: new MemberStrategy(), + task: new TaskStrategy(), + process: new ProcessStrategy(), +}; + +export function getNodeStrategy(kind: GraphNodeKind): NodeRenderStrategy { + return STRATEGIES[kind]; +} + +export function getAllStrategies(): NodeRenderStrategy[] { + return Object.values(STRATEGIES); +} + +export type { NodeRenderStrategy, NodeRenderState } from './types'; diff --git a/packages/agent-graph/src/strategies/memberStrategy.ts b/packages/agent-graph/src/strategies/memberStrategy.ts new file mode 100644 index 00000000..789b47a1 --- /dev/null +++ b/packages/agent-graph/src/strategies/memberStrategy.ts @@ -0,0 +1,72 @@ +/** + * Render strategy for member and lead nodes. + * Uses the holographic hexagonal rendering from draw-agents.ts. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawAgents } from '../canvas/draw-agents'; +import { NODE, HIT_DETECTION } from '../constants/canvas-constants'; + +export class MemberStrategy implements NodeRenderStrategy { + readonly kind = 'member' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + // drawAgents handles both member and lead — we delegate to it + drawAgents( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusMember + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusMember + 20; + } + + getChargeStrength(): number { + return -600; + } +} + +export class LeadStrategy implements NodeRenderStrategy { + readonly kind = 'lead' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawAgents( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusLead + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusLead + 30; + } + + getChargeStrength(): number { + return -1200; + } +} diff --git a/packages/agent-graph/src/strategies/processStrategy.ts b/packages/agent-graph/src/strategies/processStrategy.ts new file mode 100644 index 00000000..f96b78f1 --- /dev/null +++ b/packages/agent-graph/src/strategies/processStrategy.ts @@ -0,0 +1,39 @@ +/** + * Render strategy for process nodes. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawProcesses } from '../canvas/draw-processes'; +import { NODE, HIT_DETECTION } from '../constants/canvas-constants'; + +export class ProcessStrategy implements NodeRenderStrategy { + readonly kind = 'process' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawProcesses( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const r = NODE.radiusProcess + HIT_DETECTION.agentPadding; + const dx = wx - x; + const dy = wy - y; + return dx * dx + dy * dy <= r * r; + } + + getCollisionRadius(): number { + return NODE.radiusProcess + 10; + } + + getChargeStrength(): number { + return -200; + } +} diff --git a/packages/agent-graph/src/strategies/taskStrategy.ts b/packages/agent-graph/src/strategies/taskStrategy.ts new file mode 100644 index 00000000..96a9a92b --- /dev/null +++ b/packages/agent-graph/src/strategies/taskStrategy.ts @@ -0,0 +1,38 @@ +/** + * Render strategy for task pill nodes. + */ + +import type { GraphNode } from '../ports/types'; +import type { NodeRenderStrategy, NodeRenderState } from './types'; +import { drawTasks } from '../canvas/draw-tasks'; +import { TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; + +export class TaskStrategy implements NodeRenderStrategy { + readonly kind = 'task' as const; + + draw(ctx: CanvasRenderingContext2D, node: GraphNode, state: NodeRenderState): void { + drawTasks( + ctx, + [node], + state.time, + state.isSelected ? node.id : null, + state.isHovered ? node.id : null, + ); + } + + hitTest(node: GraphNode, wx: number, wy: number): boolean { + const x = node.x ?? 0; + const y = node.y ?? 0; + const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; + const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; + return wx >= x - halfW && wx <= x + halfW && wy >= y - halfH && wy <= y + halfH; + } + + getCollisionRadius(): number { + return Math.max(TASK_PILL.width, TASK_PILL.height) / 2 + 10; + } + + getChargeStrength(): number { + return -300; + } +} diff --git a/packages/agent-graph/src/strategies/types.ts b/packages/agent-graph/src/strategies/types.ts new file mode 100644 index 00000000..b683d09a --- /dev/null +++ b/packages/agent-graph/src/strategies/types.ts @@ -0,0 +1,48 @@ +/** + * Strategy interfaces for per-kind node rendering, hit testing, and layout. + * Open-Closed principle: new node kinds add new strategies, no changes to GraphCanvas. + */ + +import type { GraphNode, GraphNodeKind } from '../ports/types'; + +/** + * Rendering state passed to strategy draw methods (animation context). + */ +export interface NodeRenderState { + isSelected: boolean; + isHovered: boolean; + time: number; + cameraZoom: number; +} + +/** + * Strategy for rendering a specific node kind. + * Liskov: all strategies are interchangeable via the registry. + */ +export interface NodeRenderStrategy { + readonly kind: GraphNodeKind; + + /** + * Draw the node on the canvas. + */ + draw( + ctx: CanvasRenderingContext2D, + node: GraphNode, + state: NodeRenderState, + ): void; + + /** + * Test whether the world-space point (wx, wy) is inside this node. + */ + hitTest(node: GraphNode, wx: number, wy: number): boolean; + + /** + * Get collision radius for d3-force collide simulation. + */ + getCollisionRadius(): number; + + /** + * Get charge strength for d3-force many-body simulation. + */ + getChargeStrength(): number; +} diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx new file mode 100644 index 00000000..2b6fa632 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -0,0 +1,295 @@ +/** + * GraphCanvas — Canvas 2D rendering component with imperative RAF draw loop. + * + * ARCHITECTURE: The canvas draws imperatively via drawRef, NOT via React re-renders. + * GraphView calls `drawRef.current()` from the unified RAF loop. + * React only manages: mount/unmount, resize, mouse events. + */ + +import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react'; +import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; +import { drawBackground, createDepthParticles, updateDepthParticles, type DepthParticle } from '../canvas/background-layer'; +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 { drawProcesses } from '../canvas/draw-processes'; +import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; +import { BloomRenderer } from '../canvas/bloom-renderer'; +import type { CameraTransform } from '../hooks/useGraphCamera'; + +// ─── Draw State (passed by ref, not by props — no React re-renders) ───────── + +export interface GraphDrawState { + nodes: GraphNode[]; + edges: GraphEdge[]; + particles: GraphParticle[]; + effects: VisualEffect[]; + time: number; + camera: CameraTransform; + selectedNodeId: string | null; + hoveredNodeId: string | null; +} + +export interface GraphCanvasHandle { + /** Call this from RAF to draw one frame */ + draw: (state: GraphDrawState) => void; + /** Get the canvas element for coordinate transforms */ + getCanvas: () => HTMLCanvasElement | null; +} + +export interface GraphCanvasProps { + showHexGrid?: boolean; + showStarField?: boolean; + bloomIntensity?: number; + onWheel?: (e: WheelEvent) => void; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseMove?: (e: React.MouseEvent) => void; + onMouseUp?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; + className?: string; +} + +export const GraphCanvas = forwardRef(function GraphCanvas( + { + showHexGrid = true, + showStarField = true, + bloomIntensity = 0.6, + onWheel, + onMouseDown, + onMouseMove, + onMouseUp, + onDoubleClick, + onContextMenu, + className, + }, + ref, +) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const bloomRef = useRef(new BloomRenderer(bloomIntensity)); + const starsRef = useRef([]); + const sizeRef = useRef({ w: 0, h: 0 }); + + // Performance tracking + const perfRef = useRef({ frames: 0, fps: 0, frameTimeMs: 0, lastFpsUpdate: 0, frameTimes: [] as number[] }); + // Rate-limited error logging (prevent console flood at 60fps) + const lastDrawErrorRef = useRef(0); + + // Update bloom intensity without recreating + useEffect(() => { + bloomRef.current.setIntensity(bloomIntensity); + }, [bloomIntensity]); + + // Handle resize + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + const dpr = window.devicePixelRatio || 1; + const canvas = canvasRef.current; + if (!canvas) continue; + + canvas.width = width * dpr; + canvas.height = height * dpr; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + sizeRef.current = { w: width, h: height }; + bloomRef.current.resize(width * dpr, height * dpr); + starsRef.current = createDepthParticles(width, height); + } + }); + + observer.observe(container); + return () => observer.disconnect(); + }, []); + + // Persistent per-frame collections (reused, never GC'd) + const nodeMapCache = useRef(new Map()); + const edgeMapCache = useRef(new Map()); + const visibleNodesCache = useRef([]); + const visibleEdgesCache = useRef([]); + const visibleNodeIdsCache = useRef(new Set()); + const activeParticleEdgesCache = useRef(new Set()); + + // Imperative draw function — called from RAF, NOT from React render + useImperativeHandle(ref, () => ({ + draw: (state: GraphDrawState) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const frameStart = performance.now(); + + const dpr = window.devicePixelRatio || 1; + const { w, h } = sizeRef.current; + if (w === 0 || h === 0) return; + + try { + + const cam = state.camera; + const zoom = cam.zoom; + + // ─── Frustum culling: compute visible world-space bounds ────────── + const viewLeft = -cam.x / zoom; + const viewTop = -cam.y / zoom; + const viewRight = (w - cam.x) / zoom; + const viewBottom = (h - cam.y) / zoom; + const pad = 200; // overdraw padding for glow/labels + + // ─── Reuse cached maps (avoid per-frame allocation) ─────────────── + const nodeMap = nodeMapCache.current; + nodeMap.clear(); + for (const n of state.nodes) nodeMap.set(n.id, n); + + const edgeMap = edgeMapCache.current; + edgeMap.clear(); + for (const e of state.edges) edgeMap.set(e.id, e); + + // ─── Filter visible nodes (frustum cull) — reuse array ──────────── + const visibleNodes = visibleNodesCache.current; + visibleNodes.length = 0; + for (const n of state.nodes) { + const x = n.x ?? 0; + const y = n.y ?? 0; + if (x > viewLeft - pad && x < viewRight + pad && + y > viewTop - pad && y < viewBottom + pad) { + visibleNodes.push(n); + } + } + + // ─── Active particle edges — reuse Set ─────────────────────────── + const activeParticleEdges = activeParticleEdgesCache.current; + activeParticleEdges.clear(); + for (const p of state.particles) activeParticleEdges.add(p.edgeId); + + // ─── Draw ───────────────────────────────────────────────────────── + ctx.save(); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + // 1. Background (screen space) + updateDepthParticles(starsRef.current, w, h, state.time > 0 ? 0.016 : 0); + drawBackground(ctx, w, h, starsRef.current, cam, state.time, { + showHexGrid, + showStarField, + }); + + // 2. World-space content + ctx.save(); + ctx.translate(cam.x, cam.y); + ctx.scale(zoom, zoom); + + // 2a. Edges (only those connecting visible nodes) — reuse collections + const visibleNodeIds = visibleNodeIdsCache.current; + visibleNodeIds.clear(); + for (const n of visibleNodes) visibleNodeIds.add(n.id); + + const visibleEdges = visibleEdgesCache.current; + visibleEdges.length = 0; + for (const e of state.edges) { + if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) { + visibleEdges.push(e); + } + } + drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges); + + // 2b. Particles (cap at 50 for performance) + const cappedParticles = state.particles.length > 50 + ? state.particles.slice(-50) + : state.particles; + drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time); + + // 2c. Visible nodes only (back to front: process → task → member/lead) + drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + + // 2d. Effects + drawEffects(ctx, state.effects); + + ctx.restore(); // world space + ctx.restore(); // DPR scale + + // 3. Bloom post-processing — skip when scene is fully idle (saves 3 blur passes) + const hasActivity = state.particles.length > 0 || state.effects.length > 0; + if (bloomIntensity > 0 && hasActivity) { + bloomRef.current.apply(canvas, ctx); + } + + // 4. Performance overlay (enabled via ?perf in URL) + const perf = perfRef.current; + const frameMs = performance.now() - frameStart; + perf.frameTimes.push(frameMs); + perf.frames++; + if (perf.frameTimes.length > 120) perf.frameTimes.shift(); + + const now = performance.now(); + if (now - perf.lastFpsUpdate > 1000) { + perf.fps = perf.frames; + perf.frames = 0; + perf.lastFpsUpdate = now; + const sorted = [...perf.frameTimes].sort((a, b) => a - b); + perf.frameTimeMs = sorted[Math.floor(sorted.length * 0.95)] ?? 0; + } + + if (typeof window !== 'undefined' && window.location?.search?.includes('perf')) { + ctx.save(); + ctx.scale(dpr, dpr); + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(w - 130, 4, 126, 48); + ctx.font = '10px monospace'; + ctx.fillStyle = perf.fps >= 50 ? '#66ffaa' : perf.fps >= 30 ? '#ffbb44' : '#ff5566'; + ctx.textAlign = 'right'; + ctx.fillText(`${perf.fps} fps`, w - 10, 18); + ctx.fillStyle = '#aaeeff'; + ctx.fillText(`p95: ${perf.frameTimeMs.toFixed(1)}ms`, w - 10, 32); + ctx.fillText(`${state.nodes.length} nodes ${state.edges.length} edges`, w - 10, 46); + ctx.restore(); + } + + } catch (err) { + // Rate-limited error logging — max once per 5 seconds + const now = performance.now(); + if (now - lastDrawErrorRef.current > 5000) { + lastDrawErrorRef.current = now; + console.error('[AgentGraph] Draw error:', err); + } + } + }, + getCanvas: () => canvasRef.current, + }), [showHexGrid, showStarField, bloomIntensity]); + + // Wheel handler (passive: false required for preventDefault) + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !onWheel) return; + const handler = (e: WheelEvent) => { + e.preventDefault(); + onWheel(e); + }; + canvas.addEventListener('wheel', handler, { passive: false }); + return () => canvas.removeEventListener('wheel', handler); + }, [onWheel]); + + return ( +
+ +
+ ); +}); diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx new file mode 100644 index 00000000..d3537463 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -0,0 +1,113 @@ +/** + * GraphControls — floating toolbar over the canvas. + * Zoom, fit, filter toggles, pause, pin-as-tab, close. + */ + +import { useCallback } from 'react'; + +export interface GraphFilterState { + showTasks: boolean; + showProcesses: boolean; + showEdges: boolean; + paused: boolean; +} + +export interface GraphControlsProps { + filters: GraphFilterState; + onFiltersChange: (filters: GraphFilterState) => void; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomToFit: () => void; + onRequestClose?: () => void; + onRequestPinAsTab?: () => void; + teamName: string; + isAlive?: boolean; +} + +export function GraphControls({ + filters, + onFiltersChange, + onZoomIn, + onZoomOut, + onZoomToFit, + onRequestClose, + onRequestPinAsTab, + teamName, + isAlive, +}: GraphControlsProps): React.JSX.Element { + const toggle = useCallback( + (key: keyof GraphFilterState) => { + onFiltersChange({ ...filters, [key]: !filters[key] }); + }, + [filters, onFiltersChange], + ); + + return ( +
+ {/* Left: title + status */} +
+
+ {isAlive && ( +
+ )} + + {teamName} + +
+
+ + {/* Center: filters */} +
+ toggle('showTasks')} label="Tasks" /> + toggle('showProcesses')} label="Proc" /> + toggle('showEdges')} label="Edges" /> +
+ toggle('paused')} label={filters.paused ? '▶' : '⏸'} /> +
+ + {/* Right: zoom + actions */} +
+ + + + {onRequestPinAsTab && ( + <> +
+ + + )} + {onRequestClose && ( + + )} +
+
+ ); +} + +// ─── Toolbar Button ───────────────────────────────────────────────────────── + +function ToolbarButton({ + active, + onClick, + label, +}: { + active?: boolean; + onClick?: () => void; + label: string; +}): React.JSX.Element { + return ( + + ); +} diff --git a/packages/agent-graph/src/ui/GraphOverlay.tsx b/packages/agent-graph/src/ui/GraphOverlay.tsx new file mode 100644 index 00000000..bf4154f3 --- /dev/null +++ b/packages/agent-graph/src/ui/GraphOverlay.tsx @@ -0,0 +1,165 @@ +/** + * GraphOverlay — HTML popovers positioned over Canvas nodes. + * Uses camera worldToScreen transform for positioning. + */ + +import { useCallback } from 'react'; +import type { GraphNode } from '../ports/types'; +import type { GraphEventPort } from '../ports/GraphEventPort'; +import { getStateColor, getTaskStatusColor } from '../constants/colors'; + +export interface GraphOverlayProps { + selectedNode: GraphNode | null; + worldToScreen: (wx: number, wy: number) => { x: number; y: number }; + events?: GraphEventPort; + onDeselect: () => void; +} + +export function GraphOverlay({ + selectedNode, + worldToScreen, + events, + onDeselect, +}: GraphOverlayProps): React.JSX.Element | null { + if (!selectedNode) return null; + + const screenPos = worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0); + + return ( +
+ +
+ ); +} + +// ─── Node Popover ─────────────────────────────────────────────────────────── + +function NodePopover({ + node, + events, + onClose, +}: { + node: GraphNode; + events?: GraphEventPort; + onClose: () => void; +}): React.JSX.Element { + const handleAction = useCallback( + (action: string) => { + const ref = node.domainRef; + switch (action) { + case 'sendMessage': + if (ref.kind === 'member' || ref.kind === 'lead') { + events?.onSendMessage?.(ref.kind === 'member' ? ref.memberName : 'team-lead', ref.teamName); + } + break; + case 'openDetail': + if (ref.kind === 'task') events?.onOpenTaskDetail?.(ref.taskId, ref.teamName); + else if (ref.kind === 'member') events?.onOpenMemberProfile?.(ref.memberName, ref.teamName); + break; + case 'openUrl': + if (node.processUrl) window.open(node.processUrl, '_blank'); + break; + } + onClose(); + }, + [node, events, onClose], + ); + + const color = node.kind === 'task' + ? getTaskStatusColor(node.taskStatus) + : getStateColor(node.state); + + return ( +
+ {/* Header */} +
+
+ + {node.label} + +
+ + {/* Info */} + {node.sublabel && ( +
+ {node.sublabel} +
+ )} + {node.role && ( +
+ {node.role} +
+ )} + + {/* Status badges */} +
+ + {node.reviewState && node.reviewState !== 'none' && ( + + )} +
+ + {/* Actions */} +
+ {(node.kind === 'member' || node.kind === 'lead') && ( + handleAction('sendMessage')} /> + )} + {(node.kind === 'task' || node.kind === 'member') && ( + handleAction('openDetail')} /> + )} + {node.kind === 'process' && node.processUrl && ( + handleAction('openUrl')} /> + )} +
+
+ ); +} + +// ─── UI Primitives ────────────────────────────────────────────────────────── + +function StatusBadge({ label, color }: { label: string; color: string }): React.JSX.Element { + return ( + + {label} + + ); +} + +function ActionButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element { + return ( + + ); +} diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx new file mode 100644 index 00000000..89c70d4c --- /dev/null +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -0,0 +1,342 @@ +/** + * GraphView — main orchestrator with UNIFIED RAF loop. + * + * ARCHITECTURE: One RAF loop that: + * 1. Ticks d3-force simulation (updates node positions in refs) + * 2. Updates particles and effects (in refs) + * 3. Calls canvasRef.draw() imperatively (no React re-renders) + * + * React useState ONLY for: selectedNodeId, filters (user-facing UI state). + * ALL animation state (positions, particles, effects, time) lives in refs. + */ + +import { useState, useCallback, useEffect, useRef } from 'react'; +import type { GraphDataPort } from '../ports/GraphDataPort'; +import type { GraphEventPort } from '../ports/GraphEventPort'; +import type { GraphConfigPort } from '../ports/GraphConfigPort'; +import type { GraphNode } from '../ports/types'; +import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; +import { GraphControls, type GraphFilterState } from './GraphControls'; +import { GraphOverlay } from './GraphOverlay'; +import { useGraphSimulation } from '../hooks/useGraphSimulation'; +import { useGraphCamera } from '../hooks/useGraphCamera'; +import { useGraphInteraction } from '../hooks/useGraphInteraction'; +import { findNodeAt } from '../canvas/hit-detection'; +import { ANIM_SPEED } from '../constants/canvas-constants'; + +export interface GraphViewProps { + data: GraphDataPort; + events?: GraphEventPort; + config?: Partial; + className?: string; + onRequestClose?: () => void; + onRequestPinAsTab?: () => void; +} + +export function GraphView({ + data, + events, + config, + className, + onRequestClose, + onRequestPinAsTab, +}: GraphViewProps): React.JSX.Element { + // ─── React state (user-facing only) ───────────────────────────────────── + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [filters, setFilters] = useState({ + showTasks: config?.showTasks ?? true, + showProcesses: config?.showProcesses ?? true, + showEdges: true, + paused: !(config?.animationEnabled ?? true), + }); + + // Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change + const selectedNodeIdRef = useRef(null); + selectedNodeIdRef.current = selectedNodeId; + + const containerRef = useRef(null); + const canvasHandle = useRef(null); + const rafRef = useRef(0); + const lastTimeRef = useRef(0); + const runningRef = useRef(false); + + // ─── Hooks ────────────────────────────────────────────────────────────── + const simulation = useGraphSimulation(); + const camera = useGraphCamera(); + + // Stable refs for RAF loop (avoid recreating animate on hook identity change) + const simulationRef = useRef(simulation); + simulationRef.current = simulation; + const cameraRef = useRef(camera); + cameraRef.current = camera; + + const interaction = useGraphInteraction( + useCallback((nodeId: string, x: number, y: number) => { + const state = simulation.stateRef.current; + const node = state.nodes.find((n) => n.id === nodeId); + if (node) { + node.fx = x; + node.fy = y; + node.x = x; + node.y = y; + } + }, [simulation.stateRef]), + ); + + // ─── Sync data from adapter → simulation ──────────────────────────────── + useEffect(() => { + const filteredNodes = data.nodes.filter((n) => { + if (n.kind === 'task' && !filters.showTasks) return false; + if (n.kind === 'process' && !filters.showProcesses) return false; + return true; + }); + const filteredEdges = filters.showEdges + ? data.edges + : data.edges.filter((e) => e.type === 'parent-child'); + simulation.updateData(filteredNodes, filteredEdges, data.particles); + }, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]); + + // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── + const idleFrameSkip = useRef(0); + + const animate = useCallback(() => { + if (!runningRef.current) return; + + const now = performance.now() / 1000; + const dt = Math.min( + lastTimeRef.current > 0 ? now - lastTimeRef.current : ANIM_SPEED.defaultDeltaTime, + ANIM_SPEED.maxDeltaTime, + ); + lastTimeRef.current = now; + + // 1. Tick simulation + simulationRef.current.tick(dt); + + // 2. Update camera inertia + cameraRef.current.updateInertia(); + + // 3. Adaptive frame rate: skip every other frame when idle (no particles, no effects, sim settled) + const state = simulationRef.current.stateRef.current; + const isIdle = state.particles.length === 0 && state.effects.length === 0; + if (isIdle) { + idleFrameSkip.current++; + if (idleFrameSkip.current % 2 !== 0) { + rafRef.current = requestAnimationFrame(animate); + return; // skip draw, halve fps when idle + } + } else { + idleFrameSkip.current = 0; + } + + // 4. Draw canvas imperatively (NO React re-render) + canvasHandle.current?.draw({ + nodes: state.nodes, + edges: state.edges, + particles: state.particles, + effects: state.effects, + time: state.time, + camera: cameraRef.current.transformRef.current, + selectedNodeId: selectedNodeIdRef.current, + hoveredNodeId: interaction.hoveredNodeId.current, + }); + + rafRef.current = requestAnimationFrame(animate); + // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs + }, []); + + // Start/stop RAF + useEffect(() => { + if (!filters.paused) { + runningRef.current = true; + lastTimeRef.current = 0; + rafRef.current = requestAnimationFrame(animate); + } else { + runningRef.current = false; + cancelAnimationFrame(rafRef.current); + } + return () => { + runningRef.current = false; + cancelAnimationFrame(rafRef.current); + }; + }, [filters.paused, animate]); + + // ─── Auto-fit: center graph immediately when data arrives ────────────── + const hasAutoFit = useRef(false); + useEffect(() => { + if (data.nodes.length > 0 && !hasAutoFit.current) { + hasAutoFit.current = true; + // Immediate fit (simulation already settled from 120 pre-ticks) + const el = containerRef.current; + if (el) { + camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + } + // Second fit after mount stabilizes (ResizeObserver may fire late) + const timer = setTimeout(() => { + if (el) { + camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + } + }, 300); + return () => clearTimeout(timer); + } + }, [data.nodes.length, camera, simulation.stateRef]); + + // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ + const isPanningRef = useRef(false); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (e.button !== 0) return; // only left click + + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + + // Check if we hit a node + interaction.handleMouseDown(world.x, world.y, simulation.stateRef.current.nodes); + + if (interaction.dragNodeId.current) { + // Hit a node → will drag it + isPanningRef.current = false; + } else { + // Hit empty space → pan + isPanningRef.current = true; + camera.handlePanStart(e.clientX, e.clientY); + } + }, [camera, interaction, simulation.stateRef]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + // Dragging with left button held + if (e.buttons & 1) { + if (isPanningRef.current) { + camera.handlePanMove(e.clientX, e.clientY); + return; + } + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes); + return; + } + + // No button held — hover detection + cursor update + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + interaction.hoveredNodeId.current = findNodeAt(world.x, world.y, simulation.stateRef.current.nodes); + canvas.style.cursor = interaction.hoveredNodeId.current ? 'pointer' : 'grab'; + }, [camera, interaction, simulation.stateRef]); + + const handleMouseUp = useCallback(() => { + if (isPanningRef.current) { + camera.handlePanEnd(); + isPanningRef.current = false; + setSelectedNodeId(null); // hide popover after pan + return; + } + + const clickedId = interaction.handleMouseUp(); + if (clickedId) { + setSelectedNodeId(clickedId); + const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId); + if (node) events?.onNodeClick?.(node.domainRef); + } else { + setSelectedNodeId(null); // click on empty space — hide popover + if (!interaction.isDragging.current) { + events?.onBackgroundClick?.(); + } + } + }, [interaction, simulation.stateRef, events, camera]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + const canvas = canvasHandle.current?.getCanvas(); + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); + const nodeId = interaction.handleDoubleClick(world.x, world.y, simulation.stateRef.current.nodes); + if (nodeId) { + const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId); + if (node) { + // Unpin if pinned (toggle) + if (node.fx != null) { + node.fx = null; + node.fy = null; + } + events?.onNodeDoubleClick?.(node.domainRef); + } + } + }, [camera, interaction, simulation.stateRef, events]); + + // ─── Keyboard ─────────────────────────────────────────────────────────── + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Don't capture from inputs + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return; + + if (e.key === 'Escape') { + if (selectedNodeId) { + setSelectedNodeId(null); + } else { + onRequestClose?.(); + } + } + if (e.key === 'f' || e.key === 'F') { + const el = containerRef.current; + if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + } + if (e.key === ' ') { + e.preventDefault(); + setFilters((f) => ({ ...f, paused: !f.paused })); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [selectedNodeId, onRequestClose, camera, simulation.stateRef]); + + // ─── Selected node for overlay ────────────────────────────────────────── + const selectedNode: GraphNode | null = + selectedNodeId + ? simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null + : null; + + // ─── Render ───────────────────────────────────────────────────────────── + return ( +
+ + + { + const el = containerRef.current; + if (el) camera.zoomToFit(simulation.stateRef.current.nodes, el.clientWidth, el.clientHeight); + }} + onRequestClose={onRequestClose} + onRequestPinAsTab={onRequestPinAsTab} + teamName={data.teamName} + isAlive={data.isAlive} + /> + + setSelectedNodeId(null)} + /> +
+ ); +} diff --git a/packages/agent-graph/tsconfig.json b/packages/agent-graph/tsconfig.json new file mode 100644 index 00000000..4196ea0e --- /dev/null +++ b/packages/agent-graph/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 977139de..f81f1a5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@claude-teams/agent-graph': + specifier: workspace:* + version: link:packages/agent-graph '@codemirror/autocomplete': specifier: ^6.20.0 version: 6.20.0 @@ -515,6 +518,22 @@ importers: specifier: ^3.1.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(happy-dom@20.0.2)(sass@1.98.0)(terser@5.46.0) + packages/agent-graph: + dependencies: + d3-force: + specifier: ^3.0.0 + version: 3.0.0 + react: + specifier: ^18.0.0 + version: 18.3.1 + react-dom: + specifier: ^18.0.0 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 + packages: 7zip-bin@5.2.0: @@ -9045,6 +9064,11 @@ packages: rc9@3.0.0: resolution: {integrity: sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA==} + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -9111,6 +9135,10 @@ packages: '@types/react': optional: true + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -9377,6 +9405,9 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -21042,6 +21073,12 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -21121,6 +21158,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + react@19.2.4: {} read-binary-file-arch@1.0.6: @@ -21496,6 +21537,10 @@ snapshots: sax@1.6.0: {} + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + scheduler@0.27.0: {} scslre@0.3.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 73c2b7d9..e8e77a86 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,5 +2,6 @@ packages: - agent-teams-controller - mcp-server - landing + - packages/agent-graph ignoredBuiltDependencies: - esbuild diff --git a/src/main/index.ts b/src/main/index.ts index e8378d75..524ae9ce 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -22,6 +22,7 @@ import './sentry'; import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; import { SchedulerService } from '@main/services/schedule/SchedulerService'; +import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService'; import { CrossTeamService } from '@main/services/team/CrossTeamService'; import { FileContentResolver } from '@main/services/team/FileContentResolver'; @@ -30,7 +31,6 @@ import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; import { TeamBackupService } from '@main/services/team/TeamBackupService'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamInboxWriter } from '@main/services/team/TeamInboxWriter'; -import { JsonTaskChangePresenceRepository } from '@main/services/team/cache/JsonTaskChangePresenceRepository'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { CONTEXT_CHANGED, @@ -104,8 +104,8 @@ import { SshConnectionManager, TaskBoundaryParser, TeamDataService, - TeamMemberLogsFinder, TeamLogSourceTracker, + TeamMemberLogsFinder, TeamProvisioningService, UpdaterService, } from './services'; diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c38aa3e1..cabe55b4 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -19,7 +19,6 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, - TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -27,6 +26,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, diff --git a/src/main/services/infrastructure/PtyTerminalService.ts b/src/main/services/infrastructure/PtyTerminalService.ts index 89008836..ba23ebb5 100644 --- a/src/main/services/infrastructure/PtyTerminalService.ts +++ b/src/main/services/infrastructure/PtyTerminalService.ts @@ -9,13 +9,12 @@ import crypto from 'node:crypto'; import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getHomeDir } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; // eslint-disable-next-line boundaries/element-types -- IPC channel constants shared between main and preload import { TERMINAL_DATA, TERMINAL_EXIT } from '@preload/constants/ipcChannels'; import { createLogger } from '@shared/utils/logger'; import type { PtySpawnOptions } from '@shared/types/terminal'; -import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; - import type { BrowserWindow } from 'electron'; const logger = createLogger('PtyTerminalService'); diff --git a/src/main/services/infrastructure/UpdaterService.ts b/src/main/services/infrastructure/UpdaterService.ts index 170b0ca5..826b873e 100644 --- a/src/main/services/infrastructure/UpdaterService.ts +++ b/src/main/services/infrastructure/UpdaterService.ts @@ -17,9 +17,10 @@ import electronUpdater from 'electron-updater'; const { autoUpdater } = electronUpdater; +import { net } from 'electron'; + import type { UpdaterStatus } from '@shared/types'; import type { BrowserWindow } from 'electron'; -import { net } from 'electron'; const logger = createLogger('UpdaterService'); diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 116918a7..6a29c300 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -9,25 +9,26 @@ import { createHash } from 'crypto'; import { readFile, stat } from 'fs/promises'; import * as path from 'path'; -import { TaskChangeComputer } from './TaskChangeComputer'; -import { TaskChangeWorkerClient, getTaskChangeWorkerClient } from './TaskChangeWorkerClient'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; -import { TeamConfigReader } from './TeamConfigReader'; +import { TaskChangeComputer } from './TaskChangeComputer'; import { buildTaskChangePresenceDescriptor, computeTaskChangePresenceProjectFingerprint, normalizeTaskChangePresenceFilePath, } from './taskChangePresenceUtils'; +import { getTaskChangeWorkerClient } from './TaskChangeWorkerClient'; import { type ResolvedTaskChangeComputeInput, type TaskChangeEffectiveOptions, type TaskChangeTaskMeta, } from './taskChangeWorkerTypes'; +import { TeamConfigReader } from './TeamConfigReader'; -import type { TaskBoundaryParser } from './TaskBoundaryParser'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; -import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; +import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; +import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); diff --git a/src/main/services/team/TaskChangeComputer.ts b/src/main/services/team/TaskChangeComputer.ts index 893a9ee2..4d8cd308 100644 --- a/src/main/services/team/TaskChangeComputer.ts +++ b/src/main/services/team/TaskChangeComputer.ts @@ -3,10 +3,11 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import * as readline from 'readline'; -import { countLineChanges } from './UnifiedLineCounter'; import { normalizeTaskChangePresenceFilePath } from './taskChangePresenceUtils'; +import { countLineChanges } from './UnifiedLineCounter'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; +import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { AgentChangeSet, @@ -17,7 +18,6 @@ import type { TaskChangeScope, TaskChangeSetV2, } from '@shared/types'; -import type { ResolvedTaskChangeComputeInput } from './taskChangeWorkerTypes'; const logger = createLogger('Service:TaskChangeComputer'); diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 9db2a91c..f70070bf 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -30,6 +30,7 @@ import * as path from 'path'; import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; import { atomicWriteAsync } from './atomicWrite'; +import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; @@ -42,8 +43,10 @@ import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificatio import { TeamTaskReader } from './TeamTaskReader'; import { TeamTaskWriter } from './TeamTaskWriter'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; -import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; +import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; +import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; +import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { AddMemberRequest, AttachmentMeta, @@ -56,6 +59,7 @@ import type { SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TaskRef, TeamConfig, @@ -66,15 +70,11 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, - TaskChangePresenceState, TeamTaskWithKanban, ToolCallMeta, UpdateKanbanPatch, } from '@shared/types'; import type { AgentTeamsController } from 'agent-teams-controller'; -import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; -import type { PersistedTaskChangePresenceIndex } from './cache/taskChangePresenceCacheTypes'; -import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; const { createController } = agentTeamsControllerModule; @@ -243,8 +243,7 @@ export class TeamDataService { }); const presenceEntry = presenceIndex.entries[task.id]; result[task.id] = - presenceEntry && - presenceEntry.taskSignature === descriptor.taskSignature && + presenceEntry?.taskSignature === descriptor.taskSignature && presenceEntry.logSourceGeneration === logSourceSnapshot.logSourceGeneration ? presenceEntry.presence : 'unknown'; diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 863a8b81..64ad4880 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -216,7 +216,7 @@ export class TeamLogSourceTracker { const scheduleRecompute = (): void => { const current = this.stateByTeam.get(teamName); - if (!current || !current.desiredTracking) { + if (!current?.desiredTracking) { return; } if (current.refreshTimer) { diff --git a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts index 1d1df59b..0e573e5e 100644 --- a/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts +++ b/src/main/services/team/cache/JsonTaskChangePresenceRepository.ts @@ -10,8 +10,8 @@ import { } from './taskChangePresenceCacheSchema'; import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes'; -import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes'; +import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository'; const logger = createLogger('Service:JsonTaskChangePresenceRepository'); diff --git a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts index 16c5f78b..b65af3e8 100644 --- a/src/main/services/team/cache/taskChangePresenceCacheSchema.ts +++ b/src/main/services/team/cache/taskChangePresenceCacheSchema.ts @@ -1,8 +1,8 @@ import { - TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, type PersistedTaskChangePresence, type PersistedTaskChangePresenceEntry, type PersistedTaskChangePresenceIndex, + TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION, } from './taskChangePresenceCacheTypes'; function isIsoString(value: unknown): value is string { diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index e2df6084..75a6e638 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -16,8 +16,8 @@ export { TeamDataService } from './TeamDataService'; export { TeamInboxReader } from './TeamInboxReader'; export { TeamInboxWriter } from './TeamInboxWriter'; export { TeamKanbanManager } from './TeamKanbanManager'; -export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; export { TeamLogSourceTracker } from './TeamLogSourceTracker'; +export { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; export { TeamMemberResolver } from './TeamMemberResolver'; export { TeamMembersMetaStore } from './TeamMembersMetaStore'; export { TeamProvisioningService } from './TeamProvisioningService'; diff --git a/src/main/workers/task-change-worker.ts b/src/main/workers/task-change-worker.ts index 06f92042..77a6b119 100644 --- a/src/main/workers/task-change-worker.ts +++ b/src/main/workers/task-change-worker.ts @@ -18,7 +18,7 @@ function postMessage(message: TaskChangeWorkerResponse): void { } parentPort?.on('message', async (message: TaskChangeWorkerRequest) => { - if (!message || message.op !== 'computeTaskChanges') { + if (message?.op !== 'computeTaskChanges') { postMessage({ id: message?.id ?? 'unknown', ok: false, diff --git a/src/preload/index.ts b/src/preload/index.ts index df252ede..13b70812 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -121,7 +121,6 @@ import { TEAM_GET_ATTACHMENTS, TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, - TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, @@ -129,6 +128,7 @@ import { TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, + TEAM_GET_TASK_CHANGE_PRESENCE, TEAM_KILL_PROCESS, TEAM_LAUNCH, TEAM_LEAD_ACTIVITY, diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 39931ad9..1219722e 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -4,6 +4,7 @@ */ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; +import { TeamGraphTab } from '@renderer/features/agent-graph/ui/TeamGraphTab'; import { DashboardView } from '../dashboard/DashboardView'; import { ExtensionStoreView } from '../extensions/ExtensionStoreView'; @@ -65,6 +66,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { )} {tab.type === 'schedules' && } + {tab.type === 'graph' && ( + + + + )}
); })} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 6c22d0e4..0f34ad2a 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -17,6 +17,7 @@ import { Calendar, FileText, LayoutDashboard, + Network, Pin, Puzzle, Search, @@ -52,6 +53,7 @@ const TAB_ICONS = { report: Activity, extensions: Puzzle, schedules: Calendar, + graph: Network, } as const; export const SortableTab = ({ diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 875a1fa8..b1226889 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -42,6 +42,7 @@ import { FolderOpen, GitBranch, History, + Network, Pencil, Play, Plus, @@ -71,6 +72,11 @@ import type { AddMemberEntry } from './dialogs/AddMemberDialog'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); +const TeamGraphOverlay = lazy(() => + import('@renderer/features/agent-graph/ui/TeamGraphOverlay').then((m) => ({ + default: m.TeamGraphOverlay, + })) +); import { MemberList } from './members/MemberList'; import { MessagesPanel } from './messages/MessagesPanel'; import { ChangeReviewDialog } from './review/ChangeReviewDialog'; @@ -192,20 +198,33 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [editDialogOpen, setEditDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); const contentRef = useRef(null); const provisioningBannerRef = useRef(null); const wasProvisioningRef = useRef(false); - // Set inert on background content when editor overlay is open (a11y focus trap) + // Set inert on background content when editor/graph overlay is open (a11y focus trap) useEffect(() => { const el = contentRef.current; if (!el) return; - if (editorOpen) { + if (editorOpen || graphOpen) { el.setAttribute('inert', ''); } else { el.removeAttribute('inert'); } - }, [editorOpen]); + }, [editorOpen, graphOpen]); + + // Listen for Cmd+Shift+G keyboard shortcut + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + setGraphOpen((prev) => !prev); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [teamName]); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); @@ -1406,18 +1425,32 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} defaultOpen action={ - +
+ + +
} > )} + + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + /> + + )} ); }; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 108377d8..7831af44 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -90,8 +90,8 @@ import type { FileChangeSummary, KanbanTaskState, ResolvedTeamMember, - TaskChangeSetV2, TaskAttachmentMeta, + TaskChangeSetV2, TeamTaskWithKanban, } from '@shared/types'; diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index d4bb55d3..6c5e28ef 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -317,8 +317,7 @@ export const KanbanBoard = ({ ); const previous = stableTaskMapRef.current; if ( - previous && - previous.signatures.length === signatures.length && + previous?.signatures.length === signatures.length && previous.signatures.every((signature, index) => signature === signatures[index]) ) { return previous.map; diff --git a/src/renderer/features/CLAUDE.md b/src/renderer/features/CLAUDE.md new file mode 100644 index 00000000..50d59279 --- /dev/null +++ b/src/renderer/features/CLAUDE.md @@ -0,0 +1,494 @@ +# Features Directory — Architecture Guide + +All new renderer features live here. Each feature is a self-contained module following **Clean Architecture**, **SOLID**, and **class-based** patterns. + +--- + +## Quick Start + +```bash +mkdir -p src/renderer/features//{ports,adapters,domain,ui,hooks,__tests__} +``` + +--- + +## Directory Structure + +### Full Feature + +``` +src/renderer/features// + ├── ports/ # Interfaces (contracts) — NO implementations + │ ├── DataPort.ts # What data the feature needs (input) + │ ├── EventPort.ts # Callbacks the feature fires (output) + │ ├── ConfigPort.ts# Configuration / theme overrides + │ └── types.ts # Domain value types for this feature + │ + ├── adapters/ # Bridge between project infrastructure and feature + │ └── Adapter.ts # Zustand store → DataPort (ONLY place that imports store) + │ + ├── domain/ # Business logic — pure TS, no React, no UI + │ ├── models/ # Domain entities and value objects (classes) + │ └── services/ # Domain services and use cases (classes) + │ + ├── ui/ # React components — presentation only + │ ├── View.tsx # Main component (orchestrator, entry point) + │ ├── Overlay.tsx # Full-screen overlay variant (if applicable) + │ └── Tab.tsx # Tab wrapper variant (if applicable) + │ + ├── hooks/ # React hooks — thin bridges to domain classes + │ └── use.ts # Instantiates domain services, subscribes to store + │ + ├── __tests__/ # Tests colocated with feature + │ ├── adapters.test.ts # Adapter mapping correctness + │ ├── domain.test.ts # Domain logic unit tests + │ └── ports.test.ts # Port type validation + │ + └── index.ts # Public API barrel — exports ONLY from ui/ and ports/ +``` + +### Minimal Feature (no domain layer) + +Small features that don't need business logic: + +``` +src/renderer/features// + ├── Adapter.ts # Zustand → feature data + ├── View.tsx # Main component + └── index.ts # Public API +``` + +### When to Extract a Workspace Package + +Some features benefit from a separate `packages//` workspace package: + +| Keep in `features/` | Extract to `packages/` | +|---------------------|----------------------| +| Tightly coupled to our UI | Reusable in other projects | +| Uses our Zustand store | Framework-agnostic (only React peer dep) | +| Small (<500 LOC) | Large (>1000 LOC of core logic) | +| No external deps | Has its own dependencies (d3-force, etc.) | + +Example: `agent-graph` has BOTH: +- `packages/agent-graph/` — Canvas rendering, d3-force simulation (reusable, no project coupling) +- `features/agent-graph/` — Adapter + overlay + tab (thin integration, imports from store) + +--- + +## Real-World Example: agent-graph + +``` +features/agent-graph/ ← Integration layer (3 files) + ├── useTeamGraphAdapter.ts ← Adapter: TeamData → GraphDataPort + ├── TeamGraphOverlay.tsx ← UI: full-screen overlay + └── TeamGraphTab.tsx ← UI: tab wrapper + +packages/agent-graph/ ← Isolated package (34 files) + ├── src/ports/ ← GraphDataPort, GraphEventPort, types + ├── src/canvas/ ← Canvas 2D renderers + ├── src/strategies/ ← Strategy pattern per node kind + ├── src/hooks/ ← Simulation, camera, interaction + └── src/components/ ← GraphView, GraphCanvas, Controls +``` + +The adapter (`useTeamGraphAdapter.ts`) is the **only file** that imports from `@renderer/store`. Everything else depends only on port interfaces. + +--- + +## SOLID Principles + +### S — Single Responsibility + +Each layer has exactly one reason to change: + +| Layer | Changes when... | Does NOT change when... | +|-------|----------------|------------------------| +| `ports/` | Feature contract changes | Store structure changes | +| `adapters/` | Store data model changes | Canvas rendering changes | +| `domain/` | Business rules change | React version updates | +| `ui/` | UX/layout changes | Data mapping changes | + +### O — Open-Closed + +Extend via new classes, never modify existing ones: + +```typescript +// ✅ New node kind = new class, zero changes to existing code +class ReviewNodeRenderer implements NodeRenderer { ... } + +// Register it — the registry and canvas loop don't change +NodeRendererRegistry.register(new ReviewNodeRenderer()); +``` + +### L — Liskov Substitution + +Any implementation of a port can replace another without breaking the feature: + +```typescript +// Both adapters satisfy GraphDataPort — feature works with either +class LiveTeamAdapter implements GraphDataPort { ... } // Real-time Zustand data +class MockTeamAdapter implements GraphDataPort { ... } // Static test data +class ReplayTeamAdapter implements GraphDataPort { ... } // Recorded session playback + +// Feature doesn't know or care which one it gets +const view = ; +``` + +### I — Interface Segregation + +Split ports by consumer. Each consumer depends only on what it needs: + +```typescript +// ✅ Three small ports +interface GraphDataPort { nodes: GraphNode[]; edges: GraphEdge[]; } +interface GraphEventPort { onNodeClick?(ref: DomainRef): void; } +interface GraphConfigPort { bloomIntensity?: number; showTasks?: boolean; } + +// ❌ One massive interface — forces every consumer to know about everything +interface GraphPort { + nodes: GraphNode[]; edges: GraphEdge[]; + onNodeClick?(ref: DomainRef): void; + bloomIntensity?: number; showTasks?: boolean; +} +``` + +### D — Dependency Inversion + +High-level modules (feature UI) depend on abstractions (ports), not on low-level modules (Zustand store). + +``` +UI → depends on → Port interface ← implemented by ← Adapter → depends on → Store + +Feature code never touches the store. The adapter translates in both directions. +``` + +--- + +## Class-Based Patterns + +Prefer **classes** over functions for domain logic, services, adapters, and stateful code. Use the **latest ECMAScript class features** (ES2024+). + +### Modern Class Syntax + +```typescript +class TeamGraphAdapter implements GraphDataPort { + // ─── ES private fields (NOT TypeScript `private`) ───────────── + readonly #store: StoreApi; + #cachedNodes: GraphNode[] = []; + #lastTeamName = ''; + + // ─── Static factory (prefer for complex initialization) ─────── + static create(store: StoreApi): TeamGraphAdapter { + return new TeamGraphAdapter(store); + } + + // ─── Constructor with DI ────────────────────────────────────── + constructor(store: StoreApi) { + this.#store = store; + } + + // ─── Accessors (get/set) ────────────────────────────────────── + get nodes(): readonly GraphNode[] { + return this.#cachedNodes; + } + + // ─── Public method (port contract) ──────────────────────────── + adapt(teamData: TeamData): GraphDataPort { + if (teamData.teamName === this.#lastTeamName) return this; + this.#lastTeamName = teamData.teamName; + this.#cachedNodes = this.#buildNodes(teamData); + return this; + } + + // ─── ES private method ──────────────────────────────────────── + #buildNodes(data: TeamData): GraphNode[] { + return data.members.map(m => ({ id: m.name, kind: 'member', ... })); + } + + // ─── Disposable (cleanup) ───────────────────────────────────── + [Symbol.dispose](): void { + this.#cachedNodes = []; + } +} +``` + +### Key Rules + +| Rule | Do | Don't | +|------|-----|-------| +| Private fields | `#field` (ES private) | `private field` (TS keyword) | +| Private methods | `#method()` | `private method()` | +| Readonly fields | `readonly #field` | Mutable when immutability intended | +| Static factory | `static create()` | Complex constructor logic | +| Disposal | `[Symbol.dispose]()` or `dispose()` | Forgetting cleanup | +| Type narrowing | `instanceof` checks | `as` casts | + +### When to Use Classes vs Functions + +| Use Case | Pattern | Why | +|----------|---------|-----| +| Domain models with state | **Class** | Encapsulation, lifecycle | +| Adapters (data mapping) | **Class** with caching | State for memoization | +| Services (business logic) | **Class** with DI | Testable, injectable | +| Canvas renderers | **Class** implementing strategy | Polymorphism | +| React components | **Function component** | React requires it | +| React hooks | **Function** | React requires it | +| Pure stateless utilities | **Function** | Simpler, no overhead | +| Constants | `as const` object | Immutable | + +### Dependency Injection + +Always inject dependencies through the constructor: + +```typescript +class FeatureService { + readonly #data: FeatureDataPort; + readonly #events: FeatureEventPort; + + constructor(data: FeatureDataPort, events: FeatureEventPort) { + this.#data = data; + this.#events = events; + } + + execute(): void { + const result = this.#data.getNodes(); + this.#events.onResult?.(result); + } +} + +// Wiring in a hook: +function useFeature(): FeatureService { + const adapter = useMemo(() => FeatureAdapter.create(store), [store]); + return useMemo(() => new FeatureService(adapter, eventHandler), [adapter]); +} +``` + +### Strategy Pattern + +```typescript +interface NodeRenderer { + readonly kind: string; + draw(ctx: CanvasRenderingContext2D, node: Node): void; + hitTest(node: Node, x: number, y: number): boolean; +} + +class MemberNodeRenderer implements NodeRenderer { + readonly kind = 'member'; + draw(ctx: CanvasRenderingContext2D, node: Node): void { /* ... */ } + hitTest(node: Node, x: number, y: number): boolean { /* ... */ } +} + +class NodeRendererRegistry { + readonly #renderers = new Map(); + + register(renderer: NodeRenderer): this { + this.#renderers.set(renderer.kind, renderer); + return this; + } + + get(kind: string): NodeRenderer | undefined { + return this.#renderers.get(kind); + } +} + +// Usage: +const registry = new NodeRendererRegistry() + .register(new MemberNodeRenderer()) + .register(new TaskNodeRenderer()); +``` + +--- + +## Error Handling + +```typescript +// Domain errors — typed, not string messages +class FeatureError extends Error { + constructor( + readonly code: 'INVALID_DATA' | 'RENDER_FAILED' | 'ADAPTER_ERROR', + message: string, + readonly cause?: unknown, + ) { + super(message); + this.name = 'FeatureError'; + } +} + +// In adapters — catch and wrap external errors +class FeatureAdapter { + adapt(data: unknown): FeatureDataPort { + try { + return this.#transform(data); + } catch (err) { + throw new FeatureError('ADAPTER_ERROR', 'Failed to adapt data', err); + } + } +} + +// In UI — catch at boundary, show fallback +function FeatureView({ data }: Props) { + // React error boundary or try/catch in event handlers + // Never let feature errors crash the host app +} +``` + +--- + +## Inter-Feature Communication + +Features MUST NOT import from each other directly. If two features need to share data: + +``` +Feature A → emits event → Host app (TeamDetailView) → passes data → Feature B +``` + +Pattern: use `CustomEvent` on `window` (same as keyboard shortcuts): + +```typescript +// Feature A fires: +window.dispatchEvent(new CustomEvent('feature-a:data-ready', { detail: { ... } })); + +// Host app listens and passes to Feature B via props/ports +``` + +--- + +## Testing + +Tests live in `__tests__/` inside the feature directory. + +```typescript +// __tests__/adapters.test.ts — test data mapping +describe('FeatureAdapter', () => { + it('maps TeamData members to GraphNodes', () => { + const adapter = new FeatureAdapter(); + const result = adapter.adapt(mockTeamData); + expect(result.nodes).toHaveLength(3); + expect(result.nodes[0].kind).toBe('lead'); + }); +}); + +// __tests__/domain.test.ts — test business logic +describe('SimulationService', () => { + it('applies orbit force to task nodes', () => { + const service = new SimulationService(mockConfig); + service.tick(0.016); + expect(service.nodes[0].x).toBeDefined(); + }); +}); +``` + +Run: `pnpm test -- --testPathPattern=features/` + +--- + +## Integration with Main App + +Features connect through minimal **registration points** in shared files: + +### Tab Registration (3 files) + +```typescript +// 1. src/renderer/types/tabs.ts — add to union +type: '...' | ''; + +// 2. src/renderer/components/layout/PaneContent.tsx — add route +{tab.type === '' && ( + + + +)} + +// 3. src/renderer/components/layout/SortableTab.tsx — add icon +: SomeIcon, +``` + +### Overlay Registration (1 file) + +```typescript +// In host component (e.g., TeamDetailView.tsx): +const FeatureOverlay = lazy(() => + import('@renderer/features//ui/FeatureOverlay') + .then(m => ({ default: m.FeatureOverlay })) +); +``` + +### Keyboard Shortcut (1 file) + +```typescript +// In useKeyboardShortcuts.ts: +if (key === '' && event.shiftKey && !event.altKey) { + window.dispatchEvent(new CustomEvent('toggle-', { detail })); +} +``` + +--- + +## Naming Conventions + +| Entity | Convention | Example | +|--------|-----------|---------| +| Feature directory | `kebab-case` | `agent-graph/` | +| Port interfaces | `PascalCase` + `Port` suffix | `GraphDataPort` | +| Domain classes | `PascalCase` | `SimulationService` | +| Adapter classes | `PascalCase` + `Adapter` suffix | `TeamGraphAdapter` | +| UI components | `PascalCase` | `GraphView`, `GraphOverlay` | +| Hooks | `camelCase` + `use` prefix | `useTeamGraphAdapter` | +| Test files | `.test.ts` | `adapters.test.ts` | +| Type files | `camelCase` or `types.ts` | `types.ts` | +| Barrel | `index.ts` | `index.ts` | + +--- + +## Existing Features + +| Feature | Path | Companion Package | Description | +|---------|------|-------------------|-------------| +| `agent-graph` | `features/agent-graph/` | `packages/agent-graph/` | Force-directed graph visualization | + +--- + +## Anti-Patterns + +```typescript +// ❌ Feature imports from another feature +import { X } from '@renderer/features/other-feature/X'; + +// ❌ UI component imports store directly (only adapters may) +import { useStore } from '@renderer/store'; + +// ❌ Feature imports from @renderer/components/* +import { KanbanBoard } from '@renderer/components/team/kanban/KanbanBoard'; + +// ❌ TypeScript `private` instead of ES #private +class Bad { private field = 1; } // Use: #field = 1; + +// ❌ Mutable global state +let globalCache = {}; + +// ❌ `any` or `as any` +const data = response as any; + +// ❌ God-class with mixed responsibilities +class FeatureManager { + fetchData() { ... } + renderUI() { ... } + handleClick() { ... } + saveToStorage() { ... } +} +``` + +--- + +## Checklist for New Feature PR + +- [ ] Feature lives in `src/renderer/features//` +- [ ] Port interfaces defined (`DataPort`, `EventPort` at minimum) +- [ ] Adapter is the ONLY file importing from `@renderer/store` +- [ ] No cross-feature imports +- [ ] Classes use ES `#private` fields, not TypeScript `private` +- [ ] `index.ts` exports only public API (ui components + port types) +- [ ] Integration points documented (which shared files were modified) +- [ ] Tests in `__tests__/` for adapter and domain logic +- [ ] Typecheck passes: `pnpm typecheck` +- [ ] Build passes: `pnpm build` diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts new file mode 100644 index 00000000..1d2291ad --- /dev/null +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -0,0 +1,399 @@ +/** + * TeamGraphAdapter — transforms Zustand TeamData → GraphDataPort. + * + * This is the ONLY file in this feature that imports from @renderer/store. + * If the project data model changes, ONLY this class needs updating. + * + * Class-based with ES #private fields, caching, and DI-ready constructor. + */ + +import { isLeadMember } from '@shared/utils/leadDetection'; + +import type { + GraphDataPort, + GraphEdge, + GraphNode, + GraphNodeState, + GraphParticle, +} from '@claude-teams/agent-graph'; +import type { InboxMessage, MemberSpawnStatusEntry, TeamData } from '@shared/types/team'; +import type { LeadContextUsage } from '@shared/types/team'; + +export class TeamGraphAdapter { + // ─── ES #private fields ────────────────────────────────────────────────── + #lastTeamName = ''; + #lastDataHash = ''; + #cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult(''); + readonly #seenRelated = new Set(); + readonly #seenMessageIds = new Set(); + #initialMessagesSeen = false; + + // ─── Static factory ────────────────────────────────────────────────────── + static create(): TeamGraphAdapter { + return new TeamGraphAdapter(); + } + + static #emptyResult(teamName: string): GraphDataPort { + return { nodes: [], edges: [], particles: [], teamName, isAlive: false }; + } + + // ─── Public API ────────────────────────────────────────────────────────── + + /** + * Adapt team data into a GraphDataPort snapshot. + * Returns cached result if inputs haven't changed (referential check). + */ + adapt( + teamData: TeamData | null, + teamName: string, + spawnStatuses?: Record, + leadContext?: LeadContextUsage + ): GraphDataPort { + if (teamData?.teamName !== teamName) { + return TeamGraphAdapter.#emptyResult(teamName); + } + + // Simple hash for change detection (avoids full deep equality) + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}`; + if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { + return this.#cachedResult; + } + + // Reset particle tracking when team changes + if (teamName !== this.#lastTeamName) { + this.#seenMessageIds.clear(); + this.#initialMessagesSeen = false; + } + + this.#lastTeamName = teamName; + this.#lastDataHash = hash; + this.#seenRelated.clear(); + + const nodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + const particles: GraphParticle[] = []; + + const leadId = `lead:${teamName}`; + + this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext); + this.#buildMemberNodes(nodes, edges, leadId, teamData, teamName, spawnStatuses); + this.#buildTaskNodes(nodes, edges, teamData, teamName); + this.#buildProcessNodes(nodes, edges, teamData, teamName); + this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges); + + this.#cachedResult = { + nodes, + edges, + particles, + teamName, + teamColor: teamData.config.color ?? undefined, + isAlive: teamData.isAlive, + }; + + return this.#cachedResult; + } + + // ─── Disposal ──────────────────────────────────────────────────────────── + + [Symbol.dispose](): void { + this.#cachedResult = TeamGraphAdapter.#emptyResult(''); + this.#seenRelated.clear(); + this.#seenMessageIds.clear(); + this.#initialMessagesSeen = false; + this.#lastDataHash = ''; + } + + // ─── Private: node builders ────────────────────────────────────────────── + + #buildLeadNode( + nodes: GraphNode[], + leadId: string, + data: TeamData, + teamName: string, + leadContext?: LeadContextUsage + ): void { + const percent = leadContext?.percent; + nodes.push({ + id: leadId, + kind: 'lead', + label: data.config.name || teamName, + state: data.isAlive ? 'active' : 'idle', + color: data.config.color ?? undefined, + contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, + domainRef: { kind: 'lead', teamName }, + }); + } + + #buildMemberNodes( + nodes: GraphNode[], + edges: GraphEdge[], + leadId: string, + data: TeamData, + teamName: string, + spawnStatuses?: Record + ): void { + for (const member of data.members) { + if (member.removedAt) continue; + if (isLeadMember(member)) continue; + + const memberId = `member:${teamName}:${member.name}`; + const spawn = spawnStatuses?.[member.name]; + + nodes.push({ + id: memberId, + kind: 'member', + label: member.name, + state: TeamGraphAdapter.#mapMemberStatus(member.status, spawn?.status), + color: member.color ?? undefined, + role: member.role ?? undefined, + spawnStatus: spawn?.status, + domainRef: { kind: 'member', teamName, memberName: member.name }, + }); + + edges.push({ + id: `edge:parent:${leadId}:${memberId}`, + source: leadId, + target: memberId, + type: 'parent-child', + }); + } + } + + #buildTaskNodes(nodes: GraphNode[], edges: GraphEdge[], data: TeamData, teamName: string): void { + for (const task of data.tasks) { + if (task.status === 'deleted') continue; + const taskId = `task:${teamName}:${task.id}`; + const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; + + nodes.push({ + id: taskId, + kind: 'task', + label: task.displayId ?? `#${task.id.slice(0, 6)}`, + sublabel: task.subject, + state: TeamGraphAdapter.#mapTaskStatus(task.status), + taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status), + reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState), + displayId: task.displayId ?? undefined, + ownerId: ownerMemberId, + needsClarification: task.needsClarification ?? null, + domainRef: { kind: 'task', teamName, taskId: task.id }, + }); + + if (ownerMemberId) { + edges.push({ + id: `edge:own:${ownerMemberId}:${taskId}`, + source: ownerMemberId, + target: taskId, + type: 'ownership', + }); + } + + const seenBlockEdges = new Set(); + for (const blockedById of task.blockedBy ?? []) { + const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`; + if (seenBlockEdges.has(edgeId)) continue; + seenBlockEdges.add(edgeId); + edges.push({ + id: edgeId, + source: `task:${teamName}:${blockedById}`, + target: taskId, + type: 'blocking', + }); + } + + for (const blocksId of task.blocks ?? []) { + const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`; + if (seenBlockEdges.has(edgeId)) continue; + seenBlockEdges.add(edgeId); + edges.push({ + id: edgeId, + source: taskId, + target: `task:${teamName}:${blocksId}`, + type: 'blocking', + }); + } + + for (const relatedId of task.related ?? []) { + const key = [task.id, relatedId].sort().join(':'); + if (this.#seenRelated.has(key)) continue; + this.#seenRelated.add(key); + edges.push({ + id: `edge:rel:${key}`, + source: taskId, + target: `task:${teamName}:${relatedId}`, + type: 'related', + }); + } + } + } + + #buildProcessNodes( + nodes: GraphNode[], + edges: GraphEdge[], + data: TeamData, + teamName: string + ): void { + for (const proc of data.processes) { + if (proc.stoppedAt) continue; + const procId = `process:${teamName}:${proc.id}`; + const ownerId = proc.registeredBy ? `member:${teamName}:${proc.registeredBy}` : null; + + nodes.push({ + id: procId, + kind: 'process', + label: proc.label, + state: 'active', + processUrl: proc.url ?? undefined, + domainRef: { kind: 'process', teamName, processId: proc.id }, + }); + + if (ownerId) { + edges.push({ + id: `edge:proc:${ownerId}:${procId}`, + source: ownerId, + target: procId, + type: 'ownership', + }); + } + } + } + + #buildMessageParticles( + particles: GraphParticle[], + messages: readonly InboxMessage[], + teamName: string, + leadId: string, + edges: GraphEdge[] + ): void { + const recent = messages.slice(-20); + + // First call: record all existing message IDs without creating particles. + // This prevents old messages from spawning particles when the graph opens. + if (!this.#initialMessagesSeen) { + this.#initialMessagesSeen = true; + for (const msg of recent) { + const msgKey = msg.messageId ?? msg.timestamp; + this.#seenMessageIds.add(msgKey); + } + return; + } + + // Subsequent calls: only create particles for messages not yet seen. + for (const msg of recent) { + const msgKey = msg.messageId ?? msg.timestamp; + if (this.#seenMessageIds.has(msgKey)) continue; + this.#seenMessageIds.add(msgKey); + + const edgeId = TeamGraphAdapter.#resolveMessageEdge(msg, teamName, leadId, edges); + if (!edgeId) continue; + + const ts = typeof msg.timestamp === 'string' ? new Date(msg.timestamp).getTime() : 0; + particles.push({ + id: `particle:msg:${msgKey}`, + edgeId, + progress: (ts % 800) / 1000, + kind: 'message', + color: msg.color ?? '#66ccff', + label: msg.summary ?? undefined, + }); + } + } + + // ─── Static mappers ────────────────────────────────────────────────────── + + static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState { + if (spawnStatus === 'spawning') return 'thinking'; + if (spawnStatus === 'error') return 'error'; + if (spawnStatus === 'waiting') return 'waiting'; + switch (status) { + case 'active': + return 'active'; + case 'idle': + return 'idle'; + case 'terminated': + return 'terminated'; + default: + return 'idle'; + } + } + + static #mapTaskStatus(status: string): GraphNodeState { + switch (status) { + case 'pending': + return 'waiting'; + case 'in_progress': + return 'active'; + case 'completed': + return 'complete'; + default: + return 'idle'; + } + } + + static #mapTaskStatusLiteral( + status: string + ): 'pending' | 'in_progress' | 'completed' | 'deleted' { + switch (status) { + case 'pending': + return 'pending'; + case 'in_progress': + return 'in_progress'; + case 'completed': + return 'completed'; + case 'deleted': + return 'deleted'; + default: + return 'pending'; + } + } + + static #mapReviewState(state: string | undefined): 'none' | 'review' | 'needsFix' | 'approved' { + switch (state) { + case 'review': + return 'review'; + case 'needsFix': + return 'needsFix'; + case 'approved': + return 'approved'; + default: + return 'none'; + } + } + + static #resolveMessageEdge( + msg: InboxMessage, + teamName: string, + leadId: string, + edges: GraphEdge[] + ): string | null { + const { from, to } = msg; + + if (from && to) { + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId); + const toId = TeamGraphAdapter.#resolveParticipantId(to, teamName, leadId); + return ( + edges.find((e) => e.source === fromId && e.target === toId)?.id ?? + edges.find((e) => e.source === toId && e.target === fromId)?.id ?? + null + ); + } + + if (from && !to) { + const fromId = TeamGraphAdapter.#resolveParticipantId(from, teamName, leadId); + return ( + edges.find( + (e) => + (e.source === leadId && e.target === fromId) || + (e.source === fromId && e.target === leadId) + )?.id ?? null + ); + } + + return null; + } + + static #resolveParticipantId(name: string, teamName: string, leadId: string): string { + if (name === 'user' || name === 'team-lead') return leadId; + return `member:${teamName}:${name}`; + } +} diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts new file mode 100644 index 00000000..ec11d302 --- /dev/null +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -0,0 +1,30 @@ +/** + * React hook bridge for TeamGraphAdapter class. + * Thin wrapper — instantiates the class adapter and calls adapt() with store data. + */ + +import { useMemo, useRef } from 'react'; + +import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; + +import { TeamGraphAdapter } from './TeamGraphAdapter'; + +import type { GraphDataPort } from '@claude-teams/agent-graph'; + +export function useTeamGraphAdapter(teamName: string): GraphDataPort { + const adapterRef = useRef(TeamGraphAdapter.create()); + + const { teamData, spawnStatuses, leadContext } = useStore( + useShallow((s) => ({ + teamData: s.selectedTeamData, + spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, + leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, + })) + ); + + return useMemo( + () => adapterRef.current.adapt(teamData, teamName, spawnStatuses, leadContext), + [teamData, teamName, spawnStatuses, leadContext] + ); +} diff --git a/src/renderer/features/agent-graph/index.ts b/src/renderer/features/agent-graph/index.ts new file mode 100644 index 00000000..1c15bd65 --- /dev/null +++ b/src/renderer/features/agent-graph/index.ts @@ -0,0 +1,9 @@ +/** + * agent-graph feature — public API. + * Only exports UI components and adapter types. + */ + +export { TeamGraphAdapter } from './adapters/TeamGraphAdapter'; +export type { TeamGraphOverlayProps } from './ui/TeamGraphOverlay'; +export { TeamGraphOverlay } from './ui/TeamGraphOverlay'; +export { TeamGraphTab } from './ui/TeamGraphTab'; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx new file mode 100644 index 00000000..5e7f7aca --- /dev/null +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -0,0 +1,57 @@ +/** + * TeamGraphOverlay — full-screen overlay showing the agent graph. + * Follows the exact ProjectEditorOverlay pattern (lazy-loaded, fixed z-50). + */ + +import { useCallback } from 'react'; + +import { GraphView } from '@claude-teams/agent-graph'; + +import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + +import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; + +export interface TeamGraphOverlayProps { + teamName: string; + onClose: () => void; + onPinAsTab?: () => void; +} + +export const TeamGraphOverlay = ({ + teamName, + onClose, + onPinAsTab, +}: TeamGraphOverlayProps): React.JSX.Element => { + const graphData = useTeamGraphAdapter(teamName); + + const events: GraphEventPort = { + onNodeClick: useCallback((_ref: GraphDomainRef) => { + // Popover shown by GraphView internally + }, []), + onNodeDoubleClick: useCallback((ref: GraphDomainRef) => { + // TODO: open TaskDetailDialog or MemberDetailDialog based on ref.kind + console.log('Double-click:', ref); + }, []), + onSendMessage: useCallback((_memberName: string, _teamName: string) => { + // TODO: open SendMessageDialog + }, []), + onOpenTaskDetail: useCallback((_taskId: string, _teamName: string) => { + // TODO: open TaskDetailDialog + }, []), + onBackgroundClick: useCallback(() => { + // Deselect handled by GraphView + }, []), + }; + + return ( +
+ +
+ ); +}; diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx new file mode 100644 index 00000000..3238ea49 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -0,0 +1,31 @@ +/** + * TeamGraphTab — wraps GraphView for use as a dedicated tab. + */ + +import { useCallback } from 'react'; + +import { GraphView } from '@claude-teams/agent-graph'; + +import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; + +import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; + +export interface TeamGraphTabProps { + teamName: string; +} + +export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element => { + const graphData = useTeamGraphAdapter(teamName); + + const events: GraphEventPort = { + onNodeDoubleClick: useCallback((ref: GraphDomainRef) => { + console.log('Double-click in tab:', ref); + }, []), + }; + + return ( +
+ +
+ ); +}; diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index ae763dd1..007a01aa 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -174,6 +174,18 @@ export function useKeyboardShortcuts(): void { return; } + // Cmd+Shift+G: Toggle team graph overlay + if (key === 'g' && event.shiftKey && !event.altKey) { + event.preventDefault(); + const activeTab = openTabs.find((t) => t.id === activeTabId); + if (activeTab?.type === 'team' && activeTab.teamName) { + window.dispatchEvent( + new CustomEvent('toggle-team-graph', { detail: { teamName: activeTab.teamName } }) + ); + } + return; + } + // Cmd+W: Close selected tabs (if multi-selected) or active tab if (key === 'w' && !event.altKey) { event.preventDefault(); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 418a64e8..3e7e388c 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -180,8 +180,7 @@ export function initializeNotificationListeners(): () => void { const selectedTeamData = state.selectedTeamData; if ( !selectedTeamName || - !selectedTeamData || - selectedTeamData.teamName !== selectedTeamName || + selectedTeamData?.teamName !== selectedTeamName || !isTeamVisibleInAnyPane(selectedTeamName) ) { return; @@ -210,15 +209,14 @@ export function initializeNotificationListeners(): () => void { const current = useStore.getState(); if ( current.selectedTeamName !== selectedTeamName || - !current.selectedTeamData || - current.selectedTeamData.teamName !== selectedTeamName || + current.selectedTeamData?.teamName !== selectedTeamName || !isTeamVisibleInAnyPane(selectedTeamName) ) { return; } const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id); - if (!currentTask || currentTask.status !== 'in_progress') { + if (currentTask?.status !== 'in_progress') { return; } @@ -342,8 +340,7 @@ export function initializeNotificationListeners(): () => void { const { selectedTeamName, selectedTeamData } = useStore.getState(); if ( !selectedTeamName || - !selectedTeamData || - selectedTeamData.teamName !== selectedTeamName || + selectedTeamData?.teamName !== selectedTeamName || !isTeamVisibleInAnyPane(selectedTeamName) ) { return new Set(); diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 05d0c52c..2a452f6e 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -141,8 +141,8 @@ import type { MemberSpawnStatusEntry, SendMessageRequest, SendMessageResult, - TaskComment, TaskChangePresenceState, + TaskComment, TeamCreateRequest, TeamData, TeamLaunchRequest, diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 65ffe18c..f8ebc7a2 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -85,7 +85,8 @@ export interface Tab { | 'team' | 'report' | 'extensions' - | 'schedules'; + | 'schedules' + | 'graph'; /** Session ID (required when type === 'session') */ sessionId?: string; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 88aa7202..e0b9c277 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -55,6 +55,7 @@ import type { SendMessageRequest, SendMessageResult, TaskAttachmentMeta, + TaskChangePresenceState, TaskComment, TeamChangeEvent, TeamClaudeLogsQuery, @@ -73,7 +74,6 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, - TaskChangePresenceState, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index d367a286..30a4bb7e 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -150,7 +150,7 @@ describe('CliInstallerService', () => { const mockWindow = { isDestroyed: () => false, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => false }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); @@ -177,7 +177,7 @@ describe('CliInstallerService', () => { const mockWindow = { isDestroyed: () => false, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => false }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); @@ -206,7 +206,7 @@ describe('CliInstallerService', () => { it('accepts a BrowserWindow instance', () => { const mockWindow = { isDestroyed: () => false, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => false }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow); expect(true).toBe(true); @@ -425,7 +425,7 @@ describe('CliInstallerService', () => { const mockWindow = { isDestroyed: () => true, - webContents: { send: vi.fn() }, + webContents: { send: vi.fn(), isDestroyed: () => true }, }; service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);