refactor: update README and security documentation; enhance activity lane layout and kanban integration
This commit is contained in:
parent
688752b3f5
commit
90b637c6d8
13 changed files with 725 additions and 239 deletions
7
.github/SECURITY.md
vendored
7
.github/SECURITY.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue