fix(agent-graph): keep graph state consistent across panes

This commit is contained in:
777genius 2026-04-12 20:15:52 +03:00
parent 02d516cb4e
commit f74b7a3701
28 changed files with 2410 additions and 372 deletions

View file

@ -24,11 +24,12 @@ export function drawAgents(
nodes: GraphNode[], nodes: GraphNode[],
time: number, time: number,
selectedId: string | null, selectedId: string | null,
hoveredId: string | null hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | null
): void { ): void {
for (const node of nodes) { for (const node of nodes) {
if (node.kind !== 'member' && node.kind !== 'lead') continue; if (node.kind !== 'member' && node.kind !== 'lead') continue;
const opacity = getNodeOpacity(node); const opacity = getNodeOpacity(node) * getFocusOpacity(node.id, focusNodeIds);
if (opacity < MIN_VISIBLE_OPACITY) continue; if (opacity < MIN_VISIBLE_OPACITY) continue;
const x = node.x ?? 0; const x = node.x ?? 0;
@ -95,6 +96,10 @@ export function drawAgents(
drawToolCard(ctx, x, y, r, node.activeTool, time); drawToolCard(ctx, x, y, r, node.activeTool, time);
} }
if (node.exceptionTone) {
drawExceptionPip(ctx, x, y, r, node.exceptionTone);
}
// Name + role label (single line: "jack · developer") // Name + role label (single line: "jack · developer")
const labelText = node.role ? `${node.label} · ${node.role}` : node.label; const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel); drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
@ -123,7 +128,8 @@ export function drawCrossTeamNodes(
nodes: GraphNode[], nodes: GraphNode[],
time: number, time: number,
selectedId: string | null, selectedId: string | null,
hoveredId: string | null hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | null
): void { ): void {
for (const node of nodes) { for (const node of nodes) {
if (node.kind !== 'crossteam') continue; if (node.kind !== 'crossteam') continue;
@ -136,7 +142,7 @@ export function drawCrossTeamNodes(
const isHovered = node.id === hoveredId; const isHovered = node.id === hoveredId;
ctx.save(); ctx.save();
ctx.globalAlpha = isHovered ? 0.7 : 0.5; ctx.globalAlpha = (isHovered ? 0.7 : 0.5) * getFocusOpacity(node.id, focusNodeIds);
// Subtle glow // Subtle glow
const glowR = r + AGENT_DRAW.glowPadding; const glowR = r + AGENT_DRAW.glowPadding;
@ -188,6 +194,35 @@ function getNodeOpacity(node: GraphNode): number {
return 1; return 1;
} }
function getFocusOpacity(
nodeId: string,
focusNodeIds?: ReadonlySet<string> | null
): number {
return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1;
}
function drawExceptionPip(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
tone: NonNullable<GraphNode['exceptionTone']>
): void {
const pipX = x + r * 0.58;
const pipY = y - r * 0.58;
const pipColor = tone === 'error' ? '#ef4444' : '#f59e0b';
ctx.save();
ctx.beginPath();
ctx.arc(pipX, pipY, 4.5, 0, Math.PI * 2);
ctx.fillStyle = pipColor;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#050510';
ctx.stroke();
ctx.restore();
}
function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
ctx.save(); ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';

View file

@ -74,6 +74,7 @@ export function drawEdges(
nodeMap: Map<string, GraphNode>, nodeMap: Map<string, GraphNode>,
_time: number, _time: number,
hasActiveParticles: Set<string>, hasActiveParticles: Set<string>,
focusEdgeIds?: ReadonlySet<string> | null,
): void { ): void {
for (const edge of edges) { for (const edge of edges) {
const source = nodeMap.get(edge.source); const source = nodeMap.get(edge.source);
@ -87,13 +88,14 @@ export function drawEdges(
const alpha = isActive const alpha = isActive
? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6)
: BEAM.idleAlpha; : BEAM.idleAlpha;
const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1;
if (alpha < MIN_VISIBLE_OPACITY) continue; if (alpha * focusAlpha < MIN_VISIBLE_OPACITY) continue;
const cp = computeControlPoints(source.x, source.y, target.x, target.y); const cp = computeControlPoints(source.x, source.y, target.x, target.y);
ctx.save(); ctx.save();
ctx.globalAlpha = alpha; ctx.globalAlpha = alpha * focusAlpha;
// Subtle glow pass when edge has active particles // Subtle glow pass when edge has active particles
if (isActive) { if (isActive) {

View file

@ -27,8 +27,10 @@ export function drawParticles(
edgeMap: Map<string, GraphEdge>, edgeMap: Map<string, GraphEdge>,
nodeMap: Map<string, GraphNode>, nodeMap: Map<string, GraphNode>,
time: number, time: number,
focusEdgeIds?: ReadonlySet<string> | null,
): void { ): void {
for (const p of particles) { for (const p of particles) {
if (focusEdgeIds && !focusEdgeIds.has(p.edgeId)) continue;
const edge = edgeMap.get(p.edgeId); const edge = edgeMap.get(p.edgeId);
if (!edge) continue; if (!edge) continue;

View file

@ -17,6 +17,7 @@ export function drawProcesses(
time: number, time: number,
selectedId: string | null, selectedId: string | null,
hoveredId: string | null, hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | null,
): void { ): void {
for (const node of nodes) { for (const node of nodes) {
if (node.kind !== 'process') continue; if (node.kind !== 'process') continue;
@ -26,9 +27,10 @@ export function drawProcesses(
const r = NODE.radiusProcess; const r = NODE.radiusProcess;
const isSelected = node.id === selectedId; const isSelected = node.id === selectedId;
const isHovered = node.id === hoveredId; const isHovered = node.id === hoveredId;
const focusOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
ctx.save(); ctx.save();
ctx.globalAlpha = 0.8; ctx.globalAlpha = 0.8 * focusOpacity;
// Glow — use cached sprite instead of createRadialGradient per frame // Glow — use cached sprite instead of createRadialGradient per frame
const procColor = node.color ?? COLORS.tool_calling; const procColor = node.color ?? COLORS.tool_calling;

View file

@ -19,11 +19,12 @@ export function drawTasks(
time: number, time: number,
selectedId: string | null, selectedId: string | null,
hoveredId: string | null, hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | null
): void { ): void {
for (const node of nodes) { for (const node of nodes) {
if (node.kind !== 'task') continue; if (node.kind !== 'task') continue;
const opacity = getTaskOpacity(node); const opacity = getTaskOpacity(node, focusNodeIds);
if (opacity < MIN_VISIBLE_OPACITY) continue; if (opacity < MIN_VISIBLE_OPACITY) continue;
const x = node.x ?? 0; const x = node.x ?? 0;
@ -42,8 +43,12 @@ export function drawTasks(
// ─── Private ──────────────────────────────────────────────────────────────── // ─── Private ────────────────────────────────────────────────────────────────
function getTaskOpacity(_node: GraphNode): number { function getTaskOpacity(
if (_node.taskStatus === 'deleted') return 0; node: GraphNode,
focusNodeIds?: ReadonlySet<string> | null
): number {
if (node.taskStatus === 'deleted') return 0;
if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25;
return 1; return 1;
} }
@ -54,7 +59,7 @@ function drawTaskPill(
node: GraphNode, node: GraphNode,
time: number, time: number,
isSelected: boolean, isSelected: boolean,
isHovered: boolean, isHovered: boolean
): void { ): void {
const w = TASK_PILL.width; const w = TASK_PILL.width;
const h = TASK_PILL.height; const h = TASK_PILL.height;
@ -65,6 +70,15 @@ function drawTaskPill(
const statusColor = getTaskStatusColor(node.taskStatus); const statusColor = getTaskStatusColor(node.taskStatus);
const reviewColor = getReviewStateColor(node.reviewState); const reviewColor = getReviewStateColor(node.reviewState);
ctx.save();
ctx.translate(x, y);
if (node.isOverflowStack) {
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
ctx.restore();
return;
}
// Pulse only for active work — completed + approved = static // Pulse only for active work — completed + approved = static
const needsAttention = const needsAttention =
(node.taskStatus === 'in_progress' && node.reviewState !== 'approved') || (node.taskStatus === 'in_progress' && node.reviewState !== 'approved') ||
@ -72,13 +86,12 @@ function drawTaskPill(
node.reviewState === 'needsFix' || node.reviewState === 'needsFix' ||
(node.needsClarification != null); (node.needsClarification != null);
const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved'; const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved';
const breathe = needsAttention && !isFinished const breathe =
? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) needsAttention && !isFinished
: 1; ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed)
: 1;
const scale = breathe; const scale = breathe;
ctx.save();
ctx.translate(x, y);
ctx.scale(scale, scale); ctx.scale(scale, scale);
// Shadow — stronger for attention tasks, red for blocked // Shadow — stronger for attention tasks, red for blocked
@ -122,9 +135,10 @@ function drawTaskPill(
if (reviewColor !== 'transparent') { if (reviewColor !== 'transparent') {
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1); ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1);
const reviewAlpha = node.reviewState === 'approved' const reviewAlpha =
? 0.6 // static — no pulse node.reviewState === 'approved'
: 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix ? 0.6
: 0.5 + 0.3 * Math.sin(time * 3);
ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha); ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha);
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
@ -147,7 +161,10 @@ function drawTaskPill(
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary; ctx.fillStyle = COLORS.textPrimary;
const textX = -halfW + 10; const textX = -halfW + 10;
const maxW = w - 18; const hasReviewChip =
node.reviewState !== 'approved' &&
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName));
const maxW = hasReviewChip ? w - 64 : w - 18;
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font); const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
ctx.fillText(subject, textX, -4); ctx.fillText(subject, textX, -4);
} }
@ -169,6 +186,13 @@ function drawTaskPill(
ctx.fillText('\u2713', halfW - 8, 0); // ✓ ctx.fillText('\u2713', halfW - 8, 0); // ✓
} }
if (
node.reviewState !== 'approved' &&
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && node.reviewerName))
) {
drawReviewChip(ctx, halfW, -halfH, node);
}
// Comment count badge — on the bottom-right border edge, 1.5x bigger // Comment count badge — on the bottom-right border edge, 1.5x bigger
if (node.totalCommentCount && node.totalCommentCount > 0) { if (node.totalCommentCount && node.totalCommentCount > 0) {
const badgeX = halfW - 6; const badgeX = halfW - 6;
@ -215,12 +239,93 @@ function drawTaskPill(
ctx.restore(); ctx.restore();
} }
function drawOverflowStack(
ctx: CanvasRenderingContext2D,
halfW: number,
halfH: number,
r: number,
node: GraphNode,
isSelected: boolean,
isHovered: boolean
): void {
for (const [offset, alpha] of [
[6, 0.18],
[3, 0.28],
] as const) {
ctx.beginPath();
ctx.roundRect(-halfW + offset, -halfH - offset, TASK_PILL.width, TASK_PILL.height, r);
ctx.fillStyle = hexWithAlpha('#334155', alpha);
ctx.fill();
}
ctx.beginPath();
ctx.roundRect(-halfW, -halfH, TASK_PILL.width, TASK_PILL.height, r);
ctx.fillStyle = isSelected
? COLORS.cardBgSelected
: isHovered
? 'rgba(15, 20, 40, 0.78)'
: COLORS.cardBg;
ctx.fill();
ctx.strokeStyle = hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55);
ctx.lineWidth = isSelected ? 2 : 1;
ctx.stroke();
ctx.font = 'bold 12px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary;
ctx.fillText(node.label, -halfW + 12, -2);
ctx.font = '7px monospace';
ctx.fillStyle = COLORS.textDim;
ctx.fillText('more tasks', -halfW + 12, 10);
}
function drawReviewChip(
ctx: CanvasRenderingContext2D,
halfW: number,
halfH: number,
node: GraphNode
): void {
const chipText = node.reviewMode === 'manual' ? 'REV' : node.reviewerName ?? 'REV';
const chipColor = node.reviewMode === 'manual' ? '#8b5cf6' : (node.reviewerColor ?? '#38bdf8');
const chipX = halfW - 44;
const chipY = halfH + 10;
const chipW = 34;
const chipH = 12;
ctx.beginPath();
ctx.roundRect(chipX, chipY, chipW, chipH, 6);
ctx.fillStyle = hexWithAlpha(chipColor, 0.2);
ctx.fill();
ctx.strokeStyle = hexWithAlpha(chipColor, 0.55);
ctx.lineWidth = 1;
ctx.stroke();
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = hexWithAlpha(chipColor, 0.95);
ctx.fillText(
chipText.length > 8 ? `${chipText.slice(0, 7)}` : chipText,
chipX + chipW / 2,
chipY + chipH / 2 + 0.5
);
if (node.changePresence === 'has_changes') {
ctx.beginPath();
ctx.arc(chipX + chipW + 4, chipY + chipH / 2, 2.5, 0, Math.PI * 2);
ctx.fillStyle = '#38bdf8';
ctx.fill();
}
}
/** /**
* Draw kanban column headers above task columns. * Draw kanban column headers above task columns.
*/ */
export function drawColumnHeaders( export function drawColumnHeaders(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
zones: KanbanZoneInfo[], zones: KanbanZoneInfo[]
): void { ): void {
for (const zone of zones) { for (const zone of zones) {
// Section header for unassigned tasks — larger, centered above all columns // Section header for unassigned tasks — larger, centered above all columns

View file

@ -94,7 +94,7 @@ export class KanbanLayoutEngine {
// ─── Private ────────────────────────────────────────────────────────────── // ─── Private ──────────────────────────────────────────────────────────────
static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null { static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null {
const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = 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 baseY = ownerY + offsetY; const baseY = ownerY + offsetY;
@ -129,8 +129,8 @@ export class KanbanLayoutEngine {
for (const [colIdx, col] of activeColumns.entries()) { for (const [colIdx, col] of activeColumns.entries()) {
const colX = baseX + colIdx * columnWidth; const colX = baseX + colIdx * columnWidth;
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
const overflow = Math.max(0, col.tasks.length - maxVisibleRows); const overflow = col.tasks.find((task) => task.isOverflowStack)?.overflowCount ?? 0;
const visibleCount = Math.min(col.tasks.length, maxVisibleRows); const visibleCount = col.tasks.length;
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
headers.push({ headers.push({
@ -144,13 +144,6 @@ export class KanbanLayoutEngine {
// Position tasks below header // Position tasks below header
for (const [rowIdx, task] of col.tasks.entries()) { for (const [rowIdx, task] of col.tasks.entries()) {
if (rowIdx >= maxVisibleRows) {
task.x = -99999;
task.y = -99999;
task.fx = task.x;
task.fy = task.y;
continue;
}
const targetX = colX; const targetX = colX;
const targetY = baseY + headerHeight + rowIdx * rowHeight; const targetY = baseY + headerHeight + rowIdx * rowHeight;
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
@ -207,6 +200,7 @@ export class KanbanLayoutEngine {
// Add zone header for unassigned section // Add zone header for unassigned section
if (tasks.length > 0) { if (tasks.length > 0) {
const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
this.zones.push({ this.zones.push({
ownerId: '__unassigned__', ownerId: '__unassigned__',
ownerX: centerX, ownerX: centerX,
@ -216,7 +210,7 @@ export class KanbanLayoutEngine {
x: centerX, x: centerX,
y: baseY - 10, y: baseY - 10,
color: COLORS.taskPending, color: COLORS.taskPending,
overflowCount: Math.max(0, tasks.length - cols * KANBAN_ZONE.maxVisibleRows), overflowCount,
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
}], }],
}); });

View file

@ -78,6 +78,10 @@ export interface GraphNode {
resultPreview?: string; resultPreview?: string;
source: 'runtime' | 'member_log' | 'inbox'; source: 'runtime' | 'member_log' | 'inbox';
}>; }>;
/** Compact abnormal-state indicator */
exceptionTone?: 'warning' | 'error';
/** Short human-readable abnormal-state label */
exceptionLabel?: string;
// ─── Task-specific ───────────────────────────────────────────────────── // ─── Task-specific ─────────────────────────────────────────────────────
/** Short display ID (e.g., "#3") */ /** Short display ID (e.g., "#3") */
@ -90,6 +94,14 @@ export interface GraphNode {
taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted'; taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted';
/** Review state overlay */ /** Review state overlay */
reviewState?: 'none' | 'review' | 'needsFix' | 'approved'; reviewState?: 'none' | 'review' | 'needsFix' | 'approved';
/** Reviewer shown as a compact handoff chip for active review cycles */
reviewerName?: string | null;
/** Reviewer chip mode */
reviewMode?: 'assigned' | 'manual';
/** Reviewer color override for compact review chip */
reviewerColor?: string;
/** Cheap persisted change-presence state used only for active review chips */
changePresence?: 'has_changes' | 'no_changes' | 'unknown';
/** Requires clarification indicator */ /** Requires clarification indicator */
needsClarification?: 'lead' | 'user' | null; needsClarification?: 'lead' | 'user' | null;
/** Task is blocked by other tasks */ /** Task is blocked by other tasks */
@ -102,6 +114,12 @@ export interface GraphNode {
totalCommentCount?: number; totalCommentCount?: number;
/** Unread comment count on this task */ /** Unread comment count on this task */
unreadCommentCount?: number; unreadCommentCount?: number;
/** Synthetic overflow stack node instead of hidden task tails */
isOverflowStack?: boolean;
/** Number of hidden tasks behind this overflow stack */
overflowCount?: number;
/** Raw task IDs hidden behind this overflow stack */
overflowTaskIds?: string[];
// ─── Process-specific ────────────────────────────────────────────────── // ─── Process-specific ──────────────────────────────────────────────────
/** Clickable URL for process */ /** Clickable URL for process */
@ -163,5 +181,11 @@ export type GraphDomainRef =
| { kind: 'lead'; teamName: string; memberName: string } | { kind: 'lead'; teamName: string; memberName: string }
| { kind: 'member'; teamName: string; memberName: string } | { kind: 'member'; teamName: string; memberName: string }
| { kind: 'task'; teamName: string; taskId: string } | { kind: 'task'; teamName: string; taskId: string }
| {
kind: 'task_overflow';
teamName: string;
ownerMemberName?: string | null;
columnKey: string;
}
| { kind: 'process'; teamName: string; processId: string } | { kind: 'process'; teamName: string; processId: string }
| { kind: 'crossteam'; teamName: string; externalTeamName: string }; | { kind: 'crossteam'; teamName: string; externalTeamName: string };

View file

@ -30,6 +30,8 @@ export interface GraphDrawState {
camera: CameraTransform; camera: CameraTransform;
selectedNodeId: string | null; selectedNodeId: string | null;
hoveredNodeId: string | null; hoveredNodeId: string | null;
focusNodeIds: ReadonlySet<string> | null;
focusEdgeIds: ReadonlySet<string> | null;
} }
export interface GraphCanvasHandle { export interface GraphCanvasHandle {
@ -199,20 +201,48 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
visibleEdges.push(e); visibleEdges.push(e);
} }
} }
drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges); drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges, state.focusEdgeIds);
// 2b. Particles (cap at 100 for performance) // 2b. Particles (cap at 100 for performance)
const cappedParticles = state.particles.length > 100 const cappedParticles = state.particles.length > 100
? state.particles.slice(-100) ? state.particles.slice(-100)
: state.particles; : state.particles;
drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time); drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time, state.focusEdgeIds);
// 2c. Visible nodes only (back to front: process → task → member/lead) // 2c. Visible nodes only (back to front: process → task → member/lead)
drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawProcesses(
drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); ctx,
visibleNodes,
state.time,
state.selectedNodeId,
state.hoveredNodeId,
state.focusNodeIds
);
drawCrossTeamNodes(
ctx,
visibleNodes,
state.time,
state.selectedNodeId,
state.hoveredNodeId,
state.focusNodeIds
);
drawColumnHeaders(ctx, KanbanLayoutEngine.zones); drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); drawTasks(
drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); ctx,
visibleNodes,
state.time,
state.selectedNodeId,
state.hoveredNodeId,
state.focusNodeIds
);
drawAgents(
ctx,
visibleNodes,
state.time,
state.selectedNodeId,
state.hoveredNodeId,
state.focusNodeIds
);
// 2d. Effects // 2d. Effects
drawEffects(ctx, state.effects); drawEffects(ctx, state.effects);

View file

@ -10,7 +10,7 @@
* ALL animation state (positions, particles, effects, time) lives in refs. * ALL animation state (positions, particles, effects, time) lives in refs.
*/ */
import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom'; import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import type { GraphDataPort } from '../ports/GraphDataPort'; import type { GraphDataPort } from '../ports/GraphDataPort';
import type { GraphEventPort } from '../ports/GraphEventPort'; import type { GraphEventPort } from '../ports/GraphEventPort';
@ -19,6 +19,7 @@ import type { GraphNode } from '../ports/types';
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas'; import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphControls, type GraphFilterState } from './GraphControls';
import { GraphOverlay } from './GraphOverlay'; import { GraphOverlay } from './GraphOverlay';
import { buildFocusState } from './buildFocusState';
import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphSimulation } from '../hooks/useGraphSimulation';
import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphCamera } from '../hooks/useGraphCamera';
import { useGraphInteraction } from '../hooks/useGraphInteraction'; import { useGraphInteraction } from '../hooks/useGraphInteraction';
@ -114,6 +115,10 @@ export function GraphView({
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
const idleFrameSkip = useRef(0); const idleFrameSkip = useRef(0);
const focusState = useMemo(
() => buildFocusState(selectedNodeId, data.nodes, data.edges),
[selectedNodeId, data.edges, data.nodes]
);
const animate = useCallback(() => { const animate = useCallback(() => {
if (!runningRef.current) return; if (!runningRef.current) return;
@ -154,11 +159,13 @@ export function GraphView({
camera: cameraRef.current.transformRef.current, camera: cameraRef.current.transformRef.current,
selectedNodeId: selectedNodeIdRef.current, selectedNodeId: selectedNodeIdRef.current,
hoveredNodeId: interaction.hoveredNodeId.current, hoveredNodeId: interaction.hoveredNodeId.current,
focusNodeIds: focusState.focusNodeIds,
focusEdgeIds: focusState.focusEdgeIds,
}); });
rafRef.current = requestAnimationFrame(animate); rafRef.current = requestAnimationFrame(animate);
// eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs
}, []); }, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]);
// Start/stop RAF // Start/stop RAF
useEffect(() => { useEffect(() => {

View file

@ -0,0 +1,152 @@
import type { GraphEdge, GraphNode } from '../ports/types';
export interface GraphFocusState {
focusNodeIds: ReadonlySet<string> | null;
focusEdgeIds: ReadonlySet<string> | null;
}
function addNode(nodeIds: Set<string>, nodeId: string | null | undefined): void {
if (nodeId) {
nodeIds.add(nodeId);
}
}
function addNodeAndIncidentEdges(
nodeIds: Set<string>,
edgeIds: Set<string>,
nodeId: string | null | undefined,
adjacency: Map<string, GraphEdge[]>
): void {
if (!nodeId) return;
nodeIds.add(nodeId);
for (const edge of adjacency.get(nodeId) ?? []) {
edgeIds.add(edge.id);
nodeIds.add(edge.source);
nodeIds.add(edge.target);
}
}
export function buildFocusState(
selectedNodeId: string | null,
nodes: GraphNode[],
edges: GraphEdge[]
): GraphFocusState {
if (!selectedNodeId) {
return { focusNodeIds: null, focusEdgeIds: null };
}
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
if (
!selectedNode ||
selectedNode.kind === 'process' ||
selectedNode.kind === 'crossteam' ||
selectedNode.isOverflowStack
) {
return { focusNodeIds: null, focusEdgeIds: null };
}
const nodeIds = new Set<string>([selectedNodeId]);
const edgeIds = new Set<string>();
const adjacency = new Map<string, GraphEdge[]>();
for (const edge of edges) {
const sourceEdges = adjacency.get(edge.source) ?? [];
sourceEdges.push(edge);
adjacency.set(edge.source, sourceEdges);
const targetEdges = adjacency.get(edge.target) ?? [];
targetEdges.push(edge);
adjacency.set(edge.target, targetEdges);
}
const selectedMemberName =
selectedNode.domainRef.kind === 'member' || selectedNode.domainRef.kind === 'lead'
? selectedNode.domainRef.memberName
: null;
if (selectedNode.kind === 'lead') {
addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency);
} else if (selectedNode.kind === 'member') {
addNodeAndIncidentEdges(nodeIds, edgeIds, selectedNodeId, adjacency);
for (const node of nodes) {
if (node.kind !== 'task') continue;
if (node.isOverflowStack) {
if (node.ownerId === selectedNodeId) {
nodeIds.add(node.id);
for (const edge of adjacency.get(node.id) ?? []) {
edgeIds.add(edge.id);
}
}
continue;
}
const isOwnedTask = node.ownerId === selectedNodeId;
const isReviewTask =
selectedMemberName != null &&
node.reviewerName === selectedMemberName &&
node.domainRef.kind === 'task' &&
node.domainRef.taskId !== selectedNode.currentTaskId;
if (!isOwnedTask && !isReviewTask) continue;
nodeIds.add(node.id);
for (const edge of adjacency.get(node.id) ?? []) {
if (edge.type === 'ownership' || edge.type === 'blocking') {
edgeIds.add(edge.id);
nodeIds.add(edge.source);
nodeIds.add(edge.target);
}
}
}
} else if (selectedNode.kind === 'task') {
if (selectedNode.ownerId) {
addNode(nodeIds, selectedNode.ownerId);
}
if (selectedNode.reviewerName) {
const reviewerNode = nodes.find(
(node) =>
node.kind === 'member' &&
node.domainRef.kind === 'member' &&
node.domainRef.memberName === selectedNode.reviewerName
);
if (reviewerNode) {
nodeIds.add(reviewerNode.id);
}
}
for (const edge of adjacency.get(selectedNodeId) ?? []) {
if (edge.type === 'ownership' || edge.type === 'blocking') {
edgeIds.add(edge.id);
nodeIds.add(edge.source);
nodeIds.add(edge.target);
}
}
}
const focusedMemberIds = Array.from(nodeIds).filter((nodeId) => {
const node = nodes.find((candidate) => candidate.id === nodeId);
return node?.kind === 'member';
});
for (const memberId of focusedMemberIds) {
for (const edge of adjacency.get(memberId) ?? []) {
if (edge.type === 'parent-child') {
edgeIds.add(edge.id);
nodeIds.add(edge.source);
nodeIds.add(edge.target);
}
}
}
for (const edge of edges) {
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
edgeIds.add(edge.id);
}
}
return {
focusNodeIds: nodeIds,
focusEdgeIds: edgeIds,
};
}

View file

@ -1,5 +1,4 @@
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentProps } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
@ -36,8 +35,8 @@ import {
type TaskChangeRequestOptions, type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest'; } from '@renderer/utils/taskChangeRequest';
import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
AlertTriangle, AlertTriangle,
@ -73,6 +72,7 @@ import { TrashDialog } from './kanban/TrashDialog';
import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog';
import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog';
import type { ComponentProps } from 'react';
const ProjectEditorOverlay = lazy(() => const ProjectEditorOverlay = lazy(() =>
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
@ -92,13 +92,13 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
import { ClaudeLogsSection } from './ClaudeLogsSection'; import { ClaudeLogsSection } from './ClaudeLogsSection';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { ProcessesSection } from './ProcessesSection'; import { ProcessesSection } from './ProcessesSection';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { import {
isLeadSessionMissing, isLeadSessionMissing,
shouldSuppressMissingLeadSessionFetch, shouldSuppressMissingLeadSessionFetch,
} from './teamSessionFetchGuards'; } from './teamSessionFetchGuards';
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
import { TeamSessionsSection } from './TeamSessionsSection'; import { TeamSessionsSection } from './TeamSessionsSection';
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { KanbanSortState } from './kanban/KanbanSortPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover';
@ -2781,10 +2781,10 @@ export const TeamDetailView = ({
if (task) setSelectedTask(task); if (task) setSelectedTask(task);
}} }}
onOpenMemberProfile={(memberName) => { onOpenMemberProfile={(memberName) => {
setSendDialogRecipient(memberName); const member = data.members.find((m) => m.name === memberName);
setSendDialogDefaultText(undefined); if (member) {
setSendDialogDefaultChip(undefined); setSelectedMember(member);
setSendDialogOpen(true); }
}} }}
/> />
</Suspense> </Suspense>

View file

@ -4,20 +4,27 @@
* This is the ONLY file in this feature that imports from @renderer/store. * This is the ONLY file in this feature that imports from @renderer/store.
* If the project data model changes, ONLY this class needs updating. * If the project data model changes, ONLY this class needs updating.
* *
* Class-based with ES #private fields, caching, and DI-ready constructor. * Class-based with ES #private fields and DI-ready constructor.
*/ */
import { getUnreadCount } from '@renderer/services/commentReadStorage'; import { getUnreadCount } from '@renderer/services/commentReadStorage';
import { agentAvatarUrl, getMemberRuntimeAdvisoryLabel } from '@renderer/utils/memberHelpers';
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary'; import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam'; import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
import { import {
getIdleGraphLabel,
classifyIdleNotificationText, classifyIdleNotificationText,
getIdleGraphLabel,
} from '@shared/utils/idleNotificationSemantics'; } from '@shared/utils/idleNotificationSemantics';
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { collapseOverflowStacks } from '../utils/collapseOverflowStacks';
import {
isTaskBlocked,
isTaskInReviewCycle,
resolveTaskReviewer,
} from '../utils/taskGraphSemantics';
import type { import type {
GraphDataPort, GraphDataPort,
GraphEdge, GraphEdge,
@ -28,6 +35,7 @@ import type {
import type { import type {
ActiveToolCall, ActiveToolCall,
InboxMessage, InboxMessage,
LeadActivityState,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
TeamData, TeamData,
} from '@shared/types/team'; } from '@shared/types/team';
@ -36,8 +44,6 @@ import type { LeadContextUsage } from '@shared/types/team';
export class TeamGraphAdapter { export class TeamGraphAdapter {
// ─── ES #private fields ────────────────────────────────────────────────── // ─── ES #private fields ──────────────────────────────────────────────────
#lastTeamName = ''; #lastTeamName = '';
#lastDataHash = '';
#cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult('');
readonly #seenRelated = new Set<string>(); readonly #seenRelated = new Set<string>();
readonly #seenMessageIds = new Set<string>(); readonly #seenMessageIds = new Set<string>();
#initialMessagesSeen = false; #initialMessagesSeen = false;
@ -57,12 +63,12 @@ export class TeamGraphAdapter {
/** /**
* Adapt team data into a GraphDataPort snapshot. * Adapt team data into a GraphDataPort snapshot.
* Returns cached result if inputs haven't changed (referential check).
*/ */
adapt( adapt(
teamData: TeamData | null, teamData: TeamData | null,
teamName: string, teamName: string,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>, spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
leadActivity?: LeadActivityState,
leadContext?: LeadContextUsage, leadContext?: LeadContextUsage,
pendingApprovalAgents?: Set<string>, pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>, activeTools?: Record<string, Record<string, ActiveToolCall>>,
@ -74,89 +80,6 @@ export class TeamGraphAdapter {
return TeamGraphAdapter.#emptyResult(teamName); return TeamGraphAdapter.#emptyResult(teamName);
} }
// Simple hash for change detection (avoids full deep equality)
const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0);
const memberKey = teamData.members
.map(
(member) =>
`${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.providerId ?? ''}:${member.model ?? ''}:${member.effort ?? ''}:${member.removedAt ?? ''}`
)
.sort()
.join('|');
const taskKey = teamData.tasks
.map(
(task) =>
`${task.id}:${task.status}:${task.owner ?? ''}:${task.reviewState ?? ''}:${task.displayId ?? ''}:${task.subject}:${task.updatedAt ?? ''}`
)
.sort()
.join('|');
const processKey = teamData.processes
.map(
(proc) =>
`${proc.id}:${proc.label}:${proc.registeredBy ?? ''}:${proc.url ?? ''}:${proc.stoppedAt ?? ''}`
)
.sort()
.join('|');
const messageKey = teamData.messages
.slice(0, 25)
.map((msg) => TeamGraphAdapter.#getMessageParticleKey(msg))
.join('|');
const commentKey = teamData.tasks
.map((task) => {
const comments = task.comments ?? [];
const tail = comments
.slice(Math.max(0, comments.length - 5))
.map((comment) => `${comment.id}:${comment.author}:${comment.createdAt}`)
.join(',');
return `${task.id}:${comments.length}:${tail}`;
})
.sort()
.join('|');
const approvalKey = pendingApprovalAgents?.size
? Array.from(pendingApprovalAgents).sort().join(',')
: '';
const activeToolKey = activeTools
? Object.entries(activeTools)
.flatMap(([memberName, tools]) =>
Object.values(tools).map(
(tool) =>
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
)
)
.sort()
.join('|')
: '';
const finishedVisibleKey = finishedVisible
? Object.entries(finishedVisible)
.flatMap(([memberName, tools]) =>
Object.values(tools).map(
(tool) =>
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
)
)
.sort()
.join('|')
: '';
const historyKey = toolHistory
? Object.entries(toolHistory)
.map(
([memberName, tools]) =>
`${memberName}:${tools
.slice(0, 3)
.map(
(tool) =>
`${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
)
.join(',')}`
)
.sort()
.join('|')
: '';
const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}:${commentReadState ? Object.keys(commentReadState).length : 0}`;
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
return this.#cachedResult;
}
// Reset particle tracking when team changes // Reset particle tracking when team changes
if (teamName !== this.#lastTeamName) { if (teamName !== this.#lastTeamName) {
this.#seenMessageIds.clear(); this.#seenMessageIds.clear();
@ -166,7 +89,6 @@ export class TeamGraphAdapter {
} }
this.#lastTeamName = teamName; this.#lastTeamName = teamName;
this.#lastDataHash = hash;
this.#seenRelated.clear(); this.#seenRelated.clear();
const nodes: GraphNode[] = []; const nodes: GraphNode[] = [];
@ -182,6 +104,8 @@ export class TeamGraphAdapter {
teamData, teamData,
teamName, teamName,
leadName, leadName,
pendingApprovalAgents,
leadActivity,
leadContext, leadContext,
activeTools, activeTools,
finishedVisible, finishedVisible,
@ -212,7 +136,7 @@ export class TeamGraphAdapter {
); );
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
this.#cachedResult = { return {
nodes, nodes,
edges, edges,
particles, particles,
@ -220,20 +144,17 @@ export class TeamGraphAdapter {
teamColor: teamData.config.color ?? undefined, teamColor: teamData.config.color ?? undefined,
isAlive: teamData.isAlive, isAlive: teamData.isAlive,
}; };
return this.#cachedResult;
} }
// ─── Disposal ──────────────────────────────────────────────────────────── // ─── Disposal ────────────────────────────────────────────────────────────
[Symbol.dispose](): void { [Symbol.dispose](): void {
this.#cachedResult = TeamGraphAdapter.#emptyResult('');
this.#seenRelated.clear(); this.#seenRelated.clear();
this.#seenMessageIds.clear(); this.#seenMessageIds.clear();
this.#initialMessagesSeen = false; this.#initialMessagesSeen = false;
this.#seenCommentCounts.clear(); this.#seenCommentCounts.clear();
this.#initialCommentsSeen = false; this.#initialCommentsSeen = false;
this.#lastDataHash = ''; this.#lastTeamName = '';
} }
// ─── Private: node builders ────────────────────────────────────────────── // ─── Private: node builders ──────────────────────────────────────────────
@ -269,6 +190,8 @@ export class TeamGraphAdapter {
data: TeamData, data: TeamData,
teamName: string, teamName: string,
leadName: string, leadName: string,
pendingApprovalAgents?: Set<string>,
leadActivity?: LeadActivityState,
leadContext?: LeadContextUsage, leadContext?: LeadContextUsage,
activeTools?: Record<string, Record<string, ActiveToolCall>>, activeTools?: Record<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>, finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
@ -280,15 +203,28 @@ export class TeamGraphAdapter {
activeTools?.[leadName], activeTools?.[leadName],
finishedVisible?.[leadName] finishedVisible?.[leadName]
); );
const hasRunningTool = Object.keys(activeTools?.[leadName] ?? {}).length > 0;
const pendingApproval =
pendingApprovalAgents?.has(leadName) || pendingApprovalAgents?.has('lead') || false;
const leadState =
leadActivity === 'offline'
? 'terminated'
: leadActivity === 'idle'
? 'idle'
: hasRunningTool
? 'tool_calling'
: 'active';
const leadException =
leadActivity === 'offline'
? { exceptionTone: 'error' as const, exceptionLabel: 'offline' }
: pendingApproval
? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' }
: undefined;
nodes.push({ nodes.push({
id: leadId, id: leadId,
kind: 'lead', kind: 'lead',
label: data.config.name || teamName, label: data.config.name || teamName,
state: !data.isAlive state: leadState,
? 'idle'
: Object.keys(activeTools?.[leadName] ?? {}).length > 0
? 'tool_calling'
: 'active',
color: data.config.color ?? undefined, color: data.config.color ?? undefined,
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
leadMember?.providerId, leadMember?.providerId,
@ -297,6 +233,7 @@ export class TeamGraphAdapter {
), ),
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: agentAvatarUrl(leadName, 64), avatarUrl: agentAvatarUrl(leadName, 64),
pendingApproval,
activeTool: activeTool activeTool: activeTool
? { ? {
name: activeTool.toolName, name: activeTool.toolName,
@ -320,6 +257,7 @@ export class TeamGraphAdapter {
resultPreview: tool.resultPreview, resultPreview: tool.resultPreview,
source: tool.source, source: tool.source,
})), })),
...leadException,
domainRef: { kind: 'lead', teamName, memberName: leadName }, domainRef: { kind: 'lead', teamName, memberName: leadName },
}); });
} }
@ -347,6 +285,12 @@ export class TeamGraphAdapter {
finishedVisible?.[member.name] finishedVisible?.[member.name]
); );
const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0; const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0;
const exception = TeamGraphAdapter.#buildMemberException(
member.runtimeAdvisory,
member.providerId,
spawn,
pendingApprovalAgents?.has(member.name) ?? false
);
nodes.push({ nodes.push({
id: memberId, id: memberId,
@ -369,6 +313,8 @@ export class TeamGraphAdapter {
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
: undefined, : undefined,
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
exceptionTone: exception?.exceptionTone,
exceptionLabel: exception?.exceptionLabel,
activeTool: activeTool activeTool: activeTool
? { ? {
name: activeTool.toolName, name: activeTool.toolName,
@ -411,25 +357,33 @@ export class TeamGraphAdapter {
teamName: string, teamName: string,
commentReadState?: Record<string, unknown> commentReadState?: Record<string, unknown>
): void { ): void {
// Build lookup tables for fast resolution const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
const completedTaskIds = new Set<string>();
const taskDisplayIds = new Map<string, string>(); const taskDisplayIds = new Map<string, string>();
const memberColorByName = new Map<string, string>();
for (const t of data.tasks) { for (const t of data.tasks) {
if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id); taskStateById.set(t.id, { status: t.status });
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`); taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
} }
for (const member of data.members) {
if (member.color) {
memberColorByName.set(member.name, member.color);
}
}
const rawTaskNodes: GraphNode[] = [];
for (const task of data.tasks) { for (const task of data.tasks) {
if (task.status === 'deleted') continue; if (task.status === 'deleted') continue;
const taskId = `task:${teamName}:${task.id}`; const taskId = `task:${teamName}:${task.id}`;
const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null; const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null;
const kanbanTaskState = data.kanbanState.tasks[task.id];
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
const isReviewCycle = isTaskInReviewCycle(task);
// Task is blocked if any blockedBy task is still not completed const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
const isBlocked = const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState);
(task.blockedBy?.length ?? 0) > 0 &&
task.blockedBy!.some((id) => !completedTaskIds.has(id));
// Resolve display IDs for dependencies
const blockedByDisplayIds = task.blockedBy?.length const blockedByDisplayIds = task.blockedBy?.length
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
: undefined; : undefined;
@ -437,7 +391,6 @@ export class TeamGraphAdapter {
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
: undefined; : undefined;
// Comment counts
const totalCommentCount = task.comments?.length ?? 0; const totalCommentCount = task.comments?.length ?? 0;
const unreadCommentCount = commentReadState const unreadCommentCount = commentReadState
? getUnreadCount( ? getUnreadCount(
@ -448,66 +401,88 @@ export class TeamGraphAdapter {
) )
: 0; : 0;
nodes.push({ rawTaskNodes.push({
id: taskId, id: taskId,
kind: 'task', kind: 'task',
label: task.displayId ?? `#${task.id.slice(0, 6)}`, label: task.displayId ?? `#${task.id.slice(0, 6)}`,
sublabel: task.subject, sublabel: task.subject,
state: TeamGraphAdapter.#mapTaskStatus(task.status), state: TeamGraphAdapter.#mapTaskStatus(task.status),
taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status), taskStatus,
reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState), reviewState,
reviewerName: isReviewCycle ? reviewerName : null,
reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined,
reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined,
changePresence: task.changePresence,
displayId: task.displayId ?? undefined, displayId: task.displayId ?? undefined,
ownerId: ownerMemberId, ownerId: ownerMemberId,
needsClarification: task.needsClarification ?? null, needsClarification: task.needsClarification ?? null,
isBlocked, isBlocked: isTaskBlocked(task, taskStateById),
blockedByDisplayIds, blockedByDisplayIds,
blocksDisplayIds, blocksDisplayIds,
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined, totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined, unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
domainRef: { kind: 'task', teamName, taskId: task.id }, domainRef: { kind: 'task', teamName, taskId: task.id },
}); });
}
if (ownerMemberId) { const visibleTaskNodes = collapseOverflowStacks(rawTaskNodes, teamName, 6);
edges.push({ const visibleTaskIds = new Set(
id: `edge:own:${ownerMemberId}:${taskId}`, visibleTaskNodes.flatMap((taskNode) =>
source: ownerMemberId, taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
target: taskId, )
type: 'ownership', );
});
}
const seenBlockEdges = new Set<string>(); nodes.push(...visibleTaskNodes);
for (const blockedById of task.blockedBy ?? []) {
const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`; for (const taskNode of visibleTaskNodes) {
if (seenBlockEdges.has(edgeId)) continue; if (!taskNode.ownerId) continue;
seenBlockEdges.add(edgeId); edges.push({
id: `edge:own:${taskNode.ownerId}:${taskNode.id}`,
source: taskNode.ownerId,
target: taskNode.id,
type: 'ownership',
});
}
const seenBlockingEdges = new Set<string>();
for (const task of data.tasks) {
if (task.status === 'deleted' || !visibleTaskIds.has(task.id)) continue;
const taskNodeId = `task:${teamName}:${task.id}`;
for (const blockerId of task.blockedBy ?? []) {
if (!visibleTaskIds.has(blockerId)) continue;
const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, blockerId, task.id);
if (seenBlockingEdges.has(edgeId)) continue;
seenBlockingEdges.add(edgeId);
edges.push({ edges.push({
id: edgeId, id: edgeId,
source: `task:${teamName}:${blockedById}`, source: `task:${teamName}:${blockerId}`,
target: taskId, target: taskNodeId,
type: 'blocking', type: 'blocking',
}); });
} }
for (const blocksId of task.blocks ?? []) { for (const blockedId of task.blocks ?? []) {
const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`; if (!visibleTaskIds.has(blockedId)) continue;
if (seenBlockEdges.has(edgeId)) continue; const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, task.id, blockedId);
seenBlockEdges.add(edgeId); if (seenBlockingEdges.has(edgeId)) continue;
seenBlockingEdges.add(edgeId);
edges.push({ edges.push({
id: edgeId, id: edgeId,
source: taskId, source: taskNodeId,
target: `task:${teamName}:${blocksId}`, target: `task:${teamName}:${blockedId}`,
type: 'blocking', type: 'blocking',
}); });
} }
for (const relatedId of task.related ?? []) { for (const relatedId of task.related ?? []) {
if (!visibleTaskIds.has(relatedId)) continue;
const key = [task.id, relatedId].sort().join(':'); const key = [task.id, relatedId].sort().join(':');
if (this.#seenRelated.has(key)) continue; if (this.#seenRelated.has(key)) continue;
this.#seenRelated.add(key); this.#seenRelated.add(key);
edges.push({ edges.push({
id: `edge:rel:${key}`, id: `edge:rel:${key}`,
source: taskId, source: taskNodeId,
target: `task:${teamName}:${relatedId}`, target: `task:${teamName}:${relatedId}`,
type: 'related', type: 'related',
}); });
@ -751,6 +726,35 @@ export class TeamGraphAdapter {
// ─── Static mappers ────────────────────────────────────────────────────── // ─── Static mappers ──────────────────────────────────────────────────────
static #buildBlockingEdgeId(teamName: string, blockerId: string, blockedId: string): string {
return `edge:block:task:${teamName}:${blockerId}:task:${teamName}:${blockedId}`;
}
static #buildMemberException(
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
providerId: TeamData['members'][number]['providerId'],
spawn: MemberSpawnStatusEntry | undefined,
pendingApproval: boolean
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
if (spawn?.launchState === 'failed_to_start' || spawn?.status === 'error') {
return { exceptionTone: 'error', exceptionLabel: 'spawn failed' };
}
if (pendingApproval) {
return { exceptionTone: 'warning', exceptionLabel: 'awaiting approval' };
}
if (spawn?.status === 'waiting' || spawn?.status === 'spawning') {
return { exceptionTone: 'warning', exceptionLabel: 'starting' };
}
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, providerId);
if (runtimeAdvisoryLabel) {
return {
exceptionTone: 'warning',
exceptionLabel: runtimeAdvisoryLabel,
};
}
return undefined;
}
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState { static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {
if (spawnStatus === 'spawning') return 'thinking'; if (spawnStatus === 'spawning') return 'thinking';
if (spawnStatus === 'error') return 'error'; if (spawnStatus === 'error') return 'error';
@ -851,7 +855,7 @@ export class TeamGraphAdapter {
): string { ): string {
const normalized = name.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
if (normalized === 'user' || normalized === 'team-lead') return leadId; if (normalized === 'user' || normalized === 'team-lead') return leadId;
if (leadName && normalized === leadName.trim().toLowerCase()) return leadId; if (normalized === leadName?.trim().toLowerCase()) return leadId;
return `member:${teamName}:${name}`; return `member:${teamName}:${name}`;
} }

View file

@ -7,6 +7,7 @@ import { useMemo, useRef, useSyncExternalStore } from 'react';
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { TeamGraphAdapter } from './TeamGraphAdapter'; import { TeamGraphAdapter } from './TeamGraphAdapter';
@ -19,6 +20,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
const { const {
teamData, teamData,
spawnStatuses, spawnStatuses,
leadActivity,
leadContext, leadContext,
pendingApprovals, pendingApprovals,
activeTools, activeTools,
@ -26,8 +28,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
toolHistory, toolHistory,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
teamData: s.selectedTeamData, teamData: selectTeamDataForName(s, teamName),
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
pendingApprovals: s.pendingApprovals, pendingApprovals: s.pendingApprovals,
activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined,
@ -39,10 +42,12 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
const pendingApprovalAgents = useMemo(() => { const pendingApprovalAgents = useMemo(() => {
const agents = new Set<string>(); const agents = new Set<string>();
for (const a of pendingApprovals) { for (const a of pendingApprovals) {
if (a.source !== 'lead') agents.add(a.source); if (a.teamName === teamName) {
agents.add(a.source);
}
} }
return agents; return agents;
}, [pendingApprovals]); }, [pendingApprovals, teamName]);
const commentReadState = useSyncExternalStore(subscribe, getSnapshot); const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
@ -52,6 +57,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
teamData, teamData,
teamName, teamName,
spawnStatuses, spawnStatuses,
leadActivity,
leadContext, leadContext,
pendingApprovalAgents, pendingApprovalAgents,
activeTools, activeTools,
@ -63,6 +69,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
teamData, teamData,
teamName, teamName,
spawnStatuses, spawnStatuses,
leadActivity,
leadContext, leadContext,
pendingApprovalAgents, pendingApprovalAgents,
activeTools, activeTools,

View file

@ -6,12 +6,16 @@
import { Badge } from '@renderer/components/ui/badge'; import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
import type { GraphNode } from '@claude-teams/agent-graph';
import { GraphTaskCard } from './GraphTaskCard'; import { GraphTaskCard } from './GraphTaskCard';
import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics';
import type { GraphNode } from '@claude-teams/agent-graph';
import type { TeamTaskWithKanban } from '@shared/types';
// ─── Tool name/preview formatters ─────────────────────────────────────────── // ─── Tool name/preview formatters ───────────────────────────────────────────
@ -37,7 +41,7 @@ function formatToolPreview(preview: string | undefined): string | undefined {
); );
} catch { } catch {
// Truncated JSON — extract first quoted value // Truncated JSON — extract first quoted value
const match = preview.match(/"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/); const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
if (match) return match[1]; if (match) return match[1];
} }
} }
@ -100,6 +104,16 @@ export const GraphNodePopover = ({
} }
if (node.kind === 'task') { if (node.kind === 'task') {
if (node.isOverflowStack || node.domainRef.kind === 'task_overflow') {
return (
<OverflowPopoverContent
node={node}
teamName={teamName}
onClose={onClose}
onOpenTaskDetail={onOpenTaskDetail}
/>
);
}
return ( return (
<GraphTaskCard <GraphTaskCard
node={node} node={node}
@ -151,6 +165,18 @@ export const GraphNodePopover = ({
{node.processRegisteredAt && ( {node.processRegisteredAt && (
<div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div> <div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div>
)} )}
{node.exceptionLabel && (
<Badge
variant="outline"
className={`px-1.5 py-0 text-[10px] ${
node.exceptionTone === 'error'
? 'border-red-500/30 text-red-400'
: 'border-amber-500/30 text-amber-400'
}`}
>
{node.exceptionLabel}
</Badge>
)}
</div> </div>
{node.processUrl && ( {node.processUrl && (
<a <a
@ -166,6 +192,74 @@ export const GraphNodePopover = ({
); );
}; };
const OverflowPopoverContent = ({
node,
teamName,
onClose,
onOpenTaskDetail,
}: {
node: GraphNode;
teamName: string;
onClose: () => void;
onOpenTaskDetail?: (taskId: string) => void;
}): React.JSX.Element => {
const teamData = useStore((state) => selectTeamDataForName(state, teamName));
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
const hiddenTasks = (node.overflowTaskIds ?? [])
.map((taskId) => tasksById.get(taskId) ?? null)
.filter((task): task is TeamTaskWithKanban => task != null);
return (
<div className="min-w-[240px] max-w-[320px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-[var(--color-text)]">Hidden tasks</div>
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{node.overflowCount ?? hiddenTasks.length}
</Badge>
</div>
<div className="mt-2 max-h-[260px] space-y-1 overflow-y-auto pr-1">
{hiddenTasks.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)]">No hidden tasks available.</div>
) : (
hiddenTasks.map((task) => {
const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]);
return (
<button
key={task.id}
type="button"
className="flex w-full items-start justify-between gap-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] px-2 py-2 text-left transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => {
onOpenTaskDetail?.(task.id);
onClose();
}}
>
<div className="min-w-0">
<div className="font-mono text-[10px] text-[var(--color-text-muted)]">
{task.displayId ?? `#${task.id.slice(0, 6)}`}
</div>
<div className="truncate text-xs text-[var(--color-text)]">{task.subject}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{task.owner && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{task.owner}
</Badge>
)}
{isTaskInReviewCycle(task) && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{reviewer ?? 'REV'}
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</div>
);
};
// ─── Member Popover ───────────────────────────────────────────────────────── // ─── Member Popover ─────────────────────────────────────────────────────────
const MemberPopoverContent = ({ const MemberPopoverContent = ({
@ -261,6 +355,18 @@ const MemberPopoverContent = ({
{getSpawnStatusBadgeLabel(node.spawnStatus)} {getSpawnStatusBadgeLabel(node.spawnStatus)}
</Badge> </Badge>
)} )}
{node.exceptionLabel && (
<Badge
variant="outline"
className={`px-1.5 py-0 text-[10px] ${
node.exceptionTone === 'error'
? 'border-red-500/30 text-red-400'
: 'border-amber-500/30 text-amber-400'
}`}
>
{node.exceptionLabel}
</Badge>
)}
</div> </div>
{/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */} {/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */}

View file

@ -7,8 +7,11 @@ import { useMemo } from 'react';
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { isTaskBlocked, resolveTaskGraphColumn } from '../utils/taskGraphSemantics';
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
@ -32,16 +35,12 @@ interface GraphTaskCardProps {
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
function resolveColumn(task: TeamTask): KanbanColumnId { function resolveColumn(task: TeamTask): KanbanColumnId {
if (task.reviewState === 'approved') return 'approved'; return resolveTaskGraphColumn(task);
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'completed') return 'done';
return 'todo';
} }
function getGlowStyle(task: TeamTask): React.CSSProperties { function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap<string, TeamTask>): React.CSSProperties {
const col = resolveColumn(task); const col = resolveColumn(task);
const blocked = (task.blockedBy?.length ?? 0) > 0; const blocked = isTaskBlocked(task, taskMap);
if (blocked) { if (blocked) {
return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' }; return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' };
} }
@ -87,9 +86,9 @@ export const GraphTaskCard = ({
const { task, tasks, members } = useStore( const { task, tasks, members } = useStore(
useShallow((s) => ({ useShallow((s) => ({
task: s.selectedTeamData?.tasks.find((t) => t.id === taskId), tasks: selectTeamDataForName(s, teamName)?.tasks ?? [],
tasks: s.selectedTeamData?.tasks ?? [], members: selectTeamDataForName(s, teamName)?.members ?? [],
members: s.selectedTeamData?.members ?? [], task: selectTeamDataForName(s, teamName)?.tasks.find((t) => t.id === taskId),
})) }))
); );
@ -118,7 +117,7 @@ export const GraphTaskCard = ({
} }
const columnId = resolveColumn(task); const columnId = resolveColumn(task);
const taskWithKanban = task as TeamTaskWithKanban; const taskWithKanban = task;
const closeAct = (fn?: (id: string) => void) => (taskId: string) => { const closeAct = (fn?: (id: string) => void) => (taskId: string) => {
fn?.(taskId); fn?.(taskId);
@ -128,7 +127,7 @@ export const GraphTaskCard = ({
return ( return (
<div <div
className={`min-w-[260px] max-w-[320px] rounded-lg shadow-2xl ${getPulseClass(task)}`} className={`min-w-[260px] max-w-[320px] rounded-lg shadow-2xl ${getPulseClass(task)}`}
style={getGlowStyle(task)} style={getGlowStyle(task, taskMap)}
> >
<KanbanTaskCard <KanbanTaskCard
task={taskWithKanban} task={taskWithKanban}

View file

@ -0,0 +1,81 @@
import type { GraphNode } from '@claude-teams/agent-graph';
function resolveOverflowColumnKey(task: GraphNode): string {
if (task.reviewState === 'approved') return 'approved';
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
if (task.taskStatus === 'completed') return 'done';
if (task.taskStatus === 'in_progress') return 'wip';
return 'todo';
}
function extractOwnerMemberName(task: GraphNode, teamName: string): string | null {
if (!task.ownerId) return null;
const prefix = `member:${teamName}:`;
return task.ownerId.startsWith(prefix) ? task.ownerId.slice(prefix.length) : null;
}
export function collapseOverflowStacks(
taskNodes: GraphNode[],
teamName: string,
maxVisibleRows: number
): GraphNode[] {
if (maxVisibleRows <= 1) {
return taskNodes;
}
const grouped = new Map<string, GraphNode[]>();
const groupOrder: string[] = [];
for (const task of taskNodes) {
const groupKey = `${task.ownerId ?? '__unassigned__'}:${resolveOverflowColumnKey(task)}`;
const current = grouped.get(groupKey);
if (current) {
current.push(task);
} else {
grouped.set(groupKey, [task]);
groupOrder.push(groupKey);
}
}
const visibleTasks: GraphNode[] = [];
for (const groupKey of groupOrder) {
const groupTasks = grouped.get(groupKey) ?? [];
if (groupTasks.length <= maxVisibleRows) {
visibleTasks.push(...groupTasks);
continue;
}
const keptTasks = groupTasks.slice(0, maxVisibleRows - 1);
const hiddenTasks = groupTasks.slice(maxVisibleRows - 1);
const representative = hiddenTasks[0] ?? groupTasks[groupTasks.length - 1];
const columnKey = resolveOverflowColumnKey(representative);
const ownerMemberName = extractOwnerMemberName(representative, teamName);
visibleTasks.push(...keptTasks);
visibleTasks.push({
id: `task:${teamName}:overflow:${groupKey}`,
kind: 'task',
label: `+${hiddenTasks.length}`,
state: 'waiting',
displayId: `+${hiddenTasks.length}`,
sublabel: `${hiddenTasks.length} more tasks`,
ownerId: representative.ownerId ?? null,
taskStatus: representative.taskStatus,
reviewState: representative.reviewState,
isOverflowStack: true,
overflowCount: hiddenTasks.length,
overflowTaskIds: hiddenTasks.flatMap((task) =>
task.domainRef.kind === 'task' ? [task.domainRef.taskId] : []
),
domainRef: {
kind: 'task_overflow',
teamName,
ownerMemberName,
columnKey,
},
});
}
return visibleTasks;
}

View file

@ -0,0 +1,48 @@
import type { KanbanTaskState, KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
type TaskColumnInput = Pick<TeamTaskWithKanban, 'status' | 'reviewState' | 'kanbanColumn'>;
type TaskReviewerInput = Pick<TeamTaskWithKanban, 'reviewer' | 'reviewState' | 'kanbanColumn'>;
type TaskBlockInput = Pick<TeamTask, 'blockedBy'>;
type TaskBlockState = Pick<TeamTask, 'status'>;
export function resolveTaskGraphColumn(task: TaskColumnInput): KanbanColumnId {
if (task.reviewState === 'approved') return 'approved';
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
return task.kanbanColumn;
}
if (task.status === 'in_progress') return 'in_progress';
if (task.status === 'completed') return 'done';
return 'todo';
}
export function isTaskInReviewCycle(task: TaskColumnInput): boolean {
return (
task.reviewState === 'review' ||
task.reviewState === 'needsFix' ||
task.kanbanColumn === 'review'
);
}
export function resolveTaskReviewer(
task: TaskReviewerInput,
kanbanTaskState?: Pick<KanbanTaskState, 'reviewer'>
): string | null {
const reviewer = task.reviewer?.trim() || kanbanTaskState?.reviewer?.trim() || '';
return reviewer.length > 0 ? reviewer : null;
}
export function isTaskBlocked(
task: TaskBlockInput,
taskStateById: ReadonlyMap<string, TaskBlockState>
): boolean {
const blockedBy = task.blockedBy?.filter((taskId) => taskId.length > 0) ?? [];
if (blockedBy.length === 0) {
return false;
}
return blockedBy.some((taskId) => {
const blocker = taskStateById.get(taskId);
return !blocker || (blocker.status !== 'completed' && blocker.status !== 'deleted');
});
}

View file

@ -37,6 +37,7 @@ import {
createTeamSlice, createTeamSlice,
getLastResolvedTeamDataRefreshAt, getLastResolvedTeamDataRefreshAt,
isTeamDataRefreshPending, isTeamDataRefreshPending,
selectTeamDataForName,
} from './slices/teamSlice'; } from './slices/teamSlice';
import { createUISlice } from './slices/uiSlice'; import { createUISlice } from './slices/uiSlice';
import { createUpdateSlice } from './slices/updateSlice'; import { createUpdateSlice } from './slices/updateSlice';
@ -397,13 +398,8 @@ export function initializeNotificationListeners(): () => void {
} }
const state = useStore.getState(); const state = useStore.getState();
const selectedTeamName = state.selectedTeamName; const visibleTeamNames = Array.from(getVisibleTeamNamesInAnyPane(state));
const selectedTeamData = state.selectedTeamData; if (visibleTeamNames.length === 0) {
if (
!selectedTeamName ||
selectedTeamData?.teamName !== selectedTeamName ||
!isTeamVisibleInAnyPane(selectedTeamName)
) {
return; return;
} }
@ -417,44 +413,58 @@ export function initializeNotificationListeners(): () => void {
} }
} }
const candidateTasks = selectedTeamData.tasks.filter((task) => {
if (task.status !== 'in_progress') {
return false;
}
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
});
if (candidateTasks.length === 0) {
inProgressChangePresenceCursorByTeam.delete(selectedTeamName);
return;
}
inProgressChangePresencePollInFlight = true; inProgressChangePresencePollInFlight = true;
try { try {
const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0; for (const teamName of visibleTeamNames) {
const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); const teamData = selectTeamDataForName(state, teamName);
const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; if (teamData?.teamName !== teamName) {
const nextTask = sourceTasks[cursor % sourceTasks.length]; if (!isTeamDataRefreshPending(teamName)) {
void state.refreshTeamData(teamName, { withDedup: true });
}
continue;
}
inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length); const candidateTasks = teamData.tasks.filter((task) => {
if (task.status !== 'in_progress') {
return false;
}
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
});
if (candidateTasks.length === 0) {
inProgressChangePresenceCursorByTeam.delete(teamName);
continue;
}
const current = useStore.getState(); const cursor = inProgressChangePresenceCursorByTeam.get(teamName) ?? 0;
if ( const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown');
current.selectedTeamName !== selectedTeamName || const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks;
current.selectedTeamData?.teamName !== selectedTeamName || const nextTask = sourceTasks[cursor % sourceTasks.length];
!isTeamVisibleInAnyPane(selectedTeamName)
) { inProgressChangePresenceCursorByTeam.set(teamName, (cursor + 1) % sourceTasks.length);
return;
const current = useStore.getState();
if (!isTeamVisibleInAnyPane(teamName)) {
continue;
}
const currentTeamData = selectTeamDataForName(current, teamName);
if (currentTeamData?.teamName !== teamName) {
if (!isTeamDataRefreshPending(teamName)) {
void current.refreshTeamData(teamName, { withDedup: true });
}
continue;
}
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
if (currentTask?.status !== 'in_progress') {
continue;
}
const requestOptions = buildTaskChangeRequestOptions(currentTask);
const cacheKey = buildTaskChangePresenceKey(teamName, currentTask.id, requestOptions);
current.invalidateTaskChangePresence([cacheKey]);
await current.checkTaskHasChanges(teamName, currentTask.id, requestOptions);
} }
const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id);
if (currentTask?.status !== 'in_progress') {
return;
}
const requestOptions = buildTaskChangeRequestOptions(currentTask);
const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions);
current.invalidateTaskChangePresence([cacheKey]);
await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions);
} catch { } catch {
// Best-effort polling for in-progress tasks only. // Best-effort polling for in-progress tasks only.
} finally { } finally {
@ -557,41 +567,41 @@ export function initializeNotificationListeners(): () => void {
); );
}; };
const isTeamVisibleInAnyPane = (teamName: string): boolean => { const getVisibleTeamNamesInAnyPane = (state = useStore.getState()): Set<string> => {
const { paneLayout } = useStore.getState(); const { paneLayout } = state;
return paneLayout.panes.some((pane) => { const visibleTeamNames = new Set<string>();
if (!pane.activeTabId) return false;
return pane.tabs.some(
(tab) => tab.id === pane.activeTabId && tab.type === 'team' && tab.teamName === teamName
);
});
};
const getTrackedChangePresenceTeams = (): Set<string> => {
const { selectedTeamName, selectedTeamData } = useStore.getState();
if (
!selectedTeamName ||
selectedTeamData?.teamName !== selectedTeamName ||
!isTeamVisibleInAnyPane(selectedTeamName)
) {
return new Set<string>();
}
return new Set([selectedTeamName]);
};
const getTrackedToolActivityTeams = (): Set<string> => {
const { paneLayout } = useStore.getState();
const tracked = new Set<string>();
for (const pane of paneLayout.panes) { for (const pane of paneLayout.panes) {
if (!pane.activeTabId) continue; if (!pane.activeTabId) continue;
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId); const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
if (activeTab?.type === 'team' && activeTab.teamName) { if (
tracked.add(activeTab.teamName); (activeTab?.type === 'team' || activeTab?.type === 'graph') &&
activeTab.teamName != null
) {
visibleTeamNames.add(activeTab.teamName);
}
}
return visibleTeamNames;
};
const isTeamVisibleInAnyPane = (teamName: string): boolean => {
return getVisibleTeamNamesInAnyPane().has(teamName);
};
const getTrackedChangePresenceTeams = (): Set<string> => {
const state = useStore.getState();
const tracked = new Set<string>();
for (const teamName of getVisibleTeamNamesInAnyPane(state)) {
if (selectTeamDataForName(state, teamName)) {
tracked.add(teamName);
} }
} }
return tracked; return tracked;
}; };
const getTrackedToolActivityTeams = (): Set<string> => {
return getVisibleTeamNamesInAnyPane();
};
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => { const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
teamLastRelevantActivityAt.set(teamName, timestamp); teamLastRelevantActivityAt.set(teamName, timestamp);
}; };
@ -606,15 +616,11 @@ export function initializeNotificationListeners(): () => void {
} }
const activeTab = focusedPane.tabs.find((tab) => tab.id === focusedPane.activeTabId); const activeTab = focusedPane.tabs.find((tab) => tab.id === focusedPane.activeTabId);
if (activeTab?.type !== 'team' || !activeTab.teamName) { if ((activeTab?.type !== 'team' && activeTab?.type !== 'graph') || !activeTab.teamName) {
return null; return null;
} }
if (state.selectedTeamName !== activeTab.teamName) { if (!selectTeamDataForName(state, activeTab.teamName)) {
return null;
}
if (state.selectedTeamData?.teamName !== activeTab.teamName) {
return null; return null;
} }
@ -632,7 +638,7 @@ export function initializeNotificationListeners(): () => void {
return; return;
} }
if (current.selectedTeamLoading) { if (current.selectedTeamName === teamName && current.selectedTeamLoading) {
return; return;
} }
@ -695,7 +701,8 @@ export function initializeNotificationListeners(): () => void {
if ( if (
state.paneLayout === prevState.paneLayout && state.paneLayout === prevState.paneLayout &&
state.selectedTeamName === prevState.selectedTeamName && state.selectedTeamName === prevState.selectedTeamName &&
state.selectedTeamData === prevState.selectedTeamData state.selectedTeamData === prevState.selectedTeamData &&
state.teamDataCacheByName === prevState.teamDataCacheByName
) { ) {
return; return;
} }
@ -917,6 +924,17 @@ export function initializeNotificationListeners(): () => void {
}, },
}; };
const cachedTeamData = prev.teamDataCacheByName[event.teamName];
if (cachedTeamData) {
nextState.teamDataCacheByName = {
...prev.teamDataCacheByName,
[event.teamName]: {
...cachedTeamData,
isAlive: nextActivity !== 'offline',
},
};
}
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive, // Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive,
// which isn't refreshed for lead-activity events. // which isn't refreshed for lead-activity events.
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) { if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
@ -1140,7 +1158,7 @@ export function initializeNotificationListeners(): () => void {
const timer = setTimeout(() => { const timer = setTimeout(() => {
teamPresenceRefreshTimers.delete(event.teamName); teamPresenceRefreshTimers.delete(event.teamName);
const current = useStore.getState(); const current = useStore.getState();
void current.refreshSelectedTeamChangePresence(event.teamName); void current.refreshTeamChangePresence(event.teamName);
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
teamPresenceRefreshTimers.set(event.teamName, timer); teamPresenceRefreshTimers.set(event.teamName, timer);
return; return;

View file

@ -40,9 +40,9 @@ const teamRefreshBurstDiagnostics = new Map<
{ windowStartedAt: number; count: number; lastWarnAt: number } { windowStartedAt: number; count: number; lastWarnAt: number }
>(); >();
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>(); const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
type RefreshTeamDataOptions = { interface RefreshTeamDataOptions {
withDedup?: boolean; withDedup?: boolean;
}; }
export function isTeamDataRefreshPending(teamName: string): boolean { export function isTeamDataRefreshPending(teamName: string): boolean {
return ( return (
@ -56,6 +56,16 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und
return lastResolvedTeamDataRefreshAtByTeam.get(teamName); return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
} }
export function __resetTeamSliceModuleStateForTests(): void {
inFlightTeamDataRequests.clear();
inFlightRefreshTeamDataCalls.clear();
pendingFreshTeamDataRefreshes.clear();
lastResolvedTeamDataRefreshAtByTeam.clear();
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
teamRefreshBurstDiagnostics.clear();
memberSpawnUiEqualLastWarnAtByTeam.clear();
}
function nowIso(): string { function nowIso(): string {
return new Date().toISOString(); return new Date().toISOString();
} }
@ -487,9 +497,9 @@ import type {
KanbanColumnId, KanbanColumnId,
LeadActivityState, LeadActivityState,
LeadContextUsage, LeadContextUsage,
PersistedTeamLaunchSummary,
MemberSpawnStatusesSnapshot,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
PersistedTeamLaunchSummary,
SendMessageRequest, SendMessageRequest,
SendMessageResult, SendMessageResult,
TaskChangePresenceState, TaskChangePresenceState,
@ -852,11 +862,7 @@ function preserveKnownTaskChangePresence(
} }
const previousTask = prevTaskById.get(task.id); const previousTask = prevTaskById.get(task.id);
if ( if (!previousTask?.changePresence || previousTask.changePresence === 'unknown') {
!previousTask ||
!previousTask.changePresence ||
previousTask.changePresence === 'unknown'
) {
return task; return task;
} }
@ -915,6 +921,46 @@ export interface TeamLaunchParams {
limitContext?: boolean; limitContext?: boolean;
} }
export function selectTeamDataForName(
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>,
teamName: string | null | undefined
): TeamData | null {
if (!teamName) {
return null;
}
return (
state.teamDataCacheByName[teamName] ??
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
);
}
function isVisibleInActiveTeamSurface(
state: Pick<AppState, 'paneLayout'>,
teamName: string | null | undefined
): boolean {
if (!teamName) {
return false;
}
return state.paneLayout.panes.some((pane) => {
if (!pane.activeTabId) {
return false;
}
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
return (
(activeTab?.type === 'team' || activeTab?.type === 'graph') && activeTab.teamName === teamName
);
});
}
function shouldInvalidateCachedTeamDataForError(teamName: string, message: string): boolean {
return (
message === 'TEAM_DRAFT' ||
message.includes('TEAM_DRAFT') ||
message === `Team not found: ${teamName}` ||
message === 'Team config not found'
);
}
export interface TeamSlice { export interface TeamSlice {
teams: TeamSummary[]; teams: TeamSummary[];
/** O(1) lookup to avoid array scans in render-hot paths */ /** O(1) lookup to avoid array scans in render-hot paths */
@ -947,6 +993,8 @@ export interface TeamSlice {
) => void; ) => void;
selectedTeamName: string | null; selectedTeamName: string | null;
selectedTeamData: TeamData | null; selectedTeamData: TeamData | null;
/** Team-scoped detailed cache used by multi-pane views like agent graph. */
teamDataCacheByName: Record<string, TeamData>;
selectedTeamLoading: boolean; selectedTeamLoading: boolean;
selectedTeamLoadNonce: number; selectedTeamLoadNonce: number;
selectedTeamError: string | null; selectedTeamError: string | null;
@ -994,7 +1042,7 @@ export interface TeamSlice {
taskId: string, taskId: string,
presence: TaskChangePresenceState presence: TaskChangePresenceState
) => void; ) => void;
refreshSelectedTeamChangePresence: (teamName: string) => Promise<void>; refreshTeamChangePresence: (teamName: string) => Promise<void>;
selectTeam: ( selectTeam: (
teamName: string, teamName: string,
opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean }
@ -1239,6 +1287,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
globalTasksError: null, globalTasksError: null,
selectedTeamName: null, selectedTeamName: null,
selectedTeamData: null, selectedTeamData: null,
teamDataCacheByName: {},
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamLoadNonce: 0, selectedTeamLoadNonce: 0,
selectedTeamError: null, selectedTeamError: null,
@ -1660,20 +1709,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => { setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => {
set((state) => { set((state) => {
let selectedChanged = false; const currentTeamData = selectTeamDataForName(state, teamName);
const nextSelectedTeamData = let cacheChanged = false;
state.selectedTeamName === teamName && state.selectedTeamData const nextTeamData = currentTeamData
? { ? {
...state.selectedTeamData, ...currentTeamData,
tasks: state.selectedTeamData.tasks.map((task) => { tasks: currentTeamData.tasks.map((task) => {
if (task.id !== taskId || task.changePresence === presence) { if (task.id !== taskId || task.changePresence === presence) {
return task; return task;
} }
selectedChanged = true; cacheChanged = true;
return { ...task, changePresence: presence }; return { ...task, changePresence: presence };
}), }),
} }
: state.selectedTeamData; : null;
let globalChanged = false; let globalChanged = false;
const nextGlobalTasks = state.globalTasks.map((task) => { const nextGlobalTasks = state.globalTasks.map((task) => {
@ -1684,20 +1733,30 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return { ...task, changePresence: presence }; return { ...task, changePresence: presence };
}); });
if (!selectedChanged && !globalChanged) { if (!cacheChanged && !globalChanged) {
return {}; return {};
} }
return { return {
...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}), ...(cacheChanged && nextTeamData
? {
teamDataCacheByName: {
...state.teamDataCacheByName,
[teamName]: nextTeamData,
},
}
: {}),
...(cacheChanged && state.selectedTeamName === teamName && nextTeamData
? { selectedTeamData: nextTeamData }
: {}),
...(globalChanged ? { globalTasks: nextGlobalTasks } : {}), ...(globalChanged ? { globalTasks: nextGlobalTasks } : {}),
}; };
}); });
}, },
refreshSelectedTeamChangePresence: async (teamName: string) => { refreshTeamChangePresence: async (teamName: string) => {
const selected = get().selectedTeamData; const currentTeamData = selectTeamDataForName(get(), teamName);
if (get().selectedTeamName !== teamName || !selected) { if (!currentTeamData) {
return; return;
} }
@ -1706,17 +1765,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
api.teams.getTaskChangePresence(teamName) api.teams.getTaskChangePresence(teamName)
); );
if (get().selectedTeamName !== teamName || !get().selectedTeamData) {
return;
}
set((state) => { set((state) => {
if (state.selectedTeamName !== teamName || !state.selectedTeamData) { const teamData = selectTeamDataForName(state, teamName);
if (!teamData) {
return {}; return {};
} }
let changed = false; let changed = false;
const nextTasks = state.selectedTeamData.tasks.map((task) => { const nextTasks = teamData.tasks.map((task) => {
const nextPresence = presenceByTaskId[task.id] ?? 'unknown'; const nextPresence = presenceByTaskId[task.id] ?? 'unknown';
if (task.changePresence === nextPresence) { if (task.changePresence === nextPresence) {
return task; return task;
@ -1729,11 +1785,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return {}; return {};
} }
const nextTeamData = {
...teamData,
tasks: nextTasks,
};
return { return {
selectedTeamData: { teamDataCacheByName: {
...state.selectedTeamData, ...state.teamDataCacheByName,
tasks: nextTasks, [teamName]: nextTeamData,
}, },
...(state.selectedTeamName === teamName ? { selectedTeamData: nextTeamData } : {}),
}; };
}); });
} catch { } catch {
@ -1754,8 +1816,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
return; return;
} }
const requestNonce = get().selectedTeamLoadNonce + 1; const requestNonce = get().selectedTeamLoadNonce + 1;
const previousSelectedTeamName = get().selectedTeamName; const previousData = selectTeamDataForName(get(), teamName);
const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null;
// Stale-while-revalidate: keep previous data visible while loading new team. // Stale-while-revalidate: keep previous data visible while loading new team.
// Skeleton only shows on first load (when data is null). // Skeleton only shows on first load (when data is null).
@ -1797,18 +1858,23 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
set({ teamByName: { ...prevByName, [teamName]: patched } }); set({ teamByName: { ...prevByName, [teamName]: patched } });
} }
const nextTeamData = previousData
? {
...data,
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
}
: data;
const setStartedAt = performance.now(); const setStartedAt = performance.now();
set({ set((state) => ({
selectedTeamName: teamName, selectedTeamName: teamName,
selectedTeamData: previousData selectedTeamData: nextTeamData,
? { teamDataCacheByName: {
...data, ...state.teamDataCacheByName,
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), [teamName]: nextTeamData,
} },
: data,
selectedTeamLoading: false, selectedTeamLoading: false,
selectedTeamError: null, selectedTeamError: null,
}); }));
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
const setMs = performance.now() - setStartedAt; const setMs = performance.now() - setStartedAt;
const postStartedAt = performance.now(); const postStartedAt = performance.now();
@ -1925,10 +1991,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
const startedAt = performance.now(); const startedAt = performance.now();
const state = get();
if (state.selectedTeamName !== teamName) {
return;
}
inFlightRefreshTeamDataCalls.add(teamName); inFlightRefreshTeamDataCalls.add(teamName);
// Silent refresh — update data without showing loading skeleton. // Silent refresh — update data without showing loading skeleton.
// Only selectTeam() sets loading: true (for initial load). // Only selectTeam() sets loading: true (for initial load).
@ -1942,25 +2004,30 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
); );
} }
try { try {
const previousData = get().selectedTeamData; const previousData = selectTeamDataForName(get(), teamName);
const data = opts?.withDedup const data = opts?.withDedup
? await fetchTeamDataDeduped(teamName) ? await fetchTeamDataDeduped(teamName)
: await fetchTeamDataFresh(teamName); : await fetchTeamDataFresh(teamName);
const ipcMs = performance.now() - startedAt; const ipcMs = performance.now() - startedAt;
// Re-check after async: the user might have navigated away. const nextTeamData = previousData
if (get().selectedTeamName !== teamName) { ? {
return; ...data,
} tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
}
: data;
const setStartedAt = performance.now(); const setStartedAt = performance.now();
set({ set((state) => ({
selectedTeamData: previousData teamDataCacheByName: {
...state.teamDataCacheByName,
[teamName]: nextTeamData,
},
...(state.selectedTeamName === teamName
? { ? {
...data, selectedTeamData: nextTeamData,
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), selectedTeamError: null,
} }
: data, : {}),
selectedTeamError: null, }));
});
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
const setMs = performance.now() - setStartedAt; const setMs = performance.now() - setStartedAt;
const postStartedAt = performance.now(); const postStartedAt = performance.now();
@ -1988,9 +2055,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
burstCount, burstCount,
}); });
} catch (error) { } catch (error) {
if (get().selectedTeamName !== teamName) {
return;
}
const msg = const msg =
error instanceof IpcError error instanceof IpcError
? error.message ? error.message
@ -2002,19 +2066,42 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
// Preserve existing data instead of showing a fatal error. // Preserve existing data instead of showing a fatal error.
if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) { if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) {
logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`); logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`);
set({ selectedTeamError: null }); if (get().selectedTeamName === teamName) {
set({ selectedTeamError: null });
}
return; return;
} }
if (msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT')) { if (shouldInvalidateCachedTeamDataForError(teamName, msg)) {
set({ set((state) => {
selectedTeamLoading: false, const nextCache = state.teamDataCacheByName[teamName]
selectedTeamData: null, ? { ...state.teamDataCacheByName }
selectedTeamError: 'TEAM_DRAFT', : null;
if (nextCache) {
delete nextCache[teamName];
}
if (state.selectedTeamName !== teamName && !nextCache) {
return {};
}
return {
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
...(state.selectedTeamName === teamName
? {
selectedTeamLoading: false,
selectedTeamData: null,
selectedTeamError:
msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT') ? 'TEAM_DRAFT' : msg,
}
: {}),
};
}); });
return; return;
} }
if (get().selectedTeamName !== teamName) {
return;
}
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`); logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
// Non-destructive: if we already have data, keep it visible. // Non-destructive: if we already have data, keep it visible.
@ -2089,10 +2176,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
sendingMessage: false, sendingMessage: false,
sendMessageError: null, sendMessageError: null,
lastSendMessageResult: result, lastSendMessageResult: result,
selectedTeamData: ...(selectTeamDataForName(state, teamName)
state.selectedTeamName === teamName && state.selectedTeamData ? {
? upsertLocalSentMessage(state.selectedTeamData, optimisticMessage) teamDataCacheByName: {
: state.selectedTeamData, ...state.teamDataCacheByName,
[teamName]: upsertLocalSentMessage(
selectTeamDataForName(state, teamName)!,
optimisticMessage
),
},
}
: {}),
...(state.selectedTeamName === teamName && state.selectedTeamData
? {
selectedTeamData: upsertLocalSentMessage(state.selectedTeamData, optimisticMessage),
}
: {}),
})); }));
await get().refreshTeamData(teamName); await get().refreshTeamData(teamName);
} catch (error) { } catch (error) {
@ -2303,12 +2402,40 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deleteTeam: async (teamName: string) => { deleteTeam: async (teamName: string) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
set((state) => {
const nextCache = state.teamDataCacheByName[teamName]
? { ...state.teamDataCacheByName }
: null;
if (nextCache) {
delete nextCache[teamName];
}
if (state.selectedTeamName === teamName) {
return {
selectedTeamName: null,
selectedTeamData: null,
selectedTeamLoading: false,
selectedTeamError: null,
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
};
}
return nextCache ? { teamDataCacheByName: nextCache } : {};
});
await get().fetchTeams(); await get().fetchTeams();
await get().fetchAllTasks(); await get().fetchAllTasks();
}, },
restoreTeam: async (teamName: string) => { restoreTeam: async (teamName: string) => {
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName)); await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
set((state) => {
if (!state.teamDataCacheByName[teamName]) {
return {};
}
const nextCache = { ...state.teamDataCacheByName };
delete nextCache[teamName];
return {
teamDataCacheByName: nextCache,
};
});
await get().fetchTeams(); await get().fetchTeams();
await get().fetchAllTasks(); await get().fetchAllTasks();
}, },
@ -2316,8 +2443,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
permanentlyDeleteTeam: async (teamName: string) => { permanentlyDeleteTeam: async (teamName: string) => {
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
const state = get(); const state = get();
const nextCache = { ...state.teamDataCacheByName };
delete nextCache[teamName];
if (state.selectedTeamName === teamName) { if (state.selectedTeamName === teamName) {
set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null }); set({
selectedTeamName: null,
selectedTeamData: null,
selectedTeamError: null,
teamDataCacheByName: nextCache,
});
} else if (state.teamDataCacheByName[teamName]) {
set({
teamDataCacheByName: nextCache,
});
} }
await get().fetchTeams(); await get().fetchTeams();
await get().fetchAllTasks(); await get().fetchAllTasks();
@ -2872,11 +3010,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
const isCanonicalRun = const isCanonicalRun =
get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId; get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId;
let hydratedVisibleTeam = false;
if (isCanonicalRun && becameConfigReady) { if (isCanonicalRun && becameConfigReady) {
const state = get(); const state = get();
if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) { if (isVisibleInActiveTeamSurface(state, progress.teamName)) {
void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true }); if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) {
void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true });
} else {
void state.refreshTeamData(progress.teamName, { withDedup: true });
}
hydratedVisibleTeam = true;
} }
} }
@ -2916,10 +3060,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) { if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) {
void get().fetchTeams(); void get().fetchTeams();
if (hydratedVisibleTeam) {
return;
}
const state = get();
if (!isVisibleInActiveTeamSurface(state, progress.teamName)) {
return;
}
// If the user already opened the team tab, reload team data now that // If the user already opened the team tab, reload team data now that
// config.json is guaranteed to exist. // config.json is guaranteed to exist.
if (get().selectedTeamName === progress.teamName) { if (state.selectedTeamName === progress.teamName) {
void get().selectTeam(progress.teamName); void state.selectTeam(progress.teamName);
} else {
void state.refreshTeamData(progress.teamName, { withDedup: true });
} }
} }
}, },

View file

@ -347,7 +347,7 @@ export function getMemberRuntimeAdvisoryLabel(
providerId?: TeamProviderId, providerId?: TeamProviderId,
nowMs = Date.now() nowMs = Date.now()
): string | null { ): string | null {
if (!advisory || advisory.kind !== 'sdk_retrying') { if (advisory?.kind !== 'sdk_retrying') {
return null; return null;
} }
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId); const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
@ -366,7 +366,7 @@ export function getMemberRuntimeAdvisoryTitle(
advisory: MemberRuntimeAdvisory | undefined, advisory: MemberRuntimeAdvisory | undefined,
providerId?: TeamProviderId providerId?: TeamProviderId
): string | undefined { ): string | undefined {
if (!advisory || advisory.kind !== 'sdk_retrying') { if (advisory?.kind !== 'sdk_retrying') {
return undefined; return undefined;
} }
return formatRuntimeAdvisoryTitle(advisory, providerId); return formatRuntimeAdvisoryTitle(advisory, providerId);

View file

@ -1,9 +1,9 @@
import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince';
import { import {
getTaskChangeStateBucket, getTaskChangeStateBucket,
isTaskChangeSummaryCacheable, isTaskChangeSummaryCacheable,
type TaskChangeStateBucket, type TaskChangeStateBucket,
} from '@shared/utils/taskChangeState'; } from '@shared/utils/taskChangeState';
import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince';
import type { ReviewAPI } from '@shared/types/api'; import type { ReviewAPI } from '@shared/types/api';
import type { TeamTaskWithKanban } from '@shared/types/team'; import type { TeamTaskWithKanban } from '@shared/types/team';

View file

@ -1,3 +1,5 @@
import type { EnhancedChunk } from '@main/types';
export interface TeamMember { export interface TeamMember {
name: string; name: string;
agentId?: string; agentId?: string;
@ -152,6 +154,169 @@ export interface TaskRef {
teamName: string; teamName: string;
} }
export type BoardTaskRefKind = 'canonical' | 'display' | 'unknown';
export type BoardTaskResolution = 'resolved' | 'deleted' | 'unresolved' | 'ambiguous';
export type BoardTaskActivityLinkKind = 'execution' | 'lifecycle' | 'board_action';
export type BoardTaskActivityTargetRole = 'subject' | 'related';
export type BoardTaskActivityPhase = 'work' | 'review';
export type BoardTaskActorRelation = 'same_task' | 'other_active_task' | 'idle' | 'ambiguous';
export type BoardTaskActivityStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
export type BoardTaskActivityRelationship = 'blocked-by' | 'blocks' | 'related';
export type BoardTaskActivityCategory =
| 'status'
| 'review'
| 'comment'
| 'assignment'
| 'read'
| 'attachment'
| 'relationship'
| 'clarification'
| 'other';
export type BoardTaskRelationshipPerspective = 'outgoing' | 'incoming' | 'symmetric';
export interface BoardTaskLocator {
ref: string;
refKind: BoardTaskRefKind;
canonicalId?: string;
}
export interface BoardTaskActivityTaskRef {
locator: BoardTaskLocator;
resolution: BoardTaskResolution;
taskRef?: TaskRef;
}
export interface BoardTaskActivityActor {
memberName?: string;
role: 'member' | 'lead' | 'unknown';
sessionId: string;
agentId?: string;
isSidechain: boolean;
}
export interface BoardTaskActivityAction {
canonicalToolName?: string;
toolUseId?: string;
category: BoardTaskActivityCategory;
peerTask?: BoardTaskActivityTaskRef;
relationshipPerspective?: BoardTaskRelationshipPerspective;
details?: {
status?: BoardTaskActivityStatus;
owner?: string | null;
clarification?: 'lead' | 'user' | null;
reviewer?: string;
relationship?: BoardTaskActivityRelationship;
commentId?: string;
attachmentId?: string;
filename?: string;
};
}
export interface BoardTaskActivityActorContext {
relation: BoardTaskActorRelation;
activeTask?: BoardTaskActivityTaskRef;
activePhase?: BoardTaskActivityPhase;
activeExecutionSeq?: number;
}
export interface BoardTaskActivityEntry {
id: string;
timestamp: string;
task: BoardTaskActivityTaskRef;
linkKind: BoardTaskActivityLinkKind;
targetRole: BoardTaskActivityTargetRole;
actor: BoardTaskActivityActor;
actorContext: BoardTaskActivityActorContext;
action?: BoardTaskActivityAction;
source: {
messageUuid: string;
filePath: string;
toolUseId?: string;
sourceOrder: number;
};
}
export interface BoardTaskExactLogActor {
memberName?: string;
role: 'member' | 'lead' | 'unknown';
sessionId: string;
agentId?: string;
isSidechain: boolean;
}
export interface BoardTaskExactLogSource {
filePath: string;
messageUuid: string;
toolUseId?: string;
sourceOrder: number;
}
interface BoardTaskExactLogSummaryBase {
id: string;
timestamp: string;
actor: BoardTaskExactLogActor;
source: BoardTaskExactLogSource;
anchorKind: 'tool' | 'message';
actionLabel: string;
actionCategory?: BoardTaskActivityCategory;
canonicalToolName?: string;
linkKinds: BoardTaskActivityLinkKind[];
}
export type BoardTaskExactLogSummary =
| (BoardTaskExactLogSummaryBase & {
canLoadDetail: true;
sourceGeneration: string;
})
| (BoardTaskExactLogSummaryBase & {
canLoadDetail: false;
});
export interface BoardTaskExactLogDetail {
id: string;
chunks: EnhancedChunk[];
}
export interface BoardTaskExactLogSummariesResponse {
items: BoardTaskExactLogSummary[];
}
export type BoardTaskExactLogDetailResult =
| { status: 'ok'; detail: BoardTaskExactLogDetail }
| { status: 'stale' }
| { status: 'missing' };
export interface BoardTaskLogActor {
memberName?: string;
role: 'member' | 'lead' | 'unknown';
sessionId: string;
agentId?: string;
isSidechain: boolean;
}
export interface BoardTaskLogParticipant {
key: string;
label: string;
role: 'member' | 'lead' | 'unknown';
isLead: boolean;
isSidechain: boolean;
}
export interface BoardTaskLogSegment {
id: string;
participantKey: string;
actor: BoardTaskLogActor;
startTimestamp: string;
endTimestamp: string;
chunks: EnhancedChunk[];
}
export interface BoardTaskLogStreamResponse {
participants: BoardTaskLogParticipant[];
defaultFilter: 'all' | string;
segments: BoardTaskLogSegment[];
}
export interface TaskComment { export interface TaskComment {
id: string; id: string;
author: string; author: string;

View file

@ -1,6 +1,7 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '@renderer/store';
vi.mock('@renderer/components/ui/badge', () => ({ vi.mock('@renderer/components/ui/badge', () => ({
Badge: ({ children }: { children: React.ReactNode }) => Badge: ({ children }: { children: React.ReactNode }) =>
@ -38,9 +39,34 @@ function makeMemberNode(spawnStatus: GraphNode['spawnStatus']): GraphNode {
} as GraphNode; } as GraphNode;
} }
function makeOverflowNode(): GraphNode {
return {
id: 'task:northstar-core:overflow:alice:review',
kind: 'task',
label: '+2',
state: 'waiting',
taskStatus: 'in_progress',
reviewState: 'review',
isOverflowStack: true,
overflowCount: 2,
overflowTaskIds: ['task-1', 'task-2'],
domainRef: {
kind: 'task_overflow',
teamName: 'northstar-core',
ownerMemberName: 'alice',
columnKey: 'review',
},
};
}
describe('GraphNodePopover spawn badge labels', () => { describe('GraphNodePopover spawn badge labels', () => {
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
useStore.setState({
selectedTeamName: null,
selectedTeamData: null,
teamDataCacheByName: {},
} as never);
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@ -80,4 +106,156 @@ describe('GraphNodePopover spawn badge labels', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('shows compact exception badge for member abnormal states', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(GraphNodePopover, {
node: {
...makeMemberNode('error'),
exceptionTone: 'error',
exceptionLabel: 'spawn failed',
},
teamName: 'northstar-core',
onClose: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('spawn failed');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders overflow stack contents instead of the task card and opens task detail from the list', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
useStore.setState({
selectedTeamName: 'northstar-core',
selectedTeamData: {
teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Tighten rollout checklist',
owner: 'alice',
reviewer: 'bob',
status: 'in_progress',
reviewState: 'review',
kanbanColumn: 'review',
},
{
id: 'task-2',
displayId: '#2',
subject: 'Patch release notes',
owner: 'alice',
status: 'pending',
reviewState: 'none',
},
],
members: [],
messages: [],
kanbanState: {
teamName: 'northstar-core',
reviewers: [],
tasks: {
'task-1': {
column: 'review',
reviewer: 'bob',
movedAt: '2026-04-12T18:00:00.000Z',
},
},
},
processes: [],
},
teamDataCacheByName: {
'northstar-core': {
teamName: 'northstar-core',
config: { name: 'Northstar', members: [], projectPath: '/repo' },
tasks: [
{
id: 'task-1',
displayId: '#1',
subject: 'Tighten rollout checklist',
owner: 'alice',
reviewer: 'bob',
status: 'in_progress',
reviewState: 'review',
kanbanColumn: 'review',
},
{
id: 'task-2',
displayId: '#2',
subject: 'Patch release notes',
owner: 'alice',
status: 'pending',
reviewState: 'none',
},
],
members: [],
messages: [],
kanbanState: {
teamName: 'northstar-core',
reviewers: [],
tasks: {
'task-1': {
column: 'review',
reviewer: 'bob',
movedAt: '2026-04-12T18:00:00.000Z',
},
},
},
processes: [],
},
},
} as never);
const onOpenTaskDetail = vi.fn();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(GraphNodePopover, {
node: makeOverflowNode(),
teamName: 'northstar-core',
onClose: vi.fn(),
onOpenTaskDetail,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Hidden tasks');
expect(host.textContent).toContain('Tighten rollout checklist');
expect(host.textContent).toContain('Patch release notes');
expect(host.textContent).toContain('bob');
expect(host.textContent).not.toContain('task-card');
const taskButtons = host.querySelectorAll('button');
expect(taskButtons.length).toBeGreaterThan(0);
await act(async () => {
taskButtons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onOpenTaskDetail).toHaveBeenCalledWith('task-1');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter'; import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter';
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
import type { GraphDataPort } from '@claude-teams/agent-graph';
function createBaseTeamData( function createBaseTeamData(
overrides?: Partial<TeamData> & { overrides?: Partial<TeamData> & {
@ -53,6 +54,10 @@ function createBaseTeamData(
}; };
} }
function findNode(graph: GraphDataPort, nodeId: string) {
return graph.nodes.find((node) => node.id === nodeId);
}
describe('TeamGraphAdapter particles', () => { describe('TeamGraphAdapter particles', () => {
it('creates a message particle for a new incoming message from the newest message set', () => { it('creates a message particle for a new incoming message from the newest message set', () => {
const adapter = TeamGraphAdapter.create(); const adapter = TeamGraphAdapter.create();
@ -502,6 +507,215 @@ describe('TeamGraphAdapter particles', () => {
expect(alice?.state).toBe('idle'); expect(alice?.state).toBe('idle');
}); });
it('refreshes lead state and exception metadata when lead activity changes without team-data changes', () => {
const adapter = TeamGraphAdapter.create();
const teamData = createBaseTeamData();
adapter.adapt(teamData, 'my-team', undefined, 'active');
const graph = adapter.adapt(
teamData,
'my-team',
undefined,
'offline',
undefined,
new Set(['team-lead'])
);
expect(findNode(graph, 'lead:my-team')).toMatchObject({
state: 'terminated',
pendingApproval: true,
exceptionTone: 'error',
exceptionLabel: 'offline',
});
});
it('treats literal lead approval sources as lead-node pending approvals', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData(),
'my-team',
undefined,
'active',
undefined,
new Set(['lead'])
);
expect(findNode(graph, 'lead:my-team')).toMatchObject({
pendingApproval: true,
exceptionTone: 'warning',
exceptionLabel: 'awaiting approval',
});
});
it('refreshes member exception state when spawn status changes without team-data changes', () => {
const adapter = TeamGraphAdapter.create();
const teamData = createBaseTeamData();
adapter.adapt(teamData, 'my-team');
const graph = adapter.adapt(teamData, 'my-team', {
alice: {
status: 'waiting',
launchState: 'starting',
updatedAt: '2026-04-08T20:00:00.000Z',
},
});
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
state: 'waiting',
spawnStatus: 'waiting',
exceptionTone: 'warning',
exceptionLabel: 'starting',
});
});
it('refreshes unread comment badges when comment read state changes without task changes', () => {
const adapter = TeamGraphAdapter.create();
const teamData = createBaseTeamData({
tasks: [
{
id: 'task-comments',
displayId: '#8',
subject: 'Review unread badge',
owner: 'alice',
status: 'in_progress',
comments: [
{
id: 'comment-1',
author: 'alice',
text: 'Need a quick read receipt here',
createdAt: '2026-03-28T19:00:02.000Z',
type: 'regular',
},
],
reviewState: 'none',
} as TeamTaskWithKanban,
],
});
const unreadGraph = adapter.adapt(
teamData,
'my-team',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{}
);
const readGraph = adapter.adapt(
teamData,
'my-team',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{
'my-team/task-comments': {
readIds: ['comment-1'],
lastUpdated: Date.now(),
},
}
);
expect(findNode(unreadGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBe(1);
expect(findNode(readGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBeUndefined();
});
it('dedupes symmetric blocking links and ignores completed blockers for blocked state', () => {
const adapter = TeamGraphAdapter.create();
const inProgressGraph = adapter.adapt(
createBaseTeamData({
tasks: [
{
id: 'task-a',
displayId: '#1',
subject: 'Blocker',
owner: 'alice',
status: 'in_progress',
blocks: ['task-b'],
reviewState: 'none',
} as TeamTaskWithKanban,
{
id: 'task-b',
displayId: '#2',
subject: 'Blocked task',
owner: 'bob',
status: 'pending',
blockedBy: ['task-a'],
reviewState: 'none',
} as TeamTaskWithKanban,
],
}),
'my-team'
);
const completedGraph = adapter.adapt(
createBaseTeamData({
tasks: [
{
id: 'task-a',
displayId: '#1',
subject: 'Blocker',
owner: 'alice',
status: 'completed',
blocks: ['task-b'],
reviewState: 'none',
} as TeamTaskWithKanban,
{
id: 'task-b',
displayId: '#2',
subject: 'Blocked task',
owner: 'bob',
status: 'pending',
blockedBy: ['task-a'],
reviewState: 'none',
} as TeamTaskWithKanban,
],
}),
'my-team'
);
expect(inProgressGraph.edges.filter((edge) => edge.type === 'blocking')).toHaveLength(1);
expect(findNode(inProgressGraph, 'task:my-team:task-b')?.isBlocked).toBe(true);
expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false);
});
it('adds compact review handoff metadata for active review tasks', () => {
const adapter = TeamGraphAdapter.create();
const graph = adapter.adapt(
createBaseTeamData({
tasks: [
{
id: 'task-review',
displayId: '#5',
subject: 'Review this change',
owner: 'alice',
reviewer: 'bob',
status: 'in_progress',
reviewState: 'review',
changePresence: 'has_changes',
kanbanColumn: 'review',
} as TeamTaskWithKanban,
],
}),
'my-team'
);
expect(findNode(graph, 'task:my-team:task-review')).toMatchObject({
reviewerName: 'bob',
reviewMode: 'assigned',
changePresence: 'has_changes',
reviewState: 'review',
});
});
it('adds compact runtime labels for lead and members and refreshes when runtime changes', () => { it('adds compact runtime labels for lead and members and refreshes when runtime changes', () => {
const adapter = TeamGraphAdapter.create(); const adapter = TeamGraphAdapter.create();
adapter.adapt(createBaseTeamData(), 'my-team'); adapter.adapt(createBaseTeamData(), 'my-team');

View file

@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { buildFocusState } from '../../../../packages/agent-graph/src/ui/buildFocusState';
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
const leadNode: GraphNode = {
id: 'lead:my-team',
kind: 'lead',
label: 'My Team',
state: 'active',
domainRef: { kind: 'lead', teamName: 'my-team', memberName: 'team-lead' },
};
const aliceNode: GraphNode = {
id: 'member:my-team:alice',
kind: 'member',
label: 'alice',
state: 'active',
currentTaskId: 'task-current',
domainRef: { kind: 'member', teamName: 'my-team', memberName: 'alice' },
};
const bobNode: GraphNode = {
id: 'member:my-team:bob',
kind: 'member',
label: 'bob',
state: 'idle',
currentTaskId: 'task-current',
domainRef: { kind: 'member', teamName: 'my-team', memberName: 'bob' },
};
const blockerNode: GraphNode = {
id: 'task:my-team:blocker',
kind: 'task',
label: '#1',
state: 'active',
ownerId: 'member:my-team:alice',
taskStatus: 'in_progress',
reviewState: 'none',
sublabel: 'Blocker',
domainRef: { kind: 'task', teamName: 'my-team', taskId: 'blocker' },
};
const reviewTaskNode: GraphNode = {
id: 'task:my-team:review',
kind: 'task',
label: '#2',
state: 'active',
ownerId: 'member:my-team:alice',
taskStatus: 'in_progress',
reviewState: 'review',
reviewerName: 'bob',
reviewMode: 'assigned',
sublabel: 'Review task',
domainRef: { kind: 'task', teamName: 'my-team', taskId: 'review' },
};
const overflowNode: GraphNode = {
id: 'task:my-team:overflow:alice:review',
kind: 'task',
label: '+3',
state: 'waiting',
ownerId: 'member:my-team:alice',
taskStatus: 'in_progress',
reviewState: 'review',
isOverflowStack: true,
overflowCount: 3,
overflowTaskIds: ['hidden-1', 'hidden-2', 'hidden-3'],
domainRef: {
kind: 'task_overflow',
teamName: 'my-team',
ownerMemberName: 'alice',
columnKey: 'review',
},
};
const edges: GraphEdge[] = [
{
id: 'edge:parent:lead:alice',
source: leadNode.id,
target: aliceNode.id,
type: 'parent-child',
},
{
id: 'edge:parent:lead:bob',
source: leadNode.id,
target: bobNode.id,
type: 'parent-child',
},
{
id: 'edge:own:alice:blocker',
source: aliceNode.id,
target: blockerNode.id,
type: 'ownership',
},
{
id: 'edge:own:alice:review',
source: aliceNode.id,
target: reviewTaskNode.id,
type: 'ownership',
},
{
id: 'edge:own:alice:overflow',
source: aliceNode.id,
target: overflowNode.id,
type: 'ownership',
},
{
id: 'edge:block:blocker:review',
source: blockerNode.id,
target: reviewTaskNode.id,
type: 'blocking',
},
];
const nodes = [leadNode, aliceNode, bobNode, blockerNode, reviewTaskNode, overflowNode];
describe('buildFocusState', () => {
it('focuses task selection on its owner, reviewer, direct blockers, and connecting edges', () => {
const focus = buildFocusState(reviewTaskNode.id, nodes, edges);
expect(Array.from(focus.focusNodeIds ?? []).sort()).toEqual(
[
leadNode.id,
aliceNode.id,
bobNode.id,
blockerNode.id,
reviewTaskNode.id,
].sort()
);
expect(focus.focusEdgeIds).toEqual(
new Set([
'edge:parent:lead:alice',
'edge:parent:lead:bob',
'edge:own:alice:blocker',
'edge:own:alice:review',
'edge:block:blocker:review',
])
);
});
it('includes review-assigned tasks and owned overflow stacks when focusing a member', () => {
const focus = buildFocusState(bobNode.id, nodes, edges);
expect(focus.focusNodeIds?.has(bobNode.id)).toBe(true);
expect(focus.focusNodeIds?.has(reviewTaskNode.id)).toBe(true);
expect(focus.focusNodeIds?.has(aliceNode.id)).toBe(true);
expect(focus.focusEdgeIds?.has('edge:parent:lead:bob')).toBe(true);
expect(focus.focusEdgeIds?.has('edge:own:alice:review')).toBe(true);
const aliceFocus = buildFocusState(aliceNode.id, nodes, edges);
expect(aliceFocus.focusNodeIds?.has(overflowNode.id)).toBe(true);
});
it('focuses a lead on direct neighbors only', () => {
const focus = buildFocusState(leadNode.id, nodes, edges);
expect(focus.focusNodeIds).toEqual(
new Set([leadNode.id, aliceNode.id, bobNode.id])
);
expect(focus.focusEdgeIds).toEqual(
new Set(['edge:parent:lead:alice', 'edge:parent:lead:bob'])
);
});
it('does not enable global dimming for overflow stack selections', () => {
const focus = buildFocusState(overflowNode.id, nodes, edges);
expect(focus.focusNodeIds).toBeNull();
expect(focus.focusEdgeIds).toBeNull();
});
});

View file

@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { collapseOverflowStacks } from '@renderer/features/agent-graph/utils/collapseOverflowStacks';
import type { GraphNode } from '@claude-teams/agent-graph';
function makeTaskNode(taskId: string, ownerName: string | null = 'alice'): GraphNode {
return {
id: `task:my-team:${taskId}`,
kind: 'task',
label: `#${taskId}`,
displayId: `#${taskId}`,
sublabel: `Task ${taskId}`,
state: 'waiting',
taskStatus: 'pending',
reviewState: 'none',
ownerId: ownerName ? `member:my-team:${ownerName}` : null,
domainRef: { kind: 'task', teamName: 'my-team', taskId },
};
}
describe('collapseOverflowStacks', () => {
it('keeps all tasks visible when the column fits within the max row count', () => {
const nodes = Array.from({ length: 6 }, (_, index) => makeTaskNode(`task-${index + 1}`));
const result = collapseOverflowStacks(nodes, 'my-team', 6);
expect(result).toHaveLength(6);
expect(result.every((node) => !node.isOverflowStack)).toBe(true);
});
it('replaces the hidden tail with a single overflow stack node while preserving visible order', () => {
const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`));
const result = collapseOverflowStacks(nodes, 'my-team', 6);
expect(result).toHaveLength(6);
expect(result.slice(0, 5).map((node) => node.domainRef.kind === 'task' && node.domainRef.taskId)).toEqual([
'task-1',
'task-2',
'task-3',
'task-4',
'task-5',
]);
expect(result[5]).toMatchObject({
isOverflowStack: true,
overflowCount: 2,
overflowTaskIds: ['task-6', 'task-7'],
domainRef: {
kind: 'task_overflow',
teamName: 'my-team',
ownerMemberName: 'alice',
columnKey: 'todo',
},
});
});
it('applies the same stack rules to unassigned task columns', () => {
const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`, null));
const result = collapseOverflowStacks(nodes, 'my-team', 6);
const stack = result.find((node) => node.isOverflowStack);
expect(stack).toMatchObject({
overflowCount: 2,
overflowTaskIds: ['task-6', 'task-7'],
ownerId: null,
domainRef: {
kind: 'task_overflow',
teamName: 'my-team',
ownerMemberName: null,
columnKey: 'todo',
},
});
});
});

View file

@ -71,14 +71,15 @@ describe('team change throttling', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const fetchTeams = vi.fn(async () => undefined); const fetchTeams = vi.fn(async () => undefined);
const refreshTeamData = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined);
const refreshSelectedTeamChangePresence = vi.fn(async () => undefined); const refreshTeamChangePresence = vi.fn(async () => undefined);
useStore.setState({ useStore.setState({
fetchTeams, fetchTeams,
refreshTeamData, refreshTeamData,
refreshSelectedTeamChangePresence, refreshTeamChangePresence,
selectedTeamName: null, selectedTeamName: null,
selectedTeamData: null, selectedTeamData: null,
teamDataCacheByName: {},
paneLayout: { paneLayout: {
focusedPaneId: 'p1', focusedPaneId: 'p1',
panes: [ panes: [
@ -165,6 +166,39 @@ describe('team change throttling', () => {
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
}); });
it('lead-message refreshes visible graph tabs even when the team is not selected', async () => {
useStore.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
activeTabId: 'g1',
},
],
},
} as never);
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(800);
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
});
it('lead-message does not call fetchAllTasks', async () => { it('lead-message does not call fetchAllTasks', async () => {
const fetchAllTasksSpy = vi.fn(async () => undefined); const fetchAllTasksSpy = vi.fn(async () => undefined);
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
@ -192,23 +226,64 @@ describe('team change throttling', () => {
const state = useStore.getState(); const state = useStore.getState();
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
const refreshSelectedTeamChangePresenceSpy = vi.spyOn( const refreshTeamChangePresenceSpy = vi.spyOn(state, 'refreshTeamChangePresence');
state,
'refreshSelectedTeamChangePresence'
);
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(399); await vi.advanceTimersByTimeAsync(399);
expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled(); expect(refreshTeamChangePresenceSpy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1); await vi.advanceTimersByTimeAsync(1);
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1); expect(refreshTeamChangePresenceSpy).toHaveBeenCalledTimes(1);
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
expect(refreshTeamDataSpy).not.toHaveBeenCalled(); expect(refreshTeamDataSpy).not.toHaveBeenCalled();
expect(fetchTeamsSpy).not.toHaveBeenCalled(); expect(fetchTeamsSpy).not.toHaveBeenCalled();
}); });
it('log-source-change refreshes visible graph tab change presence for non-selected teams', async () => {
useStore.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
activeTabId: 'g1',
},
],
},
} as never);
const refreshTeamChangePresenceSpy = vi.spyOn(useStore.getState(), 'refreshTeamChangePresence');
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
await vi.advanceTimersByTimeAsync(400);
expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
});
it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => { it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => {
const invalidateTaskChangePresence = vi.fn(); const invalidateTaskChangePresence = vi.fn();
const checkTaskHasChanges = vi.fn(async () => undefined); const checkTaskHasChanges = vi.fn(async () => undefined);
@ -268,6 +343,87 @@ describe('team change throttling', () => {
); );
}); });
it('polls visible non-selected graph teams from cached team data', async () => {
const invalidateTaskChangePresence = vi.fn();
const checkTaskHasChanges = vi.fn(async () => undefined);
useStore.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team', members: [], projectPath: '/repo' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team', members: [], projectPath: '/repo' },
tasks: [
{
id: 'task-1',
owner: 'alice',
status: 'in_progress',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
historyEvents: [],
reviewState: 'none',
changePresence: 'unknown',
},
{
id: 'task-2',
owner: 'alice',
status: 'in_progress',
createdAt: '2026-03-01T10:00:00.000Z',
updatedAt: '2026-03-01T10:00:00.000Z',
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
historyEvents: [],
reviewState: 'none',
changePresence: 'unknown',
},
],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
activeTabId: 'g1',
},
],
},
invalidateTaskChangePresence,
checkTaskHasChanges,
} as never);
await vi.advanceTimersByTimeAsync(10_000);
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
1,
'my-team',
'task-1',
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
);
await vi.advanceTimersByTimeAsync(10_000);
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
2,
'my-team',
'task-2',
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
);
});
it('per-team throttling: busy team does not block another visible team', async () => { it('per-team throttling: busy team does not block another visible team', async () => {
// Add a second visible team tab // Add a second visible team tab
useStore.setState({ useStore.setState({
@ -374,6 +530,41 @@ describe('team change throttling', () => {
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false); expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
}); });
it('tracks visible graph tabs for tool activity and disables tracking when graph tab disappears', async () => {
const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking);
setToolActivityTrackingSpy.mockClear();
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [
{
id: 'p1',
widthFraction: 1,
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
activeTabId: 'g1',
},
],
},
} as never);
cleanup?.();
cleanup = initializeNotificationListeners();
await vi.advanceTimersByTimeAsync(0);
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true);
useStore.setState({
paneLayout: {
focusedPaneId: 'p1',
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
},
} as never);
await vi.advanceTimersByTimeAsync(0);
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
});
it('applies targeted tool resets without clearing sibling tools', async () => { it('applies targeted tool resets without clearing sibling tools', async () => {
useStore.setState({ useStore.setState({
activeToolsByTeam: { activeToolsByTeam: {

View file

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { create } from 'zustand'; import { create } from 'zustand';
import { import {
__resetTeamSliceModuleStateForTests,
createTeamSlice, createTeamSlice,
getCurrentProvisioningProgressForTeam, getCurrentProvisioningProgressForTeam,
} from '../../../src/renderer/store/slices/teamSlice'; } from '../../../src/renderer/store/slices/teamSlice';
@ -13,6 +14,9 @@ const hoisted = vi.hoisted(() => ({
getProvisioningStatus: vi.fn(), getProvisioningStatus: vi.fn(),
getMemberSpawnStatuses: vi.fn(), getMemberSpawnStatuses: vi.fn(),
cancelProvisioning: vi.fn(), cancelProvisioning: vi.fn(),
deleteTeam: vi.fn(),
restoreTeam: vi.fn(),
permanentlyDeleteTeam: vi.fn(),
sendMessage: vi.fn(), sendMessage: vi.fn(),
requestReview: vi.fn(), requestReview: vi.fn(),
updateKanban: vi.fn(), updateKanban: vi.fn(),
@ -29,6 +33,9 @@ vi.mock('@renderer/api', () => ({
getProvisioningStatus: hoisted.getProvisioningStatus, getProvisioningStatus: hoisted.getProvisioningStatus,
getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses,
cancelProvisioning: hoisted.cancelProvisioning, cancelProvisioning: hoisted.cancelProvisioning,
deleteTeam: hoisted.deleteTeam,
restoreTeam: hoisted.restoreTeam,
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
sendMessage: hoisted.sendMessage, sendMessage: hoisted.sendMessage,
requestReview: hoisted.requestReview, requestReview: hoisted.requestReview,
updateKanban: hoisted.updateKanban, updateKanban: hoisted.updateKanban,
@ -74,6 +81,8 @@ function createSliceStore() {
getAllPaneTabs: vi.fn(() => []), getAllPaneTabs: vi.fn(() => []),
warmTaskChangeSummaries: vi.fn(async () => undefined), warmTaskChangeSummaries: vi.fn(async () => undefined),
invalidateTaskChangePresence: vi.fn(), invalidateTaskChangePresence: vi.fn(),
fetchTeams: vi.fn(async () => undefined),
fetchAllTasks: vi.fn(async () => undefined),
})); }));
} }
@ -118,6 +127,7 @@ function createMemberSpawnSnapshot(overrides: Record<string, unknown> = {}) {
describe('teamSlice actions', () => { describe('teamSlice actions', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
__resetTeamSliceModuleStateForTests();
hoisted.list.mockResolvedValue([]); hoisted.list.mockResolvedValue([]);
hoisted.getData.mockResolvedValue({ hoisted.getData.mockResolvedValue({
teamName: 'my-team', teamName: 'my-team',
@ -143,6 +153,9 @@ describe('teamSlice actions', () => {
}); });
hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null }); hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null });
hoisted.cancelProvisioning.mockResolvedValue(undefined); hoisted.cancelProvisioning.mockResolvedValue(undefined);
hoisted.deleteTeam.mockResolvedValue(undefined);
hoisted.restoreTeam.mockResolvedValue(undefined);
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
}); });
it('maps inbox verify failure to user-friendly text', async () => { it('maps inbox verify failure to user-friendly text', async () => {
@ -207,6 +220,104 @@ describe('teamSlice actions', () => {
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled(); expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
}); });
it('removes non-selected team cache entries on permanent delete', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
'other-team': {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
await store.getState().permanentlyDeleteTeam('my-team');
expect(hoisted.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
expect(store.getState().teamDataCacheByName['other-team']).toBeDefined();
});
it('clears selected team state and cache on soft delete', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'my-team',
selectedTeamData: {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
await store.getState().deleteTeam('my-team');
expect(hoisted.deleteTeam).toHaveBeenCalledWith('my-team');
expect(store.getState().selectedTeamName).toBeNull();
expect(store.getState().selectedTeamData).toBeNull();
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
});
it('drops stale cache on restore so the next open refetches fresh data', async () => {
const store = createSliceStore();
store.setState({
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
await store.getState().restoreTeam('my-team');
expect(hoisted.restoreTeam).toHaveBeenCalledWith('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
});
describe('refreshTeamData provisioning safety', () => { describe('refreshTeamData provisioning safety', () => {
it('does not set fatal error on TEAM_PROVISIONING', async () => { it('does not set fatal error on TEAM_PROVISIONING', async () => {
const store = createSliceStore(); const store = createSliceStore();
@ -261,6 +372,74 @@ describe('teamSlice actions', () => {
expect(store.getState().selectedTeamData).toEqual(existingData); expect(store.getState().selectedTeamData).toEqual(existingData);
}); });
it('clears non-selected cache on TEAM_DRAFT refresh failure', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
hoisted.getData.mockRejectedValue(new Error('TEAM_DRAFT'));
await store.getState().refreshTeamData('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
expect(store.getState().selectedTeamData?.teamName).toBe('other-team');
});
it('clears non-selected cache when the team no longer exists', async () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
teamDataCacheByName: {
'my-team': {
teamName: 'my-team',
config: { name: 'My Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
processes: [],
},
},
});
hoisted.getData.mockRejectedValue(new Error('Team not found: my-team'));
await store.getState().refreshTeamData('my-team');
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
expect(store.getState().selectedTeamData?.teamName).toBe('other-team');
});
it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => { it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => {
const store = createSliceStore(); const store = createSliceStore();
store.setState({ store.setState({
@ -512,6 +691,97 @@ describe('teamSlice actions', () => {
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed'); expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed');
}); });
it('hydrates visible non-selected graph tabs when config becomes ready', () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
paneLayout: {
focusedPaneId: 'pane-default',
panes: [
{
id: 'pane-default',
widthFraction: 1,
tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }],
activeTabId: 'graph-1',
},
],
},
currentProvisioningRunIdByTeam: {
'my-team': 'run-current',
},
});
const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData');
const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam');
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'assembling',
configReady: true,
message: 'Config written',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:01.000Z',
});
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
expect(selectTeamSpy).not.toHaveBeenCalled();
});
it('refreshes visible non-selected graph tabs when the canonical run reaches ready', () => {
const store = createSliceStore();
store.setState({
selectedTeamName: 'other-team',
selectedTeamData: {
teamName: 'other-team',
config: { name: 'Other Team' },
tasks: [],
members: [],
messages: [],
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
processes: [],
},
paneLayout: {
focusedPaneId: 'pane-default',
panes: [
{
id: 'pane-default',
widthFraction: 1,
tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }],
activeTabId: 'graph-1',
},
],
},
currentProvisioningRunIdByTeam: {
'my-team': 'run-current',
},
});
const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData');
const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam');
store.getState().onProvisioningProgress({
runId: 'run-current',
teamName: 'my-team',
state: 'ready',
message: 'Ready',
startedAt: '2026-03-12T10:00:00.000Z',
updatedAt: '2026-03-12T10:00:02.000Z',
});
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
expect(selectTeamSpy).not.toHaveBeenCalled();
});
it('keeps the current run pinned when stale progress from another run arrives', () => { it('keeps the current run pinned when stale progress from another run arrives', () => {
const store = createSliceStore(); const store = createSliceStore();
const startedAt = '2026-03-12T10:00:00.000Z'; const startedAt = '2026-03-12T10:00:00.000Z';