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

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

View file

@ -24,11 +24,12 @@ export function drawAgents(
nodes: GraphNode[],
time: number,
selectedId: string | null,
hoveredId: string | null
hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | 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<string> | 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<string> | null
): number {
return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1;
}
function drawExceptionPip(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
r: number,
tone: NonNullable<GraphNode['exceptionTone']>
): void {
const pipX = x + r * 0.58;
const pipY = y - r * 0.58;
const pipColor = tone === 'error' ? '#ef4444' : '#f59e0b';
ctx.save();
ctx.beginPath();
ctx.arc(pipX, pipY, 4.5, 0, Math.PI * 2);
ctx.fillStyle = pipColor;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = '#050510';
ctx.stroke();
ctx.restore();
}
function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';

View file

@ -74,6 +74,7 @@ export function drawEdges(
nodeMap: Map<string, GraphNode>,
_time: number,
hasActiveParticles: Set<string>,
focusEdgeIds?: ReadonlySet<string> | 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) {

View file

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

View file

@ -17,6 +17,7 @@ export function drawProcesses(
time: number,
selectedId: string | null,
hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | 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;

View file

@ -19,11 +19,12 @@ export function drawTasks(
time: number,
selectedId: string | null,
hoveredId: string | null,
focusNodeIds?: ReadonlySet<string> | 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<string> | 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

View file

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

View file

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

View file

@ -30,6 +30,8 @@ export interface GraphDrawState {
camera: CameraTransform;
selectedNodeId: string | null;
hoveredNodeId: string | null;
focusNodeIds: ReadonlySet<string> | null;
focusEdgeIds: ReadonlySet<string> | null;
}
export interface GraphCanvasHandle {
@ -199,20 +201,48 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(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);

View file

@ -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(() => {

View file

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

View file

@ -1,5 +1,4 @@
import { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import 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);
}
}}
/>
</Suspense>

View file

@ -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<string>();
readonly #seenMessageIds = new Set<string>();
#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<string, MemberSpawnStatusEntry>,
leadActivity?: LeadActivityState,
leadContext?: LeadContextUsage,
pendingApprovalAgents?: Set<string>,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
@ -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<string>,
leadActivity?: LeadActivityState,
leadContext?: LeadContextUsage,
activeTools?: Record<string, Record<string, ActiveToolCall>>,
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
@ -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<string, unknown>
): void {
// Build lookup tables for fast resolution
const completedTaskIds = new Set<string>();
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
const taskDisplayIds = new Map<string, string>();
const memberColorByName = new Map<string, string>();
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<string>();
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<string>();
for (const task of data.tasks) {
if (task.status === 'deleted' || !visibleTaskIds.has(task.id)) continue;
const taskNodeId = `task:${teamName}:${task.id}`;
for (const blockerId of task.blockedBy ?? []) {
if (!visibleTaskIds.has(blockerId)) continue;
const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, blockerId, task.id);
if (seenBlockingEdges.has(edgeId)) continue;
seenBlockingEdges.add(edgeId);
edges.push({
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<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
if (spawn?.launchState === 'failed_to_start' || spawn?.status === 'error') {
return { exceptionTone: 'error', exceptionLabel: 'spawn failed' };
}
if (pendingApproval) {
return { exceptionTone: 'warning', exceptionLabel: 'awaiting approval' };
}
if (spawn?.status === 'waiting' || spawn?.status === 'spawning') {
return { exceptionTone: 'warning', exceptionLabel: 'starting' };
}
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, providerId);
if (runtimeAdvisoryLabel) {
return {
exceptionTone: 'warning',
exceptionLabel: runtimeAdvisoryLabel,
};
}
return undefined;
}
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {
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}`;
}

View file

@ -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<string>();
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,

View file

@ -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 (
<OverflowPopoverContent
node={node}
teamName={teamName}
onClose={onClose}
onOpenTaskDetail={onOpenTaskDetail}
/>
);
}
return (
<GraphTaskCard
node={node}
@ -151,6 +165,18 @@ export const GraphNodePopover = ({
{node.processRegisteredAt && (
<div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div>
)}
{node.exceptionLabel && (
<Badge
variant="outline"
className={`px-1.5 py-0 text-[10px] ${
node.exceptionTone === 'error'
? 'border-red-500/30 text-red-400'
: 'border-amber-500/30 text-amber-400'
}`}
>
{node.exceptionLabel}
</Badge>
)}
</div>
{node.processUrl && (
<a
@ -166,6 +192,74 @@ export const GraphNodePopover = ({
);
};
const OverflowPopoverContent = ({
node,
teamName,
onClose,
onOpenTaskDetail,
}: {
node: GraphNode;
teamName: string;
onClose: () => void;
onOpenTaskDetail?: (taskId: string) => void;
}): React.JSX.Element => {
const teamData = useStore((state) => selectTeamDataForName(state, teamName));
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
const hiddenTasks = (node.overflowTaskIds ?? [])
.map((taskId) => tasksById.get(taskId) ?? null)
.filter((task): task is TeamTaskWithKanban => task != null);
return (
<div className="min-w-[240px] max-w-[320px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-[var(--color-text)]">Hidden tasks</div>
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{node.overflowCount ?? hiddenTasks.length}
</Badge>
</div>
<div className="mt-2 max-h-[260px] space-y-1 overflow-y-auto pr-1">
{hiddenTasks.length === 0 ? (
<div className="text-xs text-[var(--color-text-muted)]">No hidden tasks available.</div>
) : (
hiddenTasks.map((task) => {
const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]);
return (
<button
key={task.id}
type="button"
className="flex w-full items-start justify-between gap-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] px-2 py-2 text-left transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => {
onOpenTaskDetail?.(task.id);
onClose();
}}
>
<div className="min-w-0">
<div className="font-mono text-[10px] text-[var(--color-text-muted)]">
{task.displayId ?? `#${task.id.slice(0, 6)}`}
</div>
<div className="truncate text-xs text-[var(--color-text)]">{task.subject}</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{task.owner && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{task.owner}
</Badge>
)}
{isTaskInReviewCycle(task) && (
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
{reviewer ?? 'REV'}
</Badge>
)}
</div>
</button>
);
})
)}
</div>
</div>
);
};
// ─── Member Popover ─────────────────────────────────────────────────────────
const MemberPopoverContent = ({
@ -261,6 +355,18 @@ const MemberPopoverContent = ({
{getSpawnStatusBadgeLabel(node.spawnStatus)}
</Badge>
)}
{node.exceptionLabel && (
<Badge
variant="outline"
className={`px-1.5 py-0 text-[10px] ${
node.exceptionTone === 'error'
? 'border-red-500/30 text-red-400'
: 'border-amber-500/30 text-amber-400'
}`}
>
{node.exceptionLabel}
</Badge>
)}
</div>
{/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */}

View file

@ -7,8 +7,11 @@ import { useMemo } from 'react';
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
import { 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<string, TeamTask>): 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 (
<div
className={`min-w-[260px] max-w-[320px] rounded-lg shadow-2xl ${getPulseClass(task)}`}
style={getGlowStyle(task)}
style={getGlowStyle(task, taskMap)}
>
<KanbanTaskCard
task={taskWithKanban}

View file

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

View file

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

View file

@ -37,6 +37,7 @@ import {
createTeamSlice,
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<string> => {
const { selectedTeamName, selectedTeamData } = useStore.getState();
if (
!selectedTeamName ||
selectedTeamData?.teamName !== selectedTeamName ||
!isTeamVisibleInAnyPane(selectedTeamName)
) {
return new Set<string>();
}
return new Set([selectedTeamName]);
};
const getTrackedToolActivityTeams = (): Set<string> => {
const { paneLayout } = useStore.getState();
const tracked = new Set<string>();
const getVisibleTeamNamesInAnyPane = (state = useStore.getState()): Set<string> => {
const { paneLayout } = state;
const visibleTeamNames = new Set<string>();
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<string> => {
const state = useStore.getState();
const tracked = new Set<string>();
for (const teamName of getVisibleTeamNamesInAnyPane(state)) {
if (selectTeamDataForName(state, teamName)) {
tracked.add(teamName);
}
}
return tracked;
};
const getTrackedToolActivityTeams = (): Set<string> => {
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;

View file

@ -40,9 +40,9 @@ const teamRefreshBurstDiagnostics = new Map<
{ windowStartedAt: number; count: number; lastWarnAt: number }
>();
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
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<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>,
teamName: string | null | undefined
): TeamData | null {
if (!teamName) {
return null;
}
return (
state.teamDataCacheByName[teamName] ??
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
);
}
function isVisibleInActiveTeamSurface(
state: Pick<AppState, 'paneLayout'>,
teamName: string | null | undefined
): boolean {
if (!teamName) {
return false;
}
return state.paneLayout.panes.some((pane) => {
if (!pane.activeTabId) {
return false;
}
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
return (
(activeTab?.type === 'team' || activeTab?.type === 'graph') && activeTab.teamName === teamName
);
});
}
function shouldInvalidateCachedTeamDataForError(teamName: string, message: string): boolean {
return (
message === 'TEAM_DRAFT' ||
message.includes('TEAM_DRAFT') ||
message === `Team not found: ${teamName}` ||
message === 'Team config not found'
);
}
export interface TeamSlice {
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<string, TeamData>;
selectedTeamLoading: boolean;
selectedTeamLoadNonce: number;
selectedTeamError: string | null;
@ -994,7 +1042,7 @@ export interface TeamSlice {
taskId: string,
presence: TaskChangePresenceState
) => void;
refreshSelectedTeamChangePresence: (teamName: string) => Promise<void>;
refreshTeamChangePresence: (teamName: string) => Promise<void>;
selectTeam: (
teamName: string,
opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean }
@ -1239,6 +1287,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
globalTasksError: null,
selectedTeamName: null,
selectedTeamData: null,
teamDataCacheByName: {},
selectedTeamLoading: false,
selectedTeamLoadNonce: 0,
selectedTeamError: null,
@ -1660,20 +1709,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
burstCount,
});
} catch (error) {
if (get().selectedTeamName !== teamName) {
return;
}
const msg =
error instanceof IpcError
? error.message
@ -2002,19 +2066,42 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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 });
}
}
},

View file

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

View file

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

View file

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

View file

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

View file

@ -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<TeamData> & {
@ -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');

View file

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

View file

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

View file

@ -71,14 +71,15 @@ describe('team change throttling', () => {
vi.useFakeTimers();
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: {

View file

@ -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<string, unknown> = {}) {
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';