From 90b637c6d88cc564f88cb32eec3f3ce9bc1ff2f0 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 22:06:50 +0300 Subject: [PATCH] refactor: update README and security documentation; enhance activity lane layout and kanban integration --- .github/SECURITY.md | 7 +- README.md | 19 +- .../src/hooks/useGraphSimulation.ts | 95 +++-- .../agent-graph/src/layout/activityLane.ts | 173 +++++++-- .../agent-graph/src/layout/kanbanLayout.ts | 45 ++- .../agent-graph/src/layout/launchAnchor.ts | 38 +- packages/agent-graph/src/ui/GraphView.tsx | 24 ++ .../renderer/ui/GraphActivityHud.tsx | 327 ++++++++++++------ .../renderer/ui/TeamGraphOverlay.tsx | 65 ++-- .../agent-graph/renderer/ui/TeamGraphTab.tsx | 69 ++-- .../components/dashboard/TmuxStatusBanner.tsx | 6 +- .../features/agent-graph/activityLane.test.ts | 58 +++- .../features/agent-graph/kanbanLayout.test.ts | 38 +- 13 files changed, 725 insertions(+), 239 deletions(-) diff --git a/.github/SECURITY.md b/.github/SECURITY.md index c23e4b37..579f1c17 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -34,10 +34,11 @@ Or with Docker Compose, uncomment `network_mode: "none"` in `docker/docker-compo ## IPC & Input Validation -- All IPC handlers validate inputs with strict path containment checks -- File reads are constrained to the project root and `~/.claude/` +- Electron IPC and standalone HTTP handlers validate IDs, paths, and payloads at the boundary +- Project editing and write operations are constrained to the selected project root +- Read-only discovery may access local Claude data under `~/.claude/` and app-owned state paths when needed - Path traversal attacks are blocked -- Sensitive credential paths are rejected +- Sensitive config and credential-like paths are rejected or treated as protected targets ## Supported Versions diff --git a/README.md b/README.md index ed65dcb3..0ffe93dc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Settings

-

Claude Agent Teams UI

+

Agent Teams UI

You're the CTO, agents are your team. They handle tasks themselves, message each other, review each other. You just look at the kanban board and drink coffee. @@ -23,7 +23,7 @@

- 100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions/logins or API keys where supported. Not just coding agents. + 100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.

demo @@ -91,10 +91,10 @@ No prerequisites - the app can detect supported runtimes/providers and guide set - [Installation](#installation) - [Table of contents](#table-of-contents) - [What is this](#what-is-this) +- [Developer architecture docs](#developer-architecture-docs) - [Comparison](#comparison) - [Quick start](#quick-start) - [FAQ](#faq) -- [Developer architecture docs](#developer-architecture-docs) - [Development](#development) - [Tech stack](#tech-stack) - [Build for distribution](#build-for-distribution) @@ -108,15 +108,14 @@ No prerequisites - the app can detect supported runtimes/providers and guide set A local orchestration layer for AI agent teams across Claude and Codex. -- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions/logins or API keys where supported +- **Claude + Codex orchestration** — auto-detect available Claude/Codex runtimes and use the provider access you already have - subscriptions or API keys - **Assemble your team** — create agent teams with different roles that work autonomously in parallel - **Agents talk to each other** — they communicate, create and manage their own tasks, review, leave comments - **Cross-team communication** — agents can fully communicate across different teams; you can configure or prompt them to collaborate and message each other between teams - **Sit back and watch** — tasks change status on the kanban board while agents handle everything on their own - **Review changes like in Cursor** — see what code each task changed, then approve, reject, or comment - **Built-in review workflow** — easily see how agents review each other's tasks to make sure everything went exactly as planned -- **Full tool visibility** — inspect exactly which tools an agent used to complete each task -- **Task-specific logs and messages** — clearly see agent/runtime logs and messages in isolation for each individual task, making it easy to trace what happened for any assignment +- **Task-specific logs and messages** — clearly see agent/runtime logs (tools), actions and messages in isolation for each individual task, making it easy to trace what happened for any assignment - **Live process section** — see which agents are running processes and open URLs directly in the browser - **Stay in control** — send a direct message to any agent, drop a comment on a task, or pick a quick action right on the kanban card whenever you want to clarify something or add new work - **Flexible autonomy** — let agents run fully autonomous, or review and approve each action one by one (you'll get a notification) — configure the level of control that fits your security needs @@ -168,7 +167,7 @@ For feature architecture and implementation guidance: ## Comparison -| Feature | Claude Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI | +| Feature | Agent Teams UI | Vibe Kanban | Aperant | Cursor | Claude Code CLI | |---|---|---|---|---|---| | **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ | | **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) | @@ -306,7 +305,7 @@ pnpm dist # macOS + Windows + Linux - [ ] Planning mode to organize agent plans before execution - [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop -- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models +- [ ] Support more models/providers (including local) e.g OpenCode (with many providers) - [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config) - [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc. - [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them) @@ -317,6 +316,8 @@ pnpm dist # macOS + Windows + Linux - [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow - [ ] Custom kanban columns - [ ] Run terminal commands +- [ ] Monitor agents processes/stats +- [ ] Reusable agents with SOUL.md --- @@ -326,7 +327,7 @@ See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for development guidelines. Pleas ## Security -IPC handlers validate all inputs with strict path containment checks. File reads are constrained to the project root and `~/.claude`. Sensitive credential paths are blocked. See [SECURITY.md](.github/SECURITY.md) for details. +IPC and standalone HTTP handlers validate IDs, paths, and payload shape at the boundary. Project editing and write operations are constrained to the selected project root, while read-only discovery also accesses local Claude data under `~/.claude/` and app-owned state paths when required. Path traversal and sensitive config/credential targets are blocked. See [SECURITY.md](.github/SECURITY.md) for details. ## License diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index bd6dd240..2955c737 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -26,7 +26,6 @@ import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { LAUNCH_ANCHOR_LAYOUT, getActivityAnchorId, - getHandoffAnchorBounds, getLaunchAnchorBounds, getLaunchAnchorId, getLaunchAnchorTarget, @@ -34,7 +33,14 @@ import { isLaunchAnchorId, type WorldBounds, } from '../layout/launchAnchor'; -import { ACTIVITY_ANCHOR_LAYOUT, getActivityAnchorTarget } from '../layout/activityLane'; +import { + ACTIVITY_ANCHOR_LAYOUT, + buildVisibleActivityLaneBounds, + getActivityLaneBounds, + getActivityAnchorTarget, + packActivityLaneWorldRects, + resolveActivityLaneSide, +} from '../layout/activityLane'; // ─── Force Node/Link types (properly typed, no loose `string`) ────────────── @@ -91,35 +97,71 @@ function syncLaunchAnchors(forceNodes: ForceNode[]): void { } const leadNode = forceNodes.find((node) => node.kind === 'lead'); const leadX = leadNode?.x ?? leadNode?.fx ?? null; + const pendingActivityAnchors: Array<{ + node: ForceNode; + target: { x: number; y: number }; + side: 'left' | 'right'; + }> = []; for (const node of forceNodes) { - let target: { x: number; y: number } | null = null; if (node.kind === 'launch-anchor' && node.anchorForLeadId) { const leadNode = forceNodeMap.get(node.anchorForLeadId); if (!leadNode) continue; - target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); - } else if (node.kind === 'activity-anchor' && node.anchorForNodeId) { + const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); + node.fx = target.x; + node.fy = target.y; + node.x = target.x; + node.y = target.y; + node.vx = 0; + node.vy = 0; + continue; + } + + if (node.kind === 'activity-anchor' && node.anchorForNodeId) { const ownerNode = forceNodeMap.get(node.anchorForNodeId); if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue; - target = getActivityAnchorTarget({ + const target = getActivityAnchorTarget({ nodeX: ownerNode.x ?? 0, nodeY: ownerNode.y ?? 0, nodeKind: ownerNode.kind, leadX, }); - } else { - continue; - } - if (!target) { - continue; + pendingActivityAnchors.push({ + node, + target, + side: resolveActivityLaneSide({ + nodeKind: ownerNode.kind, + nodeX: ownerNode.x ?? 0, + leadX, + }), + }); } + } - node.fx = target.x; - node.fy = target.y; - node.x = target.x; - node.y = target.y; - node.vx = 0; - node.vy = 0; + const packedActivityAnchors = packActivityLaneWorldRects( + pendingActivityAnchors.map(({ node, target, side }) => ({ + id: node.id, + side, + x: target.x, + y: target.y, + width: ACTIVITY_ANCHOR_LAYOUT.reservedWidth, + height: ACTIVITY_ANCHOR_LAYOUT.reservedHeight, + })), + 18, + ); + + for (const entry of pendingActivityAnchors) { + const packed = packedActivityAnchors.get(entry.node.id); + const centerX = (packed?.x ?? entry.target.x) + + ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2; + const centerY = (packed?.y ?? entry.target.y) + + ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2; + entry.node.fx = centerX; + entry.node.fy = centerY; + entry.node.x = centerX; + entry.node.y = centerY; + entry.node.vx = 0; + entry.node.vy = 0; } } @@ -142,8 +184,12 @@ function updateLaunchAnchorCaches( continue; } if (node.kind === 'activity-anchor' && node.anchorForNodeId) { - activityPositions.set(node.anchorForNodeId, { x, y }); - bounds.push(getHandoffAnchorBounds(x, y)); + const topLeft = { + x: x - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2, + y: y - ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2, + }; + activityPositions.set(node.anchorForNodeId, topLeft); + bounds.push(getActivityLaneBounds(topLeft.x, topLeft.y)); } } } @@ -314,7 +360,12 @@ export function useGraphSimulation(): UseGraphSimulationResult { } // Position tasks in kanban zones relative to their owners - KanbanLayoutEngine.layout(nodes); + KanbanLayoutEngine.layout(nodes, { + activityLaneBounds: buildVisibleActivityLaneBounds( + nodes, + activityAnchorPositionsRef.current + ), + }); updateLaunchAnchorCaches( sim.nodes(), launchAnchorPositionsRef.current, @@ -515,7 +566,9 @@ function tickFrame( } // Re-layout tasks in kanban zones — always run to handle new/moved tasks - KanbanLayoutEngine.layout(state.nodes); + KanbanLayoutEngine.layout(state.nodes, { + activityLaneBounds: buildVisibleActivityLaneBounds(state.nodes, activityAnchorPositions), + }); // Update particle progress — in-place removal (no new array allocation) let pw = 0; diff --git a/packages/agent-graph/src/layout/activityLane.ts b/packages/agent-graph/src/layout/activityLane.ts index a5766933..46b1810c 100644 --- a/packages/agent-graph/src/layout/activityLane.ts +++ b/packages/agent-graph/src/layout/activityLane.ts @@ -1,20 +1,21 @@ -import { KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants'; +import { CAMERA, KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants'; import type { GraphActivityItem, GraphNode } from '../ports/types'; export const ACTIVITY_LANE = { width: 296, - itemHeight: 58, - rowHeight: 62, + itemHeight: 72, + rowHeight: 80, maxVisibleItems: 3, - headerHeight: 18, - overflowHeight: 18, + headerHeight: 20, + overflowHeight: 32, horizontalGapLead: 76, horizontalGapMember: 84, - bottomClearance: 18, + ownerClearanceLead: 92, + ownerClearanceMember: 104, viewportPadding: 12, visiblePadding: 80, - minScale: 0, - maxScale: 1, + minScale: CAMERA.minZoom, + maxScale: CAMERA.maxZoom, } as const; const RESERVED_HEIGHT = @@ -26,10 +27,10 @@ export const ACTIVITY_ANCHOR_LAYOUT = { reservedWidth: ACTIVITY_LANE.width, reservedHeight: RESERVED_HEIGHT, memberOffsetX: ACTIVITY_LANE.width / 2 + NODE.radiusMember + ACTIVITY_LANE.horizontalGapMember, - memberOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance), + memberOffsetY: -(RESERVED_HEIGHT + NODE.radiusMember + ACTIVITY_LANE.ownerClearanceMember), leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead), - leadOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance), - collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 12, + leadOffsetY: -(RESERVED_HEIGHT + NODE.radiusLead + ACTIVITY_LANE.ownerClearanceLead), + collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 56, } as const; export interface ActivityLaneWindow { @@ -51,6 +52,32 @@ export interface ActivityLaneItemHit { export type ActivityLaneSide = 'left' | 'right'; +export interface ActivityLaneScreenRect { + id: string; + side: ActivityLaneSide; + x: number; + y: number; + width: number; + height: number; +} + +export interface ActivityLaneWorldRect { + id: string; + side: ActivityLaneSide; + x: number; + y: number; + width: number; + height: number; +} + +export interface ActivityLaneWorldBounds { + ownerId: string; + left: number; + top: number; + right: number; + bottom: number; +} + export function resolveActivityLaneSide(args: { nodeKind: 'lead' | 'member'; nodeX: number; @@ -72,18 +99,14 @@ export function getActivityAnchorTarget(args: { nodeKind: 'lead' | 'member'; leadX?: number | null; }): { x: number; y: number } { - const { nodeX, nodeY, nodeKind, leadX } = args; - const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX }); - if (side === 'left') { - return { - x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX, - y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY, - }; - } - + const { nodeX, nodeY, nodeKind } = args; return { - x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX, - y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY, + x: nodeX - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2, + y: + nodeY + + (nodeKind === 'lead' + ? ACTIVITY_ANCHOR_LAYOUT.leadOffsetY + : ACTIVITY_ANCHOR_LAYOUT.memberOffsetY), }; } @@ -93,16 +116,42 @@ export function getActivityLaneBounds(anchorX: number, anchorY: number): { right: number; bottom: number; } { - const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2; - const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2; return { - left: anchorX - halfWidth, - top: anchorY - halfHeight, - right: anchorX + halfWidth, - bottom: anchorY + halfHeight, + left: anchorX, + top: anchorY, + right: anchorX + ACTIVITY_ANCHOR_LAYOUT.reservedWidth, + bottom: anchorY + ACTIVITY_ANCHOR_LAYOUT.reservedHeight, }; } +export function buildVisibleActivityLaneBounds( + nodes: GraphNode[], + activityPositions: ReadonlyMap +): ActivityLaneWorldBounds[] { + const bounds: ActivityLaneWorldBounds[] = []; + + for (const node of nodes) { + if (node.kind !== 'lead' && node.kind !== 'member') { + continue; + } + const visibleCount = node.activityItems?.length ?? 0; + const overflowCount = node.activityOverflowCount ?? 0; + if (visibleCount <= 0 && overflowCount <= 0) { + continue; + } + const topLeft = activityPositions.get(node.id); + if (!topLeft) { + continue; + } + bounds.push({ + ownerId: node.id, + ...getActivityLaneBounds(topLeft.x, topLeft.y), + }); + } + + return bounds; +} + export function getActivityLaneScale(zoom: number): number { return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom)); } @@ -120,10 +169,8 @@ export function getActivityAnchorScreenPlacement(args: { const scale = getActivityLaneScale(zoom); const scaledWidth = ACTIVITY_LANE.width * scale; const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale; - const screenX = anchorX * zoom + cameraX; - const screenY = anchorY * zoom + cameraY; - const x = screenX - scaledWidth / 2; - const y = screenY - scaledHeight / 2; + const x = anchorX * zoom + cameraX; + const y = anchorY * zoom + cameraY; const right = x + scaledWidth; const bottom = y + scaledHeight; @@ -152,6 +199,64 @@ export function getVisibleActivityWindow( }; } +export function packActivityLaneScreenRects( + rects: ActivityLaneScreenRect[], + gap = 8 +): Map { + return packActivityLaneRects(rects, gap, true); +} + +export function packActivityLaneWorldRects( + rects: ActivityLaneWorldRect[], + gap = 8 +): Map { + return packActivityLaneRects(rects, gap, false); +} + +function packActivityLaneRects( + rects: T[], + gap = 8, + groupBySide = true +): Map { + const placements = new Map(); + + const sideGroups = groupBySide ? (['left', 'right'] as const) : (['left'] as const); + + for (const side of sideGroups) { + const sideRects = rects + .filter((rect) => !groupBySide || rect.side === side) + .sort((a, b) => (a.y === b.y ? a.x - b.x : a.y - b.y)); + const placed: Array = []; + + for (const rect of sideRects) { + let placedY = rect.y; + + for (const prev of placed) { + if (!rangesOverlap(rect.x, rect.x + rect.width, prev.x, prev.x + prev.width)) { + continue; + } + + const prevBottom = prev.placedY + prev.height; + if (placedY < prevBottom + gap && placedY + rect.height > prev.placedY - gap) { + placedY = prevBottom + gap; + } + } + + placed.push({ ...rect, placedY }); + placements.set(rect.id, { x: rect.x, y: placedY }); + } + } + + return placements; +} + export function findActivityItemAt( worldX: number, worldY: number, @@ -194,3 +299,7 @@ export function findActivityItemAt( export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } { return node.kind === 'lead' || node.kind === 'member'; } + +function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return aStart < bEnd && aEnd > bStart; +} diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index ccbb3be2..9618f491 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -8,9 +8,10 @@ */ import type { GraphNode } from '../ports/types'; -import { KANBAN_ZONE } from '../constants/canvas-constants'; +import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { COLORS } from '../constants/colors'; import { resolveActivityLaneSide } from './activityLane'; +import type { ActivityLaneWorldBounds } from './activityLane'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -41,6 +42,8 @@ const COLUMN_LABELS: Record = { approved: { label: 'Approved', color: COLORS.reviewApproved }, }; +const ACTIVITY_KANBAN_CLEARANCE = 24; + export function getOwnerKanbanBaseX(args: { ownerX: number; ownerKind: GraphNode['kind']; @@ -84,11 +87,15 @@ export class KanbanLayoutEngine { * 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 { + static layout( + nodes: GraphNode[], + options?: { activityLaneBounds?: readonly ActivityLaneWorldBounds[] } + ): void { const nodeMap = this.#nodeMap; nodeMap.clear(); for (const n of nodes) nodeMap.set(n.id, n); const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; + const activityLaneBounds = options?.activityLaneBounds ?? []; const tasksByOwner = this.#tasksByOwner; tasksByOwner.clear(); @@ -115,7 +122,13 @@ export class KanbanLayoutEngine { for (const [ownerId, tasks] of tasksByOwner) { const owner = nodeMap.get(ownerId); if (!owner || owner.x == null || owner.y == null) continue; - const zoneInfo = KanbanLayoutEngine.#layoutZone(tasks, owner, ownerId, leadX); + const zoneInfo = KanbanLayoutEngine.#layoutZone( + tasks, + owner, + ownerId, + leadX, + activityLaneBounds + ); if (zoneInfo) this.zones.push(zoneInfo); } @@ -128,13 +141,13 @@ export class KanbanLayoutEngine { tasks: GraphNode[], owner: GraphNode, ownerId: string, - leadX: number | null + leadX: number | null, + activityLaneBounds: readonly ActivityLaneWorldBounds[] ): KanbanZoneInfo | null { const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; const headerHeight = 20; // space for column header label const ownerX = owner.x ?? 0; const ownerY = owner.y ?? 0; - const baseY = ownerY + offsetY; // Classify tasks into columns const colTasks = KanbanLayoutEngine.#colTasks; @@ -166,6 +179,24 @@ export class KanbanLayoutEngine { columnWidth, leadX, }); + const taskZoneLeft = baseX - TASK_PILL.width / 2; + const taskZoneRight = + baseX + (activeColumns.length - 1) * columnWidth + TASK_PILL.width / 2; + const overlappingActivityBottom = activityLaneBounds.reduce((maxBottom, bounds) => { + if (bounds.ownerId === ownerId) { + return Math.max(maxBottom, bounds.bottom); + } + if (!rangesOverlap(taskZoneLeft, taskZoneRight, bounds.left, bounds.right)) { + return maxBottom; + } + return Math.max(maxBottom, bounds.bottom); + }, -Infinity); + const baseY = Math.max( + ownerY + offsetY, + overlappingActivityBottom > -Infinity + ? overlappingActivityBottom + ACTIVITY_KANBAN_CLEARANCE + : -Infinity + ); // Build headers + position tasks const headers: KanbanColumnHeader[] = []; @@ -274,3 +305,7 @@ export class KanbanLayoutEngine { } } } + +function rangesOverlap(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean { + return aStart < bEnd && bStart < aEnd; +} diff --git a/packages/agent-graph/src/layout/launchAnchor.ts b/packages/agent-graph/src/layout/launchAnchor.ts index 522c818d..4d3dad83 100644 --- a/packages/agent-graph/src/layout/launchAnchor.ts +++ b/packages/agent-graph/src/layout/launchAnchor.ts @@ -1,8 +1,7 @@ import { NODE } from '../constants/canvas-constants'; import { ACTIVITY_ANCHOR_LAYOUT, - getActivityAnchorTarget, - getActivityLaneBounds, + resolveActivityLaneSide, } from './activityLane'; export interface WorldBounds { @@ -76,9 +75,38 @@ export const getLaunchHudBounds = getLaunchAnchorBounds; export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT; export const getHandoffAnchorId = getActivityAnchorId; export const isHandoffAnchorId = isActivityAnchorId; -export { getActivityAnchorTarget }; -export const getHandoffAnchorTarget = getActivityAnchorTarget; -export const getHandoffAnchorBounds = getActivityLaneBounds; + +export function getHandoffAnchorTarget(args: { + nodeX: number; + nodeY: number; + nodeKind: 'lead' | 'member'; + leadX?: number | null; +}): { x: number; y: number } { + const { nodeX, nodeY, nodeKind, leadX } = args; + const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX }); + if (side === 'left') { + return { + x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX, + y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY, + }; + } + + return { + x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX, + y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY, + }; +} + +export function getHandoffAnchorBounds(anchorX: number, anchorY: number): WorldBounds { + const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2; + const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2; + return { + left: anchorX - halfWidth, + top: anchorY - halfHeight, + right: anchorX + halfWidth, + bottom: anchorY + halfHeight, + }; +} export function getLaunchAnchorScreenPlacement(args: { anchorX: number; diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 90279acb..fe3c1e4c 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -65,6 +65,13 @@ export interface GraphViewProps { getActivityAnchorScreenPlacement: ( ownerNodeId: string, ) => { x: number; y: number; scale: number; visible: boolean } | null; + getActivityAnchorWorldPosition: ( + ownerNodeId: string, + ) => { x: number; y: number } | null; + getCameraZoom: () => number; + worldToScreen: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; + getViewportSize: () => { width: number; height: number }; getNodeScreenPosition: ( nodeId: string, ) => { x: number; y: number; visible: boolean } | null; @@ -229,6 +236,11 @@ export function GraphView({ viewportHeight: viewport.height, }); }, [getViewportSize]); + const getActivityAnchorWorldPosition = useCallback( + (ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId), + [], + ); + const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []); const getNodeScreenPosition = useCallback((nodeId: string) => { const viewport = getViewportSize(); if (viewport.width <= 0 || viewport.height <= 0) { @@ -247,6 +259,13 @@ export function GraphView({ visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80, }; }, [getViewportSize]); + const getNodeWorldPosition = useCallback((nodeId: string) => { + const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); + if (!node || node.x == null || node.y == null) { + return null; + } + return { x: node.x, y: node.y }; + }, []); const animate = useCallback(() => { if (!runningRef.current) return; @@ -710,6 +729,11 @@ export function GraphView({ {renderHud({ getLaunchAnchorScreenPlacement, getActivityAnchorScreenPlacement, + getActivityAnchorWorldPosition, + getCameraZoom, + worldToScreen: camera.worldToScreen, + getNodeWorldPosition, + getViewportSize, getNodeScreenPosition, focusNodeIds: focusState.focusNodeIds, })} diff --git a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx index 0be0a066..448ff848 100644 --- a/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphActivityHud.tsx @@ -33,6 +33,11 @@ interface GraphActivityHudProps { getActivityAnchorScreenPlacement: ( ownerNodeId: string ) => { x: number; y: number; scale: number; visible: boolean } | null; + getActivityAnchorWorldPosition?: (ownerNodeId: string) => { x: number; y: number } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + getViewportSize?: () => { width: number; height: number }; getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null; focusNodeIds: ReadonlySet | null; enabled?: boolean; @@ -50,12 +55,19 @@ export const GraphActivityHud = ({ teamName, nodes, getActivityAnchorScreenPlacement, + getActivityAnchorWorldPosition = () => null, + getCameraZoom = () => 1, + worldToScreen, + getNodeWorldPosition = () => null, + getViewportSize, getNodeScreenPosition = () => null, focusNodeIds, enabled = true, onOpenTaskDetail, onOpenMemberProfile, }: GraphActivityHudProps): React.JSX.Element | null => { + const ACTIVITY_LANE_WIDTH = 296; + const worldLayerRef = useRef(null); const shellRefs = useRef(new Map()); const connectorRefs = useRef(new Map()); const connectorPathRefs = useRef(new Map()); @@ -140,16 +152,35 @@ export const GraphActivityHud = ({ let frameId = 0; const updatePositions = (): void => { + const worldLayer = worldLayerRef.current; + if (worldLayer && worldToScreen) { + const origin = worldToScreen(0, 0); + const zoom = Math.max(getCameraZoom(), 0.001); + worldLayer.style.transform = `translate(${Math.round(origin.x)}px, ${Math.round(origin.y)}px) scale(${zoom.toFixed(3)})`; + } + + const measurableLanes: Array<{ + lane: (typeof visibleLanes)[number]; + shell: HTMLDivElement; + connector: SVGSVGElement | null; + connectorPath: SVGPathElement | null; + laneTopLeft: { x: number; y: number }; + nodeWorld: { x: number; y: number }; + scale: number; + }> = []; + for (const lane of visibleLanes) { const shell = shellRefs.current.get(lane.node.id); if (!shell) { continue; } - const connector = connectorRefs.current.get(lane.node.id); + const connector = connectorRefs.current.get(lane.node.id) ?? null; const connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null; const placement = getActivityAnchorScreenPlacement(lane.node.id); - if (!placement?.visible) { + const laneTopLeft = getActivityAnchorWorldPosition(lane.node.id); + const nodeWorld = getNodeWorldPosition(lane.node.id); + if (!placement || !laneTopLeft || !nodeWorld) { shell.style.opacity = '0'; if (connector) { connector.style.opacity = '0'; @@ -157,27 +188,57 @@ export const GraphActivityHud = ({ continue; } + const scale = Math.max(getCameraZoom(), 0.001); + const widthScreen = Math.max(1, (shell.offsetWidth || ACTIVITY_LANE_WIDTH) * scale); + const heightScreen = Math.max(1, (shell.offsetHeight || 220) * scale); + const viewport = getViewportSize?.(); + const laneVisible = viewport + ? placement.x + widthScreen > -80 && + placement.x < viewport.width + 80 && + placement.y + heightScreen > -80 && + placement.y < viewport.height + 80 + : placement.visible; + + const nodeScreen = getNodeScreenPosition(lane.node.id); + if (!nodeScreen?.visible || !laneVisible) { + shell.style.opacity = '0'; + if (connector) { + connector.style.opacity = '0'; + } + continue; + } + + measurableLanes.push({ + lane, + shell, + connector, + connectorPath, + laneTopLeft, + nodeWorld, + scale, + }); + } + + for (const entry of measurableLanes) { + const { lane, shell, connector, connectorPath, laneTopLeft, nodeWorld, scale } = entry; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1; shell.style.opacity = String(baseOpacity); - shell.style.transform = `translate(${Math.round(placement.x)}px, ${Math.round(placement.y)}px) scale(${placement.scale.toFixed(3)})`; + shell.style.left = `${Math.round(laneTopLeft.x)}px`; + shell.style.top = `${Math.round(laneTopLeft.y)}px`; + shell.style.transform = ''; if (connector && connectorPath) { - const nodeScreen = getNodeScreenPosition(lane.node.id); - if (!nodeScreen?.visible) { - connector.style.opacity = '0'; - continue; - } - const scaledWidth = (shell.offsetWidth || 296) * placement.scale; - const laneCenterX = placement.x + scaledWidth / 2; - const laneIsLeft = laneCenterX < nodeScreen.x; - const endX = laneIsLeft ? placement.x + scaledWidth - 8 : placement.x + 8; - const endY = placement.y + 10 * placement.scale; - const startX = nodeScreen.x; - const startY = nodeScreen.y - 10; + const widthWorld = shell.offsetWidth || ACTIVITY_LANE_WIDTH; + const laneCenterX = laneTopLeft.x + widthWorld / 2; + const laneIsLeft = laneCenterX < nodeWorld.x; + const endX = laneIsLeft ? laneTopLeft.x + widthWorld - 8 : laneTopLeft.x + 8; + const endY = laneTopLeft.y + 10; + const startX = nodeWorld.x; + const startY = nodeWorld.y - 10 / scale; const minX = Math.min(startX, endX); const minY = Math.min(startY, endY); - const width = Math.max(1, Math.abs(endX - startX)); - const height = Math.max(1, Math.abs(endY - startY)); + const connectorWidth = Math.max(1, Math.abs(endX - startX)); + const connectorHeight = Math.max(1, Math.abs(endY - startY)); const localStartX = startX - minX; const localStartY = startY - minY; const localEndX = endX - minX; @@ -192,9 +253,12 @@ export const GraphActivityHud = ({ connector.style.opacity = String(baseOpacity); connector.style.left = `${Math.round(minX)}px`; connector.style.top = `${Math.round(minY)}px`; - connector.setAttribute('width', String(Math.ceil(width))); - connector.setAttribute('height', String(Math.ceil(height))); - connector.setAttribute('viewBox', `0 0 ${Math.ceil(width)} ${Math.ceil(height)}`); + connector.setAttribute('width', String(Math.ceil(connectorWidth))); + connector.setAttribute('height', String(Math.ceil(connectorHeight))); + connector.setAttribute( + 'viewBox', + `0 0 ${Math.ceil(connectorWidth)} ${Math.ceil(connectorHeight)}` + ); connectorPath.setAttribute( 'd', `M ${localStartX.toFixed(1)} ${localStartY.toFixed(1)} C ${c1x.toFixed(1)} ${c1y.toFixed(1)}, ${c2x.toFixed(1)} ${c2y.toFixed(1)}, ${localEndX.toFixed(1)} ${localEndY.toFixed(1)}` @@ -213,7 +277,12 @@ export const GraphActivityHud = ({ enabled, focusNodeIds, getActivityAnchorScreenPlacement, + getActivityAnchorWorldPosition, + getCameraZoom, + getNodeWorldPosition, getNodeScreenPosition, + getViewportSize, + worldToScreen, visibleLanes, ]); @@ -269,100 +338,154 @@ export const GraphActivityHud = ({ [onOpenMemberProfile] ); + const forwardWheelToGraph = useCallback((event: WheelEvent, shell: HTMLDivElement) => { + const graphRoot = shell.closest('.team-graph-view'); + const canvas = graphRoot?.querySelector('canvas'); + if (!(canvas instanceof HTMLCanvasElement)) { + return; + } + event.preventDefault(); + canvas.dispatchEvent( + new WheelEvent('wheel', { + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + clientX: event.clientX, + clientY: event.clientY, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + bubbles: true, + cancelable: true, + }) + ); + }, []); + + useEffect(() => { + if (!enabled) { + return; + } + + const listeners: Array<{ shell: HTMLDivElement; handler: (event: WheelEvent) => void }> = []; + + for (const lane of visibleLanes) { + const shell = shellRefs.current.get(lane.node.id); + if (!shell) { + continue; + } + const handler = (event: WheelEvent) => forwardWheelToGraph(event, shell); + shell.addEventListener('wheel', handler, { passive: false }); + listeners.push({ shell, handler }); + } + + return () => { + for (const { shell, handler } of listeners) { + shell.removeEventListener('wheel', handler); + } + }; + }, [enabled, forwardWheelToGraph, visibleLanes]); + if (!enabled || !teamData || visibleLanes.length === 0) { return null; } return ( <> - {visibleLanes.map((lane) => ( -
- { - connectorRefs.current.set(lane.node.id, element); - }} - className="pointer-events-none absolute z-[9] overflow-visible opacity-0" - > - + {visibleLanes.map((lane) => ( +
+ { - connectorPathRefs.current.set(lane.node.id, element); + connectorRefs.current.set(lane.node.id, element); }} - d="" - fill="none" - stroke="rgba(148, 163, 184, 0.3)" - strokeWidth="1.25" - strokeLinecap="round" - strokeDasharray="3 4" - /> - -
{ - shellRefs.current.set(lane.node.id, element); - }} - className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0" - > -
- Activity -
-
- {lane.entries.map((entry, index) => { - const messageKey = toMessageKey(entry.message); - const renderProps = resolveMessageRenderProps(entry.message, messageContext); - const timelineItem: TimelineItem = { type: 'message', message: entry.message }; - const isUnread = !entry.message.read && !readSet.has(messageKey); + className="pointer-events-none absolute z-[9] overflow-visible opacity-0" + > + { + connectorPathRefs.current.set(lane.node.id, element); + }} + d="" + fill="none" + stroke="rgba(148, 163, 184, 0.3)" + strokeWidth="1.25" + strokeLinecap="round" + strokeDasharray="3 4" + /> + +
{ + shellRefs.current.set(lane.node.id, element); + }} + className="pointer-events-auto absolute z-10 origin-top-left opacity-0" + style={{ width: `${ACTIVITY_LANE_WIDTH}px`, maxWidth: `${ACTIVITY_LANE_WIDTH}px` }} + > +
+ Activity +
+
+ {lane.entries.map((entry, index) => { + const messageKey = toMessageKey(entry.message); + const renderProps = resolveMessageRenderProps(entry.message, messageContext); + const timelineItem: TimelineItem = { type: 'message', message: entry.message }; + const isUnread = !entry.message.read && !readSet.has(messageKey); - return ( -
handleMessageClick(timelineItem)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - handleMessageClick(timelineItem); - } - }} + return ( +
handleMessageClick(timelineItem)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleMessageClick(timelineItem); + } + }} + > + +
+ ); + })} + + {lane.overflowCount > 0 ? ( +
- ); - })} - - {lane.overflowCount > 0 ? ( - - ) : null} + +{lane.overflowCount} more + + ) : null} +
-
- ))} + ))} +
( - <> - - - - )} + renderHud={(hudProps) => { + const extraHudProps = hudProps as typeof hudProps & { + getViewportSize?: () => { width: number; height: number }; + getActivityAnchorWorldPosition?: ( + ownerNodeId: string + ) => { x: number; y: number } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + }; + const { + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + getViewportSize, + getNodeScreenPosition, + focusNodeIds, + } = extraHudProps; + + return ( + <> + + + + ); + }} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => ( setFullscreen(true)} onOpenTeamPage={openTeamPage} onCreateTask={openCreateTask} - renderHud={({ - getLaunchAnchorScreenPlacement, - getActivityAnchorScreenPlacement, - getNodeScreenPosition, - focusNodeIds, - }) => ( - <> - - - - )} + renderHud={(hudProps) => { + const extraHudProps = hudProps as typeof hudProps & { + getViewportSize?: () => { width: number; height: number }; + getActivityAnchorWorldPosition?: ( + ownerNodeId: string + ) => { x: number; y: number } | null; + getCameraZoom?: () => number; + worldToScreen?: (x: number, y: number) => { x: number; y: number }; + getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null; + }; + const { + getLaunchAnchorScreenPlacement, + getActivityAnchorScreenPlacement, + getViewportSize, + getNodeScreenPosition, + focusNodeIds, + } = extraHudProps; + + return ( + <> + + + + ); + }} renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => ( { className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-text-muted)' }} > - Persistent team agents are more reliable on the process/tmux path. Without tmux, the - app falls back to the heavier in-process path. {getPrimaryDetail(state.status)} + You can keep using the app without tmux, but installing it is recommended for the best + experience. It enables the more reliable process/tmux path with persistent teammates, + cleaner restarts, and better recovery for long-running tasks.{' '} + {getPrimaryDetail(state.status)}

{state.status.error && (

diff --git a/test/renderer/features/agent-graph/activityLane.test.ts b/test/renderer/features/agent-graph/activityLane.test.ts index 6a4c6b60..3883211a 100644 --- a/test/renderer/features/agent-graph/activityLane.test.ts +++ b/test/renderer/features/agent-graph/activityLane.test.ts @@ -6,6 +6,8 @@ import { getActivityAnchorScreenPlacement, getActivityAnchorTarget, getActivityLaneBounds, + packActivityLaneScreenRects, + packActivityLaneWorldRects, getVisibleActivityWindow, } from '../../../../packages/agent-graph/src/layout/activityLane'; @@ -28,7 +30,7 @@ describe('activity lane helpers', () => { expect(window.overflowCount).toBe(3); }); - it('places the lead lane to the left and member lane to the right', () => { + it('places activity lanes above their owners', () => { const leadTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'lead' }); const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' }); const memberLeftOfLeadTarget = getActivityAnchorTarget({ @@ -38,13 +40,21 @@ describe('activity lane helpers', () => { leadX: 100, }); - expect(leadTarget.x).toBeLessThan(100); - expect(memberTarget.x).toBeGreaterThan(100); - expect(memberLeftOfLeadTarget.x).toBeLessThan(80); + expect(leadTarget.x).toBe(100 - ACTIVITY_LANE.width / 2); + expect(memberTarget.x).toBe(100 - ACTIVITY_LANE.width / 2); + expect(memberLeftOfLeadTarget.x).toBe(80 - ACTIVITY_LANE.width / 2); expect(leadTarget.y).toBeLessThan(80); expect(memberTarget.y).toBeLessThan(80); }); + it('keeps the activity lane fully above the owner node', () => { + const ownerY = 120; + const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: ownerY, nodeKind: 'member' }); + const bounds = getActivityLaneBounds(memberTarget.x, memberTarget.y); + + expect(bounds.bottom).toBeLessThan(ownerY); + }); + it('hits visible activity pills in the owner lane', () => { const node: GraphNode = { id: 'member:team:alice', @@ -80,8 +90,8 @@ describe('activity lane helpers', () => { viewportHeight: 600, }); - expect(placement.x).toBe(40 - ACTIVITY_LANE.width / 2); - expect(placement.y).toBe(60 - (ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.overflowHeight) / 2); + expect(placement.x).toBe(40); + expect(placement.y).toBe(60); expect(placement.visible).toBe(true); }); @@ -99,4 +109,40 @@ describe('activity lane helpers', () => { expect(placement.x).toBeLessThan(0); expect(placement.visible).toBe(true); }); + + it('packs overlapping lanes on the same side without moving independent lanes', () => { + const placements = packActivityLaneScreenRects([ + { id: 'lane-a', side: 'right', x: 400, y: 100, width: 296, height: 220 }, + { id: 'lane-b', side: 'right', x: 420, y: 150, width: 296, height: 220 }, + { id: 'lane-c', side: 'left', x: 120, y: 150, width: 296, height: 220 }, + ]); + + expect(placements.get('lane-a')).toEqual({ x: 400, y: 100 }); + expect(placements.get('lane-b')).toEqual({ x: 420, y: 328 }); + expect(placements.get('lane-c')).toEqual({ x: 120, y: 150 }); + }); + + it('packs world lanes globally even when they came from different legacy sides', () => { + const placements = packActivityLaneWorldRects([ + { id: 'lane-a', side: 'left', x: 100, y: 100, width: 296, height: 220 }, + { id: 'lane-b', side: 'right', x: 120, y: 140, width: 296, height: 220 }, + ]); + + expect(placements.get('lane-a')).toEqual({ x: 100, y: 100 }); + expect(placements.get('lane-b')).toEqual({ x: 120, y: 328 }); + }); + + it('tracks graph zoom so activity lanes behave like world elements', () => { + const placement = getActivityAnchorScreenPlacement({ + anchorX: 40, + anchorY: 60, + cameraX: 0, + cameraY: 0, + zoom: 4, + viewportWidth: 800, + viewportHeight: 600, + }); + + expect(placement.scale).toBe(4); + }); }); diff --git a/test/renderer/features/agent-graph/kanbanLayout.test.ts b/test/renderer/features/agent-graph/kanbanLayout.test.ts index 28a39e4d..930a615b 100644 --- a/test/renderer/features/agent-graph/kanbanLayout.test.ts +++ b/test/renderer/features/agent-graph/kanbanLayout.test.ts @@ -6,6 +6,7 @@ import { getOwnerKanbanBaseX, } from '../../../../packages/agent-graph/src/layout/kanbanLayout'; import { + ACTIVITY_LANE, getActivityAnchorTarget, getActivityLaneBounds, } from '../../../../packages/agent-graph/src/layout/activityLane'; @@ -78,7 +79,7 @@ describe('kanban layout activity-lane avoidance', () => { expect(baseX).toBe(-220); }); - it('keeps member task pills out of the reserved right-side activity lane', () => { + it('keeps member task pills below the reserved activity lane', () => { const lead = createLeadNode(0, 0); const member = createMemberNode('member:jack', 220, 40, 'jack'); const tasks = [ @@ -96,12 +97,12 @@ describe('kanban layout activity-lane avoidance', () => { leadX: lead.x ?? null, }); const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); - const rightmostTaskEdge = Math.max(...tasks.map((task) => (task.x ?? 0) + TASK_PILL.width / 2)); + const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2)); - expect(rightmostTaskEdge).toBeLessThan(laneBounds.left); + expect(topmostTaskEdge).toBeGreaterThan(laneBounds.bottom); }); - it('keeps member task pills out of the reserved left-side activity lane', () => { + it('keeps left-side member task pills below the reserved activity lane', () => { const lead = createLeadNode(0, 0); const member = createMemberNode('member:alice', -220, 40, 'alice'); const tasks = [ @@ -119,8 +120,33 @@ describe('kanban layout activity-lane avoidance', () => { leadX: lead.x ?? null, }); const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); - const leftmostTaskEdge = Math.min(...tasks.map((task) => (task.x ?? 0) - TASK_PILL.width / 2)); + const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2)); - expect(leftmostTaskEdge).toBeGreaterThan(laneBounds.right); + expect(topmostTaskEdge).toBeGreaterThan(laneBounds.bottom); + }); + + it('pushes task zones below overlapping activity lanes from nearby owners', () => { + const lead = createLeadNode(0, 0); + const member = createMemberNode('member:alice', 120, 120, 'alice'); + const tasks = [ + createTaskNode('task:todo', member.id, 'pending'), + createTaskNode('task:wip', member.id, 'in_progress'), + ]; + + const nearbyLane = { + ownerId: 'member:tom', + left: 20, + top: -120, + right: 20 + ACTIVITY_LANE.width, + bottom: 180, + }; + + KanbanLayoutEngine.layout([lead, member, ...tasks], { + activityLaneBounds: [nearbyLane], + }); + + const topmostTaskEdge = Math.min(...tasks.map((task) => (task.y ?? 0) - TASK_PILL.height / 2)); + + expect(topmostTaskEdge).toBeGreaterThan(nearbyLane.bottom); }); });