From f74b7a3701ea11314df1ef62d9d991eee9d0d645 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 12 Apr 2026 20:15:52 +0300 Subject: [PATCH] fix(agent-graph): keep graph state consistent across panes --- .../agent-graph/src/canvas/draw-agents.ts | 43 ++- packages/agent-graph/src/canvas/draw-edges.ts | 6 +- .../agent-graph/src/canvas/draw-particles.ts | 2 + .../agent-graph/src/canvas/draw-processes.ts | 4 +- packages/agent-graph/src/canvas/draw-tasks.ts | 133 +++++++- .../agent-graph/src/layout/kanbanLayout.ts | 16 +- packages/agent-graph/src/ports/types.ts | 24 ++ packages/agent-graph/src/ui/GraphCanvas.tsx | 42 ++- packages/agent-graph/src/ui/GraphView.tsx | 11 +- .../agent-graph/src/ui/buildFocusState.ts | 152 +++++++++ .../components/team/TeamDetailView.tsx | 16 +- .../agent-graph/adapters/TeamGraphAdapter.ts | 276 +++++++-------- .../adapters/useTeamGraphAdapter.ts | 13 +- .../agent-graph/ui/GraphNodePopover.tsx | 112 +++++- .../features/agent-graph/ui/GraphTaskCard.tsx | 23 +- .../utils/collapseOverflowStacks.ts | 81 +++++ .../agent-graph/utils/taskGraphSemantics.ts | 48 +++ src/renderer/store/index.ts | 170 +++++----- src/renderer/store/slices/teamSlice.ts | 319 +++++++++++++----- src/renderer/utils/memberHelpers.ts | 4 +- src/renderer/utils/taskChangeRequest.ts | 2 +- src/shared/types/team.ts | 165 +++++++++ .../agent-graph/GraphNodePopover.test.ts | 178 ++++++++++ .../agent-graph/TeamGraphAdapter.test.ts | 214 ++++++++++++ .../agent-graph/buildFocusState.test.ts | 173 ++++++++++ .../collapseOverflowStacks.test.ts | 76 +++++ .../renderer/store/teamChangeThrottle.test.ts | 209 +++++++++++- test/renderer/store/teamSlice.test.ts | 270 +++++++++++++++ 28 files changed, 2410 insertions(+), 372 deletions(-) create mode 100644 packages/agent-graph/src/ui/buildFocusState.ts create mode 100644 src/renderer/features/agent-graph/utils/collapseOverflowStacks.ts create mode 100644 src/renderer/features/agent-graph/utils/taskGraphSemantics.ts create mode 100644 test/renderer/features/agent-graph/buildFocusState.test.ts create mode 100644 test/renderer/features/agent-graph/collapseOverflowStacks.test.ts diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 97c4c27c..87fa7b5d 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -24,11 +24,12 @@ export function drawAgents( nodes: GraphNode[], time: number, selectedId: string | null, - hoveredId: string | null + hoveredId: string | null, + focusNodeIds?: ReadonlySet | null ): void { for (const node of nodes) { 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; const x = node.x ?? 0; @@ -95,6 +96,10 @@ export function drawAgents( 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") const labelText = node.role ? `${node.label} · ${node.role}` : node.label; drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel); @@ -123,7 +128,8 @@ export function drawCrossTeamNodes( nodes: GraphNode[], time: number, selectedId: string | null, - hoveredId: string | null + hoveredId: string | null, + focusNodeIds?: ReadonlySet | null ): void { for (const node of nodes) { if (node.kind !== 'crossteam') continue; @@ -136,7 +142,7 @@ export function drawCrossTeamNodes( const isHovered = node.id === hoveredId; ctx.save(); - ctx.globalAlpha = isHovered ? 0.7 : 0.5; + ctx.globalAlpha = (isHovered ? 0.7 : 0.5) * getFocusOpacity(node.id, focusNodeIds); // Subtle glow const glowR = r + AGENT_DRAW.glowPadding; @@ -188,6 +194,35 @@ function getNodeOpacity(node: GraphNode): number { return 1; } +function getFocusOpacity( + nodeId: string, + focusNodeIds?: ReadonlySet | null +): number { + return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1; +} + +function drawExceptionPip( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + tone: NonNullable +): 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 { ctx.save(); ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'; diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts index 9982168b..7731610b 100644 --- a/packages/agent-graph/src/canvas/draw-edges.ts +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -74,6 +74,7 @@ export function drawEdges( nodeMap: Map, _time: number, hasActiveParticles: Set, + focusEdgeIds?: ReadonlySet | null, ): void { for (const edge of edges) { const source = nodeMap.get(edge.source); @@ -87,13 +88,14 @@ export function drawEdges( const alpha = isActive ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) : 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); ctx.save(); - ctx.globalAlpha = alpha; + ctx.globalAlpha = alpha * focusAlpha; // Subtle glow pass when edge has active particles if (isActive) { diff --git a/packages/agent-graph/src/canvas/draw-particles.ts b/packages/agent-graph/src/canvas/draw-particles.ts index ce222779..a5086876 100644 --- a/packages/agent-graph/src/canvas/draw-particles.ts +++ b/packages/agent-graph/src/canvas/draw-particles.ts @@ -27,8 +27,10 @@ export function drawParticles( edgeMap: Map, nodeMap: Map, time: number, + focusEdgeIds?: ReadonlySet | null, ): void { for (const p of particles) { + if (focusEdgeIds && !focusEdgeIds.has(p.edgeId)) continue; const edge = edgeMap.get(p.edgeId); if (!edge) continue; diff --git a/packages/agent-graph/src/canvas/draw-processes.ts b/packages/agent-graph/src/canvas/draw-processes.ts index 25485126..cfde4344 100644 --- a/packages/agent-graph/src/canvas/draw-processes.ts +++ b/packages/agent-graph/src/canvas/draw-processes.ts @@ -17,6 +17,7 @@ export function drawProcesses( time: number, selectedId: string | null, hoveredId: string | null, + focusNodeIds?: ReadonlySet | null, ): void { for (const node of nodes) { if (node.kind !== 'process') continue; @@ -26,9 +27,10 @@ export function drawProcesses( const r = NODE.radiusProcess; const isSelected = node.id === selectedId; const isHovered = node.id === hoveredId; + const focusOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1; ctx.save(); - ctx.globalAlpha = 0.8; + ctx.globalAlpha = 0.8 * focusOpacity; // Glow — use cached sprite instead of createRadialGradient per frame const procColor = node.color ?? COLORS.tool_calling; diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index 04184bf8..3c825e62 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -19,11 +19,12 @@ export function drawTasks( time: number, selectedId: string | null, hoveredId: string | null, + focusNodeIds?: ReadonlySet | null ): void { for (const node of nodes) { if (node.kind !== 'task') continue; - const opacity = getTaskOpacity(node); + const opacity = getTaskOpacity(node, focusNodeIds); if (opacity < MIN_VISIBLE_OPACITY) continue; const x = node.x ?? 0; @@ -42,8 +43,12 @@ export function drawTasks( // ─── Private ──────────────────────────────────────────────────────────────── -function getTaskOpacity(_node: GraphNode): number { - if (_node.taskStatus === 'deleted') return 0; +function getTaskOpacity( + node: GraphNode, + focusNodeIds?: ReadonlySet | null +): number { + if (node.taskStatus === 'deleted') return 0; + if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25; return 1; } @@ -54,7 +59,7 @@ function drawTaskPill( node: GraphNode, time: number, isSelected: boolean, - isHovered: boolean, + isHovered: boolean ): void { const w = TASK_PILL.width; const h = TASK_PILL.height; @@ -65,6 +70,15 @@ function drawTaskPill( const statusColor = getTaskStatusColor(node.taskStatus); 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 const needsAttention = (node.taskStatus === 'in_progress' && node.reviewState !== 'approved') || @@ -72,13 +86,12 @@ function drawTaskPill( node.reviewState === 'needsFix' || (node.needsClarification != null); const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved'; - const breathe = needsAttention && !isFinished - ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) - : 1; + const breathe = + needsAttention && !isFinished + ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) + : 1; const scale = breathe; - ctx.save(); - ctx.translate(x, y); ctx.scale(scale, scale); // Shadow — stronger for attention tasks, red for blocked @@ -122,9 +135,10 @@ function drawTaskPill( if (reviewColor !== 'transparent') { ctx.beginPath(); ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1); - const reviewAlpha = node.reviewState === 'approved' - ? 0.6 // static — no pulse - : 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix + const reviewAlpha = + node.reviewState === 'approved' + ? 0.6 + : 0.5 + 0.3 * Math.sin(time * 3); ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha); ctx.lineWidth = 1.5; ctx.stroke(); @@ -147,7 +161,10 @@ function drawTaskPill( ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.textPrimary; 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); ctx.fillText(subject, textX, -4); } @@ -169,6 +186,13 @@ function drawTaskPill( 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 if (node.totalCommentCount && node.totalCommentCount > 0) { const badgeX = halfW - 6; @@ -215,12 +239,93 @@ function drawTaskPill( 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. */ export function drawColumnHeaders( ctx: CanvasRenderingContext2D, - zones: KanbanZoneInfo[], + zones: KanbanZoneInfo[] ): void { for (const zone of zones) { // Section header for unassigned tasks — larger, centered above all columns diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index e8bb24e4..d11d179e 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -94,7 +94,7 @@ export class KanbanLayoutEngine { // ─── Private ────────────────────────────────────────────────────────────── 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 baseY = ownerY + offsetY; @@ -129,8 +129,8 @@ export class KanbanLayoutEngine { for (const [colIdx, col] of activeColumns.entries()) { const colX = baseX + colIdx * columnWidth; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; - const overflow = Math.max(0, col.tasks.length - maxVisibleRows); - const visibleCount = Math.min(col.tasks.length, maxVisibleRows); + const overflow = col.tasks.find((task) => task.isOverflowStack)?.overflowCount ?? 0; + const visibleCount = col.tasks.length; // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) headers.push({ @@ -144,13 +144,6 @@ export class KanbanLayoutEngine { // Position tasks below header 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 targetY = baseY + headerHeight + rowIdx * rowHeight; 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 if (tasks.length > 0) { + const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0); this.zones.push({ ownerId: '__unassigned__', ownerX: centerX, @@ -216,7 +210,7 @@ export class KanbanLayoutEngine { x: centerX, y: baseY - 10, color: COLORS.taskPending, - overflowCount: Math.max(0, tasks.length - cols * KANBAN_ZONE.maxVisibleRows), + overflowCount, overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight, }], }); diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index a7b96067..59a7beed 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -78,6 +78,10 @@ export interface GraphNode { resultPreview?: string; source: 'runtime' | 'member_log' | 'inbox'; }>; + /** Compact abnormal-state indicator */ + exceptionTone?: 'warning' | 'error'; + /** Short human-readable abnormal-state label */ + exceptionLabel?: string; // ─── Task-specific ───────────────────────────────────────────────────── /** Short display ID (e.g., "#3") */ @@ -90,6 +94,14 @@ export interface GraphNode { taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted'; /** Review state overlay */ 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 */ needsClarification?: 'lead' | 'user' | null; /** Task is blocked by other tasks */ @@ -102,6 +114,12 @@ export interface GraphNode { totalCommentCount?: number; /** Unread comment count on this task */ 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 ────────────────────────────────────────────────── /** Clickable URL for process */ @@ -163,5 +181,11 @@ export type GraphDomainRef = | { kind: 'lead'; teamName: string; memberName: string } | { kind: 'member'; teamName: string; memberName: string } | { kind: 'task'; teamName: string; taskId: string } + | { + kind: 'task_overflow'; + teamName: string; + ownerMemberName?: string | null; + columnKey: string; + } | { kind: 'process'; teamName: string; processId: string } | { kind: 'crossteam'; teamName: string; externalTeamName: string }; diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 3548c49b..6fc2a726 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -30,6 +30,8 @@ export interface GraphDrawState { camera: CameraTransform; selectedNodeId: string | null; hoveredNodeId: string | null; + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; } export interface GraphCanvasHandle { @@ -199,20 +201,48 @@ export const GraphCanvas = forwardRef(funct 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) const cappedParticles = state.particles.length > 100 ? state.particles.slice(-100) : 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) - drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); - drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawProcesses( + 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); - drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); - drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId); + drawTasks( + ctx, + visibleNodes, + state.time, + state.selectedNodeId, + state.hoveredNodeId, + state.focusNodeIds + ); + drawAgents( + ctx, + visibleNodes, + state.time, + state.selectedNodeId, + state.hoveredNodeId, + state.focusNodeIds + ); // 2d. Effects drawEffects(ctx, state.effects); diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 90bd07a2..63dd1636 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -10,7 +10,7 @@ * 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 type { GraphDataPort } from '../ports/GraphDataPort'; 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 { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; +import { buildFocusState } from './buildFocusState'; import { useGraphSimulation } from '../hooks/useGraphSimulation'; import { useGraphCamera } from '../hooks/useGraphCamera'; import { useGraphInteraction } from '../hooks/useGraphInteraction'; @@ -114,6 +115,10 @@ export function GraphView({ // ─── UNIFIED RAF LOOP: tick simulation + draw canvas ──────────────────── const idleFrameSkip = useRef(0); + const focusState = useMemo( + () => buildFocusState(selectedNodeId, data.nodes, data.edges), + [selectedNodeId, data.edges, data.nodes] + ); const animate = useCallback(() => { if (!runningRef.current) return; @@ -154,11 +159,13 @@ export function GraphView({ camera: cameraRef.current.transformRef.current, selectedNodeId: selectedNodeIdRef.current, hoveredNodeId: interaction.hoveredNodeId.current, + focusNodeIds: focusState.focusNodeIds, + focusEdgeIds: focusState.focusEdgeIds, }); rafRef.current = requestAnimationFrame(animate); // eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs - }, []); + }, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]); // Start/stop RAF useEffect(() => { diff --git a/packages/agent-graph/src/ui/buildFocusState.ts b/packages/agent-graph/src/ui/buildFocusState.ts new file mode 100644 index 00000000..71b82ad2 --- /dev/null +++ b/packages/agent-graph/src/ui/buildFocusState.ts @@ -0,0 +1,152 @@ +import type { GraphEdge, GraphNode } from '../ports/types'; + +export interface GraphFocusState { + focusNodeIds: ReadonlySet | null; + focusEdgeIds: ReadonlySet | null; +} + +function addNode(nodeIds: Set, nodeId: string | null | undefined): void { + if (nodeId) { + nodeIds.add(nodeId); + } +} + +function addNodeAndIncidentEdges( + nodeIds: Set, + edgeIds: Set, + nodeId: string | null | undefined, + adjacency: Map +): 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([selectedNodeId]); + const edgeIds = new Set(); + const adjacency = new Map(); + + 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, + }; +} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 090b1ad3..cceee234 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1,5 +1,4 @@ import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ComponentProps } from 'react'; import { api } from '@renderer/api'; import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index'; @@ -36,8 +35,8 @@ import { type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; -import { createLogger } from '@shared/utils/logger'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; +import { createLogger } from '@shared/utils/logger'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, @@ -73,6 +72,7 @@ import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { ComponentProps } from 'react'; const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) @@ -92,13 +92,13 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; +import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { isLeadSessionMissing, shouldSuppressMissingLeadSessionFetch, } from './teamSessionFetchGuards'; -import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; -import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { KanbanSortState } from './kanban/KanbanSortPopover'; @@ -2781,10 +2781,10 @@ export const TeamDetailView = ({ if (task) setSelectedTask(task); }} onOpenMemberProfile={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); + const member = data.members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + } }} /> diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 8b1b3e5d..3bb496cb 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -4,20 +4,27 @@ * This is the ONLY file in this feature that imports from @renderer/store. * 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 { agentAvatarUrl, getMemberRuntimeAdvisoryLabel } from '@renderer/utils/memberHelpers'; import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary'; -import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { stripCrossTeamPrefix } from '@shared/constants/crossTeam'; import { - getIdleGraphLabel, classifyIdleNotificationText, + getIdleGraphLabel, } from '@shared/utils/idleNotificationSemantics'; import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; import { isLeadMember } from '@shared/utils/leadDetection'; +import { collapseOverflowStacks } from '../utils/collapseOverflowStacks'; +import { + isTaskBlocked, + isTaskInReviewCycle, + resolveTaskReviewer, +} from '../utils/taskGraphSemantics'; + import type { GraphDataPort, GraphEdge, @@ -28,6 +35,7 @@ import type { import type { ActiveToolCall, InboxMessage, + LeadActivityState, MemberSpawnStatusEntry, TeamData, } from '@shared/types/team'; @@ -36,8 +44,6 @@ import type { LeadContextUsage } from '@shared/types/team'; export class TeamGraphAdapter { // ─── ES #private fields ────────────────────────────────────────────────── #lastTeamName = ''; - #lastDataHash = ''; - #cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult(''); readonly #seenRelated = new Set(); readonly #seenMessageIds = new Set(); #initialMessagesSeen = false; @@ -57,12 +63,12 @@ export class TeamGraphAdapter { /** * Adapt team data into a GraphDataPort snapshot. - * Returns cached result if inputs haven't changed (referential check). */ adapt( teamData: TeamData | null, teamName: string, spawnStatuses?: Record, + leadActivity?: LeadActivityState, leadContext?: LeadContextUsage, pendingApprovalAgents?: Set, activeTools?: Record>, @@ -74,89 +80,6 @@ export class TeamGraphAdapter { 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 if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); @@ -166,7 +89,6 @@ export class TeamGraphAdapter { } this.#lastTeamName = teamName; - this.#lastDataHash = hash; this.#seenRelated.clear(); const nodes: GraphNode[] = []; @@ -182,6 +104,8 @@ export class TeamGraphAdapter { teamData, teamName, leadName, + pendingApprovalAgents, + leadActivity, leadContext, activeTools, finishedVisible, @@ -212,7 +136,7 @@ export class TeamGraphAdapter { ); this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges); - this.#cachedResult = { + return { nodes, edges, particles, @@ -220,20 +144,17 @@ export class TeamGraphAdapter { teamColor: teamData.config.color ?? undefined, isAlive: teamData.isAlive, }; - - return this.#cachedResult; } // ─── Disposal ──────────────────────────────────────────────────────────── [Symbol.dispose](): void { - this.#cachedResult = TeamGraphAdapter.#emptyResult(''); this.#seenRelated.clear(); this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; this.#seenCommentCounts.clear(); this.#initialCommentsSeen = false; - this.#lastDataHash = ''; + this.#lastTeamName = ''; } // ─── Private: node builders ────────────────────────────────────────────── @@ -269,6 +190,8 @@ export class TeamGraphAdapter { data: TeamData, teamName: string, leadName: string, + pendingApprovalAgents?: Set, + leadActivity?: LeadActivityState, leadContext?: LeadContextUsage, activeTools?: Record>, finishedVisible?: Record>, @@ -280,15 +203,28 @@ export class TeamGraphAdapter { activeTools?.[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({ id: leadId, kind: 'lead', label: data.config.name || teamName, - state: !data.isAlive - ? 'idle' - : Object.keys(activeTools?.[leadName] ?? {}).length > 0 - ? 'tool_calling' - : 'active', + state: leadState, color: data.config.color ?? undefined, runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( leadMember?.providerId, @@ -297,6 +233,7 @@ export class TeamGraphAdapter { ), contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, avatarUrl: agentAvatarUrl(leadName, 64), + pendingApproval, activeTool: activeTool ? { name: activeTool.toolName, @@ -320,6 +257,7 @@ export class TeamGraphAdapter { resultPreview: tool.resultPreview, source: tool.source, })), + ...leadException, domainRef: { kind: 'lead', teamName, memberName: leadName }, }); } @@ -347,6 +285,12 @@ export class TeamGraphAdapter { finishedVisible?.[member.name] ); 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({ id: memberId, @@ -369,6 +313,8 @@ export class TeamGraphAdapter { ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject : undefined, pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, + exceptionTone: exception?.exceptionTone, + exceptionLabel: exception?.exceptionLabel, activeTool: activeTool ? { name: activeTool.toolName, @@ -411,25 +357,33 @@ export class TeamGraphAdapter { teamName: string, commentReadState?: Record ): void { - // Build lookup tables for fast resolution - const completedTaskIds = new Set(); + const taskStateById = new Map>(); const taskDisplayIds = new Map(); + const memberColorByName = new Map(); + 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)}`); } + for (const member of data.members) { + if (member.color) { + memberColorByName.set(member.name, member.color); + } + } + + const rawTaskNodes: GraphNode[] = []; for (const task of data.tasks) { if (task.status === 'deleted') continue; const taskId = `task:${teamName}:${task.id}`; 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 isBlocked = - (task.blockedBy?.length ?? 0) > 0 && - task.blockedBy!.some((id) => !completedTaskIds.has(id)); + const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status); + const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState); - // Resolve display IDs for dependencies const blockedByDisplayIds = task.blockedBy?.length ? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) : undefined; @@ -437,7 +391,6 @@ export class TeamGraphAdapter { ? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`) : undefined; - // Comment counts const totalCommentCount = task.comments?.length ?? 0; const unreadCommentCount = commentReadState ? getUnreadCount( @@ -448,66 +401,88 @@ export class TeamGraphAdapter { ) : 0; - nodes.push({ + rawTaskNodes.push({ id: taskId, kind: 'task', label: task.displayId ?? `#${task.id.slice(0, 6)}`, sublabel: task.subject, state: TeamGraphAdapter.#mapTaskStatus(task.status), - taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status), - reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState), + taskStatus, + reviewState, + reviewerName: isReviewCycle ? reviewerName : null, + reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined, + reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined, + changePresence: task.changePresence, displayId: task.displayId ?? undefined, ownerId: ownerMemberId, needsClarification: task.needsClarification ?? null, - isBlocked, + isBlocked: isTaskBlocked(task, taskStateById), blockedByDisplayIds, blocksDisplayIds, totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined, unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined, domainRef: { kind: 'task', teamName, taskId: task.id }, }); + } - if (ownerMemberId) { - edges.push({ - id: `edge:own:${ownerMemberId}:${taskId}`, - source: ownerMemberId, - target: taskId, - type: 'ownership', - }); - } + const visibleTaskNodes = collapseOverflowStacks(rawTaskNodes, teamName, 6); + const visibleTaskIds = new Set( + visibleTaskNodes.flatMap((taskNode) => + taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] + ) + ); - const seenBlockEdges = new Set(); - for (const blockedById of task.blockedBy ?? []) { - const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`; - if (seenBlockEdges.has(edgeId)) continue; - seenBlockEdges.add(edgeId); + nodes.push(...visibleTaskNodes); + + for (const taskNode of visibleTaskNodes) { + if (!taskNode.ownerId) continue; + edges.push({ + id: `edge:own:${taskNode.ownerId}:${taskNode.id}`, + source: taskNode.ownerId, + target: taskNode.id, + type: 'ownership', + }); + } + + const seenBlockingEdges = new Set(); + 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({ id: edgeId, - source: `task:${teamName}:${blockedById}`, - target: taskId, + source: `task:${teamName}:${blockerId}`, + target: taskNodeId, type: 'blocking', }); } - for (const blocksId of task.blocks ?? []) { - const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`; - if (seenBlockEdges.has(edgeId)) continue; - seenBlockEdges.add(edgeId); + for (const blockedId of task.blocks ?? []) { + if (!visibleTaskIds.has(blockedId)) continue; + const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, task.id, blockedId); + if (seenBlockingEdges.has(edgeId)) continue; + seenBlockingEdges.add(edgeId); edges.push({ id: edgeId, - source: taskId, - target: `task:${teamName}:${blocksId}`, + source: taskNodeId, + target: `task:${teamName}:${blockedId}`, type: 'blocking', }); } for (const relatedId of task.related ?? []) { + if (!visibleTaskIds.has(relatedId)) continue; const key = [task.id, relatedId].sort().join(':'); if (this.#seenRelated.has(key)) continue; this.#seenRelated.add(key); edges.push({ id: `edge:rel:${key}`, - source: taskId, + source: taskNodeId, target: `task:${teamName}:${relatedId}`, type: 'related', }); @@ -751,6 +726,35 @@ export class TeamGraphAdapter { // ─── 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 | 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 { if (spawnStatus === 'spawning') return 'thinking'; if (spawnStatus === 'error') return 'error'; @@ -851,7 +855,7 @@ export class TeamGraphAdapter { ): string { const normalized = name.trim().toLowerCase(); 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}`; } diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index 9c755038..0d250c6d 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -7,6 +7,7 @@ import { useMemo, useRef, useSyncExternalStore } from 'react'; import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage'; import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; import { TeamGraphAdapter } from './TeamGraphAdapter'; @@ -19,6 +20,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const { teamData, spawnStatuses, + leadActivity, leadContext, pendingApprovals, activeTools, @@ -26,8 +28,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { toolHistory, } = useStore( useShallow((s) => ({ - teamData: s.selectedTeamData, + teamData: selectTeamDataForName(s, teamName), spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, + leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, pendingApprovals: s.pendingApprovals, activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, @@ -39,10 +42,12 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { const pendingApprovalAgents = useMemo(() => { const agents = new Set(); for (const a of pendingApprovals) { - if (a.source !== 'lead') agents.add(a.source); + if (a.teamName === teamName) { + agents.add(a.source); + } } return agents; - }, [pendingApprovals]); + }, [pendingApprovals, teamName]); const commentReadState = useSyncExternalStore(subscribe, getSnapshot); @@ -52,6 +57,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { teamData, teamName, spawnStatuses, + leadActivity, leadContext, pendingApprovalAgents, activeTools, @@ -63,6 +69,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort { teamData, teamName, spawnStatuses, + leadActivity, leadContext, pendingApprovalAgents, activeTools, diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx index 9e31fdd2..e6450ea2 100644 --- a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -6,12 +6,16 @@ import { Badge } from '@renderer/components/ui/badge'; 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 { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react'; -import type { GraphNode } from '@claude-teams/agent-graph'; - 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 ─────────────────────────────────────────── @@ -37,7 +41,7 @@ function formatToolPreview(preview: string | undefined): string | undefined { ); } catch { // 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]; } } @@ -100,6 +104,16 @@ export const GraphNodePopover = ({ } if (node.kind === 'task') { + if (node.isOverflowStack || node.domainRef.kind === 'task_overflow') { + return ( + + ); + } return ( At: {new Date(node.processRegisteredAt).toLocaleTimeString()} )} + {node.exceptionLabel && ( + + {node.exceptionLabel} + + )} {node.processUrl && ( 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 ( +
+
+
Hidden tasks
+ + {node.overflowCount ?? hiddenTasks.length} + +
+
+ {hiddenTasks.length === 0 ? ( +
No hidden tasks available.
+ ) : ( + hiddenTasks.map((task) => { + const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]); + return ( + + ); + }) + )} +
+
+ ); +}; + // ─── Member Popover ───────────────────────────────────────────────────────── const MemberPopoverContent = ({ @@ -261,6 +355,18 @@ const MemberPopoverContent = ({ {getSpawnStatusBadgeLabel(node.spawnStatus)} )} + {node.exceptionLabel && ( + + {node.exceptionLabel} + + )} {/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */} diff --git a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx index e460f174..ba2c343e 100644 --- a/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx +++ b/src/renderer/features/agent-graph/ui/GraphTaskCard.tsx @@ -7,8 +7,11 @@ import { useMemo } from 'react'; import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard'; import { useStore } from '@renderer/store'; +import { selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; +import { isTaskBlocked, resolveTaskGraphColumn } from '../utils/taskGraphSemantics'; + import type { GraphNode } from '@claude-teams/agent-graph'; import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; @@ -32,16 +35,12 @@ interface GraphTaskCardProps { // ─── Helpers ──────────────────────────────────────────────────────────────── function resolveColumn(task: TeamTask): KanbanColumnId { - if (task.reviewState === 'approved') return 'approved'; - 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'; + return resolveTaskGraphColumn(task); } -function getGlowStyle(task: TeamTask): React.CSSProperties { +function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap): React.CSSProperties { const col = resolveColumn(task); - const blocked = (task.blockedBy?.length ?? 0) > 0; + const blocked = isTaskBlocked(task, taskMap); if (blocked) { 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( useShallow((s) => ({ - task: s.selectedTeamData?.tasks.find((t) => t.id === taskId), - tasks: s.selectedTeamData?.tasks ?? [], - members: s.selectedTeamData?.members ?? [], + tasks: selectTeamDataForName(s, teamName)?.tasks ?? [], + members: selectTeamDataForName(s, teamName)?.members ?? [], + task: selectTeamDataForName(s, teamName)?.tasks.find((t) => t.id === taskId), })) ); @@ -118,7 +117,7 @@ export const GraphTaskCard = ({ } const columnId = resolveColumn(task); - const taskWithKanban = task as TeamTaskWithKanban; + const taskWithKanban = task; const closeAct = (fn?: (id: string) => void) => (taskId: string) => { fn?.(taskId); @@ -128,7 +127,7 @@ export const GraphTaskCard = ({ return (
(); + 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; +} diff --git a/src/renderer/features/agent-graph/utils/taskGraphSemantics.ts b/src/renderer/features/agent-graph/utils/taskGraphSemantics.ts new file mode 100644 index 00000000..38da36d1 --- /dev/null +++ b/src/renderer/features/agent-graph/utils/taskGraphSemantics.ts @@ -0,0 +1,48 @@ +import type { KanbanTaskState, KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types'; + +type TaskColumnInput = Pick; +type TaskReviewerInput = Pick; +type TaskBlockInput = Pick; +type TaskBlockState = Pick; + +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 +): string | null { + const reviewer = task.reviewer?.trim() || kanbanTaskState?.reviewer?.trim() || ''; + return reviewer.length > 0 ? reviewer : null; +} + +export function isTaskBlocked( + task: TaskBlockInput, + taskStateById: ReadonlyMap +): 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'); + }); +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 5df9f33e..0c41978d 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -37,6 +37,7 @@ import { createTeamSlice, getLastResolvedTeamDataRefreshAt, isTeamDataRefreshPending, + selectTeamDataForName, } from './slices/teamSlice'; import { createUISlice } from './slices/uiSlice'; import { createUpdateSlice } from './slices/updateSlice'; @@ -397,13 +398,8 @@ export function initializeNotificationListeners(): () => void { } const state = useStore.getState(); - const selectedTeamName = state.selectedTeamName; - const selectedTeamData = state.selectedTeamData; - if ( - !selectedTeamName || - selectedTeamData?.teamName !== selectedTeamName || - !isTeamVisibleInAnyPane(selectedTeamName) - ) { + const visibleTeamNames = Array.from(getVisibleTeamNamesInAnyPane(state)); + if (visibleTeamNames.length === 0) { 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; try { - const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0; - const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); - const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; - const nextTask = sourceTasks[cursor % sourceTasks.length]; + for (const teamName of visibleTeamNames) { + const teamData = selectTeamDataForName(state, teamName); + if (teamData?.teamName !== teamName) { + 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(); - if ( - current.selectedTeamName !== selectedTeamName || - current.selectedTeamData?.teamName !== selectedTeamName || - !isTeamVisibleInAnyPane(selectedTeamName) - ) { - return; + const cursor = inProgressChangePresenceCursorByTeam.get(teamName) ?? 0; + const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown'); + const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks; + const nextTask = sourceTasks[cursor % sourceTasks.length]; + + inProgressChangePresenceCursorByTeam.set(teamName, (cursor + 1) % sourceTasks.length); + + 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 { // Best-effort polling for in-progress tasks only. } finally { @@ -557,41 +567,41 @@ export function initializeNotificationListeners(): () => void { ); }; - const isTeamVisibleInAnyPane = (teamName: string): boolean => { - const { paneLayout } = useStore.getState(); - return paneLayout.panes.some((pane) => { - if (!pane.activeTabId) return false; - return pane.tabs.some( - (tab) => tab.id === pane.activeTabId && tab.type === 'team' && tab.teamName === teamName - ); - }); - }; - - const getTrackedChangePresenceTeams = (): Set => { - const { selectedTeamName, selectedTeamData } = useStore.getState(); - if ( - !selectedTeamName || - selectedTeamData?.teamName !== selectedTeamName || - !isTeamVisibleInAnyPane(selectedTeamName) - ) { - return new Set(); - } - return new Set([selectedTeamName]); - }; - - const getTrackedToolActivityTeams = (): Set => { - const { paneLayout } = useStore.getState(); - const tracked = new Set(); + const getVisibleTeamNamesInAnyPane = (state = useStore.getState()): Set => { + const { paneLayout } = state; + const visibleTeamNames = new Set(); for (const pane of paneLayout.panes) { if (!pane.activeTabId) continue; const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId); - if (activeTab?.type === 'team' && activeTab.teamName) { - tracked.add(activeTab.teamName); + if ( + (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 => { + const state = useStore.getState(); + const tracked = new Set(); + for (const teamName of getVisibleTeamNamesInAnyPane(state)) { + if (selectTeamDataForName(state, teamName)) { + tracked.add(teamName); } } return tracked; }; + const getTrackedToolActivityTeams = (): Set => { + return getVisibleTeamNamesInAnyPane(); + }; + const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => { teamLastRelevantActivityAt.set(teamName, timestamp); }; @@ -606,15 +616,11 @@ export function initializeNotificationListeners(): () => void { } 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; } - if (state.selectedTeamName !== activeTab.teamName) { - return null; - } - - if (state.selectedTeamData?.teamName !== activeTab.teamName) { + if (!selectTeamDataForName(state, activeTab.teamName)) { return null; } @@ -632,7 +638,7 @@ export function initializeNotificationListeners(): () => void { return; } - if (current.selectedTeamLoading) { + if (current.selectedTeamName === teamName && current.selectedTeamLoading) { return; } @@ -695,7 +701,8 @@ export function initializeNotificationListeners(): () => void { if ( state.paneLayout === prevState.paneLayout && state.selectedTeamName === prevState.selectedTeamName && - state.selectedTeamData === prevState.selectedTeamData + state.selectedTeamData === prevState.selectedTeamData && + state.teamDataCacheByName === prevState.teamDataCacheByName ) { 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, // which isn't refreshed for lead-activity events. if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) { @@ -1140,7 +1158,7 @@ export function initializeNotificationListeners(): () => void { const timer = setTimeout(() => { teamPresenceRefreshTimers.delete(event.teamName); const current = useStore.getState(); - void current.refreshSelectedTeamChangePresence(event.teamName); + void current.refreshTeamChangePresence(event.teamName); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); teamPresenceRefreshTimers.set(event.teamName, timer); return; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 6455953f..742e6084 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -40,9 +40,9 @@ const teamRefreshBurstDiagnostics = new Map< { windowStartedAt: number; count: number; lastWarnAt: number } >(); const memberSpawnUiEqualLastWarnAtByTeam = new Map(); -type RefreshTeamDataOptions = { +interface RefreshTeamDataOptions { withDedup?: boolean; -}; +} export function isTeamDataRefreshPending(teamName: string): boolean { return ( @@ -56,6 +56,16 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und 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 { return new Date().toISOString(); } @@ -487,9 +497,9 @@ import type { KanbanColumnId, LeadActivityState, LeadContextUsage, - PersistedTeamLaunchSummary, - MemberSpawnStatusesSnapshot, MemberSpawnStatusEntry, + MemberSpawnStatusesSnapshot, + PersistedTeamLaunchSummary, SendMessageRequest, SendMessageResult, TaskChangePresenceState, @@ -852,11 +862,7 @@ function preserveKnownTaskChangePresence( } const previousTask = prevTaskById.get(task.id); - if ( - !previousTask || - !previousTask.changePresence || - previousTask.changePresence === 'unknown' - ) { + if (!previousTask?.changePresence || previousTask.changePresence === 'unknown') { return task; } @@ -915,6 +921,46 @@ export interface TeamLaunchParams { limitContext?: boolean; } +export function selectTeamDataForName( + state: Pick, + teamName: string | null | undefined +): TeamData | null { + if (!teamName) { + return null; + } + return ( + state.teamDataCacheByName[teamName] ?? + (state.selectedTeamName === teamName ? state.selectedTeamData : null) + ); +} + +function isVisibleInActiveTeamSurface( + state: Pick, + 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 { teams: TeamSummary[]; /** O(1) lookup to avoid array scans in render-hot paths */ @@ -947,6 +993,8 @@ export interface TeamSlice { ) => void; selectedTeamName: string | null; selectedTeamData: TeamData | null; + /** Team-scoped detailed cache used by multi-pane views like agent graph. */ + teamDataCacheByName: Record; selectedTeamLoading: boolean; selectedTeamLoadNonce: number; selectedTeamError: string | null; @@ -994,7 +1042,7 @@ export interface TeamSlice { taskId: string, presence: TaskChangePresenceState ) => void; - refreshSelectedTeamChangePresence: (teamName: string) => Promise; + refreshTeamChangePresence: (teamName: string) => Promise; selectTeam: ( teamName: string, opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean } @@ -1239,6 +1287,7 @@ export const createTeamSlice: StateCreator = (set, globalTasksError: null, selectedTeamName: null, selectedTeamData: null, + teamDataCacheByName: {}, selectedTeamLoading: false, selectedTeamLoadNonce: 0, selectedTeamError: null, @@ -1660,20 +1709,20 @@ export const createTeamSlice: StateCreator = (set, setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => { set((state) => { - let selectedChanged = false; - const nextSelectedTeamData = - state.selectedTeamName === teamName && state.selectedTeamData - ? { - ...state.selectedTeamData, - tasks: state.selectedTeamData.tasks.map((task) => { - if (task.id !== taskId || task.changePresence === presence) { - return task; - } - selectedChanged = true; - return { ...task, changePresence: presence }; - }), - } - : state.selectedTeamData; + const currentTeamData = selectTeamDataForName(state, teamName); + let cacheChanged = false; + const nextTeamData = currentTeamData + ? { + ...currentTeamData, + tasks: currentTeamData.tasks.map((task) => { + if (task.id !== taskId || task.changePresence === presence) { + return task; + } + cacheChanged = true; + return { ...task, changePresence: presence }; + }), + } + : null; let globalChanged = false; const nextGlobalTasks = state.globalTasks.map((task) => { @@ -1684,20 +1733,30 @@ export const createTeamSlice: StateCreator = (set, return { ...task, changePresence: presence }; }); - if (!selectedChanged && !globalChanged) { + if (!cacheChanged && !globalChanged) { return {}; } return { - ...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}), + ...(cacheChanged && nextTeamData + ? { + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }, + } + : {}), + ...(cacheChanged && state.selectedTeamName === teamName && nextTeamData + ? { selectedTeamData: nextTeamData } + : {}), ...(globalChanged ? { globalTasks: nextGlobalTasks } : {}), }; }); }, - refreshSelectedTeamChangePresence: async (teamName: string) => { - const selected = get().selectedTeamData; - if (get().selectedTeamName !== teamName || !selected) { + refreshTeamChangePresence: async (teamName: string) => { + const currentTeamData = selectTeamDataForName(get(), teamName); + if (!currentTeamData) { return; } @@ -1706,17 +1765,14 @@ export const createTeamSlice: StateCreator = (set, api.teams.getTaskChangePresence(teamName) ); - if (get().selectedTeamName !== teamName || !get().selectedTeamData) { - return; - } - set((state) => { - if (state.selectedTeamName !== teamName || !state.selectedTeamData) { + const teamData = selectTeamDataForName(state, teamName); + if (!teamData) { return {}; } let changed = false; - const nextTasks = state.selectedTeamData.tasks.map((task) => { + const nextTasks = teamData.tasks.map((task) => { const nextPresence = presenceByTaskId[task.id] ?? 'unknown'; if (task.changePresence === nextPresence) { return task; @@ -1729,11 +1785,17 @@ export const createTeamSlice: StateCreator = (set, return {}; } + const nextTeamData = { + ...teamData, + tasks: nextTasks, + }; + return { - selectedTeamData: { - ...state.selectedTeamData, - tasks: nextTasks, + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, }, + ...(state.selectedTeamName === teamName ? { selectedTeamData: nextTeamData } : {}), }; }); } catch { @@ -1754,8 +1816,7 @@ export const createTeamSlice: StateCreator = (set, return; } const requestNonce = get().selectedTeamLoadNonce + 1; - const previousSelectedTeamName = get().selectedTeamName; - const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null; + const previousData = selectTeamDataForName(get(), teamName); // Stale-while-revalidate: keep previous data visible while loading new team. // Skeleton only shows on first load (when data is null). @@ -1797,18 +1858,23 @@ export const createTeamSlice: StateCreator = (set, set({ teamByName: { ...prevByName, [teamName]: patched } }); } + const nextTeamData = previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data; const setStartedAt = performance.now(); - set({ + set((state) => ({ selectedTeamName: teamName, - selectedTeamData: previousData - ? { - ...data, - tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), - } - : data, + selectedTeamData: nextTeamData, + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }, selectedTeamLoading: false, selectedTeamError: null, - }); + })); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -1925,10 +1991,6 @@ export const createTeamSlice: StateCreator = (set, refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => { const startedAt = performance.now(); - const state = get(); - if (state.selectedTeamName !== teamName) { - return; - } inFlightRefreshTeamDataCalls.add(teamName); // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). @@ -1942,25 +2004,30 @@ export const createTeamSlice: StateCreator = (set, ); } try { - const previousData = get().selectedTeamData; + const previousData = selectTeamDataForName(get(), teamName); const data = opts?.withDedup ? await fetchTeamDataDeduped(teamName) : await fetchTeamDataFresh(teamName); const ipcMs = performance.now() - startedAt; - // Re-check after async: the user might have navigated away. - if (get().selectedTeamName !== teamName) { - return; - } + const nextTeamData = previousData + ? { + ...data, + tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + } + : data; const setStartedAt = performance.now(); - set({ - selectedTeamData: previousData + set((state) => ({ + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: nextTeamData, + }, + ...(state.selectedTeamName === teamName ? { - ...data, - tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks), + selectedTeamData: nextTeamData, + selectedTeamError: null, } - : data, - selectedTeamError: null, - }); + : {}), + })); lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now()); const setMs = performance.now() - setStartedAt; const postStartedAt = performance.now(); @@ -1988,9 +2055,6 @@ export const createTeamSlice: StateCreator = (set, burstCount, }); } catch (error) { - if (get().selectedTeamName !== teamName) { - return; - } const msg = error instanceof IpcError ? error.message @@ -2002,19 +2066,42 @@ export const createTeamSlice: StateCreator = (set, // Preserve existing data instead of showing a fatal error. if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) { logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`); - set({ selectedTeamError: null }); + if (get().selectedTeamName === teamName) { + set({ selectedTeamError: null }); + } return; } - if (msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT')) { - set({ - selectedTeamLoading: false, - selectedTeamData: null, - selectedTeamError: 'TEAM_DRAFT', + if (shouldInvalidateCachedTeamDataForError(teamName, msg)) { + set((state) => { + const nextCache = state.teamDataCacheByName[teamName] + ? { ...state.teamDataCacheByName } + : 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; } + if (get().selectedTeamName !== teamName) { + return; + } + logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`); // Non-destructive: if we already have data, keep it visible. @@ -2089,10 +2176,22 @@ export const createTeamSlice: StateCreator = (set, sendingMessage: false, sendMessageError: null, lastSendMessageResult: result, - selectedTeamData: - state.selectedTeamName === teamName && state.selectedTeamData - ? upsertLocalSentMessage(state.selectedTeamData, optimisticMessage) - : state.selectedTeamData, + ...(selectTeamDataForName(state, teamName) + ? { + teamDataCacheByName: { + ...state.teamDataCacheByName, + [teamName]: upsertLocalSentMessage( + selectTeamDataForName(state, teamName)!, + optimisticMessage + ), + }, + } + : {}), + ...(state.selectedTeamName === teamName && state.selectedTeamData + ? { + selectedTeamData: upsertLocalSentMessage(state.selectedTeamData, optimisticMessage), + } + : {}), })); await get().refreshTeamData(teamName); } catch (error) { @@ -2303,12 +2402,40 @@ export const createTeamSlice: StateCreator = (set, deleteTeam: async (teamName: string) => { 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().fetchAllTasks(); }, restoreTeam: async (teamName: string) => { 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().fetchAllTasks(); }, @@ -2316,8 +2443,19 @@ export const createTeamSlice: StateCreator = (set, permanentlyDeleteTeam: async (teamName: string) => { await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName)); const state = get(); + const nextCache = { ...state.teamDataCacheByName }; + delete nextCache[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().fetchAllTasks(); @@ -2872,11 +3010,17 @@ export const createTeamSlice: StateCreator = (set, const isCanonicalRun = get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId; + let hydratedVisibleTeam = false; if (isCanonicalRun && becameConfigReady) { const state = get(); - if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) { - void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true }); + if (isVisibleInActiveTeamSurface(state, progress.teamName)) { + 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 = (set, if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) { 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 // config.json is guaranteed to exist. - if (get().selectedTeamName === progress.teamName) { - void get().selectTeam(progress.teamName); + if (state.selectedTeamName === progress.teamName) { + void state.selectTeam(progress.teamName); + } else { + void state.refreshTeamData(progress.teamName, { withDedup: true }); } } }, diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 344b66cf..2d348feb 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -347,7 +347,7 @@ export function getMemberRuntimeAdvisoryLabel( providerId?: TeamProviderId, nowMs = Date.now() ): string | null { - if (!advisory || advisory.kind !== 'sdk_retrying') { + if (advisory?.kind !== 'sdk_retrying') { return null; } const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId); @@ -366,7 +366,7 @@ export function getMemberRuntimeAdvisoryTitle( advisory: MemberRuntimeAdvisory | undefined, providerId?: TeamProviderId ): string | undefined { - if (!advisory || advisory.kind !== 'sdk_retrying') { + if (advisory?.kind !== 'sdk_retrying') { return undefined; } return formatRuntimeAdvisoryTitle(advisory, providerId); diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index 64960d9c..1df2ae6f 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -1,9 +1,9 @@ +import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince'; import { getTaskChangeStateBucket, isTaskChangeSummaryCacheable, type TaskChangeStateBucket, } from '@shared/utils/taskChangeState'; -import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince'; import type { ReviewAPI } from '@shared/types/api'; import type { TeamTaskWithKanban } from '@shared/types/team'; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 5d37342a..65b50340 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1,3 +1,5 @@ +import type { EnhancedChunk } from '@main/types'; + export interface TeamMember { name: string; agentId?: string; @@ -152,6 +154,169 @@ export interface TaskRef { 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 { id: string; author: string; diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index ce765451..384d24ed 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -1,6 +1,7 @@ import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useStore } from '@renderer/store'; vi.mock('@renderer/components/ui/badge', () => ({ Badge: ({ children }: { children: React.ReactNode }) => @@ -38,9 +39,34 @@ function makeMemberNode(spawnStatus: GraphNode['spawnStatus']): 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', () => { afterEach(() => { document.body.innerHTML = ''; + useStore.setState({ + selectedTeamName: null, + selectedTeamData: null, + teamDataCacheByName: {}, + } as never); vi.unstubAllGlobals(); }); @@ -80,4 +106,156 @@ describe('GraphNodePopover spawn badge labels', () => { 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(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 0c6fe059..0464ea92 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter'; import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team'; +import type { GraphDataPort } from '@claude-teams/agent-graph'; function createBaseTeamData( overrides?: Partial & { @@ -53,6 +54,10 @@ function createBaseTeamData( }; } +function findNode(graph: GraphDataPort, nodeId: string) { + return graph.nodes.find((node) => node.id === nodeId); +} + describe('TeamGraphAdapter particles', () => { it('creates a message particle for a new incoming message from the newest message set', () => { const adapter = TeamGraphAdapter.create(); @@ -502,6 +507,215 @@ describe('TeamGraphAdapter particles', () => { 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', () => { const adapter = TeamGraphAdapter.create(); adapter.adapt(createBaseTeamData(), 'my-team'); diff --git a/test/renderer/features/agent-graph/buildFocusState.test.ts b/test/renderer/features/agent-graph/buildFocusState.test.ts new file mode 100644 index 00000000..a5ae5502 --- /dev/null +++ b/test/renderer/features/agent-graph/buildFocusState.test.ts @@ -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(); + }); +}); diff --git a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts new file mode 100644 index 00000000..e9a635b4 --- /dev/null +++ b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts @@ -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', + }, + }); + }); +}); diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 38b1c23f..9d7fa351 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -71,14 +71,15 @@ describe('team change throttling', () => { vi.useFakeTimers(); const fetchTeams = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); - const refreshSelectedTeamChangePresence = vi.fn(async () => undefined); + const refreshTeamChangePresence = vi.fn(async () => undefined); useStore.setState({ fetchTeams, refreshTeamData, - refreshSelectedTeamChangePresence, + refreshTeamChangePresence, selectedTeamName: null, selectedTeamData: null, + teamDataCacheByName: {}, paneLayout: { focusedPaneId: 'p1', panes: [ @@ -165,6 +166,39 @@ describe('team change throttling', () => { 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 () => { const fetchAllTasksSpy = vi.fn(async () => undefined); useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); @@ -192,23 +226,64 @@ describe('team change throttling', () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); - const refreshSelectedTeamChangePresenceSpy = vi.spyOn( - state, - 'refreshSelectedTeamChangePresence' - ); + const refreshTeamChangePresenceSpy = vi.spyOn(state, 'refreshTeamChangePresence'); hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' }); await vi.advanceTimersByTimeAsync(399); - expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled(); + expect(refreshTeamChangePresenceSpy).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); - expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1); - expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); + expect(refreshTeamChangePresenceSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team'); expect(refreshTeamDataSpy).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 () => { const invalidateTaskChangePresence = vi.fn(); 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 () => { // Add a second visible team tab useStore.setState({ @@ -374,6 +530,41 @@ describe('team change throttling', () => { 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 () => { useStore.setState({ activeToolsByTeam: { diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 46d8ed03..8a352043 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { + __resetTeamSliceModuleStateForTests, createTeamSlice, getCurrentProvisioningProgressForTeam, } from '../../../src/renderer/store/slices/teamSlice'; @@ -13,6 +14,9 @@ const hoisted = vi.hoisted(() => ({ getProvisioningStatus: vi.fn(), getMemberSpawnStatuses: vi.fn(), cancelProvisioning: vi.fn(), + deleteTeam: vi.fn(), + restoreTeam: vi.fn(), + permanentlyDeleteTeam: vi.fn(), sendMessage: vi.fn(), requestReview: vi.fn(), updateKanban: vi.fn(), @@ -29,6 +33,9 @@ vi.mock('@renderer/api', () => ({ getProvisioningStatus: hoisted.getProvisioningStatus, getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses, cancelProvisioning: hoisted.cancelProvisioning, + deleteTeam: hoisted.deleteTeam, + restoreTeam: hoisted.restoreTeam, + permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, sendMessage: hoisted.sendMessage, requestReview: hoisted.requestReview, updateKanban: hoisted.updateKanban, @@ -74,6 +81,8 @@ function createSliceStore() { getAllPaneTabs: vi.fn(() => []), warmTaskChangeSummaries: vi.fn(async () => undefined), invalidateTaskChangePresence: vi.fn(), + fetchTeams: vi.fn(async () => undefined), + fetchAllTasks: vi.fn(async () => undefined), })); } @@ -118,6 +127,7 @@ function createMemberSpawnSnapshot(overrides: Record = {}) { describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); + __resetTeamSliceModuleStateForTests(); hoisted.list.mockResolvedValue([]); hoisted.getData.mockResolvedValue({ teamName: 'my-team', @@ -143,6 +153,9 @@ describe('teamSlice actions', () => { }); hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null }); 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 () => { @@ -207,6 +220,104 @@ describe('teamSlice actions', () => { 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', () => { it('does not set fatal error on TEAM_PROVISIONING', async () => { const store = createSliceStore(); @@ -261,6 +372,74 @@ describe('teamSlice actions', () => { 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 () => { const store = createSliceStore(); store.setState({ @@ -512,6 +691,97 @@ describe('teamSlice actions', () => { 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', () => { const store = createSliceStore(); const startedAt = '2026-03-12T10:00:00.000Z';