fix(agent-graph): keep graph state consistent across panes
This commit is contained in:
parent
02d516cb4e
commit
f74b7a3701
28 changed files with 2410 additions and 372 deletions
|
|
@ -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)';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
152
packages/agent-graph/src/ui/buildFocusState.ts
Normal file
152
packages/agent-graph/src/ui/buildFocusState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
173
test/renderer/features/agent-graph/buildFocusState.test.ts
Normal file
173
test/renderer/features/agent-graph/buildFocusState.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue