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 ## IPC & Input Validation
- All IPC handlers validate inputs with strict path containment checks - Electron IPC and standalone HTTP handlers validate IDs, paths, and payloads at the boundary
- File reads are constrained to the project root and `~/.claude/` - 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 - 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 ## 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> <a href="docs/screenshots/6.png"><img src="docs/screenshots/6.png" width="65" alt="Settings" /></a>
</p> </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"> <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> <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>
<p align="center"> <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> </p>
<img width="1500" height="1065" alt="demo" src="https://github.com/user-attachments/assets/be19cfcb-93ff-403a-9a1e-8ff1a803c55e" /> <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) - [Installation](#installation)
- [Table of contents](#table-of-contents) - [Table of contents](#table-of-contents)
- [What is this](#what-is-this) - [What is this](#what-is-this)
- [Developer architecture docs](#developer-architecture-docs)
- [Comparison](#comparison) - [Comparison](#comparison)
- [Quick start](#quick-start) - [Quick start](#quick-start)
- [FAQ](#faq) - [FAQ](#faq)
- [Developer architecture docs](#developer-architecture-docs)
- [Development](#development) - [Development](#development)
- [Tech stack](#tech-stack) - [Tech stack](#tech-stack)
- [Build for distribution](#build-for-distribution) - [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. 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 - **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 - **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 - **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 - **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 - **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 - **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 (tools), actions 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 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 - **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 - **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 - **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 ## 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** | ✅ | ❌ | ❌ | — | ❌ | | **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ |
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) | | **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 - [ ] 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 - [ ] 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) - [ ] 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. - [ ] 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) - [ ] 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 - [ ] 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 - [ ] Custom kanban columns
- [ ] Run terminal commands - [ ] 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 ## 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 ## License

View file

@ -26,7 +26,6 @@ import { KanbanLayoutEngine } from '../layout/kanbanLayout';
import { import {
LAUNCH_ANCHOR_LAYOUT, LAUNCH_ANCHOR_LAYOUT,
getActivityAnchorId, getActivityAnchorId,
getHandoffAnchorBounds,
getLaunchAnchorBounds, getLaunchAnchorBounds,
getLaunchAnchorId, getLaunchAnchorId,
getLaunchAnchorTarget, getLaunchAnchorTarget,
@ -34,7 +33,14 @@ import {
isLaunchAnchorId, isLaunchAnchorId,
type WorldBounds, type WorldBounds,
} from '../layout/launchAnchor'; } 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`) ────────────── // ─── 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 leadNode = forceNodes.find((node) => node.kind === 'lead');
const leadX = leadNode?.x ?? leadNode?.fx ?? null; 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) { for (const node of forceNodes) {
let target: { x: number; y: number } | null = null;
if (node.kind === 'launch-anchor' && node.anchorForLeadId) { if (node.kind === 'launch-anchor' && node.anchorForLeadId) {
const leadNode = forceNodeMap.get(node.anchorForLeadId); const leadNode = forceNodeMap.get(node.anchorForLeadId);
if (!leadNode) continue; if (!leadNode) continue;
target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0); const target = getLaunchAnchorTarget(leadNode.x ?? 0, leadNode.y ?? 0);
} else if (node.kind === 'activity-anchor' && node.anchorForNodeId) { 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); const ownerNode = forceNodeMap.get(node.anchorForNodeId);
if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue; if (!ownerNode || (ownerNode.kind !== 'lead' && ownerNode.kind !== 'member')) continue;
target = getActivityAnchorTarget({ const target = getActivityAnchorTarget({
nodeX: ownerNode.x ?? 0, nodeX: ownerNode.x ?? 0,
nodeY: ownerNode.y ?? 0, nodeY: ownerNode.y ?? 0,
nodeKind: ownerNode.kind, nodeKind: ownerNode.kind,
leadX, leadX,
}); });
} else { pendingActivityAnchors.push({
continue; node,
} target,
if (!target) { side: resolveActivityLaneSide({
continue; nodeKind: ownerNode.kind,
nodeX: ownerNode.x ?? 0,
leadX,
}),
});
} }
}
node.fx = target.x; const packedActivityAnchors = packActivityLaneWorldRects(
node.fy = target.y; pendingActivityAnchors.map(({ node, target, side }) => ({
node.x = target.x; id: node.id,
node.y = target.y; side,
node.vx = 0; x: target.x,
node.vy = 0; 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; continue;
} }
if (node.kind === 'activity-anchor' && node.anchorForNodeId) { if (node.kind === 'activity-anchor' && node.anchorForNodeId) {
activityPositions.set(node.anchorForNodeId, { x, y }); const topLeft = {
bounds.push(getHandoffAnchorBounds(x, y)); 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 // Position tasks in kanban zones relative to their owners
KanbanLayoutEngine.layout(nodes); KanbanLayoutEngine.layout(nodes, {
activityLaneBounds: buildVisibleActivityLaneBounds(
nodes,
activityAnchorPositionsRef.current
),
});
updateLaunchAnchorCaches( updateLaunchAnchorCaches(
sim.nodes(), sim.nodes(),
launchAnchorPositionsRef.current, launchAnchorPositionsRef.current,
@ -515,7 +566,9 @@ function tickFrame(
} }
// Re-layout tasks in kanban zones — always run to handle new/moved tasks // 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) // Update particle progress — in-place removal (no new array allocation)
let pw = 0; 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'; import type { GraphActivityItem, GraphNode } from '../ports/types';
export const ACTIVITY_LANE = { export const ACTIVITY_LANE = {
width: 296, width: 296,
itemHeight: 58, itemHeight: 72,
rowHeight: 62, rowHeight: 80,
maxVisibleItems: 3, maxVisibleItems: 3,
headerHeight: 18, headerHeight: 20,
overflowHeight: 18, overflowHeight: 32,
horizontalGapLead: 76, horizontalGapLead: 76,
horizontalGapMember: 84, horizontalGapMember: 84,
bottomClearance: 18, ownerClearanceLead: 92,
ownerClearanceMember: 104,
viewportPadding: 12, viewportPadding: 12,
visiblePadding: 80, visiblePadding: 80,
minScale: 0, minScale: CAMERA.minZoom,
maxScale: 1, maxScale: CAMERA.maxZoom,
} as const; } as const;
const RESERVED_HEIGHT = const RESERVED_HEIGHT =
@ -26,10 +27,10 @@ export const ACTIVITY_ANCHOR_LAYOUT = {
reservedWidth: ACTIVITY_LANE.width, reservedWidth: ACTIVITY_LANE.width,
reservedHeight: RESERVED_HEIGHT, reservedHeight: RESERVED_HEIGHT,
memberOffsetX: ACTIVITY_LANE.width / 2 + NODE.radiusMember + ACTIVITY_LANE.horizontalGapMember, 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), leadOffsetX: -(ACTIVITY_LANE.width / 2 + NODE.radiusLead + ACTIVITY_LANE.horizontalGapLead),
leadOffsetY: -(RESERVED_HEIGHT / 2 - ACTIVITY_LANE.bottomClearance), leadOffsetY: -(RESERVED_HEIGHT + NODE.radiusLead + ACTIVITY_LANE.ownerClearanceLead),
collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 12, collisionRadius: Math.ceil(Math.hypot(ACTIVITY_LANE.width / 2, RESERVED_HEIGHT / 2)) + 56,
} as const; } as const;
export interface ActivityLaneWindow { export interface ActivityLaneWindow {
@ -51,6 +52,32 @@ export interface ActivityLaneItemHit {
export type ActivityLaneSide = 'left' | 'right'; 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: { export function resolveActivityLaneSide(args: {
nodeKind: 'lead' | 'member'; nodeKind: 'lead' | 'member';
nodeX: number; nodeX: number;
@ -72,18 +99,14 @@ export function getActivityAnchorTarget(args: {
nodeKind: 'lead' | 'member'; nodeKind: 'lead' | 'member';
leadX?: number | null; leadX?: number | null;
}): { x: number; y: number } { }): { x: number; y: number } {
const { nodeX, nodeY, nodeKind, leadX } = args; const { nodeX, nodeY, nodeKind } = args;
const side = resolveActivityLaneSide({ nodeKind, nodeX, leadX });
if (side === 'left') {
return {
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.leadOffsetX,
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.leadOffsetY,
};
}
return { return {
x: nodeX + ACTIVITY_ANCHOR_LAYOUT.memberOffsetX, x: nodeX - ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2,
y: nodeY + ACTIVITY_ANCHOR_LAYOUT.memberOffsetY, 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; right: number;
bottom: number; bottom: number;
} { } {
const halfWidth = ACTIVITY_ANCHOR_LAYOUT.reservedWidth / 2;
const halfHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight / 2;
return { return {
left: anchorX - halfWidth, left: anchorX,
top: anchorY - halfHeight, top: anchorY,
right: anchorX + halfWidth, right: anchorX + ACTIVITY_ANCHOR_LAYOUT.reservedWidth,
bottom: anchorY + halfHeight, 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 { export function getActivityLaneScale(zoom: number): number {
return Math.max(ACTIVITY_LANE.minScale, Math.min(ACTIVITY_LANE.maxScale, zoom)); 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 scale = getActivityLaneScale(zoom);
const scaledWidth = ACTIVITY_LANE.width * scale; const scaledWidth = ACTIVITY_LANE.width * scale;
const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale; const scaledHeight = ACTIVITY_ANCHOR_LAYOUT.reservedHeight * scale;
const screenX = anchorX * zoom + cameraX; const x = anchorX * zoom + cameraX;
const screenY = anchorY * zoom + cameraY; const y = anchorY * zoom + cameraY;
const x = screenX - scaledWidth / 2;
const y = screenY - scaledHeight / 2;
const right = x + scaledWidth; const right = x + scaledWidth;
const bottom = y + scaledHeight; 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( export function findActivityItemAt(
worldX: number, worldX: number,
worldY: number, worldY: number,
@ -194,3 +299,7 @@ export function findActivityItemAt(
export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } { export function isActivityOwner(node: GraphNode): node is GraphNode & { kind: 'lead' | 'member' } {
return node.kind === 'lead' || node.kind === '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 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 { COLORS } from '../constants/colors';
import { resolveActivityLaneSide } from './activityLane'; import { resolveActivityLaneSide } from './activityLane';
import type { ActivityLaneWorldBounds } from './activityLane';
/** Column header info for rendering */ /** Column header info for rendering */
export interface KanbanColumnHeader { export interface KanbanColumnHeader {
@ -41,6 +42,8 @@ const COLUMN_LABELS: Record<string, { label: string; color: string }> = {
approved: { label: 'Approved', color: COLORS.reviewApproved }, approved: { label: 'Approved', color: COLORS.reviewApproved },
}; };
const ACTIVITY_KANBAN_CLEARANCE = 24;
export function getOwnerKanbanBaseX(args: { export function getOwnerKanbanBaseX(args: {
ownerX: number; ownerX: number;
ownerKind: GraphNode['kind']; ownerKind: GraphNode['kind'];
@ -84,11 +87,15 @@ export class KanbanLayoutEngine {
* Position all task nodes in kanban columns relative to their owner. * Position all task nodes in kanban columns relative to their owner.
* Call AFTER d3-force settles member positions, BEFORE drawing. * 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; const nodeMap = this.#nodeMap;
nodeMap.clear(); nodeMap.clear();
for (const n of nodes) nodeMap.set(n.id, n); for (const n of nodes) nodeMap.set(n.id, n);
const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null; const leadX = nodes.find((node) => node.kind === 'lead')?.x ?? null;
const activityLaneBounds = options?.activityLaneBounds ?? [];
const tasksByOwner = this.#tasksByOwner; const tasksByOwner = this.#tasksByOwner;
tasksByOwner.clear(); tasksByOwner.clear();
@ -115,7 +122,13 @@ export class KanbanLayoutEngine {
for (const [ownerId, tasks] of tasksByOwner) { for (const [ownerId, tasks] of tasksByOwner) {
const owner = nodeMap.get(ownerId); const owner = nodeMap.get(ownerId);
if (!owner || owner.x == null || owner.y == null) continue; 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); if (zoneInfo) this.zones.push(zoneInfo);
} }
@ -128,13 +141,13 @@ export class KanbanLayoutEngine {
tasks: GraphNode[], tasks: GraphNode[],
owner: GraphNode, owner: GraphNode,
ownerId: string, ownerId: string,
leadX: number | null leadX: number | null,
activityLaneBounds: readonly ActivityLaneWorldBounds[]
): KanbanZoneInfo | null { ): KanbanZoneInfo | null {
const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE; const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE;
const headerHeight = 20; // space for column header label const headerHeight = 20; // space for column header label
const ownerX = owner.x ?? 0; const ownerX = owner.x ?? 0;
const ownerY = owner.y ?? 0; const ownerY = owner.y ?? 0;
const baseY = ownerY + offsetY;
// Classify tasks into columns // Classify tasks into columns
const colTasks = KanbanLayoutEngine.#colTasks; const colTasks = KanbanLayoutEngine.#colTasks;
@ -166,6 +179,24 @@ export class KanbanLayoutEngine {
columnWidth, columnWidth,
leadX, 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 // Build headers + position tasks
const headers: KanbanColumnHeader[] = []; 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 { NODE } from '../constants/canvas-constants';
import { import {
ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_ANCHOR_LAYOUT,
getActivityAnchorTarget, resolveActivityLaneSide,
getActivityLaneBounds,
} from './activityLane'; } from './activityLane';
export interface WorldBounds { export interface WorldBounds {
@ -76,9 +75,38 @@ export const getLaunchHudBounds = getLaunchAnchorBounds;
export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT; export const HANDOFF_ANCHOR_LAYOUT = ACTIVITY_ANCHOR_LAYOUT;
export const getHandoffAnchorId = getActivityAnchorId; export const getHandoffAnchorId = getActivityAnchorId;
export const isHandoffAnchorId = isActivityAnchorId; export const isHandoffAnchorId = isActivityAnchorId;
export { getActivityAnchorTarget };
export const getHandoffAnchorTarget = getActivityAnchorTarget; export function getHandoffAnchorTarget(args: {
export const getHandoffAnchorBounds = getActivityLaneBounds; 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: { export function getLaunchAnchorScreenPlacement(args: {
anchorX: number; anchorX: number;

View file

@ -65,6 +65,13 @@ export interface GraphViewProps {
getActivityAnchorScreenPlacement: ( getActivityAnchorScreenPlacement: (
ownerNodeId: string, ownerNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null; ) => { 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: ( getNodeScreenPosition: (
nodeId: string, nodeId: string,
) => { x: number; y: number; visible: boolean } | null; ) => { x: number; y: number; visible: boolean } | null;
@ -229,6 +236,11 @@ export function GraphView({
viewportHeight: viewport.height, viewportHeight: viewport.height,
}); });
}, [getViewportSize]); }, [getViewportSize]);
const getActivityAnchorWorldPosition = useCallback(
(ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId),
[],
);
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
const getNodeScreenPosition = useCallback((nodeId: string) => { const getNodeScreenPosition = useCallback((nodeId: string) => {
const viewport = getViewportSize(); const viewport = getViewportSize();
if (viewport.width <= 0 || viewport.height <= 0) { 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, visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80,
}; };
}, [getViewportSize]); }, [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(() => { const animate = useCallback(() => {
if (!runningRef.current) return; if (!runningRef.current) return;
@ -710,6 +729,11 @@ export function GraphView({
{renderHud({ {renderHud({
getLaunchAnchorScreenPlacement, getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement, getActivityAnchorScreenPlacement,
getActivityAnchorWorldPosition,
getCameraZoom,
worldToScreen: camera.worldToScreen,
getNodeWorldPosition,
getViewportSize,
getNodeScreenPosition, getNodeScreenPosition,
focusNodeIds: focusState.focusNodeIds, focusNodeIds: focusState.focusNodeIds,
})} })}

View file

@ -33,6 +33,11 @@ interface GraphActivityHudProps {
getActivityAnchorScreenPlacement: ( getActivityAnchorScreenPlacement: (
ownerNodeId: string ownerNodeId: string
) => { x: number; y: number; scale: number; visible: boolean } | null; ) => { 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; getNodeScreenPosition?: (nodeId: string) => { x: number; y: number; visible: boolean } | null;
focusNodeIds: ReadonlySet<string> | null; focusNodeIds: ReadonlySet<string> | null;
enabled?: boolean; enabled?: boolean;
@ -50,12 +55,19 @@ export const GraphActivityHud = ({
teamName, teamName,
nodes, nodes,
getActivityAnchorScreenPlacement, getActivityAnchorScreenPlacement,
getActivityAnchorWorldPosition = () => null,
getCameraZoom = () => 1,
worldToScreen,
getNodeWorldPosition = () => null,
getViewportSize,
getNodeScreenPosition = () => null, getNodeScreenPosition = () => null,
focusNodeIds, focusNodeIds,
enabled = true, enabled = true,
onOpenTaskDetail, onOpenTaskDetail,
onOpenMemberProfile, onOpenMemberProfile,
}: GraphActivityHudProps): React.JSX.Element | null => { }: 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 shellRefs = useRef(new Map<string, HTMLDivElement | null>());
const connectorRefs = useRef(new Map<string, SVGSVGElement | null>()); const connectorRefs = useRef(new Map<string, SVGSVGElement | null>());
const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>()); const connectorPathRefs = useRef(new Map<string, SVGPathElement | null>());
@ -140,16 +152,35 @@ export const GraphActivityHud = ({
let frameId = 0; let frameId = 0;
const updatePositions = (): void => { 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) { for (const lane of visibleLanes) {
const shell = shellRefs.current.get(lane.node.id); const shell = shellRefs.current.get(lane.node.id);
if (!shell) { if (!shell) {
continue; 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 connectorPath = connectorPathRefs.current.get(lane.node.id) ?? null;
const placement = getActivityAnchorScreenPlacement(lane.node.id); 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'; shell.style.opacity = '0';
if (connector) { if (connector) {
connector.style.opacity = '0'; connector.style.opacity = '0';
@ -157,27 +188,57 @@ export const GraphActivityHud = ({
continue; 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; const baseOpacity = focusNodeIds && !focusNodeIds.has(lane.node.id) ? 0.25 : 1;
shell.style.opacity = String(baseOpacity); 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) { if (connector && connectorPath) {
const nodeScreen = getNodeScreenPosition(lane.node.id); const widthWorld = shell.offsetWidth || ACTIVITY_LANE_WIDTH;
if (!nodeScreen?.visible) { const laneCenterX = laneTopLeft.x + widthWorld / 2;
connector.style.opacity = '0'; const laneIsLeft = laneCenterX < nodeWorld.x;
continue; const endX = laneIsLeft ? laneTopLeft.x + widthWorld - 8 : laneTopLeft.x + 8;
} const endY = laneTopLeft.y + 10;
const scaledWidth = (shell.offsetWidth || 296) * placement.scale; const startX = nodeWorld.x;
const laneCenterX = placement.x + scaledWidth / 2; const startY = nodeWorld.y - 10 / scale;
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 minX = Math.min(startX, endX); const minX = Math.min(startX, endX);
const minY = Math.min(startY, endY); const minY = Math.min(startY, endY);
const width = Math.max(1, Math.abs(endX - startX)); const connectorWidth = Math.max(1, Math.abs(endX - startX));
const height = Math.max(1, Math.abs(endY - startY)); const connectorHeight = Math.max(1, Math.abs(endY - startY));
const localStartX = startX - minX; const localStartX = startX - minX;
const localStartY = startY - minY; const localStartY = startY - minY;
const localEndX = endX - minX; const localEndX = endX - minX;
@ -192,9 +253,12 @@ export const GraphActivityHud = ({
connector.style.opacity = String(baseOpacity); connector.style.opacity = String(baseOpacity);
connector.style.left = `${Math.round(minX)}px`; connector.style.left = `${Math.round(minX)}px`;
connector.style.top = `${Math.round(minY)}px`; connector.style.top = `${Math.round(minY)}px`;
connector.setAttribute('width', String(Math.ceil(width))); connector.setAttribute('width', String(Math.ceil(connectorWidth)));
connector.setAttribute('height', String(Math.ceil(height))); connector.setAttribute('height', String(Math.ceil(connectorHeight)));
connector.setAttribute('viewBox', `0 0 ${Math.ceil(width)} ${Math.ceil(height)}`); connector.setAttribute(
'viewBox',
`0 0 ${Math.ceil(connectorWidth)} ${Math.ceil(connectorHeight)}`
);
connectorPath.setAttribute( connectorPath.setAttribute(
'd', '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)}` `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, enabled,
focusNodeIds, focusNodeIds,
getActivityAnchorScreenPlacement, getActivityAnchorScreenPlacement,
getActivityAnchorWorldPosition,
getCameraZoom,
getNodeWorldPosition,
getNodeScreenPosition, getNodeScreenPosition,
getViewportSize,
worldToScreen,
visibleLanes, visibleLanes,
]); ]);
@ -269,100 +338,154 @@ export const GraphActivityHud = ({
[onOpenMemberProfile] [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) { if (!enabled || !teamData || visibleLanes.length === 0) {
return null; return null;
} }
return ( return (
<> <>
{visibleLanes.map((lane) => ( <div
<div key={lane.node.id}> ref={worldLayerRef}
<svg className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left"
ref={(element) => { >
connectorRefs.current.set(lane.node.id, element); {visibleLanes.map((lane) => (
}} <div key={lane.node.id}>
className="pointer-events-none absolute z-[9] overflow-visible opacity-0" <svg
>
<path
ref={(element) => { ref={(element) => {
connectorPathRefs.current.set(lane.node.id, element); connectorRefs.current.set(lane.node.id, element);
}} }}
d="" className="pointer-events-none absolute z-[9] overflow-visible opacity-0"
fill="none" >
stroke="rgba(148, 163, 184, 0.3)" <path
strokeWidth="1.25" ref={(element) => {
strokeLinecap="round" connectorPathRefs.current.set(lane.node.id, element);
strokeDasharray="3 4" }}
/> d=""
</svg> fill="none"
<div stroke="rgba(148, 163, 184, 0.3)"
ref={(element) => { strokeWidth="1.25"
shellRefs.current.set(lane.node.id, element); strokeLinecap="round"
}} strokeDasharray="3 4"
className="pointer-events-auto absolute z-10 w-[296px] origin-top-left opacity-0" />
> </svg>
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70"> <div
Activity ref={(element) => {
</div> shellRefs.current.set(lane.node.id, element);
<div className="space-y-2"> }}
{lane.entries.map((entry, index) => { className="pointer-events-auto absolute z-10 origin-top-left opacity-0"
const messageKey = toMessageKey(entry.message); style={{ width: `${ACTIVITY_LANE_WIDTH}px`, maxWidth: `${ACTIVITY_LANE_WIDTH}px` }}
const renderProps = resolveMessageRenderProps(entry.message, messageContext); >
const timelineItem: TimelineItem = { type: 'message', message: entry.message }; <div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
const isUnread = !entry.message.read && !readSet.has(messageKey); 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 ( return (
<div <div
key={entry.graphItem.id} key={entry.graphItem.id}
className="cursor-pointer" className="min-w-0 max-w-full cursor-pointer overflow-hidden"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => handleMessageClick(timelineItem)} onClick={() => handleMessageClick(timelineItem)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') { if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault(); event.preventDefault();
handleMessageClick(timelineItem); 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 +{lane.overflowCount} more
message={entry.message} </button>
teamName={teamName} ) : null}
compactHeader </div>
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}
</div> </div>
</div> </div>
</div> ))}
))} </div>
<MessageExpandDialog <MessageExpandDialog
expandedItem={expandedItem} expandedItem={expandedItem}

View file

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

View file

@ -145,31 +145,50 @@ export const TeamGraphTab = ({
onRequestFullscreen={() => setFullscreen(true)} onRequestFullscreen={() => setFullscreen(true)}
onOpenTeamPage={openTeamPage} onOpenTeamPage={openTeamPage}
onCreateTask={openCreateTask} onCreateTask={openCreateTask}
renderHud={({ renderHud={(hudProps) => {
getLaunchAnchorScreenPlacement, const extraHudProps = hudProps as typeof hudProps & {
getActivityAnchorScreenPlacement, getViewportSize?: () => { width: number; height: number };
getNodeScreenPosition, getActivityAnchorWorldPosition?: (
focusNodeIds, ownerNodeId: string
}) => ( ) => { x: number; y: number } | null;
<> getCameraZoom?: () => number;
<GraphActivityHud worldToScreen?: (x: number, y: number) => { x: number; y: number };
teamName={teamName} getNodeWorldPosition?: (nodeId: string) => { x: number; y: number } | null;
nodes={graphData.nodes} };
getActivityAnchorScreenPlacement={getActivityAnchorScreenPlacement} const {
getNodeScreenPosition={getNodeScreenPosition} getLaunchAnchorScreenPlacement,
focusNodeIds={focusNodeIds} getActivityAnchorScreenPlacement,
enabled={isActive} getViewportSize,
onOpenTaskDetail={dispatchOpenTask} getNodeScreenPosition,
onOpenMemberProfile={dispatchOpenProfile} focusNodeIds,
/> } = extraHudProps;
<GraphProvisioningHud
teamName={teamName} return (
leadNodeId={leadNodeId} <>
getLaunchAnchorScreenPlacement={getLaunchAnchorScreenPlacement} <GraphActivityHud
enabled={isActive} 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 }) => ( renderEdgeOverlay={({ edge, sourceNode, targetNode, onClose, onSelectNode }) => (
<GraphBlockingEdgePopover <GraphBlockingEdgePopover
teamName={teamName} teamName={teamName}

View file

@ -310,8 +310,10 @@ export const TmuxStatusBanner = (): React.JSX.Element | null => {
className="mt-1 text-xs leading-relaxed" className="mt-1 text-xs leading-relaxed"
style={{ color: 'var(--color-text-muted)' }} style={{ color: 'var(--color-text-muted)' }}
> >
Persistent team agents are more reliable on the process/tmux path. Without tmux, the You can keep using the app without tmux, but installing it is recommended for the best
app falls back to the heavier in-process path. {getPrimaryDetail(state.status)} 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> </p>
{state.status.error && ( {state.status.error && (
<p className="mt-1 text-xs" style={{ color: '#fbbf24' }}> <p className="mt-1 text-xs" style={{ color: '#fbbf24' }}>

View file

@ -6,6 +6,8 @@ import {
getActivityAnchorScreenPlacement, getActivityAnchorScreenPlacement,
getActivityAnchorTarget, getActivityAnchorTarget,
getActivityLaneBounds, getActivityLaneBounds,
packActivityLaneScreenRects,
packActivityLaneWorldRects,
getVisibleActivityWindow, getVisibleActivityWindow,
} from '../../../../packages/agent-graph/src/layout/activityLane'; } from '../../../../packages/agent-graph/src/layout/activityLane';
@ -28,7 +30,7 @@ describe('activity lane helpers', () => {
expect(window.overflowCount).toBe(3); 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 leadTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'lead' });
const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' }); const memberTarget = getActivityAnchorTarget({ nodeX: 100, nodeY: 80, nodeKind: 'member' });
const memberLeftOfLeadTarget = getActivityAnchorTarget({ const memberLeftOfLeadTarget = getActivityAnchorTarget({
@ -38,13 +40,21 @@ describe('activity lane helpers', () => {
leadX: 100, leadX: 100,
}); });
expect(leadTarget.x).toBeLessThan(100); expect(leadTarget.x).toBe(100 - ACTIVITY_LANE.width / 2);
expect(memberTarget.x).toBeGreaterThan(100); expect(memberTarget.x).toBe(100 - ACTIVITY_LANE.width / 2);
expect(memberLeftOfLeadTarget.x).toBeLessThan(80); expect(memberLeftOfLeadTarget.x).toBe(80 - ACTIVITY_LANE.width / 2);
expect(leadTarget.y).toBeLessThan(80); expect(leadTarget.y).toBeLessThan(80);
expect(memberTarget.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', () => { it('hits visible activity pills in the owner lane', () => {
const node: GraphNode = { const node: GraphNode = {
id: 'member:team:alice', id: 'member:team:alice',
@ -80,8 +90,8 @@ describe('activity lane helpers', () => {
viewportHeight: 600, viewportHeight: 600,
}); });
expect(placement.x).toBe(40 - ACTIVITY_LANE.width / 2); expect(placement.x).toBe(40);
expect(placement.y).toBe(60 - (ACTIVITY_LANE.headerHeight + ACTIVITY_LANE.maxVisibleItems * ACTIVITY_LANE.rowHeight + ACTIVITY_LANE.overflowHeight) / 2); expect(placement.y).toBe(60);
expect(placement.visible).toBe(true); expect(placement.visible).toBe(true);
}); });
@ -99,4 +109,40 @@ describe('activity lane helpers', () => {
expect(placement.x).toBeLessThan(0); expect(placement.x).toBeLessThan(0);
expect(placement.visible).toBe(true); 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, getOwnerKanbanBaseX,
} from '../../../../packages/agent-graph/src/layout/kanbanLayout'; } from '../../../../packages/agent-graph/src/layout/kanbanLayout';
import { import {
ACTIVITY_LANE,
getActivityAnchorTarget, getActivityAnchorTarget,
getActivityLaneBounds, getActivityLaneBounds,
} from '../../../../packages/agent-graph/src/layout/activityLane'; } from '../../../../packages/agent-graph/src/layout/activityLane';
@ -78,7 +79,7 @@ describe('kanban layout activity-lane avoidance', () => {
expect(baseX).toBe(-220); 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 lead = createLeadNode(0, 0);
const member = createMemberNode('member:jack', 220, 40, 'jack'); const member = createMemberNode('member:jack', 220, 40, 'jack');
const tasks = [ const tasks = [
@ -96,12 +97,12 @@ describe('kanban layout activity-lane avoidance', () => {
leadX: lead.x ?? null, leadX: lead.x ?? null,
}); });
const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); 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 lead = createLeadNode(0, 0);
const member = createMemberNode('member:alice', -220, 40, 'alice'); const member = createMemberNode('member:alice', -220, 40, 'alice');
const tasks = [ const tasks = [
@ -119,8 +120,33 @@ describe('kanban layout activity-lane avoidance', () => {
leadX: lead.x ?? null, leadX: lead.x ?? null,
}); });
const laneBounds = getActivityLaneBounds(anchor.x, anchor.y); 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);
}); });
}); });