refactor: update README and security documentation; enhance activity lane layout and kanban integration

This commit is contained in:
777genius 2026-04-14 22:06:50 +03:00
parent 688752b3f5
commit 90b637c6d8
13 changed files with 725 additions and 239 deletions

7
.github/SECURITY.md vendored
View file

@ -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

View file

@ -10,7 +10,7 @@
<a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</p>
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Claude Agent Teams UI</a></h1>
<h1 align="center"><a href="https://777genius.github.io/claude_agent_teams_ui/">Agent Teams UI</a></h1>
<p align="center">
<strong><code>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.</code></strong>
@ -23,7 +23,7 @@
</p>
<p align="center">
<sub>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.</sub>
<sub>100% free, open source. Auto-detects Claude/Codex. Use the provider access you already have - subscriptions or API keys. Not just coding agents.</sub>
</p>
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/be19cfcb-93ff-403a-9a1e-8ff1a803c55e" />
@ -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

View file

@ -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;

View file

@ -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<string, { x: number; y: number }>
): 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<string, { x: number; y: number }> {
return packActivityLaneRects(rects, gap, true);
}
export function packActivityLaneWorldRects(
rects: ActivityLaneWorldRect[],
gap = 8
): Map<string, { x: number; y: number }> {
return packActivityLaneRects(rects, gap, false);
}
function packActivityLaneRects<T extends {
id: string;
side: ActivityLaneSide;
x: number;
y: number;
width: number;
height: number;
}>(
rects: T[],
gap = 8,
groupBySide = true
): Map<string, { x: number; y: number }> {
const placements = new Map<string, { x: number; y: number }>();
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<ActivityLaneScreenRect & { placedY: number }> = [];
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;
}

View file

@ -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<string, { label: string; color: string }> = {
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;
}

View file

@ -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;

View file

@ -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,
})}

View file

@ -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<string> | 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<HTMLDivElement | null>(null);
const shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
@ -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) => (
<div key={lane.node.id}>
<svg
ref={(element) => {
connectorRefs.current.set(lane.node.id, element);
}}
className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
>
<path
<div
ref={worldLayerRef}
className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left"
>
{visibleLanes.map((lane) => (
<div key={lane.node.id}>
<svg
ref={(element) => {
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"
/>
</svg>
<div
ref={(element) => {
shellRefs.current.set(lane.node.id, element);
}}
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0"
>
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
Activity
</div>
<div className="space-y-2">
{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"
>
<path
ref={(element) => {
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"
/>
</svg>
<div
ref={(element) => {
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` }}
>
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
Activity
</div>
<div className="min-w-0 max-w-full space-y-2 overflow-hidden">
{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 (
<div
key={entry.graphItem.id}
className="cursor-pointer"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(timelineItem);
}
}}
return (
<div
key={entry.graphItem.id}
className="min-w-0 max-w-full cursor-pointer overflow-hidden"
role="button"
tabIndex={0}
onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleMessageClick(timelineItem);
}
}}
>
<ActivityItem
message={entry.message}
teamName={teamName}
compactHeader
collapseMode="managed"
isCollapsed
canToggleCollapse={false}
isUnread={isUnread}
expandItemKey={messageKey}
onExpand={handleExpandItem}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
memberColorMap={messageContext.colorMap}
localMemberNames={messageContext.localMemberNames}
onMemberNameClick={handleMemberNameClick}
onTaskIdClick={onOpenTaskDetail}
zebraShade={index % 2 === 1}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</div>
);
})}
{lane.overflowCount > 0 ? (
<button
type="button"
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
onClick={() => handleOpenOwnerActivity(lane.node)}
>
<ActivityItem
message={entry.message}
teamName={teamName}
compactHeader
collapseMode="managed"
isCollapsed
canToggleCollapse={false}
isUnread={isUnread}
expandItemKey={messageKey}
onExpand={handleExpandItem}
memberRole={renderProps.memberRole}
memberColor={renderProps.memberColor}
recipientColor={renderProps.recipientColor}
memberColorMap={messageContext.colorMap}
localMemberNames={messageContext.localMemberNames}
onMemberNameClick={handleMemberNameClick}
onTaskIdClick={onOpenTaskDetail}
zebraShade={index % 2 === 1}
teamNames={teamNames}
teamColorByName={teamColorByName}
/>
</div>
);
})}
{lane.overflowCount > 0 ? (
<button
type="button"
className="w-full rounded-md border border-white/10 bg-[rgba(8,14,28,0.64)] px-3 py-1 text-center text-[11px] font-medium text-slate-300 transition-colors hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]"
onClick={() => handleOpenOwnerActivity(lane.node)}
>
+{lane.overflowCount} more
</button>
) : null}
+{lane.overflowCount} more
</button>
) : null}
</div>
</div>
</div>
</div>
))}
))}
</div>
<MessageExpandDialog
expandedItem={expandedItem}

View file

@ -113,29 +113,48 @@ export const TeamGraphOverlay = ({
onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask}
className="team-graph-view min-w-0 flex-1"
renderHud={({
getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement,
getNodeScreenPosition,
focusNodeIds,
}) => (
<>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
getNodeScreenPosition={getNodeScreenPosition}
focusNodeIds={focusNodeIds}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
<GraphProvisioningHud
teamName={teamName}
leadNodeId={leadNodeId}
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
/>
</>
)}
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 (
<>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
getActivityAnchorWorldPosition={extraHudProps.getActivityAnchorWorldPosition}
getCameraZoom={extraHudProps.getCameraZoom}
worldToScreen={extraHudProps.worldToScreen}
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
getViewportSize={getViewportSize}
getNodeScreenPosition={getNodeScreenPosition}
focusNodeIds={focusNodeIds}
onOpenTaskDetail={onOpenTaskDetail}
onOpenMemberProfile={onOpenMemberProfile}
/>
<GraphProvisioningHud
teamName={teamName}
leadNodeId={leadNodeId}
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
/>
</>
);
}}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose: closeEdge, onSelectNode }) => (
<GraphBlockingEdgePopover
teamName={teamName}

View file

@ -145,31 +145,50 @@ export const TeamGraphTab = ({
onRequestFullscreen={() => setFullscreen(true)}
onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask}
renderHud={({
getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement,
getNodeScreenPosition,
focusNodeIds,
}) => (
<>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
getNodeScreenPosition={getNodeScreenPosition}
focusNodeIds={focusNodeIds}
enabled={isActive}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
/>
<GraphProvisioningHud
teamName={teamName}
leadNodeId={leadNodeId}
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
enabled={isActive}
/>
</>
)}
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 (
<>
<GraphActivityHud
teamName={teamName}
nodes={graphData.nodes}
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement}
getActivityAnchorWorldPosition={extraHudProps.getActivityAnchorWorldPosition}
getCameraZoom={extraHudProps.getCameraZoom}
worldToScreen={extraHudProps.worldToScreen}
getNodeWorldPosition={extraHudProps.getNodeWorldPosition}
getViewportSize={getViewportSize}
getNodeScreenPosition={getNodeScreenPosition}
focusNodeIds={focusNodeIds}
enabled={isActive}
onOpenTaskDetail={dispatchOpenTask}
onOpenMemberProfile={dispatchOpenProfile}
/>
<GraphProvisioningHud
teamName={teamName}
leadNodeId={leadNodeId}
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement}
enabled={isActive}
/>
</>
);
}}
renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
<GraphBlockingEdgePopover
teamName={teamName}

View file

@ -310,8 +310,10 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
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)}
</p>
{state.status.error && (
<p className="mt-1 text-xs" style={{ color: '#fbbf24' }}>

View file

@ -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);
});
});

View file

@ -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);
});
});