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
This commit is contained in:
parent
dd42cf0069
commit
11bb49c53e
71 changed files with 5332 additions and 63 deletions
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<feature-name>/`.**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
24
packages/agent-graph/package.json
Normal file
24
packages/agent-graph/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
158
packages/agent-graph/src/canvas/background-layer.ts
Normal file
158
packages/agent-graph/src/canvas/background-layer.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
70
packages/agent-graph/src/canvas/bloom-renderer.ts
Normal file
70
packages/agent-graph/src/canvas/bloom-renderer.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
280
packages/agent-graph/src/canvas/draw-agents.ts
Normal file
280
packages/agent-graph/src/canvas/draw-agents.ts
Normal file
|
|
@ -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([]);
|
||||
}
|
||||
210
packages/agent-graph/src/canvas/draw-edges.ts
Normal file
210
packages/agent-graph/src/canvas/draw-edges.ts
Normal file
|
|
@ -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<GraphEdgeType, { color: string; startW: number; endW: number; dash?: number[] }> = {
|
||||
'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<string, GraphNode>,
|
||||
_time: number,
|
||||
hasActiveParticles: Set<string>,
|
||||
): 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();
|
||||
}
|
||||
175
packages/agent-graph/src/canvas/draw-effects.ts
Normal file
175
packages/agent-graph/src/canvas/draw-effects.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
65
packages/agent-graph/src/canvas/draw-misc.ts
Normal file
65
packages/agent-graph/src/canvas/draw-misc.ts
Normal file
|
|
@ -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';
|
||||
154
packages/agent-graph/src/canvas/draw-particles.ts
Normal file
154
packages/agent-graph/src/canvas/draw-particles.ts
Normal file
|
|
@ -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<string, GraphEdge> {
|
||||
const map = new Map<string, GraphEdge>();
|
||||
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<string, GraphEdge>,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
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();
|
||||
}
|
||||
65
packages/agent-graph/src/canvas/draw-processes.ts
Normal file
65
packages/agent-graph/src/canvas/draw-processes.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
178
packages/agent-graph/src/canvas/draw-tasks.ts
Normal file
178
packages/agent-graph/src/canvas/draw-tasks.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
66
packages/agent-graph/src/canvas/hit-detection.ts
Normal file
66
packages/agent-graph/src/canvas/hit-detection.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
11
packages/agent-graph/src/canvas/index.ts
Normal file
11
packages/agent-graph/src/canvas/index.ts
Normal file
|
|
@ -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';
|
||||
140
packages/agent-graph/src/canvas/render-cache.ts
Normal file
140
packages/agent-graph/src/canvas/render-cache.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Pre-rendered sprite cache for Canvas 2D glow effects.
|
||||
* Adapted from agent-flow (Apache 2.0).
|
||||
*/
|
||||
|
||||
const glowCache = new Map<string, HTMLCanvasElement>();
|
||||
const textCache = new Map<string, number>();
|
||||
const TEXT_CACHE_LIMIT = 2000;
|
||||
|
||||
// ─── Color resolution: named colors → hex ───────────────────────────────────
|
||||
|
||||
let _resolverCtx: CanvasRenderingContext2D | null = null;
|
||||
const _hexCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* 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<string, string>();
|
||||
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 };
|
||||
247
packages/agent-graph/src/constants/canvas-constants.ts
Normal file
247
packages/agent-graph/src/constants/canvas-constants.ts
Normal file
|
|
@ -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;
|
||||
167
packages/agent-graph/src/constants/colors.ts
Normal file
167
packages/agent-graph/src/constants/colors.ts
Normal file
|
|
@ -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})`;
|
||||
}
|
||||
27
packages/agent-graph/src/constants/index.ts
Normal file
27
packages/agent-graph/src/constants/index.ts
Normal file
|
|
@ -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';
|
||||
178
packages/agent-graph/src/hooks/useGraphCamera.ts
Normal file
178
packages/agent-graph/src/hooks/useGraphCamera.ts
Normal file
|
|
@ -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<CameraTransform>;
|
||||
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<CameraTransform>({ x: 0, y: 0, zoom: 1 }) as React.MutableRefObject<CameraTransform>;
|
||||
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,
|
||||
};
|
||||
}
|
||||
89
packages/agent-graph/src/hooks/useGraphInteraction.ts
Normal file
89
packages/agent-graph/src/hooks/useGraphInteraction.ts
Normal file
|
|
@ -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<string | null>;
|
||||
dragNodeId: React.RefObject<string | null>;
|
||||
isDragging: React.RefObject<boolean>;
|
||||
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<string | null>(null);
|
||||
const dragNodeId = useRef<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
|
||||
const clickedNodeId = useRef<string | null>(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,
|
||||
};
|
||||
}
|
||||
285
packages/agent-graph/src/hooks/useGraphSimulation.ts
Normal file
285
packages/agent-graph/src/hooks/useGraphSimulation.ts
Normal file
|
|
@ -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<ForceNode> {
|
||||
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<SimulationState>;
|
||||
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<SimulationState>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
particles: [],
|
||||
effects: [],
|
||||
time: 0,
|
||||
});
|
||||
|
||||
const simRef = useRef<Simulation<ForceNode, ForceLink> | null>(null);
|
||||
|
||||
// Initialize d3-force simulation
|
||||
const initSimulation = useCallback(() => {
|
||||
if (simRef.current) simRef.current.stop();
|
||||
|
||||
const sim = forceSimulation<ForceNode, ForceLink>([])
|
||||
.force('center', forceCenter(0, 0).strength(FORCE.centerStrength))
|
||||
.force('charge', forceManyBody<ForceNode>().strength((d) => {
|
||||
return getNodeStrategy(d.kind).getChargeStrength();
|
||||
}))
|
||||
.force('collide', forceCollide<ForceNode>().radius((d) => {
|
||||
return getNodeStrategy(d.kind).getCollisionRadius();
|
||||
}))
|
||||
.force('link', forceLink<ForceNode, 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<typeof forceLink>)?.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<string, ForceNode>();
|
||||
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<string>());
|
||||
const prevNodeStatesRef = useRef(new Map<string, string>());
|
||||
|
||||
// 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<string, { x: number; y: number; vx: number; vy: number }>();
|
||||
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<ForceNode, ForceLink> | 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<string, ForceNode>();
|
||||
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;
|
||||
}
|
||||
28
packages/agent-graph/src/index.ts
Normal file
28
packages/agent-graph/src/index.ts
Normal file
|
|
@ -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';
|
||||
131
packages/agent-graph/src/layout/kanbanLayout.ts
Normal file
131
packages/agent-graph/src/layout/kanbanLayout.ts
Normal file
|
|
@ -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<string, GraphNode>();
|
||||
static readonly #tasksByOwner = new Map<string, GraphNode[]>();
|
||||
static readonly #unassigned: GraphNode[] = [];
|
||||
static readonly #colTasks = new Map<string, GraphNode[]>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/agent-graph/src/ports/GraphConfigPort.ts
Normal file
55
packages/agent-graph/src/ports/GraphConfigPort.ts
Normal file
|
|
@ -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<Record<GraphNodeState, string>>;
|
||||
/** 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;
|
||||
}
|
||||
20
packages/agent-graph/src/ports/GraphDataPort.ts
Normal file
20
packages/agent-graph/src/ports/GraphDataPort.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
22
packages/agent-graph/src/ports/GraphEventPort.ts
Normal file
22
packages/agent-graph/src/ports/GraphEventPort.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
13
packages/agent-graph/src/ports/index.ts
Normal file
13
packages/agent-graph/src/ports/index.ts
Normal file
|
|
@ -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';
|
||||
117
packages/agent-graph/src/ports/types.ts
Normal file
117
packages/agent-graph/src/ports/types.ts
Normal file
|
|
@ -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 };
|
||||
27
packages/agent-graph/src/strategies/index.ts
Normal file
27
packages/agent-graph/src/strategies/index.ts
Normal file
|
|
@ -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<GraphNodeKind, NodeRenderStrategy> = {
|
||||
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';
|
||||
72
packages/agent-graph/src/strategies/memberStrategy.ts
Normal file
72
packages/agent-graph/src/strategies/memberStrategy.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
39
packages/agent-graph/src/strategies/processStrategy.ts
Normal file
39
packages/agent-graph/src/strategies/processStrategy.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
38
packages/agent-graph/src/strategies/taskStrategy.ts
Normal file
38
packages/agent-graph/src/strategies/taskStrategy.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
48
packages/agent-graph/src/strategies/types.ts
Normal file
48
packages/agent-graph/src/strategies/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
295
packages/agent-graph/src/ui/GraphCanvas.tsx
Normal file
295
packages/agent-graph/src/ui/GraphCanvas.tsx
Normal file
|
|
@ -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<GraphCanvasHandle, GraphCanvasProps>(function GraphCanvas(
|
||||
{
|
||||
showHexGrid = true,
|
||||
showStarField = true,
|
||||
bloomIntensity = 0.6,
|
||||
onWheel,
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bloomRef = useRef<BloomRenderer>(new BloomRenderer(bloomIntensity));
|
||||
const starsRef = useRef<DepthParticle[]>([]);
|
||||
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<string, GraphNode>());
|
||||
const edgeMapCache = useRef(new Map<string, GraphEdge>());
|
||||
const visibleNodesCache = useRef<GraphNode[]>([]);
|
||||
const visibleEdgesCache = useRef<GraphEdge[]>([]);
|
||||
const visibleNodeIdsCache = useRef(new Set<string>());
|
||||
const activeParticleEdgesCache = useRef(new Set<string>());
|
||||
|
||||
// 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 (
|
||||
<div ref={containerRef} className={`relative w-full h-full overflow-hidden ${className ?? ''}`}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0"
|
||||
style={{ cursor: 'crosshair' }}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
113
packages/agent-graph/src/ui/GraphControls.tsx
Normal file
113
packages/agent-graph/src/ui/GraphControls.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="absolute top-3 left-3 right-3 flex items-center justify-between pointer-events-none z-10">
|
||||
{/* Left: title + status */}
|
||||
<div className="flex items-center gap-2 pointer-events-auto">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg"
|
||||
style={{ background: 'rgba(8, 12, 24, 0.85)', border: '1px solid rgba(100, 200, 255, 0.1)' }}>
|
||||
{isAlive && (
|
||||
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||
)}
|
||||
<span style={{ color: '#aaeeff', fontSize: '12px', fontFamily: 'monospace' }}>
|
||||
{teamName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: filters */}
|
||||
<div className="flex items-center gap-1 pointer-events-auto">
|
||||
<ToolbarButton active={filters.showTasks} onClick={() => toggle('showTasks')} label="Tasks" />
|
||||
<ToolbarButton active={filters.showProcesses} onClick={() => toggle('showProcesses')} label="Proc" />
|
||||
<ToolbarButton active={filters.showEdges} onClick={() => toggle('showEdges')} label="Edges" />
|
||||
<div className="w-px h-4 mx-1" style={{ background: 'rgba(100, 200, 255, 0.1)' }} />
|
||||
<ToolbarButton active={!filters.paused} onClick={() => toggle('paused')} label={filters.paused ? '▶' : '⏸'} />
|
||||
</div>
|
||||
|
||||
{/* Right: zoom + actions */}
|
||||
<div className="flex items-center gap-1 pointer-events-auto">
|
||||
<ToolbarButton onClick={onZoomOut} label="−" />
|
||||
<ToolbarButton onClick={onZoomToFit} label="⊡" />
|
||||
<ToolbarButton onClick={onZoomIn} label="+" />
|
||||
{onRequestPinAsTab && (
|
||||
<>
|
||||
<div className="w-px h-4 mx-1" style={{ background: 'rgba(100, 200, 255, 0.1)' }} />
|
||||
<ToolbarButton onClick={onRequestPinAsTab} label="⊞ Pin" />
|
||||
</>
|
||||
)}
|
||||
{onRequestClose && (
|
||||
<ToolbarButton onClick={onRequestClose} label="✕" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Toolbar Button ─────────────────────────────────────────────────────────
|
||||
|
||||
function ToolbarButton({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
onClick?: () => void;
|
||||
label: string;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="px-2 py-1 rounded text-xs font-mono transition-colors"
|
||||
style={{
|
||||
background: active ? 'rgba(100, 200, 255, 0.15)' : 'rgba(100, 200, 255, 0.05)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.1)',
|
||||
color: active ? '#aaeeff' : '#66ccff90',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
165
packages/agent-graph/src/ui/GraphOverlay.tsx
Normal file
165
packages/agent-graph/src/ui/GraphOverlay.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="absolute z-20 pointer-events-auto"
|
||||
style={{
|
||||
left: `${screenPos.x + 20}px`,
|
||||
top: `${screenPos.y - 20}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
<NodePopover node={selectedNode} events={events} onClose={onDeselect} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div
|
||||
className="rounded-lg p-3 min-w-[180px] max-w-[260px] shadow-xl"
|
||||
style={{
|
||||
background: 'rgba(10, 15, 30, 0.9)',
|
||||
border: `1px solid ${color}40`,
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-mono font-bold truncate"
|
||||
style={{ color: '#aaeeff' }}
|
||||
>
|
||||
{node.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
{node.sublabel && (
|
||||
<div className="text-[10px] mb-2 truncate" style={{ color: '#66ccff90' }}>
|
||||
{node.sublabel}
|
||||
</div>
|
||||
)}
|
||||
{node.role && (
|
||||
<div className="text-[10px] mb-2" style={{ color: '#66ccff70' }}>
|
||||
{node.role}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="flex gap-1 mb-2 flex-wrap">
|
||||
<StatusBadge label={node.state} color={color} />
|
||||
{node.reviewState && node.reviewState !== 'none' && (
|
||||
<StatusBadge label={node.reviewState} color={getTaskStatusColor(node.taskStatus)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 mt-2">
|
||||
{(node.kind === 'member' || node.kind === 'lead') && (
|
||||
<ActionButton label="Message" onClick={() => handleAction('sendMessage')} />
|
||||
)}
|
||||
{(node.kind === 'task' || node.kind === 'member') && (
|
||||
<ActionButton label="Open" onClick={() => handleAction('openDetail')} />
|
||||
)}
|
||||
{node.kind === 'process' && node.processUrl && (
|
||||
<ActionButton label="Open URL" onClick={() => handleAction('openUrl')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── UI Primitives ──────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ label, color }: { label: string; color: string }): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className="text-[9px] px-1.5 py-0.5 rounded font-mono"
|
||||
style={{ background: `${color}20`, color, border: `1px solid ${color}30` }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="text-[10px] px-2 py-1 rounded font-mono cursor-pointer transition-colors"
|
||||
style={{
|
||||
background: 'rgba(100, 200, 255, 0.08)',
|
||||
border: '1px solid rgba(100, 200, 255, 0.15)',
|
||||
color: '#aaeeff',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
342
packages/agent-graph/src/ui/GraphView.tsx
Normal file
342
packages/agent-graph/src/ui/GraphView.tsx
Normal file
|
|
@ -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<GraphConfigPort>;
|
||||
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<string | null>(null);
|
||||
const [filters, setFilters] = useState<GraphFilterState>({
|
||||
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<string | null>(null);
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasHandle = useRef<GraphCanvasHandle>(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 (
|
||||
<div ref={containerRef} className={`relative w-full h-full ${className ?? ''}`}>
|
||||
<GraphCanvas
|
||||
ref={canvasHandle}
|
||||
showHexGrid={config?.showHexGrid ?? true}
|
||||
showStarField={config?.showStarField ?? true}
|
||||
bloomIntensity={config?.bloomIntensity ?? 0.6}
|
||||
onWheel={camera.handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
/>
|
||||
|
||||
<GraphControls
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onZoomIn={camera.zoomIn}
|
||||
onZoomOut={camera.zoomOut}
|
||||
onZoomToFit={() => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<GraphOverlay
|
||||
selectedNode={selectedNode}
|
||||
worldToScreen={camera.worldToScreen}
|
||||
events={events}
|
||||
onDeselect={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
packages/agent-graph/tsconfig.json
Normal file
17
packages/agent-graph/tsconfig.json
Normal file
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ packages:
|
|||
- agent-teams-controller
|
||||
- mcp-server
|
||||
- landing
|
||||
- packages/agent-graph
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 => {
|
|||
</TabUIProvider>
|
||||
)}
|
||||
{tab.type === 'schedules' && <SchedulesView />}
|
||||
{tab.type === 'graph' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<TeamGraphTab teamName={tab.teamName ?? ''} />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const provisioningBannerRef = useRef<HTMLDivElement>(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={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddMemberDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
Member
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setGraphOpen(true);
|
||||
}}
|
||||
>
|
||||
<Network size={12} />
|
||||
Graph
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-2 text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddMemberDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={12} />
|
||||
Member
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MemberList
|
||||
|
|
@ -2043,6 +2076,21 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{graphOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<TeamGraphOverlay
|
||||
teamName={teamName}
|
||||
onClose={() => setGraphOpen(false)}
|
||||
onPinAsTab={() => {
|
||||
setGraphOpen(false);
|
||||
useStore
|
||||
.getState()
|
||||
.openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName });
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -90,8 +90,8 @@ import type {
|
|||
FileChangeSummary,
|
||||
KanbanTaskState,
|
||||
ResolvedTeamMember,
|
||||
TaskChangeSetV2,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
494
src/renderer/features/CLAUDE.md
Normal file
494
src/renderer/features/CLAUDE.md
Normal file
|
|
@ -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/<feature-name>/{ports,adapters,domain,ui,hooks,__tests__}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
### Full Feature
|
||||
|
||||
```
|
||||
src/renderer/features/<feature-name>/
|
||||
├── ports/ # Interfaces (contracts) — NO implementations
|
||||
│ ├── <Feature>DataPort.ts # What data the feature needs (input)
|
||||
│ ├── <Feature>EventPort.ts # Callbacks the feature fires (output)
|
||||
│ ├── <Feature>ConfigPort.ts# Configuration / theme overrides
|
||||
│ └── types.ts # Domain value types for this feature
|
||||
│
|
||||
├── adapters/ # Bridge between project infrastructure and feature
|
||||
│ └── <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
|
||||
│ ├── <Feature>View.tsx # Main component (orchestrator, entry point)
|
||||
│ ├── <Feature>Overlay.tsx # Full-screen overlay variant (if applicable)
|
||||
│ └── <Feature>Tab.tsx # Tab wrapper variant (if applicable)
|
||||
│
|
||||
├── hooks/ # React hooks — thin bridges to domain classes
|
||||
│ └── use<Feature>.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/<feature-name>/
|
||||
├── <Feature>Adapter.ts # Zustand → feature data
|
||||
├── <Feature>View.tsx # Main component
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
### When to Extract a Workspace Package
|
||||
|
||||
Some features benefit from a separate `packages/<name>/` 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 = <GraphView data={adapter} />;
|
||||
```
|
||||
|
||||
### 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<string, NodeRenderer>();
|
||||
|
||||
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/<name>`
|
||||
|
||||
---
|
||||
|
||||
## 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: '...' | '<feature>';
|
||||
|
||||
// 2. src/renderer/components/layout/PaneContent.tsx — add route
|
||||
{tab.type === '<feature>' && (
|
||||
<TabUIProvider tabId={tab.id}>
|
||||
<FeatureView ... />
|
||||
</TabUIProvider>
|
||||
)}
|
||||
|
||||
// 3. src/renderer/components/layout/SortableTab.tsx — add icon
|
||||
<feature>: SomeIcon,
|
||||
```
|
||||
|
||||
### Overlay Registration (1 file)
|
||||
|
||||
```typescript
|
||||
// In host component (e.g., TeamDetailView.tsx):
|
||||
const FeatureOverlay = lazy(() =>
|
||||
import('@renderer/features/<feature>/ui/FeatureOverlay')
|
||||
.then(m => ({ default: m.FeatureOverlay }))
|
||||
);
|
||||
```
|
||||
|
||||
### Keyboard Shortcut (1 file)
|
||||
|
||||
```typescript
|
||||
// In useKeyboardShortcuts.ts:
|
||||
if (key === '<x>' && event.shiftKey && !event.altKey) {
|
||||
window.dispatchEvent(new CustomEvent('toggle-<feature>', { 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 | `<module>.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/<name>/`
|
||||
- [ ] 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`
|
||||
399
src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts
Normal file
399
src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts
Normal file
|
|
@ -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<string>();
|
||||
readonly #seenMessageIds = new Set<string>();
|
||||
#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<string, MemberSpawnStatusEntry>,
|
||||
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<string, MemberSpawnStatusEntry>
|
||||
): 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<string>();
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>(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]
|
||||
);
|
||||
}
|
||||
9
src/renderer/features/agent-graph/index.ts
Normal file
9
src/renderer/features/agent-graph/index.ts
Normal file
|
|
@ -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';
|
||||
57
src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx
Normal file
57
src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex flex-col" style={{ background: '#050510' }}>
|
||||
<GraphView
|
||||
data={graphData}
|
||||
events={events}
|
||||
onRequestClose={onClose}
|
||||
onRequestPinAsTab={onPinAsTab}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
31
src/renderer/features/agent-graph/ui/TeamGraphTab.tsx
Normal file
31
src/renderer/features/agent-graph/ui/TeamGraphTab.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="size-full" style={{ background: '#050510' }}>
|
||||
<GraphView data={graphData} events={events} className="size-full" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
|
|
|||
|
|
@ -141,8 +141,8 @@ import type {
|
|||
MemberSpawnStatusEntry,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
TaskChangePresenceState,
|
||||
TaskComment,
|
||||
TeamCreateRequest,
|
||||
TeamData,
|
||||
TeamLaunchRequest,
|
||||
|
|
|
|||
|
|
@ -85,7 +85,8 @@ export interface Tab {
|
|||
| 'team'
|
||||
| 'report'
|
||||
| 'extensions'
|
||||
| 'schedules';
|
||||
| 'schedules'
|
||||
| 'graph';
|
||||
|
||||
/** Session ID (required when type === 'session') */
|
||||
sessionId?: string;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue