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[],
|
nodes: GraphNode[],
|
||||||
time: number,
|
time: number,
|
||||||
selectedId: string | null,
|
selectedId: string | null,
|
||||||
hoveredId: string | null
|
hoveredId: string | null,
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null
|
||||||
): void {
|
): void {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.kind !== 'member' && node.kind !== 'lead') continue;
|
if (node.kind !== 'member' && node.kind !== 'lead') continue;
|
||||||
const opacity = getNodeOpacity(node);
|
const opacity = getNodeOpacity(node) * getFocusOpacity(node.id, focusNodeIds);
|
||||||
if (opacity < MIN_VISIBLE_OPACITY) continue;
|
if (opacity < MIN_VISIBLE_OPACITY) continue;
|
||||||
|
|
||||||
const x = node.x ?? 0;
|
const x = node.x ?? 0;
|
||||||
|
|
@ -95,6 +96,10 @@ export function drawAgents(
|
||||||
drawToolCard(ctx, x, y, r, node.activeTool, time);
|
drawToolCard(ctx, x, y, r, node.activeTool, time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.exceptionTone) {
|
||||||
|
drawExceptionPip(ctx, x, y, r, node.exceptionTone);
|
||||||
|
}
|
||||||
|
|
||||||
// Name + role label (single line: "jack · developer")
|
// Name + role label (single line: "jack · developer")
|
||||||
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
|
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
|
||||||
drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
|
drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
|
||||||
|
|
@ -123,7 +128,8 @@ export function drawCrossTeamNodes(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
time: number,
|
time: number,
|
||||||
selectedId: string | null,
|
selectedId: string | null,
|
||||||
hoveredId: string | null
|
hoveredId: string | null,
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null
|
||||||
): void {
|
): void {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.kind !== 'crossteam') continue;
|
if (node.kind !== 'crossteam') continue;
|
||||||
|
|
@ -136,7 +142,7 @@ export function drawCrossTeamNodes(
|
||||||
const isHovered = node.id === hoveredId;
|
const isHovered = node.id === hoveredId;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = isHovered ? 0.7 : 0.5;
|
ctx.globalAlpha = (isHovered ? 0.7 : 0.5) * getFocusOpacity(node.id, focusNodeIds);
|
||||||
|
|
||||||
// Subtle glow
|
// Subtle glow
|
||||||
const glowR = r + AGENT_DRAW.glowPadding;
|
const glowR = r + AGENT_DRAW.glowPadding;
|
||||||
|
|
@ -188,6 +194,35 @@ function getNodeOpacity(node: GraphNode): number {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFocusOpacity(
|
||||||
|
nodeId: string,
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null
|
||||||
|
): number {
|
||||||
|
return focusNodeIds && !focusNodeIds.has(nodeId) ? 0.25 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawExceptionPip(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
r: number,
|
||||||
|
tone: NonNullable<GraphNode['exceptionTone']>
|
||||||
|
): void {
|
||||||
|
const pipX = x + r * 0.58;
|
||||||
|
const pipY = y - r * 0.58;
|
||||||
|
const pipColor = tone === 'error' ? '#ef4444' : '#f59e0b';
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(pipX, pipY, 4.5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = pipColor;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.strokeStyle = '#050510';
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
|
function drawDepthShadow(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export function drawEdges(
|
||||||
nodeMap: Map<string, GraphNode>,
|
nodeMap: Map<string, GraphNode>,
|
||||||
_time: number,
|
_time: number,
|
||||||
hasActiveParticles: Set<string>,
|
hasActiveParticles: Set<string>,
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null,
|
||||||
): void {
|
): void {
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const source = nodeMap.get(edge.source);
|
const source = nodeMap.get(edge.source);
|
||||||
|
|
@ -87,13 +88,14 @@ export function drawEdges(
|
||||||
const alpha = isActive
|
const alpha = isActive
|
||||||
? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6)
|
? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6)
|
||||||
: BEAM.idleAlpha;
|
: BEAM.idleAlpha;
|
||||||
|
const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1;
|
||||||
|
|
||||||
if (alpha < MIN_VISIBLE_OPACITY) continue;
|
if (alpha * focusAlpha < MIN_VISIBLE_OPACITY) continue;
|
||||||
|
|
||||||
const cp = computeControlPoints(source.x, source.y, target.x, target.y);
|
const cp = computeControlPoints(source.x, source.y, target.x, target.y);
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = alpha;
|
ctx.globalAlpha = alpha * focusAlpha;
|
||||||
|
|
||||||
// Subtle glow pass when edge has active particles
|
// Subtle glow pass when edge has active particles
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ export function drawParticles(
|
||||||
edgeMap: Map<string, GraphEdge>,
|
edgeMap: Map<string, GraphEdge>,
|
||||||
nodeMap: Map<string, GraphNode>,
|
nodeMap: Map<string, GraphNode>,
|
||||||
time: number,
|
time: number,
|
||||||
|
focusEdgeIds?: ReadonlySet<string> | null,
|
||||||
): void {
|
): void {
|
||||||
for (const p of particles) {
|
for (const p of particles) {
|
||||||
|
if (focusEdgeIds && !focusEdgeIds.has(p.edgeId)) continue;
|
||||||
const edge = edgeMap.get(p.edgeId);
|
const edge = edgeMap.get(p.edgeId);
|
||||||
if (!edge) continue;
|
if (!edge) continue;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export function drawProcesses(
|
||||||
time: number,
|
time: number,
|
||||||
selectedId: string | null,
|
selectedId: string | null,
|
||||||
hoveredId: string | null,
|
hoveredId: string | null,
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null,
|
||||||
): void {
|
): void {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.kind !== 'process') continue;
|
if (node.kind !== 'process') continue;
|
||||||
|
|
@ -26,9 +27,10 @@ export function drawProcesses(
|
||||||
const r = NODE.radiusProcess;
|
const r = NODE.radiusProcess;
|
||||||
const isSelected = node.id === selectedId;
|
const isSelected = node.id === selectedId;
|
||||||
const isHovered = node.id === hoveredId;
|
const isHovered = node.id === hoveredId;
|
||||||
|
const focusOpacity = focusNodeIds && !focusNodeIds.has(node.id) ? 0.25 : 1;
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = 0.8;
|
ctx.globalAlpha = 0.8 * focusOpacity;
|
||||||
|
|
||||||
// Glow — use cached sprite instead of createRadialGradient per frame
|
// Glow — use cached sprite instead of createRadialGradient per frame
|
||||||
const procColor = node.color ?? COLORS.tool_calling;
|
const procColor = node.color ?? COLORS.tool_calling;
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@ export function drawTasks(
|
||||||
time: number,
|
time: number,
|
||||||
selectedId: string | null,
|
selectedId: string | null,
|
||||||
hoveredId: string | null,
|
hoveredId: string | null,
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null
|
||||||
): void {
|
): void {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.kind !== 'task') continue;
|
if (node.kind !== 'task') continue;
|
||||||
|
|
||||||
const opacity = getTaskOpacity(node);
|
const opacity = getTaskOpacity(node, focusNodeIds);
|
||||||
if (opacity < MIN_VISIBLE_OPACITY) continue;
|
if (opacity < MIN_VISIBLE_OPACITY) continue;
|
||||||
|
|
||||||
const x = node.x ?? 0;
|
const x = node.x ?? 0;
|
||||||
|
|
@ -42,8 +43,12 @@ export function drawTasks(
|
||||||
|
|
||||||
// ─── Private ────────────────────────────────────────────────────────────────
|
// ─── Private ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getTaskOpacity(_node: GraphNode): number {
|
function getTaskOpacity(
|
||||||
if (_node.taskStatus === 'deleted') return 0;
|
node: GraphNode,
|
||||||
|
focusNodeIds?: ReadonlySet<string> | null
|
||||||
|
): number {
|
||||||
|
if (node.taskStatus === 'deleted') return 0;
|
||||||
|
if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25;
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,7 +59,7 @@ function drawTaskPill(
|
||||||
node: GraphNode,
|
node: GraphNode,
|
||||||
time: number,
|
time: number,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isHovered: boolean,
|
isHovered: boolean
|
||||||
): void {
|
): void {
|
||||||
const w = TASK_PILL.width;
|
const w = TASK_PILL.width;
|
||||||
const h = TASK_PILL.height;
|
const h = TASK_PILL.height;
|
||||||
|
|
@ -65,6 +70,15 @@ function drawTaskPill(
|
||||||
const statusColor = getTaskStatusColor(node.taskStatus);
|
const statusColor = getTaskStatusColor(node.taskStatus);
|
||||||
const reviewColor = getReviewStateColor(node.reviewState);
|
const reviewColor = getReviewStateColor(node.reviewState);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(x, y);
|
||||||
|
|
||||||
|
if (node.isOverflowStack) {
|
||||||
|
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
|
||||||
|
ctx.restore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Pulse only for active work — completed + approved = static
|
// Pulse only for active work — completed + approved = static
|
||||||
const needsAttention =
|
const needsAttention =
|
||||||
(node.taskStatus === 'in_progress' && node.reviewState !== 'approved') ||
|
(node.taskStatus === 'in_progress' && node.reviewState !== 'approved') ||
|
||||||
|
|
@ -72,13 +86,12 @@ function drawTaskPill(
|
||||||
node.reviewState === 'needsFix' ||
|
node.reviewState === 'needsFix' ||
|
||||||
(node.needsClarification != null);
|
(node.needsClarification != null);
|
||||||
const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved';
|
const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved';
|
||||||
const breathe = needsAttention && !isFinished
|
const breathe =
|
||||||
? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed)
|
needsAttention && !isFinished
|
||||||
: 1;
|
? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed)
|
||||||
|
: 1;
|
||||||
const scale = breathe;
|
const scale = breathe;
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(x, y);
|
|
||||||
ctx.scale(scale, scale);
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
// Shadow — stronger for attention tasks, red for blocked
|
// Shadow — stronger for attention tasks, red for blocked
|
||||||
|
|
@ -122,9 +135,10 @@ function drawTaskPill(
|
||||||
if (reviewColor !== 'transparent') {
|
if (reviewColor !== 'transparent') {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1);
|
ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1);
|
||||||
const reviewAlpha = node.reviewState === 'approved'
|
const reviewAlpha =
|
||||||
? 0.6 // static — no pulse
|
node.reviewState === 'approved'
|
||||||
: 0.5 + 0.3 * Math.sin(time * 3); // pulsing for review/needsFix
|
? 0.6
|
||||||
|
: 0.5 + 0.3 * Math.sin(time * 3);
|
||||||
ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha);
|
ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha);
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
@ -147,7 +161,10 @@ function drawTaskPill(
|
||||||
ctx.textBaseline = 'middle';
|
ctx.textBaseline = 'middle';
|
||||||
ctx.fillStyle = COLORS.textPrimary;
|
ctx.fillStyle = COLORS.textPrimary;
|
||||||
const textX = -halfW + 10;
|
const textX = -halfW + 10;
|
||||||
const maxW = w - 18;
|
const hasReviewChip =
|
||||||
|
node.reviewState !== 'approved' &&
|
||||||
|
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName));
|
||||||
|
const maxW = hasReviewChip ? w - 64 : w - 18;
|
||||||
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
|
const subject = truncateText(ctx, node.sublabel, maxW, ctx.font);
|
||||||
ctx.fillText(subject, textX, -4);
|
ctx.fillText(subject, textX, -4);
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +186,13 @@ function drawTaskPill(
|
||||||
ctx.fillText('\u2713', halfW - 8, 0); // ✓
|
ctx.fillText('\u2713', halfW - 8, 0); // ✓
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.reviewState !== 'approved' &&
|
||||||
|
(node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && node.reviewerName))
|
||||||
|
) {
|
||||||
|
drawReviewChip(ctx, halfW, -halfH, node);
|
||||||
|
}
|
||||||
|
|
||||||
// Comment count badge — on the bottom-right border edge, 1.5x bigger
|
// Comment count badge — on the bottom-right border edge, 1.5x bigger
|
||||||
if (node.totalCommentCount && node.totalCommentCount > 0) {
|
if (node.totalCommentCount && node.totalCommentCount > 0) {
|
||||||
const badgeX = halfW - 6;
|
const badgeX = halfW - 6;
|
||||||
|
|
@ -215,12 +239,93 @@ function drawTaskPill(
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawOverflowStack(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
halfW: number,
|
||||||
|
halfH: number,
|
||||||
|
r: number,
|
||||||
|
node: GraphNode,
|
||||||
|
isSelected: boolean,
|
||||||
|
isHovered: boolean
|
||||||
|
): void {
|
||||||
|
for (const [offset, alpha] of [
|
||||||
|
[6, 0.18],
|
||||||
|
[3, 0.28],
|
||||||
|
] as const) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-halfW + offset, -halfH - offset, TASK_PILL.width, TASK_PILL.height, r);
|
||||||
|
ctx.fillStyle = hexWithAlpha('#334155', alpha);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(-halfW, -halfH, TASK_PILL.width, TASK_PILL.height, r);
|
||||||
|
ctx.fillStyle = isSelected
|
||||||
|
? COLORS.cardBgSelected
|
||||||
|
: isHovered
|
||||||
|
? 'rgba(15, 20, 40, 0.78)'
|
||||||
|
: COLORS.cardBg;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55);
|
||||||
|
ctx.lineWidth = isSelected ? 2 : 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.font = 'bold 12px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillStyle = COLORS.textPrimary;
|
||||||
|
ctx.fillText(node.label, -halfW + 12, -2);
|
||||||
|
|
||||||
|
ctx.font = '7px monospace';
|
||||||
|
ctx.fillStyle = COLORS.textDim;
|
||||||
|
ctx.fillText('more tasks', -halfW + 12, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawReviewChip(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
halfW: number,
|
||||||
|
halfH: number,
|
||||||
|
node: GraphNode
|
||||||
|
): void {
|
||||||
|
const chipText = node.reviewMode === 'manual' ? 'REV' : node.reviewerName ?? 'REV';
|
||||||
|
const chipColor = node.reviewMode === 'manual' ? '#8b5cf6' : (node.reviewerColor ?? '#38bdf8');
|
||||||
|
const chipX = halfW - 44;
|
||||||
|
const chipY = halfH + 10;
|
||||||
|
const chipW = 34;
|
||||||
|
const chipH = 12;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(chipX, chipY, chipW, chipH, 6);
|
||||||
|
ctx.fillStyle = hexWithAlpha(chipColor, 0.2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = hexWithAlpha(chipColor, 0.55);
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.font = 'bold 7px monospace';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillStyle = hexWithAlpha(chipColor, 0.95);
|
||||||
|
ctx.fillText(
|
||||||
|
chipText.length > 8 ? `${chipText.slice(0, 7)}…` : chipText,
|
||||||
|
chipX + chipW / 2,
|
||||||
|
chipY + chipH / 2 + 0.5
|
||||||
|
);
|
||||||
|
|
||||||
|
if (node.changePresence === 'has_changes') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(chipX + chipW + 4, chipY + chipH / 2, 2.5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#38bdf8';
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw kanban column headers above task columns.
|
* Draw kanban column headers above task columns.
|
||||||
*/
|
*/
|
||||||
export function drawColumnHeaders(
|
export function drawColumnHeaders(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
zones: KanbanZoneInfo[],
|
zones: KanbanZoneInfo[]
|
||||||
): void {
|
): void {
|
||||||
for (const zone of zones) {
|
for (const zone of zones) {
|
||||||
// Section header for unassigned tasks — larger, centered above all columns
|
// Section header for unassigned tasks — larger, centered above all columns
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ export class KanbanLayoutEngine {
|
||||||
// ─── Private ──────────────────────────────────────────────────────────────
|
// ─── Private ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null {
|
static #layoutZone(tasks: GraphNode[], ownerX: number, ownerY: number, ownerId: string): KanbanZoneInfo | null {
|
||||||
const { columnWidth, rowHeight, offsetY, columns, maxVisibleRows } = KANBAN_ZONE;
|
const { columnWidth, rowHeight, offsetY, columns } = KANBAN_ZONE;
|
||||||
const headerHeight = 20; // space for column header label
|
const headerHeight = 20; // space for column header label
|
||||||
const baseY = ownerY + offsetY;
|
const baseY = ownerY + offsetY;
|
||||||
|
|
||||||
|
|
@ -129,8 +129,8 @@ export class KanbanLayoutEngine {
|
||||||
for (const [colIdx, col] of activeColumns.entries()) {
|
for (const [colIdx, col] of activeColumns.entries()) {
|
||||||
const colX = baseX + colIdx * columnWidth;
|
const colX = baseX + colIdx * columnWidth;
|
||||||
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
|
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
|
||||||
const overflow = Math.max(0, col.tasks.length - maxVisibleRows);
|
const overflow = col.tasks.find((task) => task.isOverflowStack)?.overflowCount ?? 0;
|
||||||
const visibleCount = Math.min(col.tasks.length, maxVisibleRows);
|
const visibleCount = col.tasks.length;
|
||||||
|
|
||||||
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
|
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
|
||||||
headers.push({
|
headers.push({
|
||||||
|
|
@ -144,13 +144,6 @@ export class KanbanLayoutEngine {
|
||||||
|
|
||||||
// Position tasks below header
|
// Position tasks below header
|
||||||
for (const [rowIdx, task] of col.tasks.entries()) {
|
for (const [rowIdx, task] of col.tasks.entries()) {
|
||||||
if (rowIdx >= maxVisibleRows) {
|
|
||||||
task.x = -99999;
|
|
||||||
task.y = -99999;
|
|
||||||
task.fx = task.x;
|
|
||||||
task.fy = task.y;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const targetX = colX;
|
const targetX = colX;
|
||||||
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
||||||
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
|
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
|
||||||
|
|
@ -207,6 +200,7 @@ export class KanbanLayoutEngine {
|
||||||
|
|
||||||
// Add zone header for unassigned section
|
// Add zone header for unassigned section
|
||||||
if (tasks.length > 0) {
|
if (tasks.length > 0) {
|
||||||
|
const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
|
||||||
this.zones.push({
|
this.zones.push({
|
||||||
ownerId: '__unassigned__',
|
ownerId: '__unassigned__',
|
||||||
ownerX: centerX,
|
ownerX: centerX,
|
||||||
|
|
@ -216,7 +210,7 @@ export class KanbanLayoutEngine {
|
||||||
x: centerX,
|
x: centerX,
|
||||||
y: baseY - 10,
|
y: baseY - 10,
|
||||||
color: COLORS.taskPending,
|
color: COLORS.taskPending,
|
||||||
overflowCount: Math.max(0, tasks.length - cols * KANBAN_ZONE.maxVisibleRows),
|
overflowCount,
|
||||||
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,10 @@ export interface GraphNode {
|
||||||
resultPreview?: string;
|
resultPreview?: string;
|
||||||
source: 'runtime' | 'member_log' | 'inbox';
|
source: 'runtime' | 'member_log' | 'inbox';
|
||||||
}>;
|
}>;
|
||||||
|
/** Compact abnormal-state indicator */
|
||||||
|
exceptionTone?: 'warning' | 'error';
|
||||||
|
/** Short human-readable abnormal-state label */
|
||||||
|
exceptionLabel?: string;
|
||||||
|
|
||||||
// ─── Task-specific ─────────────────────────────────────────────────────
|
// ─── Task-specific ─────────────────────────────────────────────────────
|
||||||
/** Short display ID (e.g., "#3") */
|
/** Short display ID (e.g., "#3") */
|
||||||
|
|
@ -90,6 +94,14 @@ export interface GraphNode {
|
||||||
taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted';
|
taskStatus?: 'pending' | 'in_progress' | 'completed' | 'deleted';
|
||||||
/** Review state overlay */
|
/** Review state overlay */
|
||||||
reviewState?: 'none' | 'review' | 'needsFix' | 'approved';
|
reviewState?: 'none' | 'review' | 'needsFix' | 'approved';
|
||||||
|
/** Reviewer shown as a compact handoff chip for active review cycles */
|
||||||
|
reviewerName?: string | null;
|
||||||
|
/** Reviewer chip mode */
|
||||||
|
reviewMode?: 'assigned' | 'manual';
|
||||||
|
/** Reviewer color override for compact review chip */
|
||||||
|
reviewerColor?: string;
|
||||||
|
/** Cheap persisted change-presence state used only for active review chips */
|
||||||
|
changePresence?: 'has_changes' | 'no_changes' | 'unknown';
|
||||||
/** Requires clarification indicator */
|
/** Requires clarification indicator */
|
||||||
needsClarification?: 'lead' | 'user' | null;
|
needsClarification?: 'lead' | 'user' | null;
|
||||||
/** Task is blocked by other tasks */
|
/** Task is blocked by other tasks */
|
||||||
|
|
@ -102,6 +114,12 @@ export interface GraphNode {
|
||||||
totalCommentCount?: number;
|
totalCommentCount?: number;
|
||||||
/** Unread comment count on this task */
|
/** Unread comment count on this task */
|
||||||
unreadCommentCount?: number;
|
unreadCommentCount?: number;
|
||||||
|
/** Synthetic overflow stack node instead of hidden task tails */
|
||||||
|
isOverflowStack?: boolean;
|
||||||
|
/** Number of hidden tasks behind this overflow stack */
|
||||||
|
overflowCount?: number;
|
||||||
|
/** Raw task IDs hidden behind this overflow stack */
|
||||||
|
overflowTaskIds?: string[];
|
||||||
|
|
||||||
// ─── Process-specific ──────────────────────────────────────────────────
|
// ─── Process-specific ──────────────────────────────────────────────────
|
||||||
/** Clickable URL for process */
|
/** Clickable URL for process */
|
||||||
|
|
@ -163,5 +181,11 @@ export type GraphDomainRef =
|
||||||
| { kind: 'lead'; teamName: string; memberName: string }
|
| { kind: 'lead'; teamName: string; memberName: string }
|
||||||
| { kind: 'member'; teamName: string; memberName: string }
|
| { kind: 'member'; teamName: string; memberName: string }
|
||||||
| { kind: 'task'; teamName: string; taskId: string }
|
| { kind: 'task'; teamName: string; taskId: string }
|
||||||
|
| {
|
||||||
|
kind: 'task_overflow';
|
||||||
|
teamName: string;
|
||||||
|
ownerMemberName?: string | null;
|
||||||
|
columnKey: string;
|
||||||
|
}
|
||||||
| { kind: 'process'; teamName: string; processId: string }
|
| { kind: 'process'; teamName: string; processId: string }
|
||||||
| { kind: 'crossteam'; teamName: string; externalTeamName: string };
|
| { kind: 'crossteam'; teamName: string; externalTeamName: string };
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export interface GraphDrawState {
|
||||||
camera: CameraTransform;
|
camera: CameraTransform;
|
||||||
selectedNodeId: string | null;
|
selectedNodeId: string | null;
|
||||||
hoveredNodeId: string | null;
|
hoveredNodeId: string | null;
|
||||||
|
focusNodeIds: ReadonlySet<string> | null;
|
||||||
|
focusEdgeIds: ReadonlySet<string> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphCanvasHandle {
|
export interface GraphCanvasHandle {
|
||||||
|
|
@ -199,20 +201,48 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
|
||||||
visibleEdges.push(e);
|
visibleEdges.push(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges);
|
drawEdges(ctx, visibleEdges, nodeMap, state.time, activeParticleEdges, state.focusEdgeIds);
|
||||||
|
|
||||||
// 2b. Particles (cap at 100 for performance)
|
// 2b. Particles (cap at 100 for performance)
|
||||||
const cappedParticles = state.particles.length > 100
|
const cappedParticles = state.particles.length > 100
|
||||||
? state.particles.slice(-100)
|
? state.particles.slice(-100)
|
||||||
: state.particles;
|
: state.particles;
|
||||||
drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time);
|
drawParticles(ctx, cappedParticles, edgeMap, nodeMap, state.time, state.focusEdgeIds);
|
||||||
|
|
||||||
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
// 2c. Visible nodes only (back to front: process → task → member/lead)
|
||||||
drawProcesses(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
drawProcesses(
|
||||||
drawCrossTeamNodes(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
ctx,
|
||||||
|
visibleNodes,
|
||||||
|
state.time,
|
||||||
|
state.selectedNodeId,
|
||||||
|
state.hoveredNodeId,
|
||||||
|
state.focusNodeIds
|
||||||
|
);
|
||||||
|
drawCrossTeamNodes(
|
||||||
|
ctx,
|
||||||
|
visibleNodes,
|
||||||
|
state.time,
|
||||||
|
state.selectedNodeId,
|
||||||
|
state.hoveredNodeId,
|
||||||
|
state.focusNodeIds
|
||||||
|
);
|
||||||
drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
|
drawColumnHeaders(ctx, KanbanLayoutEngine.zones);
|
||||||
drawTasks(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
drawTasks(
|
||||||
drawAgents(ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId);
|
ctx,
|
||||||
|
visibleNodes,
|
||||||
|
state.time,
|
||||||
|
state.selectedNodeId,
|
||||||
|
state.hoveredNodeId,
|
||||||
|
state.focusNodeIds
|
||||||
|
);
|
||||||
|
drawAgents(
|
||||||
|
ctx,
|
||||||
|
visibleNodes,
|
||||||
|
state.time,
|
||||||
|
state.selectedNodeId,
|
||||||
|
state.hoveredNodeId,
|
||||||
|
state.focusNodeIds
|
||||||
|
);
|
||||||
|
|
||||||
// 2d. Effects
|
// 2d. Effects
|
||||||
drawEffects(ctx, state.effects);
|
drawEffects(ctx, state.effects);
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
* ALL animation state (positions, particles, effects, time) lives in refs.
|
* ALL animation state (positions, particles, effects, time) lives in refs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
||||||
import type { GraphDataPort } from '../ports/GraphDataPort';
|
import type { GraphDataPort } from '../ports/GraphDataPort';
|
||||||
import type { GraphEventPort } from '../ports/GraphEventPort';
|
import type { GraphEventPort } from '../ports/GraphEventPort';
|
||||||
|
|
@ -19,6 +19,7 @@ import type { GraphNode } from '../ports/types';
|
||||||
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
|
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
|
||||||
import { GraphControls, type GraphFilterState } from './GraphControls';
|
import { GraphControls, type GraphFilterState } from './GraphControls';
|
||||||
import { GraphOverlay } from './GraphOverlay';
|
import { GraphOverlay } from './GraphOverlay';
|
||||||
|
import { buildFocusState } from './buildFocusState';
|
||||||
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
import { useGraphSimulation } from '../hooks/useGraphSimulation';
|
||||||
import { useGraphCamera } from '../hooks/useGraphCamera';
|
import { useGraphCamera } from '../hooks/useGraphCamera';
|
||||||
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
import { useGraphInteraction } from '../hooks/useGraphInteraction';
|
||||||
|
|
@ -114,6 +115,10 @@ export function GraphView({
|
||||||
|
|
||||||
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
|
||||||
const idleFrameSkip = useRef(0);
|
const idleFrameSkip = useRef(0);
|
||||||
|
const focusState = useMemo(
|
||||||
|
() => buildFocusState(selectedNodeId, data.nodes, data.edges),
|
||||||
|
[selectedNodeId, data.edges, data.nodes]
|
||||||
|
);
|
||||||
|
|
||||||
const animate = useCallback(() => {
|
const animate = useCallback(() => {
|
||||||
if (!runningRef.current) return;
|
if (!runningRef.current) return;
|
||||||
|
|
@ -154,11 +159,13 @@ export function GraphView({
|
||||||
camera: cameraRef.current.transformRef.current,
|
camera: cameraRef.current.transformRef.current,
|
||||||
selectedNodeId: selectedNodeIdRef.current,
|
selectedNodeId: selectedNodeIdRef.current,
|
||||||
hoveredNodeId: interaction.hoveredNodeId.current,
|
hoveredNodeId: interaction.hoveredNodeId.current,
|
||||||
|
focusNodeIds: focusState.focusNodeIds,
|
||||||
|
focusEdgeIds: focusState.focusEdgeIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(animate);
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs
|
||||||
}, []);
|
}, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]);
|
||||||
|
|
||||||
// Start/stop RAF
|
// Start/stop RAF
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
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 { lazy, memo, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { ComponentProps } from 'react';
|
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
||||||
|
|
@ -36,8 +35,8 @@ import {
|
||||||
type TaskChangeRequestOptions,
|
type TaskChangeRequestOptions,
|
||||||
} from '@renderer/utils/taskChangeRequest';
|
} from '@renderer/utils/taskChangeRequest';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -73,6 +72,7 @@ import { TrashDialog } from './kanban/TrashDialog';
|
||||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||||
|
|
||||||
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
import type { AddMemberEntry } from './dialogs/AddMemberDialog';
|
||||||
|
import type { ComponentProps } from 'react';
|
||||||
|
|
||||||
const ProjectEditorOverlay = lazy(() =>
|
const ProjectEditorOverlay = lazy(() =>
|
||||||
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay }))
|
||||||
|
|
@ -92,13 +92,13 @@ import { TeamSidebarRail } from './sidebar/TeamSidebarRail';
|
||||||
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
import { ClaudeLogsSection } from './ClaudeLogsSection';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
import { ProcessesSection } from './ProcessesSection';
|
import { ProcessesSection } from './ProcessesSection';
|
||||||
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
||||||
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import {
|
import {
|
||||||
isLeadSessionMissing,
|
isLeadSessionMissing,
|
||||||
shouldSuppressMissingLeadSessionFetch,
|
shouldSuppressMissingLeadSessionFetch,
|
||||||
} from './teamSessionFetchGuards';
|
} from './teamSessionFetchGuards';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provisioningSteps';
|
|
||||||
|
|
||||||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||||
import type { KanbanSortState } from './kanban/KanbanSortPopover';
|
import type { KanbanSortState } from './kanban/KanbanSortPopover';
|
||||||
|
|
@ -2781,10 +2781,10 @@ export const TeamDetailView = ({
|
||||||
if (task) setSelectedTask(task);
|
if (task) setSelectedTask(task);
|
||||||
}}
|
}}
|
||||||
onOpenMemberProfile={(memberName) => {
|
onOpenMemberProfile={(memberName) => {
|
||||||
setSendDialogRecipient(memberName);
|
const member = data.members.find((m) => m.name === memberName);
|
||||||
setSendDialogDefaultText(undefined);
|
if (member) {
|
||||||
setSendDialogDefaultChip(undefined);
|
setSelectedMember(member);
|
||||||
setSendDialogOpen(true);
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,27 @@
|
||||||
* This is the ONLY file in this feature that imports from @renderer/store.
|
* This is the ONLY file in this feature that imports from @renderer/store.
|
||||||
* If the project data model changes, ONLY this class needs updating.
|
* If the project data model changes, ONLY this class needs updating.
|
||||||
*
|
*
|
||||||
* Class-based with ES #private fields, caching, and DI-ready constructor.
|
* Class-based with ES #private fields and DI-ready constructor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
import { getUnreadCount } from '@renderer/services/commentReadStorage';
|
||||||
|
import { agentAvatarUrl, getMemberRuntimeAdvisoryLabel } from '@renderer/utils/memberHelpers';
|
||||||
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
import { formatTeamRuntimeSummary } from '@renderer/utils/teamRuntimeSummary';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
|
||||||
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
import { stripCrossTeamPrefix } from '@shared/constants/crossTeam';
|
||||||
import {
|
import {
|
||||||
getIdleGraphLabel,
|
|
||||||
classifyIdleNotificationText,
|
classifyIdleNotificationText,
|
||||||
|
getIdleGraphLabel,
|
||||||
} from '@shared/utils/idleNotificationSemantics';
|
} from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
|
import { collapseOverflowStacks } from '../utils/collapseOverflowStacks';
|
||||||
|
import {
|
||||||
|
isTaskBlocked,
|
||||||
|
isTaskInReviewCycle,
|
||||||
|
resolveTaskReviewer,
|
||||||
|
} from '../utils/taskGraphSemantics';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
GraphDataPort,
|
GraphDataPort,
|
||||||
GraphEdge,
|
GraphEdge,
|
||||||
|
|
@ -28,6 +35,7 @@ import type {
|
||||||
import type {
|
import type {
|
||||||
ActiveToolCall,
|
ActiveToolCall,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
|
LeadActivityState,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
TeamData,
|
TeamData,
|
||||||
} from '@shared/types/team';
|
} from '@shared/types/team';
|
||||||
|
|
@ -36,8 +44,6 @@ import type { LeadContextUsage } from '@shared/types/team';
|
||||||
export class TeamGraphAdapter {
|
export class TeamGraphAdapter {
|
||||||
// ─── ES #private fields ──────────────────────────────────────────────────
|
// ─── ES #private fields ──────────────────────────────────────────────────
|
||||||
#lastTeamName = '';
|
#lastTeamName = '';
|
||||||
#lastDataHash = '';
|
|
||||||
#cachedResult: GraphDataPort = TeamGraphAdapter.#emptyResult('');
|
|
||||||
readonly #seenRelated = new Set<string>();
|
readonly #seenRelated = new Set<string>();
|
||||||
readonly #seenMessageIds = new Set<string>();
|
readonly #seenMessageIds = new Set<string>();
|
||||||
#initialMessagesSeen = false;
|
#initialMessagesSeen = false;
|
||||||
|
|
@ -57,12 +63,12 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapt team data into a GraphDataPort snapshot.
|
* Adapt team data into a GraphDataPort snapshot.
|
||||||
* Returns cached result if inputs haven't changed (referential check).
|
|
||||||
*/
|
*/
|
||||||
adapt(
|
adapt(
|
||||||
teamData: TeamData | null,
|
teamData: TeamData | null,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
|
leadActivity?: LeadActivityState,
|
||||||
leadContext?: LeadContextUsage,
|
leadContext?: LeadContextUsage,
|
||||||
pendingApprovalAgents?: Set<string>,
|
pendingApprovalAgents?: Set<string>,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
|
|
@ -74,89 +80,6 @@ export class TeamGraphAdapter {
|
||||||
return TeamGraphAdapter.#emptyResult(teamName);
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple hash for change detection (avoids full deep equality)
|
|
||||||
const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0);
|
|
||||||
const memberKey = teamData.members
|
|
||||||
.map(
|
|
||||||
(member) =>
|
|
||||||
`${member.name}:${member.status}:${member.currentTaskId ?? ''}:${member.role ?? ''}:${member.color ?? ''}:${member.agentType ?? ''}:${member.providerId ?? ''}:${member.model ?? ''}:${member.effort ?? ''}:${member.removedAt ?? ''}`
|
|
||||||
)
|
|
||||||
.sort()
|
|
||||||
.join('|');
|
|
||||||
const taskKey = teamData.tasks
|
|
||||||
.map(
|
|
||||||
(task) =>
|
|
||||||
`${task.id}:${task.status}:${task.owner ?? ''}:${task.reviewState ?? ''}:${task.displayId ?? ''}:${task.subject}:${task.updatedAt ?? ''}`
|
|
||||||
)
|
|
||||||
.sort()
|
|
||||||
.join('|');
|
|
||||||
const processKey = teamData.processes
|
|
||||||
.map(
|
|
||||||
(proc) =>
|
|
||||||
`${proc.id}:${proc.label}:${proc.registeredBy ?? ''}:${proc.url ?? ''}:${proc.stoppedAt ?? ''}`
|
|
||||||
)
|
|
||||||
.sort()
|
|
||||||
.join('|');
|
|
||||||
const messageKey = teamData.messages
|
|
||||||
.slice(0, 25)
|
|
||||||
.map((msg) => TeamGraphAdapter.#getMessageParticleKey(msg))
|
|
||||||
.join('|');
|
|
||||||
const commentKey = teamData.tasks
|
|
||||||
.map((task) => {
|
|
||||||
const comments = task.comments ?? [];
|
|
||||||
const tail = comments
|
|
||||||
.slice(Math.max(0, comments.length - 5))
|
|
||||||
.map((comment) => `${comment.id}:${comment.author}:${comment.createdAt}`)
|
|
||||||
.join(',');
|
|
||||||
return `${task.id}:${comments.length}:${tail}`;
|
|
||||||
})
|
|
||||||
.sort()
|
|
||||||
.join('|');
|
|
||||||
const approvalKey = pendingApprovalAgents?.size
|
|
||||||
? Array.from(pendingApprovalAgents).sort().join(',')
|
|
||||||
: '';
|
|
||||||
const activeToolKey = activeTools
|
|
||||||
? Object.entries(activeTools)
|
|
||||||
.flatMap(([memberName, tools]) =>
|
|
||||||
Object.values(tools).map(
|
|
||||||
(tool) =>
|
|
||||||
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.sort()
|
|
||||||
.join('|')
|
|
||||||
: '';
|
|
||||||
const finishedVisibleKey = finishedVisible
|
|
||||||
? Object.entries(finishedVisible)
|
|
||||||
.flatMap(([memberName, tools]) =>
|
|
||||||
Object.values(tools).map(
|
|
||||||
(tool) =>
|
|
||||||
`${memberName}:${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.sort()
|
|
||||||
.join('|')
|
|
||||||
: '';
|
|
||||||
const historyKey = toolHistory
|
|
||||||
? Object.entries(toolHistory)
|
|
||||||
.map(
|
|
||||||
([memberName, tools]) =>
|
|
||||||
`${memberName}:${tools
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(
|
|
||||||
(tool) =>
|
|
||||||
`${tool.toolUseId}:${tool.state}:${tool.toolName}:${tool.preview ?? ''}:${tool.resultPreview ?? ''}:${tool.startedAt}:${tool.finishedAt ?? ''}`
|
|
||||||
)
|
|
||||||
.join(',')}`
|
|
||||||
)
|
|
||||||
.sort()
|
|
||||||
.join('|')
|
|
||||||
: '';
|
|
||||||
const hash = `${teamData.teamName}:${teamData.config.name ?? ''}:${teamData.config.color ?? ''}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.processes.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${memberKey}:${taskKey}:${processKey}:${messageKey}:${commentKey}:${approvalKey}:${activeToolKey}:${finishedVisibleKey}:${historyKey}:${commentReadState ? Object.keys(commentReadState).length : 0}`;
|
|
||||||
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
|
|
||||||
return this.#cachedResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset particle tracking when team changes
|
// Reset particle tracking when team changes
|
||||||
if (teamName !== this.#lastTeamName) {
|
if (teamName !== this.#lastTeamName) {
|
||||||
this.#seenMessageIds.clear();
|
this.#seenMessageIds.clear();
|
||||||
|
|
@ -166,7 +89,6 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#lastTeamName = teamName;
|
this.#lastTeamName = teamName;
|
||||||
this.#lastDataHash = hash;
|
|
||||||
this.#seenRelated.clear();
|
this.#seenRelated.clear();
|
||||||
|
|
||||||
const nodes: GraphNode[] = [];
|
const nodes: GraphNode[] = [];
|
||||||
|
|
@ -182,6 +104,8 @@ export class TeamGraphAdapter {
|
||||||
teamData,
|
teamData,
|
||||||
teamName,
|
teamName,
|
||||||
leadName,
|
leadName,
|
||||||
|
pendingApprovalAgents,
|
||||||
|
leadActivity,
|
||||||
leadContext,
|
leadContext,
|
||||||
activeTools,
|
activeTools,
|
||||||
finishedVisible,
|
finishedVisible,
|
||||||
|
|
@ -212,7 +136,7 @@ export class TeamGraphAdapter {
|
||||||
);
|
);
|
||||||
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
|
this.#buildCommentParticles(particles, teamData, teamName, leadId, leadName, edges);
|
||||||
|
|
||||||
this.#cachedResult = {
|
return {
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
particles,
|
particles,
|
||||||
|
|
@ -220,20 +144,17 @@ export class TeamGraphAdapter {
|
||||||
teamColor: teamData.config.color ?? undefined,
|
teamColor: teamData.config.color ?? undefined,
|
||||||
isAlive: teamData.isAlive,
|
isAlive: teamData.isAlive,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.#cachedResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Disposal ────────────────────────────────────────────────────────────
|
// ─── Disposal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Symbol.dispose](): void {
|
[Symbol.dispose](): void {
|
||||||
this.#cachedResult = TeamGraphAdapter.#emptyResult('');
|
|
||||||
this.#seenRelated.clear();
|
this.#seenRelated.clear();
|
||||||
this.#seenMessageIds.clear();
|
this.#seenMessageIds.clear();
|
||||||
this.#initialMessagesSeen = false;
|
this.#initialMessagesSeen = false;
|
||||||
this.#seenCommentCounts.clear();
|
this.#seenCommentCounts.clear();
|
||||||
this.#initialCommentsSeen = false;
|
this.#initialCommentsSeen = false;
|
||||||
this.#lastDataHash = '';
|
this.#lastTeamName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Private: node builders ──────────────────────────────────────────────
|
// ─── Private: node builders ──────────────────────────────────────────────
|
||||||
|
|
@ -269,6 +190,8 @@ export class TeamGraphAdapter {
|
||||||
data: TeamData,
|
data: TeamData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
|
pendingApprovalAgents?: Set<string>,
|
||||||
|
leadActivity?: LeadActivityState,
|
||||||
leadContext?: LeadContextUsage,
|
leadContext?: LeadContextUsage,
|
||||||
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
activeTools?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
finishedVisible?: Record<string, Record<string, ActiveToolCall>>,
|
||||||
|
|
@ -280,15 +203,28 @@ export class TeamGraphAdapter {
|
||||||
activeTools?.[leadName],
|
activeTools?.[leadName],
|
||||||
finishedVisible?.[leadName]
|
finishedVisible?.[leadName]
|
||||||
);
|
);
|
||||||
|
const hasRunningTool = Object.keys(activeTools?.[leadName] ?? {}).length > 0;
|
||||||
|
const pendingApproval =
|
||||||
|
pendingApprovalAgents?.has(leadName) || pendingApprovalAgents?.has('lead') || false;
|
||||||
|
const leadState =
|
||||||
|
leadActivity === 'offline'
|
||||||
|
? 'terminated'
|
||||||
|
: leadActivity === 'idle'
|
||||||
|
? 'idle'
|
||||||
|
: hasRunningTool
|
||||||
|
? 'tool_calling'
|
||||||
|
: 'active';
|
||||||
|
const leadException =
|
||||||
|
leadActivity === 'offline'
|
||||||
|
? { exceptionTone: 'error' as const, exceptionLabel: 'offline' }
|
||||||
|
: pendingApproval
|
||||||
|
? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' }
|
||||||
|
: undefined;
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: leadId,
|
id: leadId,
|
||||||
kind: 'lead',
|
kind: 'lead',
|
||||||
label: data.config.name || teamName,
|
label: data.config.name || teamName,
|
||||||
state: !data.isAlive
|
state: leadState,
|
||||||
? 'idle'
|
|
||||||
: Object.keys(activeTools?.[leadName] ?? {}).length > 0
|
|
||||||
? 'tool_calling'
|
|
||||||
: 'active',
|
|
||||||
color: data.config.color ?? undefined,
|
color: data.config.color ?? undefined,
|
||||||
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
runtimeLabel: TeamGraphAdapter.#getRuntimeLabel(
|
||||||
leadMember?.providerId,
|
leadMember?.providerId,
|
||||||
|
|
@ -297,6 +233,7 @@ export class TeamGraphAdapter {
|
||||||
),
|
),
|
||||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||||
avatarUrl: agentAvatarUrl(leadName, 64),
|
avatarUrl: agentAvatarUrl(leadName, 64),
|
||||||
|
pendingApproval,
|
||||||
activeTool: activeTool
|
activeTool: activeTool
|
||||||
? {
|
? {
|
||||||
name: activeTool.toolName,
|
name: activeTool.toolName,
|
||||||
|
|
@ -320,6 +257,7 @@ export class TeamGraphAdapter {
|
||||||
resultPreview: tool.resultPreview,
|
resultPreview: tool.resultPreview,
|
||||||
source: tool.source,
|
source: tool.source,
|
||||||
})),
|
})),
|
||||||
|
...leadException,
|
||||||
domainRef: { kind: 'lead', teamName, memberName: leadName },
|
domainRef: { kind: 'lead', teamName, memberName: leadName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -347,6 +285,12 @@ export class TeamGraphAdapter {
|
||||||
finishedVisible?.[member.name]
|
finishedVisible?.[member.name]
|
||||||
);
|
);
|
||||||
const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0;
|
const hasRunningTool = Object.keys(activeTools?.[member.name] ?? {}).length > 0;
|
||||||
|
const exception = TeamGraphAdapter.#buildMemberException(
|
||||||
|
member.runtimeAdvisory,
|
||||||
|
member.providerId,
|
||||||
|
spawn,
|
||||||
|
pendingApprovalAgents?.has(member.name) ?? false
|
||||||
|
);
|
||||||
|
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: memberId,
|
id: memberId,
|
||||||
|
|
@ -369,6 +313,8 @@ export class TeamGraphAdapter {
|
||||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
||||||
: undefined,
|
: undefined,
|
||||||
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
||||||
|
exceptionTone: exception?.exceptionTone,
|
||||||
|
exceptionLabel: exception?.exceptionLabel,
|
||||||
activeTool: activeTool
|
activeTool: activeTool
|
||||||
? {
|
? {
|
||||||
name: activeTool.toolName,
|
name: activeTool.toolName,
|
||||||
|
|
@ -411,25 +357,33 @@ export class TeamGraphAdapter {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
commentReadState?: Record<string, unknown>
|
commentReadState?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
// Build lookup tables for fast resolution
|
const taskStateById = new Map<string, Pick<TeamData['tasks'][number], 'status'>>();
|
||||||
const completedTaskIds = new Set<string>();
|
|
||||||
const taskDisplayIds = new Map<string, string>();
|
const taskDisplayIds = new Map<string, string>();
|
||||||
|
const memberColorByName = new Map<string, string>();
|
||||||
|
|
||||||
for (const t of data.tasks) {
|
for (const t of data.tasks) {
|
||||||
if (t.status === 'completed' || t.status === 'deleted') completedTaskIds.add(t.id);
|
taskStateById.set(t.id, { status: t.status });
|
||||||
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
taskDisplayIds.set(t.id, t.displayId ?? `#${t.id.slice(0, 6)}`);
|
||||||
}
|
}
|
||||||
|
for (const member of data.members) {
|
||||||
|
if (member.color) {
|
||||||
|
memberColorByName.set(member.name, member.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTaskNodes: GraphNode[] = [];
|
||||||
|
|
||||||
for (const task of data.tasks) {
|
for (const task of data.tasks) {
|
||||||
if (task.status === 'deleted') continue;
|
if (task.status === 'deleted') continue;
|
||||||
const taskId = `task:${teamName}:${task.id}`;
|
const taskId = `task:${teamName}:${task.id}`;
|
||||||
const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null;
|
const ownerMemberId = task.owner ? `member:${teamName}:${task.owner}` : null;
|
||||||
|
const kanbanTaskState = data.kanbanState.tasks[task.id];
|
||||||
|
const reviewerName = resolveTaskReviewer(task, kanbanTaskState);
|
||||||
|
const isReviewCycle = isTaskInReviewCycle(task);
|
||||||
|
|
||||||
// Task is blocked if any blockedBy task is still not completed
|
const taskStatus = TeamGraphAdapter.#mapTaskStatusLiteral(task.status);
|
||||||
const isBlocked =
|
const reviewState = TeamGraphAdapter.#mapReviewState(task.reviewState);
|
||||||
(task.blockedBy?.length ?? 0) > 0 &&
|
|
||||||
task.blockedBy!.some((id) => !completedTaskIds.has(id));
|
|
||||||
|
|
||||||
// Resolve display IDs for dependencies
|
|
||||||
const blockedByDisplayIds = task.blockedBy?.length
|
const blockedByDisplayIds = task.blockedBy?.length
|
||||||
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
? task.blockedBy.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
@ -437,7 +391,6 @@ export class TeamGraphAdapter {
|
||||||
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
? task.blocks.map((id) => taskDisplayIds.get(id) ?? `#${id.slice(0, 6)}`)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Comment counts
|
|
||||||
const totalCommentCount = task.comments?.length ?? 0;
|
const totalCommentCount = task.comments?.length ?? 0;
|
||||||
const unreadCommentCount = commentReadState
|
const unreadCommentCount = commentReadState
|
||||||
? getUnreadCount(
|
? getUnreadCount(
|
||||||
|
|
@ -448,66 +401,88 @@ export class TeamGraphAdapter {
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
nodes.push({
|
rawTaskNodes.push({
|
||||||
id: taskId,
|
id: taskId,
|
||||||
kind: 'task',
|
kind: 'task',
|
||||||
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
label: task.displayId ?? `#${task.id.slice(0, 6)}`,
|
||||||
sublabel: task.subject,
|
sublabel: task.subject,
|
||||||
state: TeamGraphAdapter.#mapTaskStatus(task.status),
|
state: TeamGraphAdapter.#mapTaskStatus(task.status),
|
||||||
taskStatus: TeamGraphAdapter.#mapTaskStatusLiteral(task.status),
|
taskStatus,
|
||||||
reviewState: TeamGraphAdapter.#mapReviewState(task.reviewState),
|
reviewState,
|
||||||
|
reviewerName: isReviewCycle ? reviewerName : null,
|
||||||
|
reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined,
|
||||||
|
reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined,
|
||||||
|
changePresence: task.changePresence,
|
||||||
displayId: task.displayId ?? undefined,
|
displayId: task.displayId ?? undefined,
|
||||||
ownerId: ownerMemberId,
|
ownerId: ownerMemberId,
|
||||||
needsClarification: task.needsClarification ?? null,
|
needsClarification: task.needsClarification ?? null,
|
||||||
isBlocked,
|
isBlocked: isTaskBlocked(task, taskStateById),
|
||||||
blockedByDisplayIds,
|
blockedByDisplayIds,
|
||||||
blocksDisplayIds,
|
blocksDisplayIds,
|
||||||
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
totalCommentCount: totalCommentCount > 0 ? totalCommentCount : undefined,
|
||||||
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
unreadCommentCount: unreadCommentCount > 0 ? unreadCommentCount : undefined,
|
||||||
domainRef: { kind: 'task', teamName, taskId: task.id },
|
domainRef: { kind: 'task', teamName, taskId: task.id },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (ownerMemberId) {
|
const visibleTaskNodes = collapseOverflowStacks(rawTaskNodes, teamName, 6);
|
||||||
edges.push({
|
const visibleTaskIds = new Set(
|
||||||
id: `edge:own:${ownerMemberId}:${taskId}`,
|
visibleTaskNodes.flatMap((taskNode) =>
|
||||||
source: ownerMemberId,
|
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
|
||||||
target: taskId,
|
)
|
||||||
type: 'ownership',
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const seenBlockEdges = new Set<string>();
|
nodes.push(...visibleTaskNodes);
|
||||||
for (const blockedById of task.blockedBy ?? []) {
|
|
||||||
const edgeId = `edge:block:task:${teamName}:${blockedById}:${taskId}`;
|
for (const taskNode of visibleTaskNodes) {
|
||||||
if (seenBlockEdges.has(edgeId)) continue;
|
if (!taskNode.ownerId) continue;
|
||||||
seenBlockEdges.add(edgeId);
|
edges.push({
|
||||||
|
id: `edge:own:${taskNode.ownerId}:${taskNode.id}`,
|
||||||
|
source: taskNode.ownerId,
|
||||||
|
target: taskNode.id,
|
||||||
|
type: 'ownership',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenBlockingEdges = new Set<string>();
|
||||||
|
for (const task of data.tasks) {
|
||||||
|
if (task.status === 'deleted' || !visibleTaskIds.has(task.id)) continue;
|
||||||
|
const taskNodeId = `task:${teamName}:${task.id}`;
|
||||||
|
|
||||||
|
for (const blockerId of task.blockedBy ?? []) {
|
||||||
|
if (!visibleTaskIds.has(blockerId)) continue;
|
||||||
|
const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, blockerId, task.id);
|
||||||
|
if (seenBlockingEdges.has(edgeId)) continue;
|
||||||
|
seenBlockingEdges.add(edgeId);
|
||||||
edges.push({
|
edges.push({
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: `task:${teamName}:${blockedById}`,
|
source: `task:${teamName}:${blockerId}`,
|
||||||
target: taskId,
|
target: taskNodeId,
|
||||||
type: 'blocking',
|
type: 'blocking',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const blocksId of task.blocks ?? []) {
|
for (const blockedId of task.blocks ?? []) {
|
||||||
const edgeId = `edge:block:${taskId}:task:${teamName}:${blocksId}`;
|
if (!visibleTaskIds.has(blockedId)) continue;
|
||||||
if (seenBlockEdges.has(edgeId)) continue;
|
const edgeId = TeamGraphAdapter.#buildBlockingEdgeId(teamName, task.id, blockedId);
|
||||||
seenBlockEdges.add(edgeId);
|
if (seenBlockingEdges.has(edgeId)) continue;
|
||||||
|
seenBlockingEdges.add(edgeId);
|
||||||
edges.push({
|
edges.push({
|
||||||
id: edgeId,
|
id: edgeId,
|
||||||
source: taskId,
|
source: taskNodeId,
|
||||||
target: `task:${teamName}:${blocksId}`,
|
target: `task:${teamName}:${blockedId}`,
|
||||||
type: 'blocking',
|
type: 'blocking',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const relatedId of task.related ?? []) {
|
for (const relatedId of task.related ?? []) {
|
||||||
|
if (!visibleTaskIds.has(relatedId)) continue;
|
||||||
const key = [task.id, relatedId].sort().join(':');
|
const key = [task.id, relatedId].sort().join(':');
|
||||||
if (this.#seenRelated.has(key)) continue;
|
if (this.#seenRelated.has(key)) continue;
|
||||||
this.#seenRelated.add(key);
|
this.#seenRelated.add(key);
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `edge:rel:${key}`,
|
id: `edge:rel:${key}`,
|
||||||
source: taskId,
|
source: taskNodeId,
|
||||||
target: `task:${teamName}:${relatedId}`,
|
target: `task:${teamName}:${relatedId}`,
|
||||||
type: 'related',
|
type: 'related',
|
||||||
});
|
});
|
||||||
|
|
@ -751,6 +726,35 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
// ─── Static mappers ──────────────────────────────────────────────────────
|
// ─── Static mappers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static #buildBlockingEdgeId(teamName: string, blockerId: string, blockedId: string): string {
|
||||||
|
return `edge:block:task:${teamName}:${blockerId}:task:${teamName}:${blockedId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static #buildMemberException(
|
||||||
|
runtimeAdvisory: TeamData['members'][number]['runtimeAdvisory'],
|
||||||
|
providerId: TeamData['members'][number]['providerId'],
|
||||||
|
spawn: MemberSpawnStatusEntry | undefined,
|
||||||
|
pendingApproval: boolean
|
||||||
|
): Pick<GraphNode, 'exceptionTone' | 'exceptionLabel'> | undefined {
|
||||||
|
if (spawn?.launchState === 'failed_to_start' || spawn?.status === 'error') {
|
||||||
|
return { exceptionTone: 'error', exceptionLabel: 'spawn failed' };
|
||||||
|
}
|
||||||
|
if (pendingApproval) {
|
||||||
|
return { exceptionTone: 'warning', exceptionLabel: 'awaiting approval' };
|
||||||
|
}
|
||||||
|
if (spawn?.status === 'waiting' || spawn?.status === 'spawning') {
|
||||||
|
return { exceptionTone: 'warning', exceptionLabel: 'starting' };
|
||||||
|
}
|
||||||
|
const runtimeAdvisoryLabel = getMemberRuntimeAdvisoryLabel(runtimeAdvisory, providerId);
|
||||||
|
if (runtimeAdvisoryLabel) {
|
||||||
|
return {
|
||||||
|
exceptionTone: 'warning',
|
||||||
|
exceptionLabel: runtimeAdvisoryLabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {
|
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {
|
||||||
if (spawnStatus === 'spawning') return 'thinking';
|
if (spawnStatus === 'spawning') return 'thinking';
|
||||||
if (spawnStatus === 'error') return 'error';
|
if (spawnStatus === 'error') return 'error';
|
||||||
|
|
@ -851,7 +855,7 @@ export class TeamGraphAdapter {
|
||||||
): string {
|
): string {
|
||||||
const normalized = name.trim().toLowerCase();
|
const normalized = name.trim().toLowerCase();
|
||||||
if (normalized === 'user' || normalized === 'team-lead') return leadId;
|
if (normalized === 'user' || normalized === 'team-lead') return leadId;
|
||||||
if (leadName && normalized === leadName.trim().toLowerCase()) return leadId;
|
if (normalized === leadName?.trim().toLowerCase()) return leadId;
|
||||||
return `member:${teamName}:${name}`;
|
return `member:${teamName}:${name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { useMemo, useRef, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
import { getSnapshot, subscribe } from '@renderer/services/commentReadStorage';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { TeamGraphAdapter } from './TeamGraphAdapter';
|
import { TeamGraphAdapter } from './TeamGraphAdapter';
|
||||||
|
|
@ -19,6 +20,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
const {
|
const {
|
||||||
teamData,
|
teamData,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
|
leadActivity,
|
||||||
leadContext,
|
leadContext,
|
||||||
pendingApprovals,
|
pendingApprovals,
|
||||||
activeTools,
|
activeTools,
|
||||||
|
|
@ -26,8 +28,9 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
toolHistory,
|
toolHistory,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teamData: s.selectedTeamData,
|
teamData: selectTeamDataForName(s, teamName),
|
||||||
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
|
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined,
|
||||||
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined,
|
||||||
pendingApprovals: s.pendingApprovals,
|
pendingApprovals: s.pendingApprovals,
|
||||||
activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined,
|
activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined,
|
||||||
|
|
@ -39,10 +42,12 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
const pendingApprovalAgents = useMemo(() => {
|
const pendingApprovalAgents = useMemo(() => {
|
||||||
const agents = new Set<string>();
|
const agents = new Set<string>();
|
||||||
for (const a of pendingApprovals) {
|
for (const a of pendingApprovals) {
|
||||||
if (a.source !== 'lead') agents.add(a.source);
|
if (a.teamName === teamName) {
|
||||||
|
agents.add(a.source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return agents;
|
return agents;
|
||||||
}, [pendingApprovals]);
|
}, [pendingApprovals, teamName]);
|
||||||
|
|
||||||
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
const commentReadState = useSyncExternalStore(subscribe, getSnapshot);
|
||||||
|
|
||||||
|
|
@ -52,6 +57,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
teamData,
|
teamData,
|
||||||
teamName,
|
teamName,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
|
leadActivity,
|
||||||
leadContext,
|
leadContext,
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
activeTools,
|
activeTools,
|
||||||
|
|
@ -63,6 +69,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
teamData,
|
teamData,
|
||||||
teamName,
|
teamName,
|
||||||
spawnStatuses,
|
spawnStatuses,
|
||||||
|
leadActivity,
|
||||||
leadContext,
|
leadContext,
|
||||||
pendingApprovalAgents,
|
pendingApprovalAgents,
|
||||||
activeTools,
|
activeTools,
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,16 @@
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||||
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
import { ExternalLink, Loader2, MessageSquare, Plus, User } from 'lucide-react';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
|
||||||
|
|
||||||
import { GraphTaskCard } from './GraphTaskCard';
|
import { GraphTaskCard } from './GraphTaskCard';
|
||||||
|
import { isTaskInReviewCycle, resolveTaskReviewer } from '../utils/taskGraphSemantics';
|
||||||
|
|
||||||
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
// ─── Tool name/preview formatters ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -37,7 +41,7 @@ function formatToolPreview(preview: string | undefined): string | undefined {
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Truncated JSON — extract first quoted value
|
// Truncated JSON — extract first quoted value
|
||||||
const match = preview.match(/"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/);
|
const match = /"(?:subject|name|label|path|query)":\s*"([^"]{1,60})"/.exec(preview);
|
||||||
if (match) return match[1];
|
if (match) return match[1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,6 +104,16 @@ export const GraphNodePopover = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.kind === 'task') {
|
if (node.kind === 'task') {
|
||||||
|
if (node.isOverflowStack || node.domainRef.kind === 'task_overflow') {
|
||||||
|
return (
|
||||||
|
<OverflowPopoverContent
|
||||||
|
node={node}
|
||||||
|
teamName={teamName}
|
||||||
|
onClose={onClose}
|
||||||
|
onOpenTaskDetail={onOpenTaskDetail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<GraphTaskCard
|
<GraphTaskCard
|
||||||
node={node}
|
node={node}
|
||||||
|
|
@ -151,6 +165,18 @@ export const GraphNodePopover = ({
|
||||||
{node.processRegisteredAt && (
|
{node.processRegisteredAt && (
|
||||||
<div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div>
|
<div>At: {new Date(node.processRegisteredAt).toLocaleTimeString()}</div>
|
||||||
)}
|
)}
|
||||||
|
{node.exceptionLabel && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`px-1.5 py-0 text-[10px] ${
|
||||||
|
node.exceptionTone === 'error'
|
||||||
|
? 'border-red-500/30 text-red-400'
|
||||||
|
: 'border-amber-500/30 text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node.exceptionLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{node.processUrl && (
|
{node.processUrl && (
|
||||||
<a
|
<a
|
||||||
|
|
@ -166,6 +192,74 @@ export const GraphNodePopover = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OverflowPopoverContent = ({
|
||||||
|
node,
|
||||||
|
teamName,
|
||||||
|
onClose,
|
||||||
|
onOpenTaskDetail,
|
||||||
|
}: {
|
||||||
|
node: GraphNode;
|
||||||
|
teamName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenTaskDetail?: (taskId: string) => void;
|
||||||
|
}): React.JSX.Element => {
|
||||||
|
const teamData = useStore((state) => selectTeamDataForName(state, teamName));
|
||||||
|
const tasksById = new Map((teamData?.tasks ?? []).map((task) => [task.id, task]));
|
||||||
|
const hiddenTasks = (node.overflowTaskIds ?? [])
|
||||||
|
.map((taskId) => tasksById.get(taskId) ?? null)
|
||||||
|
.filter((task): task is TeamTaskWithKanban => task != null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-[240px] max-w-[320px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-semibold text-[var(--color-text)]">Hidden tasks</div>
|
||||||
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||||
|
{node.overflowCount ?? hiddenTasks.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 max-h-[260px] space-y-1 overflow-y-auto pr-1">
|
||||||
|
{hiddenTasks.length === 0 ? (
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">No hidden tasks available.</div>
|
||||||
|
) : (
|
||||||
|
hiddenTasks.map((task) => {
|
||||||
|
const reviewer = resolveTaskReviewer(task, teamData?.kanbanState.tasks[task.id]);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-start justify-between gap-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-secondary)] px-2 py-2 text-left transition-colors hover:border-[var(--color-border-emphasis)]"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenTaskDetail?.(task.id);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
{task.displayId ?? `#${task.id.slice(0, 6)}`}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-[var(--color-text)]">{task.subject}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{task.owner && (
|
||||||
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||||
|
{task.owner}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isTaskInReviewCycle(task) && (
|
||||||
|
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||||
|
{reviewer ?? 'REV'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Member Popover ─────────────────────────────────────────────────────────
|
// ─── Member Popover ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const MemberPopoverContent = ({
|
const MemberPopoverContent = ({
|
||||||
|
|
@ -261,6 +355,18 @@ const MemberPopoverContent = ({
|
||||||
{getSpawnStatusBadgeLabel(node.spawnStatus)}
|
{getSpawnStatusBadgeLabel(node.spawnStatus)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{node.exceptionLabel && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`px-1.5 py-0 text-[10px] ${
|
||||||
|
node.exceptionTone === 'error'
|
||||||
|
? 'border-red-500/30 text-red-400'
|
||||||
|
: 'border-amber-500/30 text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{node.exceptionLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */}
|
{/* TODO: Context usage disabled — LeadContextUsage.percent unreliable (jumps) */}
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ import { useMemo } from 'react';
|
||||||
|
|
||||||
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
import { KanbanTaskCard } from '@renderer/components/team/kanban/KanbanTaskCard';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { selectTeamDataForName } from '@renderer/store/slices/teamSlice';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { isTaskBlocked, resolveTaskGraphColumn } from '../utils/taskGraphSemantics';
|
||||||
|
|
||||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||||
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
import type { KanbanColumnId, TeamTask, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -32,16 +35,12 @@ interface GraphTaskCardProps {
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function resolveColumn(task: TeamTask): KanbanColumnId {
|
function resolveColumn(task: TeamTask): KanbanColumnId {
|
||||||
if (task.reviewState === 'approved') return 'approved';
|
return resolveTaskGraphColumn(task);
|
||||||
if (task.reviewState === 'review' || task.reviewState === 'needsFix') return 'review';
|
|
||||||
if (task.status === 'in_progress') return 'in_progress';
|
|
||||||
if (task.status === 'completed') return 'done';
|
|
||||||
return 'todo';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGlowStyle(task: TeamTask): React.CSSProperties {
|
function getGlowStyle(task: TeamTask, taskMap: ReadonlyMap<string, TeamTask>): React.CSSProperties {
|
||||||
const col = resolveColumn(task);
|
const col = resolveColumn(task);
|
||||||
const blocked = (task.blockedBy?.length ?? 0) > 0;
|
const blocked = isTaskBlocked(task, taskMap);
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' };
|
return { boxShadow: '0 0 14px rgba(239, 68, 68, 0.4), inset 0 0 6px rgba(239, 68, 68, 0.08)' };
|
||||||
}
|
}
|
||||||
|
|
@ -87,9 +86,9 @@ export const GraphTaskCard = ({
|
||||||
|
|
||||||
const { task, tasks, members } = useStore(
|
const { task, tasks, members } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
task: s.selectedTeamData?.tasks.find((t) => t.id === taskId),
|
tasks: selectTeamDataForName(s, teamName)?.tasks ?? [],
|
||||||
tasks: s.selectedTeamData?.tasks ?? [],
|
members: selectTeamDataForName(s, teamName)?.members ?? [],
|
||||||
members: s.selectedTeamData?.members ?? [],
|
task: selectTeamDataForName(s, teamName)?.tasks.find((t) => t.id === taskId),
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -118,7 +117,7 @@ export const GraphTaskCard = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnId = resolveColumn(task);
|
const columnId = resolveColumn(task);
|
||||||
const taskWithKanban = task as TeamTaskWithKanban;
|
const taskWithKanban = task;
|
||||||
|
|
||||||
const closeAct = (fn?: (id: string) => void) => (taskId: string) => {
|
const closeAct = (fn?: (id: string) => void) => (taskId: string) => {
|
||||||
fn?.(taskId);
|
fn?.(taskId);
|
||||||
|
|
@ -128,7 +127,7 @@ export const GraphTaskCard = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`min-w-[260px] max-w-[320px] rounded-lg shadow-2xl ${getPulseClass(task)}`}
|
className={`min-w-[260px] max-w-[320px] rounded-lg shadow-2xl ${getPulseClass(task)}`}
|
||||||
style={getGlowStyle(task)}
|
style={getGlowStyle(task, taskMap)}
|
||||||
>
|
>
|
||||||
<KanbanTaskCard
|
<KanbanTaskCard
|
||||||
task={taskWithKanban}
|
task={taskWithKanban}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
createTeamSlice,
|
||||||
getLastResolvedTeamDataRefreshAt,
|
getLastResolvedTeamDataRefreshAt,
|
||||||
isTeamDataRefreshPending,
|
isTeamDataRefreshPending,
|
||||||
|
selectTeamDataForName,
|
||||||
} from './slices/teamSlice';
|
} from './slices/teamSlice';
|
||||||
import { createUISlice } from './slices/uiSlice';
|
import { createUISlice } from './slices/uiSlice';
|
||||||
import { createUpdateSlice } from './slices/updateSlice';
|
import { createUpdateSlice } from './slices/updateSlice';
|
||||||
|
|
@ -397,13 +398,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const selectedTeamName = state.selectedTeamName;
|
const visibleTeamNames = Array.from(getVisibleTeamNamesInAnyPane(state));
|
||||||
const selectedTeamData = state.selectedTeamData;
|
if (visibleTeamNames.length === 0) {
|
||||||
if (
|
|
||||||
!selectedTeamName ||
|
|
||||||
selectedTeamData?.teamName !== selectedTeamName ||
|
|
||||||
!isTeamVisibleInAnyPane(selectedTeamName)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,44 +413,58 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidateTasks = selectedTeamData.tasks.filter((task) => {
|
|
||||||
if (task.status !== 'in_progress') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
|
||||||
});
|
|
||||||
if (candidateTasks.length === 0) {
|
|
||||||
inProgressChangePresenceCursorByTeam.delete(selectedTeamName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
inProgressChangePresencePollInFlight = true;
|
inProgressChangePresencePollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const cursor = inProgressChangePresenceCursorByTeam.get(selectedTeamName) ?? 0;
|
for (const teamName of visibleTeamNames) {
|
||||||
const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown');
|
const teamData = selectTeamDataForName(state, teamName);
|
||||||
const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks;
|
if (teamData?.teamName !== teamName) {
|
||||||
const nextTask = sourceTasks[cursor % sourceTasks.length];
|
if (!isTeamDataRefreshPending(teamName)) {
|
||||||
|
void state.refreshTeamData(teamName, { withDedup: true });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
inProgressChangePresenceCursorByTeam.set(selectedTeamName, (cursor + 1) % sourceTasks.length);
|
const candidateTasks = teamData.tasks.filter((task) => {
|
||||||
|
if (task.status !== 'in_progress') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return canDisplayTaskChangesForOptions(buildTaskChangeRequestOptions(task));
|
||||||
|
});
|
||||||
|
if (candidateTasks.length === 0) {
|
||||||
|
inProgressChangePresenceCursorByTeam.delete(teamName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const current = useStore.getState();
|
const cursor = inProgressChangePresenceCursorByTeam.get(teamName) ?? 0;
|
||||||
if (
|
const unknownTasks = candidateTasks.filter((task) => task.changePresence === 'unknown');
|
||||||
current.selectedTeamName !== selectedTeamName ||
|
const sourceTasks = unknownTasks.length > 0 ? unknownTasks : candidateTasks;
|
||||||
current.selectedTeamData?.teamName !== selectedTeamName ||
|
const nextTask = sourceTasks[cursor % sourceTasks.length];
|
||||||
!isTeamVisibleInAnyPane(selectedTeamName)
|
|
||||||
) {
|
inProgressChangePresenceCursorByTeam.set(teamName, (cursor + 1) % sourceTasks.length);
|
||||||
return;
|
|
||||||
|
const current = useStore.getState();
|
||||||
|
if (!isTeamVisibleInAnyPane(teamName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTeamData = selectTeamDataForName(current, teamName);
|
||||||
|
if (currentTeamData?.teamName !== teamName) {
|
||||||
|
if (!isTeamDataRefreshPending(teamName)) {
|
||||||
|
void current.refreshTeamData(teamName, { withDedup: true });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTask = currentTeamData.tasks.find((task) => task.id === nextTask.id);
|
||||||
|
if (currentTask?.status !== 'in_progress') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestOptions = buildTaskChangeRequestOptions(currentTask);
|
||||||
|
const cacheKey = buildTaskChangePresenceKey(teamName, currentTask.id, requestOptions);
|
||||||
|
current.invalidateTaskChangePresence([cacheKey]);
|
||||||
|
await current.checkTaskHasChanges(teamName, currentTask.id, requestOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTask = current.selectedTeamData.tasks.find((task) => task.id === nextTask.id);
|
|
||||||
if (currentTask?.status !== 'in_progress') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestOptions = buildTaskChangeRequestOptions(currentTask);
|
|
||||||
const cacheKey = buildTaskChangePresenceKey(selectedTeamName, currentTask.id, requestOptions);
|
|
||||||
current.invalidateTaskChangePresence([cacheKey]);
|
|
||||||
await current.checkTaskHasChanges(selectedTeamName, currentTask.id, requestOptions);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort polling for in-progress tasks only.
|
// Best-effort polling for in-progress tasks only.
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -557,41 +567,41 @@ export function initializeNotificationListeners(): () => void {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isTeamVisibleInAnyPane = (teamName: string): boolean => {
|
const getVisibleTeamNamesInAnyPane = (state = useStore.getState()): Set<string> => {
|
||||||
const { paneLayout } = useStore.getState();
|
const { paneLayout } = state;
|
||||||
return paneLayout.panes.some((pane) => {
|
const visibleTeamNames = new Set<string>();
|
||||||
if (!pane.activeTabId) return false;
|
|
||||||
return pane.tabs.some(
|
|
||||||
(tab) => tab.id === pane.activeTabId && tab.type === 'team' && tab.teamName === teamName
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTrackedChangePresenceTeams = (): Set<string> => {
|
|
||||||
const { selectedTeamName, selectedTeamData } = useStore.getState();
|
|
||||||
if (
|
|
||||||
!selectedTeamName ||
|
|
||||||
selectedTeamData?.teamName !== selectedTeamName ||
|
|
||||||
!isTeamVisibleInAnyPane(selectedTeamName)
|
|
||||||
) {
|
|
||||||
return new Set<string>();
|
|
||||||
}
|
|
||||||
return new Set([selectedTeamName]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTrackedToolActivityTeams = (): Set<string> => {
|
|
||||||
const { paneLayout } = useStore.getState();
|
|
||||||
const tracked = new Set<string>();
|
|
||||||
for (const pane of paneLayout.panes) {
|
for (const pane of paneLayout.panes) {
|
||||||
if (!pane.activeTabId) continue;
|
if (!pane.activeTabId) continue;
|
||||||
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
|
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
|
||||||
if (activeTab?.type === 'team' && activeTab.teamName) {
|
if (
|
||||||
tracked.add(activeTab.teamName);
|
(activeTab?.type === 'team' || activeTab?.type === 'graph') &&
|
||||||
|
activeTab.teamName != null
|
||||||
|
) {
|
||||||
|
visibleTeamNames.add(activeTab.teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return visibleTeamNames;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTeamVisibleInAnyPane = (teamName: string): boolean => {
|
||||||
|
return getVisibleTeamNamesInAnyPane().has(teamName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTrackedChangePresenceTeams = (): Set<string> => {
|
||||||
|
const state = useStore.getState();
|
||||||
|
const tracked = new Set<string>();
|
||||||
|
for (const teamName of getVisibleTeamNamesInAnyPane(state)) {
|
||||||
|
if (selectTeamDataForName(state, teamName)) {
|
||||||
|
tracked.add(teamName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tracked;
|
return tracked;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTrackedToolActivityTeams = (): Set<string> => {
|
||||||
|
return getVisibleTeamNamesInAnyPane();
|
||||||
|
};
|
||||||
|
|
||||||
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
|
const noteRelevantTeamActivity = (teamName: string, timestamp = Date.now()): void => {
|
||||||
teamLastRelevantActivityAt.set(teamName, timestamp);
|
teamLastRelevantActivityAt.set(teamName, timestamp);
|
||||||
};
|
};
|
||||||
|
|
@ -606,15 +616,11 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTab = focusedPane.tabs.find((tab) => tab.id === focusedPane.activeTabId);
|
const activeTab = focusedPane.tabs.find((tab) => tab.id === focusedPane.activeTabId);
|
||||||
if (activeTab?.type !== 'team' || !activeTab.teamName) {
|
if ((activeTab?.type !== 'team' && activeTab?.type !== 'graph') || !activeTab.teamName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.selectedTeamName !== activeTab.teamName) {
|
if (!selectTeamDataForName(state, activeTab.teamName)) {
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.selectedTeamData?.teamName !== activeTab.teamName) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,7 +638,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.selectedTeamLoading) {
|
if (current.selectedTeamName === teamName && current.selectedTeamLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -695,7 +701,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
if (
|
if (
|
||||||
state.paneLayout === prevState.paneLayout &&
|
state.paneLayout === prevState.paneLayout &&
|
||||||
state.selectedTeamName === prevState.selectedTeamName &&
|
state.selectedTeamName === prevState.selectedTeamName &&
|
||||||
state.selectedTeamData === prevState.selectedTeamData
|
state.selectedTeamData === prevState.selectedTeamData &&
|
||||||
|
state.teamDataCacheByName === prevState.teamDataCacheByName
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -917,6 +924,17 @@ export function initializeNotificationListeners(): () => void {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cachedTeamData = prev.teamDataCacheByName[event.teamName];
|
||||||
|
if (cachedTeamData) {
|
||||||
|
nextState.teamDataCacheByName = {
|
||||||
|
...prev.teamDataCacheByName,
|
||||||
|
[event.teamName]: {
|
||||||
|
...cachedTeamData,
|
||||||
|
isAlive: nextActivity !== 'offline',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive,
|
// Keep TeamDetailView in sync: it historically relied on selectedTeamData.isAlive,
|
||||||
// which isn't refreshed for lead-activity events.
|
// which isn't refreshed for lead-activity events.
|
||||||
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
|
if (prev.selectedTeamName === event.teamName && prev.selectedTeamData) {
|
||||||
|
|
@ -1140,7 +1158,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
teamPresenceRefreshTimers.delete(event.teamName);
|
teamPresenceRefreshTimers.delete(event.teamName);
|
||||||
const current = useStore.getState();
|
const current = useStore.getState();
|
||||||
void current.refreshSelectedTeamChangePresence(event.teamName);
|
void current.refreshTeamChangePresence(event.teamName);
|
||||||
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
|
}, TEAM_PRESENCE_REFRESH_THROTTLE_MS);
|
||||||
teamPresenceRefreshTimers.set(event.teamName, timer);
|
teamPresenceRefreshTimers.set(event.teamName, timer);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,9 @@ const teamRefreshBurstDiagnostics = new Map<
|
||||||
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
{ windowStartedAt: number; count: number; lastWarnAt: number }
|
||||||
>();
|
>();
|
||||||
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
const memberSpawnUiEqualLastWarnAtByTeam = new Map<string, number>();
|
||||||
type RefreshTeamDataOptions = {
|
interface RefreshTeamDataOptions {
|
||||||
withDedup?: boolean;
|
withDedup?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
export function isTeamDataRefreshPending(teamName: string): boolean {
|
export function isTeamDataRefreshPending(teamName: string): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -56,6 +56,16 @@ export function getLastResolvedTeamDataRefreshAt(teamName: string): number | und
|
||||||
return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
|
return lastResolvedTeamDataRefreshAtByTeam.get(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function __resetTeamSliceModuleStateForTests(): void {
|
||||||
|
inFlightTeamDataRequests.clear();
|
||||||
|
inFlightRefreshTeamDataCalls.clear();
|
||||||
|
pendingFreshTeamDataRefreshes.clear();
|
||||||
|
lastResolvedTeamDataRefreshAtByTeam.clear();
|
||||||
|
memberSpawnStatusesIpcBackoffUntilByTeam.clear();
|
||||||
|
teamRefreshBurstDiagnostics.clear();
|
||||||
|
memberSpawnUiEqualLastWarnAtByTeam.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
@ -487,9 +497,9 @@ import type {
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
LeadContextUsage,
|
LeadContextUsage,
|
||||||
PersistedTeamLaunchSummary,
|
|
||||||
MemberSpawnStatusesSnapshot,
|
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
|
PersistedTeamLaunchSummary,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskChangePresenceState,
|
TaskChangePresenceState,
|
||||||
|
|
@ -852,11 +862,7 @@ function preserveKnownTaskChangePresence(
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousTask = prevTaskById.get(task.id);
|
const previousTask = prevTaskById.get(task.id);
|
||||||
if (
|
if (!previousTask?.changePresence || previousTask.changePresence === 'unknown') {
|
||||||
!previousTask ||
|
|
||||||
!previousTask.changePresence ||
|
|
||||||
previousTask.changePresence === 'unknown'
|
|
||||||
) {
|
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -915,6 +921,46 @@ export interface TeamLaunchParams {
|
||||||
limitContext?: boolean;
|
limitContext?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectTeamDataForName(
|
||||||
|
state: Pick<TeamSlice, 'teamDataCacheByName' | 'selectedTeamName' | 'selectedTeamData'>,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): TeamData | null {
|
||||||
|
if (!teamName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
state.teamDataCacheByName[teamName] ??
|
||||||
|
(state.selectedTeamName === teamName ? state.selectedTeamData : null)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisibleInActiveTeamSurface(
|
||||||
|
state: Pick<AppState, 'paneLayout'>,
|
||||||
|
teamName: string | null | undefined
|
||||||
|
): boolean {
|
||||||
|
if (!teamName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return state.paneLayout.panes.some((pane) => {
|
||||||
|
if (!pane.activeTabId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const activeTab = pane.tabs.find((tab) => tab.id === pane.activeTabId);
|
||||||
|
return (
|
||||||
|
(activeTab?.type === 'team' || activeTab?.type === 'graph') && activeTab.teamName === teamName
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldInvalidateCachedTeamDataForError(teamName: string, message: string): boolean {
|
||||||
|
return (
|
||||||
|
message === 'TEAM_DRAFT' ||
|
||||||
|
message.includes('TEAM_DRAFT') ||
|
||||||
|
message === `Team not found: ${teamName}` ||
|
||||||
|
message === 'Team config not found'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamSlice {
|
export interface TeamSlice {
|
||||||
teams: TeamSummary[];
|
teams: TeamSummary[];
|
||||||
/** O(1) lookup to avoid array scans in render-hot paths */
|
/** O(1) lookup to avoid array scans in render-hot paths */
|
||||||
|
|
@ -947,6 +993,8 @@ export interface TeamSlice {
|
||||||
) => void;
|
) => void;
|
||||||
selectedTeamName: string | null;
|
selectedTeamName: string | null;
|
||||||
selectedTeamData: TeamData | null;
|
selectedTeamData: TeamData | null;
|
||||||
|
/** Team-scoped detailed cache used by multi-pane views like agent graph. */
|
||||||
|
teamDataCacheByName: Record<string, TeamData>;
|
||||||
selectedTeamLoading: boolean;
|
selectedTeamLoading: boolean;
|
||||||
selectedTeamLoadNonce: number;
|
selectedTeamLoadNonce: number;
|
||||||
selectedTeamError: string | null;
|
selectedTeamError: string | null;
|
||||||
|
|
@ -994,7 +1042,7 @@ export interface TeamSlice {
|
||||||
taskId: string,
|
taskId: string,
|
||||||
presence: TaskChangePresenceState
|
presence: TaskChangePresenceState
|
||||||
) => void;
|
) => void;
|
||||||
refreshSelectedTeamChangePresence: (teamName: string) => Promise<void>;
|
refreshTeamChangePresence: (teamName: string) => Promise<void>;
|
||||||
selectTeam: (
|
selectTeam: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean }
|
opts?: { skipProjectAutoSelect?: boolean; allowReloadWhileProvisioning?: boolean }
|
||||||
|
|
@ -1239,6 +1287,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
globalTasksError: null,
|
globalTasksError: null,
|
||||||
selectedTeamName: null,
|
selectedTeamName: null,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
|
teamDataCacheByName: {},
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
selectedTeamLoadNonce: 0,
|
selectedTeamLoadNonce: 0,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
|
|
@ -1660,20 +1709,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => {
|
setSelectedTeamTaskChangePresence: (teamName, taskId, presence) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
let selectedChanged = false;
|
const currentTeamData = selectTeamDataForName(state, teamName);
|
||||||
const nextSelectedTeamData =
|
let cacheChanged = false;
|
||||||
state.selectedTeamName === teamName && state.selectedTeamData
|
const nextTeamData = currentTeamData
|
||||||
? {
|
? {
|
||||||
...state.selectedTeamData,
|
...currentTeamData,
|
||||||
tasks: state.selectedTeamData.tasks.map((task) => {
|
tasks: currentTeamData.tasks.map((task) => {
|
||||||
if (task.id !== taskId || task.changePresence === presence) {
|
if (task.id !== taskId || task.changePresence === presence) {
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
selectedChanged = true;
|
cacheChanged = true;
|
||||||
return { ...task, changePresence: presence };
|
return { ...task, changePresence: presence };
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: state.selectedTeamData;
|
: null;
|
||||||
|
|
||||||
let globalChanged = false;
|
let globalChanged = false;
|
||||||
const nextGlobalTasks = state.globalTasks.map((task) => {
|
const nextGlobalTasks = state.globalTasks.map((task) => {
|
||||||
|
|
@ -1684,20 +1733,30 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return { ...task, changePresence: presence };
|
return { ...task, changePresence: presence };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!selectedChanged && !globalChanged) {
|
if (!cacheChanged && !globalChanged) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(selectedChanged ? { selectedTeamData: nextSelectedTeamData } : {}),
|
...(cacheChanged && nextTeamData
|
||||||
|
? {
|
||||||
|
teamDataCacheByName: {
|
||||||
|
...state.teamDataCacheByName,
|
||||||
|
[teamName]: nextTeamData,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(cacheChanged && state.selectedTeamName === teamName && nextTeamData
|
||||||
|
? { selectedTeamData: nextTeamData }
|
||||||
|
: {}),
|
||||||
...(globalChanged ? { globalTasks: nextGlobalTasks } : {}),
|
...(globalChanged ? { globalTasks: nextGlobalTasks } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshSelectedTeamChangePresence: async (teamName: string) => {
|
refreshTeamChangePresence: async (teamName: string) => {
|
||||||
const selected = get().selectedTeamData;
|
const currentTeamData = selectTeamDataForName(get(), teamName);
|
||||||
if (get().selectedTeamName !== teamName || !selected) {
|
if (!currentTeamData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1706,17 +1765,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
api.teams.getTaskChangePresence(teamName)
|
api.teams.getTaskChangePresence(teamName)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (get().selectedTeamName !== teamName || !get().selectedTeamData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.selectedTeamName !== teamName || !state.selectedTeamData) {
|
const teamData = selectTeamDataForName(state, teamName);
|
||||||
|
if (!teamData) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const nextTasks = state.selectedTeamData.tasks.map((task) => {
|
const nextTasks = teamData.tasks.map((task) => {
|
||||||
const nextPresence = presenceByTaskId[task.id] ?? 'unknown';
|
const nextPresence = presenceByTaskId[task.id] ?? 'unknown';
|
||||||
if (task.changePresence === nextPresence) {
|
if (task.changePresence === nextPresence) {
|
||||||
return task;
|
return task;
|
||||||
|
|
@ -1729,11 +1785,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextTeamData = {
|
||||||
|
...teamData,
|
||||||
|
tasks: nextTasks,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedTeamData: {
|
teamDataCacheByName: {
|
||||||
...state.selectedTeamData,
|
...state.teamDataCacheByName,
|
||||||
tasks: nextTasks,
|
[teamName]: nextTeamData,
|
||||||
},
|
},
|
||||||
|
...(state.selectedTeamName === teamName ? { selectedTeamData: nextTeamData } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -1754,8 +1816,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestNonce = get().selectedTeamLoadNonce + 1;
|
const requestNonce = get().selectedTeamLoadNonce + 1;
|
||||||
const previousSelectedTeamName = get().selectedTeamName;
|
const previousData = selectTeamDataForName(get(), teamName);
|
||||||
const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null;
|
|
||||||
|
|
||||||
// Stale-while-revalidate: keep previous data visible while loading new team.
|
// Stale-while-revalidate: keep previous data visible while loading new team.
|
||||||
// Skeleton only shows on first load (when data is null).
|
// Skeleton only shows on first load (when data is null).
|
||||||
|
|
@ -1797,18 +1858,23 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
set({ teamByName: { ...prevByName, [teamName]: patched } });
|
set({ teamByName: { ...prevByName, [teamName]: patched } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextTeamData = previousData
|
||||||
|
? {
|
||||||
|
...data,
|
||||||
|
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
|
||||||
|
}
|
||||||
|
: data;
|
||||||
const setStartedAt = performance.now();
|
const setStartedAt = performance.now();
|
||||||
set({
|
set((state) => ({
|
||||||
selectedTeamName: teamName,
|
selectedTeamName: teamName,
|
||||||
selectedTeamData: previousData
|
selectedTeamData: nextTeamData,
|
||||||
? {
|
teamDataCacheByName: {
|
||||||
...data,
|
...state.teamDataCacheByName,
|
||||||
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
|
[teamName]: nextTeamData,
|
||||||
}
|
},
|
||||||
: data,
|
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
selectedTeamError: null,
|
selectedTeamError: null,
|
||||||
});
|
}));
|
||||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
const setMs = performance.now() - setStartedAt;
|
const setMs = performance.now() - setStartedAt;
|
||||||
const postStartedAt = performance.now();
|
const postStartedAt = performance.now();
|
||||||
|
|
@ -1925,10 +1991,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const state = get();
|
|
||||||
if (state.selectedTeamName !== teamName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
inFlightRefreshTeamDataCalls.add(teamName);
|
inFlightRefreshTeamDataCalls.add(teamName);
|
||||||
// Silent refresh — update data without showing loading skeleton.
|
// Silent refresh — update data without showing loading skeleton.
|
||||||
// Only selectTeam() sets loading: true (for initial load).
|
// Only selectTeam() sets loading: true (for initial load).
|
||||||
|
|
@ -1942,25 +2004,30 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const previousData = get().selectedTeamData;
|
const previousData = selectTeamDataForName(get(), teamName);
|
||||||
const data = opts?.withDedup
|
const data = opts?.withDedup
|
||||||
? await fetchTeamDataDeduped(teamName)
|
? await fetchTeamDataDeduped(teamName)
|
||||||
: await fetchTeamDataFresh(teamName);
|
: await fetchTeamDataFresh(teamName);
|
||||||
const ipcMs = performance.now() - startedAt;
|
const ipcMs = performance.now() - startedAt;
|
||||||
// Re-check after async: the user might have navigated away.
|
const nextTeamData = previousData
|
||||||
if (get().selectedTeamName !== teamName) {
|
? {
|
||||||
return;
|
...data,
|
||||||
}
|
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
|
||||||
|
}
|
||||||
|
: data;
|
||||||
const setStartedAt = performance.now();
|
const setStartedAt = performance.now();
|
||||||
set({
|
set((state) => ({
|
||||||
selectedTeamData: previousData
|
teamDataCacheByName: {
|
||||||
|
...state.teamDataCacheByName,
|
||||||
|
[teamName]: nextTeamData,
|
||||||
|
},
|
||||||
|
...(state.selectedTeamName === teamName
|
||||||
? {
|
? {
|
||||||
...data,
|
selectedTeamData: nextTeamData,
|
||||||
tasks: preserveKnownTaskChangePresence(teamName, previousData.tasks, data.tasks),
|
selectedTeamError: null,
|
||||||
}
|
}
|
||||||
: data,
|
: {}),
|
||||||
selectedTeamError: null,
|
}));
|
||||||
});
|
|
||||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
const setMs = performance.now() - setStartedAt;
|
const setMs = performance.now() - setStartedAt;
|
||||||
const postStartedAt = performance.now();
|
const postStartedAt = performance.now();
|
||||||
|
|
@ -1988,9 +2055,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
burstCount,
|
burstCount,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (get().selectedTeamName !== teamName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const msg =
|
const msg =
|
||||||
error instanceof IpcError
|
error instanceof IpcError
|
||||||
? error.message
|
? error.message
|
||||||
|
|
@ -2002,19 +2066,42 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
// Preserve existing data instead of showing a fatal error.
|
// Preserve existing data instead of showing a fatal error.
|
||||||
if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) {
|
if (msg === 'TEAM_PROVISIONING' || msg.includes('TEAM_PROVISIONING')) {
|
||||||
logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`);
|
logger.debug(`refreshTeamData(${teamName}) skipped: team is still provisioning`);
|
||||||
set({ selectedTeamError: null });
|
if (get().selectedTeamName === teamName) {
|
||||||
|
set({ selectedTeamError: null });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT')) {
|
if (shouldInvalidateCachedTeamDataForError(teamName, msg)) {
|
||||||
set({
|
set((state) => {
|
||||||
selectedTeamLoading: false,
|
const nextCache = state.teamDataCacheByName[teamName]
|
||||||
selectedTeamData: null,
|
? { ...state.teamDataCacheByName }
|
||||||
selectedTeamError: 'TEAM_DRAFT',
|
: null;
|
||||||
|
if (nextCache) {
|
||||||
|
delete nextCache[teamName];
|
||||||
|
}
|
||||||
|
if (state.selectedTeamName !== teamName && !nextCache) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
|
||||||
|
...(state.selectedTeamName === teamName
|
||||||
|
? {
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamData: null,
|
||||||
|
selectedTeamError:
|
||||||
|
msg === 'TEAM_DRAFT' || msg.includes('TEAM_DRAFT') ? 'TEAM_DRAFT' : msg,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (get().selectedTeamName !== teamName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
|
logger.warn(`refreshTeamData(${teamName}) failed: ${msg}`);
|
||||||
|
|
||||||
// Non-destructive: if we already have data, keep it visible.
|
// Non-destructive: if we already have data, keep it visible.
|
||||||
|
|
@ -2089,10 +2176,22 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
sendingMessage: false,
|
sendingMessage: false,
|
||||||
sendMessageError: null,
|
sendMessageError: null,
|
||||||
lastSendMessageResult: result,
|
lastSendMessageResult: result,
|
||||||
selectedTeamData:
|
...(selectTeamDataForName(state, teamName)
|
||||||
state.selectedTeamName === teamName && state.selectedTeamData
|
? {
|
||||||
? upsertLocalSentMessage(state.selectedTeamData, optimisticMessage)
|
teamDataCacheByName: {
|
||||||
: state.selectedTeamData,
|
...state.teamDataCacheByName,
|
||||||
|
[teamName]: upsertLocalSentMessage(
|
||||||
|
selectTeamDataForName(state, teamName)!,
|
||||||
|
optimisticMessage
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(state.selectedTeamName === teamName && state.selectedTeamData
|
||||||
|
? {
|
||||||
|
selectedTeamData: upsertLocalSentMessage(state.selectedTeamData, optimisticMessage),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}));
|
}));
|
||||||
await get().refreshTeamData(teamName);
|
await get().refreshTeamData(teamName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -2303,12 +2402,40 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
deleteTeam: async (teamName: string) => {
|
deleteTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
||||||
|
set((state) => {
|
||||||
|
const nextCache = state.teamDataCacheByName[teamName]
|
||||||
|
? { ...state.teamDataCacheByName }
|
||||||
|
: null;
|
||||||
|
if (nextCache) {
|
||||||
|
delete nextCache[teamName];
|
||||||
|
}
|
||||||
|
if (state.selectedTeamName === teamName) {
|
||||||
|
return {
|
||||||
|
selectedTeamName: null,
|
||||||
|
selectedTeamData: null,
|
||||||
|
selectedTeamLoading: false,
|
||||||
|
selectedTeamError: null,
|
||||||
|
...(nextCache ? { teamDataCacheByName: nextCache } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return nextCache ? { teamDataCacheByName: nextCache } : {};
|
||||||
|
});
|
||||||
await get().fetchTeams();
|
await get().fetchTeams();
|
||||||
await get().fetchAllTasks();
|
await get().fetchAllTasks();
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreTeam: async (teamName: string) => {
|
restoreTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
|
await unwrapIpc('team:restoreTeam', () => api.teams.restoreTeam(teamName));
|
||||||
|
set((state) => {
|
||||||
|
if (!state.teamDataCacheByName[teamName]) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const nextCache = { ...state.teamDataCacheByName };
|
||||||
|
delete nextCache[teamName];
|
||||||
|
return {
|
||||||
|
teamDataCacheByName: nextCache,
|
||||||
|
};
|
||||||
|
});
|
||||||
await get().fetchTeams();
|
await get().fetchTeams();
|
||||||
await get().fetchAllTasks();
|
await get().fetchAllTasks();
|
||||||
},
|
},
|
||||||
|
|
@ -2316,8 +2443,19 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
permanentlyDeleteTeam: async (teamName: string) => {
|
permanentlyDeleteTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
|
await unwrapIpc('team:permanentlyDeleteTeam', () => api.teams.permanentlyDeleteTeam(teamName));
|
||||||
const state = get();
|
const state = get();
|
||||||
|
const nextCache = { ...state.teamDataCacheByName };
|
||||||
|
delete nextCache[teamName];
|
||||||
if (state.selectedTeamName === teamName) {
|
if (state.selectedTeamName === teamName) {
|
||||||
set({ selectedTeamName: null, selectedTeamData: null, selectedTeamError: null });
|
set({
|
||||||
|
selectedTeamName: null,
|
||||||
|
selectedTeamData: null,
|
||||||
|
selectedTeamError: null,
|
||||||
|
teamDataCacheByName: nextCache,
|
||||||
|
});
|
||||||
|
} else if (state.teamDataCacheByName[teamName]) {
|
||||||
|
set({
|
||||||
|
teamDataCacheByName: nextCache,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await get().fetchTeams();
|
await get().fetchTeams();
|
||||||
await get().fetchAllTasks();
|
await get().fetchAllTasks();
|
||||||
|
|
@ -2872,11 +3010,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
const isCanonicalRun =
|
const isCanonicalRun =
|
||||||
get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId;
|
get().currentProvisioningRunIdByTeam[progress.teamName] === progress.runId;
|
||||||
|
let hydratedVisibleTeam = false;
|
||||||
|
|
||||||
if (isCanonicalRun && becameConfigReady) {
|
if (isCanonicalRun && becameConfigReady) {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) {
|
if (isVisibleInActiveTeamSurface(state, progress.teamName)) {
|
||||||
void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true });
|
if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) {
|
||||||
|
void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true });
|
||||||
|
} else {
|
||||||
|
void state.refreshTeamData(progress.teamName, { withDedup: true });
|
||||||
|
}
|
||||||
|
hydratedVisibleTeam = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2916,10 +3060,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
|
|
||||||
if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) {
|
if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) {
|
||||||
void get().fetchTeams();
|
void get().fetchTeams();
|
||||||
|
if (hydratedVisibleTeam) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = get();
|
||||||
|
if (!isVisibleInActiveTeamSurface(state, progress.teamName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the user already opened the team tab, reload team data now that
|
// If the user already opened the team tab, reload team data now that
|
||||||
// config.json is guaranteed to exist.
|
// config.json is guaranteed to exist.
|
||||||
if (get().selectedTeamName === progress.teamName) {
|
if (state.selectedTeamName === progress.teamName) {
|
||||||
void get().selectTeam(progress.teamName);
|
void state.selectTeam(progress.teamName);
|
||||||
|
} else {
|
||||||
|
void state.refreshTeamData(progress.teamName, { withDedup: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ export function getMemberRuntimeAdvisoryLabel(
|
||||||
providerId?: TeamProviderId,
|
providerId?: TeamProviderId,
|
||||||
nowMs = Date.now()
|
nowMs = Date.now()
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!advisory || advisory.kind !== 'sdk_retrying') {
|
if (advisory?.kind !== 'sdk_retrying') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
|
const baseLabel = formatRuntimeAdvisoryBaseLabel(advisory, providerId);
|
||||||
|
|
@ -366,7 +366,7 @@ export function getMemberRuntimeAdvisoryTitle(
|
||||||
advisory: MemberRuntimeAdvisory | undefined,
|
advisory: MemberRuntimeAdvisory | undefined,
|
||||||
providerId?: TeamProviderId
|
providerId?: TeamProviderId
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!advisory || advisory.kind !== 'sdk_retrying') {
|
if (advisory?.kind !== 'sdk_retrying') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return formatRuntimeAdvisoryTitle(advisory, providerId);
|
return formatRuntimeAdvisoryTitle(advisory, providerId);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
|
import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince';
|
||||||
import {
|
import {
|
||||||
getTaskChangeStateBucket,
|
getTaskChangeStateBucket,
|
||||||
isTaskChangeSummaryCacheable,
|
isTaskChangeSummaryCacheable,
|
||||||
type TaskChangeStateBucket,
|
type TaskChangeStateBucket,
|
||||||
} from '@shared/utils/taskChangeState';
|
} from '@shared/utils/taskChangeState';
|
||||||
import { deriveTaskSince as deriveSharedTaskSince } from '@shared/utils/taskChangeSince';
|
|
||||||
|
|
||||||
import type { ReviewAPI } from '@shared/types/api';
|
import type { ReviewAPI } from '@shared/types/api';
|
||||||
import type { TeamTaskWithKanban } from '@shared/types/team';
|
import type { TeamTaskWithKanban } from '@shared/types/team';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { EnhancedChunk } from '@main/types';
|
||||||
|
|
||||||
export interface TeamMember {
|
export interface TeamMember {
|
||||||
name: string;
|
name: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|
@ -152,6 +154,169 @@ export interface TaskRef {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BoardTaskRefKind = 'canonical' | 'display' | 'unknown';
|
||||||
|
export type BoardTaskResolution = 'resolved' | 'deleted' | 'unresolved' | 'ambiguous';
|
||||||
|
export type BoardTaskActivityLinkKind = 'execution' | 'lifecycle' | 'board_action';
|
||||||
|
export type BoardTaskActivityTargetRole = 'subject' | 'related';
|
||||||
|
export type BoardTaskActivityPhase = 'work' | 'review';
|
||||||
|
export type BoardTaskActorRelation = 'same_task' | 'other_active_task' | 'idle' | 'ambiguous';
|
||||||
|
export type BoardTaskActivityStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
||||||
|
export type BoardTaskActivityRelationship = 'blocked-by' | 'blocks' | 'related';
|
||||||
|
export type BoardTaskActivityCategory =
|
||||||
|
| 'status'
|
||||||
|
| 'review'
|
||||||
|
| 'comment'
|
||||||
|
| 'assignment'
|
||||||
|
| 'read'
|
||||||
|
| 'attachment'
|
||||||
|
| 'relationship'
|
||||||
|
| 'clarification'
|
||||||
|
| 'other';
|
||||||
|
export type BoardTaskRelationshipPerspective = 'outgoing' | 'incoming' | 'symmetric';
|
||||||
|
|
||||||
|
export interface BoardTaskLocator {
|
||||||
|
ref: string;
|
||||||
|
refKind: BoardTaskRefKind;
|
||||||
|
canonicalId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskActivityTaskRef {
|
||||||
|
locator: BoardTaskLocator;
|
||||||
|
resolution: BoardTaskResolution;
|
||||||
|
taskRef?: TaskRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskActivityActor {
|
||||||
|
memberName?: string;
|
||||||
|
role: 'member' | 'lead' | 'unknown';
|
||||||
|
sessionId: string;
|
||||||
|
agentId?: string;
|
||||||
|
isSidechain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskActivityAction {
|
||||||
|
canonicalToolName?: string;
|
||||||
|
toolUseId?: string;
|
||||||
|
category: BoardTaskActivityCategory;
|
||||||
|
peerTask?: BoardTaskActivityTaskRef;
|
||||||
|
relationshipPerspective?: BoardTaskRelationshipPerspective;
|
||||||
|
details?: {
|
||||||
|
status?: BoardTaskActivityStatus;
|
||||||
|
owner?: string | null;
|
||||||
|
clarification?: 'lead' | 'user' | null;
|
||||||
|
reviewer?: string;
|
||||||
|
relationship?: BoardTaskActivityRelationship;
|
||||||
|
commentId?: string;
|
||||||
|
attachmentId?: string;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskActivityActorContext {
|
||||||
|
relation: BoardTaskActorRelation;
|
||||||
|
activeTask?: BoardTaskActivityTaskRef;
|
||||||
|
activePhase?: BoardTaskActivityPhase;
|
||||||
|
activeExecutionSeq?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskActivityEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
task: BoardTaskActivityTaskRef;
|
||||||
|
linkKind: BoardTaskActivityLinkKind;
|
||||||
|
targetRole: BoardTaskActivityTargetRole;
|
||||||
|
actor: BoardTaskActivityActor;
|
||||||
|
actorContext: BoardTaskActivityActorContext;
|
||||||
|
action?: BoardTaskActivityAction;
|
||||||
|
source: {
|
||||||
|
messageUuid: string;
|
||||||
|
filePath: string;
|
||||||
|
toolUseId?: string;
|
||||||
|
sourceOrder: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskExactLogActor {
|
||||||
|
memberName?: string;
|
||||||
|
role: 'member' | 'lead' | 'unknown';
|
||||||
|
sessionId: string;
|
||||||
|
agentId?: string;
|
||||||
|
isSidechain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskExactLogSource {
|
||||||
|
filePath: string;
|
||||||
|
messageUuid: string;
|
||||||
|
toolUseId?: string;
|
||||||
|
sourceOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoardTaskExactLogSummaryBase {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
actor: BoardTaskExactLogActor;
|
||||||
|
source: BoardTaskExactLogSource;
|
||||||
|
anchorKind: 'tool' | 'message';
|
||||||
|
actionLabel: string;
|
||||||
|
actionCategory?: BoardTaskActivityCategory;
|
||||||
|
canonicalToolName?: string;
|
||||||
|
linkKinds: BoardTaskActivityLinkKind[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoardTaskExactLogSummary =
|
||||||
|
| (BoardTaskExactLogSummaryBase & {
|
||||||
|
canLoadDetail: true;
|
||||||
|
sourceGeneration: string;
|
||||||
|
})
|
||||||
|
| (BoardTaskExactLogSummaryBase & {
|
||||||
|
canLoadDetail: false;
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface BoardTaskExactLogDetail {
|
||||||
|
id: string;
|
||||||
|
chunks: EnhancedChunk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskExactLogSummariesResponse {
|
||||||
|
items: BoardTaskExactLogSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoardTaskExactLogDetailResult =
|
||||||
|
| { status: 'ok'; detail: BoardTaskExactLogDetail }
|
||||||
|
| { status: 'stale' }
|
||||||
|
| { status: 'missing' };
|
||||||
|
|
||||||
|
export interface BoardTaskLogActor {
|
||||||
|
memberName?: string;
|
||||||
|
role: 'member' | 'lead' | 'unknown';
|
||||||
|
sessionId: string;
|
||||||
|
agentId?: string;
|
||||||
|
isSidechain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskLogParticipant {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
role: 'member' | 'lead' | 'unknown';
|
||||||
|
isLead: boolean;
|
||||||
|
isSidechain: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskLogSegment {
|
||||||
|
id: string;
|
||||||
|
participantKey: string;
|
||||||
|
actor: BoardTaskLogActor;
|
||||||
|
startTimestamp: string;
|
||||||
|
endTimestamp: string;
|
||||||
|
chunks: EnhancedChunk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskLogStreamResponse {
|
||||||
|
participants: BoardTaskLogParticipant[];
|
||||||
|
defaultFilter: 'all' | string;
|
||||||
|
segments: BoardTaskLogSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskComment {
|
export interface TaskComment {
|
||||||
id: string;
|
id: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { act } from 'react';
|
import React, { act } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/badge', () => ({
|
vi.mock('@renderer/components/ui/badge', () => ({
|
||||||
Badge: ({ children }: { children: React.ReactNode }) =>
|
Badge: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
|
@ -38,9 +39,34 @@ function makeMemberNode(spawnStatus: GraphNode['spawnStatus']): GraphNode {
|
||||||
} as GraphNode;
|
} as GraphNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeOverflowNode(): GraphNode {
|
||||||
|
return {
|
||||||
|
id: 'task:northstar-core:overflow:alice:review',
|
||||||
|
kind: 'task',
|
||||||
|
label: '+2',
|
||||||
|
state: 'waiting',
|
||||||
|
taskStatus: 'in_progress',
|
||||||
|
reviewState: 'review',
|
||||||
|
isOverflowStack: true,
|
||||||
|
overflowCount: 2,
|
||||||
|
overflowTaskIds: ['task-1', 'task-2'],
|
||||||
|
domainRef: {
|
||||||
|
kind: 'task_overflow',
|
||||||
|
teamName: 'northstar-core',
|
||||||
|
ownerMemberName: 'alice',
|
||||||
|
columnKey: 'review',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('GraphNodePopover spawn badge labels', () => {
|
describe('GraphNodePopover spawn badge labels', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
useStore.setState({
|
||||||
|
selectedTeamName: null,
|
||||||
|
selectedTeamData: null,
|
||||||
|
teamDataCacheByName: {},
|
||||||
|
} as never);
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -80,4 +106,156 @@ describe('GraphNodePopover spawn badge labels', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows compact exception badge for member abnormal states', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphNodePopover, {
|
||||||
|
node: {
|
||||||
|
...makeMemberNode('error'),
|
||||||
|
exceptionTone: 'error',
|
||||||
|
exceptionLabel: 'spawn failed',
|
||||||
|
},
|
||||||
|
teamName: 'northstar-core',
|
||||||
|
onClose: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('spawn failed');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders overflow stack contents instead of the task card and opens task detail from the list', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
useStore.setState({
|
||||||
|
selectedTeamName: 'northstar-core',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'northstar-core',
|
||||||
|
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
displayId: '#1',
|
||||||
|
subject: 'Tighten rollout checklist',
|
||||||
|
owner: 'alice',
|
||||||
|
reviewer: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
reviewState: 'review',
|
||||||
|
kanbanColumn: 'review',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
displayId: '#2',
|
||||||
|
subject: 'Patch release notes',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'pending',
|
||||||
|
reviewState: 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: {
|
||||||
|
teamName: 'northstar-core',
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {
|
||||||
|
'task-1': {
|
||||||
|
column: 'review',
|
||||||
|
reviewer: 'bob',
|
||||||
|
movedAt: '2026-04-12T18:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'northstar-core': {
|
||||||
|
teamName: 'northstar-core',
|
||||||
|
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
displayId: '#1',
|
||||||
|
subject: 'Tighten rollout checklist',
|
||||||
|
owner: 'alice',
|
||||||
|
reviewer: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
reviewState: 'review',
|
||||||
|
kanbanColumn: 'review',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
displayId: '#2',
|
||||||
|
subject: 'Patch release notes',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'pending',
|
||||||
|
reviewState: 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: {
|
||||||
|
teamName: 'northstar-core',
|
||||||
|
reviewers: [],
|
||||||
|
tasks: {
|
||||||
|
'task-1': {
|
||||||
|
column: 'review',
|
||||||
|
reviewer: 'bob',
|
||||||
|
movedAt: '2026-04-12T18:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const onOpenTaskDetail = vi.fn();
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(GraphNodePopover, {
|
||||||
|
node: makeOverflowNode(),
|
||||||
|
teamName: 'northstar-core',
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onOpenTaskDetail,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('Hidden tasks');
|
||||||
|
expect(host.textContent).toContain('Tighten rollout checklist');
|
||||||
|
expect(host.textContent).toContain('Patch release notes');
|
||||||
|
expect(host.textContent).toContain('bob');
|
||||||
|
expect(host.textContent).not.toContain('task-card');
|
||||||
|
|
||||||
|
const taskButtons = host.querySelectorAll('button');
|
||||||
|
expect(taskButtons.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
taskButtons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onOpenTaskDetail).toHaveBeenCalledWith('task-1');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter';
|
import { TeamGraphAdapter } from '@renderer/features/agent-graph/adapters/TeamGraphAdapter';
|
||||||
|
|
||||||
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
|
import type { InboxMessage, TeamData, TeamTaskWithKanban } from '@shared/types/team';
|
||||||
|
import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||||
|
|
||||||
function createBaseTeamData(
|
function createBaseTeamData(
|
||||||
overrides?: Partial<TeamData> & {
|
overrides?: Partial<TeamData> & {
|
||||||
|
|
@ -53,6 +54,10 @@ function createBaseTeamData(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findNode(graph: GraphDataPort, nodeId: string) {
|
||||||
|
return graph.nodes.find((node) => node.id === nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
describe('TeamGraphAdapter particles', () => {
|
describe('TeamGraphAdapter particles', () => {
|
||||||
it('creates a message particle for a new incoming message from the newest message set', () => {
|
it('creates a message particle for a new incoming message from the newest message set', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
|
@ -502,6 +507,215 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
expect(alice?.state).toBe('idle');
|
expect(alice?.state).toBe('idle');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refreshes lead state and exception metadata when lead activity changes without team-data changes', () => {
|
||||||
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
const teamData = createBaseTeamData();
|
||||||
|
|
||||||
|
adapter.adapt(teamData, 'my-team', undefined, 'active');
|
||||||
|
|
||||||
|
const graph = adapter.adapt(
|
||||||
|
teamData,
|
||||||
|
'my-team',
|
||||||
|
undefined,
|
||||||
|
'offline',
|
||||||
|
undefined,
|
||||||
|
new Set(['team-lead'])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findNode(graph, 'lead:my-team')).toMatchObject({
|
||||||
|
state: 'terminated',
|
||||||
|
pendingApproval: true,
|
||||||
|
exceptionTone: 'error',
|
||||||
|
exceptionLabel: 'offline',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats literal lead approval sources as lead-node pending approvals', () => {
|
||||||
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
const graph = adapter.adapt(
|
||||||
|
createBaseTeamData(),
|
||||||
|
'my-team',
|
||||||
|
undefined,
|
||||||
|
'active',
|
||||||
|
undefined,
|
||||||
|
new Set(['lead'])
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findNode(graph, 'lead:my-team')).toMatchObject({
|
||||||
|
pendingApproval: true,
|
||||||
|
exceptionTone: 'warning',
|
||||||
|
exceptionLabel: 'awaiting approval',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes member exception state when spawn status changes without team-data changes', () => {
|
||||||
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
const teamData = createBaseTeamData();
|
||||||
|
|
||||||
|
adapter.adapt(teamData, 'my-team');
|
||||||
|
|
||||||
|
const graph = adapter.adapt(teamData, 'my-team', {
|
||||||
|
alice: {
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'starting',
|
||||||
|
updatedAt: '2026-04-08T20:00:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
|
||||||
|
state: 'waiting',
|
||||||
|
spawnStatus: 'waiting',
|
||||||
|
exceptionTone: 'warning',
|
||||||
|
exceptionLabel: 'starting',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes unread comment badges when comment read state changes without task changes', () => {
|
||||||
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
const teamData = createBaseTeamData({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-comments',
|
||||||
|
displayId: '#8',
|
||||||
|
subject: 'Review unread badge',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
id: 'comment-1',
|
||||||
|
author: 'alice',
|
||||||
|
text: 'Need a quick read receipt here',
|
||||||
|
createdAt: '2026-03-28T19:00:02.000Z',
|
||||||
|
type: 'regular',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reviewState: 'none',
|
||||||
|
} as TeamTaskWithKanban,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const unreadGraph = adapter.adapt(
|
||||||
|
teamData,
|
||||||
|
'my-team',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const readGraph = adapter.adapt(
|
||||||
|
teamData,
|
||||||
|
'my-team',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
'my-team/task-comments': {
|
||||||
|
readIds: ['comment-1'],
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findNode(unreadGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBe(1);
|
||||||
|
expect(findNode(readGraph, 'task:my-team:task-comments')?.unreadCommentCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes symmetric blocking links and ignores completed blockers for blocked state', () => {
|
||||||
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
const inProgressGraph = adapter.adapt(
|
||||||
|
createBaseTeamData({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-a',
|
||||||
|
displayId: '#1',
|
||||||
|
subject: 'Blocker',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
blocks: ['task-b'],
|
||||||
|
reviewState: 'none',
|
||||||
|
} as TeamTaskWithKanban,
|
||||||
|
{
|
||||||
|
id: 'task-b',
|
||||||
|
displayId: '#2',
|
||||||
|
subject: 'Blocked task',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'pending',
|
||||||
|
blockedBy: ['task-a'],
|
||||||
|
reviewState: 'none',
|
||||||
|
} as TeamTaskWithKanban,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'my-team'
|
||||||
|
);
|
||||||
|
|
||||||
|
const completedGraph = adapter.adapt(
|
||||||
|
createBaseTeamData({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-a',
|
||||||
|
displayId: '#1',
|
||||||
|
subject: 'Blocker',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'completed',
|
||||||
|
blocks: ['task-b'],
|
||||||
|
reviewState: 'none',
|
||||||
|
} as TeamTaskWithKanban,
|
||||||
|
{
|
||||||
|
id: 'task-b',
|
||||||
|
displayId: '#2',
|
||||||
|
subject: 'Blocked task',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'pending',
|
||||||
|
blockedBy: ['task-a'],
|
||||||
|
reviewState: 'none',
|
||||||
|
} as TeamTaskWithKanban,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'my-team'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inProgressGraph.edges.filter((edge) => edge.type === 'blocking')).toHaveLength(1);
|
||||||
|
expect(findNode(inProgressGraph, 'task:my-team:task-b')?.isBlocked).toBe(true);
|
||||||
|
expect(findNode(completedGraph, 'task:my-team:task-b')?.isBlocked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds compact review handoff metadata for active review tasks', () => {
|
||||||
|
const adapter = TeamGraphAdapter.create();
|
||||||
|
const graph = adapter.adapt(
|
||||||
|
createBaseTeamData({
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-review',
|
||||||
|
displayId: '#5',
|
||||||
|
subject: 'Review this change',
|
||||||
|
owner: 'alice',
|
||||||
|
reviewer: 'bob',
|
||||||
|
status: 'in_progress',
|
||||||
|
reviewState: 'review',
|
||||||
|
changePresence: 'has_changes',
|
||||||
|
kanbanColumn: 'review',
|
||||||
|
} as TeamTaskWithKanban,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'my-team'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(findNode(graph, 'task:my-team:task-review')).toMatchObject({
|
||||||
|
reviewerName: 'bob',
|
||||||
|
reviewMode: 'assigned',
|
||||||
|
changePresence: 'has_changes',
|
||||||
|
reviewState: 'review',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('adds compact runtime labels for lead and members and refreshes when runtime changes', () => {
|
it('adds compact runtime labels for lead and members and refreshes when runtime changes', () => {
|
||||||
const adapter = TeamGraphAdapter.create();
|
const adapter = TeamGraphAdapter.create();
|
||||||
adapter.adapt(createBaseTeamData(), 'my-team');
|
adapter.adapt(createBaseTeamData(), 'my-team');
|
||||||
|
|
|
||||||
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();
|
vi.useFakeTimers();
|
||||||
const fetchTeams = vi.fn(async () => undefined);
|
const fetchTeams = vi.fn(async () => undefined);
|
||||||
const refreshTeamData = vi.fn(async () => undefined);
|
const refreshTeamData = vi.fn(async () => undefined);
|
||||||
const refreshSelectedTeamChangePresence = vi.fn(async () => undefined);
|
const refreshTeamChangePresence = vi.fn(async () => undefined);
|
||||||
|
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
fetchTeams,
|
fetchTeams,
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
refreshSelectedTeamChangePresence,
|
refreshTeamChangePresence,
|
||||||
selectedTeamName: null,
|
selectedTeamName: null,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
|
teamDataCacheByName: {},
|
||||||
paneLayout: {
|
paneLayout: {
|
||||||
focusedPaneId: 'p1',
|
focusedPaneId: 'p1',
|
||||||
panes: [
|
panes: [
|
||||||
|
|
@ -165,6 +166,39 @@ describe('team change throttling', () => {
|
||||||
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lead-message refreshes visible graph tabs even when the team is not selected', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
paneLayout: {
|
||||||
|
focusedPaneId: 'p1',
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
widthFraction: 1,
|
||||||
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
||||||
|
activeTabId: 'g1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const refreshTeamDataSpy = vi.spyOn(useStore.getState(), 'refreshTeamData');
|
||||||
|
|
||||||
|
hoisted.onTeamChangeCb?.({}, { type: 'lead-message', teamName: 'my-team' });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('lead-message does not call fetchAllTasks', async () => {
|
it('lead-message does not call fetchAllTasks', async () => {
|
||||||
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
const fetchAllTasksSpy = vi.fn(async () => undefined);
|
||||||
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never);
|
||||||
|
|
@ -192,23 +226,64 @@ describe('team change throttling', () => {
|
||||||
const state = useStore.getState();
|
const state = useStore.getState();
|
||||||
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams');
|
||||||
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData');
|
||||||
const refreshSelectedTeamChangePresenceSpy = vi.spyOn(
|
const refreshTeamChangePresenceSpy = vi.spyOn(state, 'refreshTeamChangePresence');
|
||||||
state,
|
|
||||||
'refreshSelectedTeamChangePresence'
|
|
||||||
);
|
|
||||||
|
|
||||||
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
|
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(399);
|
await vi.advanceTimersByTimeAsync(399);
|
||||||
expect(refreshSelectedTeamChangePresenceSpy).not.toHaveBeenCalled();
|
expect(refreshTeamChangePresenceSpy).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledTimes(1);
|
expect(refreshTeamChangePresenceSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(refreshSelectedTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
|
expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
|
||||||
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
expect(refreshTeamDataSpy).not.toHaveBeenCalled();
|
||||||
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
expect(fetchTeamsSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('log-source-change refreshes visible graph tab change presence for non-selected teams', async () => {
|
||||||
|
useStore.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paneLayout: {
|
||||||
|
focusedPaneId: 'p1',
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
widthFraction: 1,
|
||||||
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
||||||
|
activeTabId: 'g1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const refreshTeamChangePresenceSpy = vi.spyOn(useStore.getState(), 'refreshTeamChangePresence');
|
||||||
|
|
||||||
|
hoisted.onTeamChangeCb?.({}, { type: 'log-source-change', teamName: 'my-team' });
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
expect(refreshTeamChangePresenceSpy).toHaveBeenCalledWith('my-team');
|
||||||
|
});
|
||||||
|
|
||||||
it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => {
|
it('polls unknown in-progress tasks in round-robin order without starving later tasks', async () => {
|
||||||
const invalidateTaskChangePresence = vi.fn();
|
const invalidateTaskChangePresence = vi.fn();
|
||||||
const checkTaskHasChanges = vi.fn(async () => undefined);
|
const checkTaskHasChanges = vi.fn(async () => undefined);
|
||||||
|
|
@ -268,6 +343,87 @@ describe('team change throttling', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('polls visible non-selected graph teams from cached team data', async () => {
|
||||||
|
const invalidateTaskChangePresence = vi.fn();
|
||||||
|
const checkTaskHasChanges = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
useStore.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team', members: [], projectPath: '/repo' },
|
||||||
|
tasks: [
|
||||||
|
{
|
||||||
|
id: 'task-1',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
createdAt: '2026-03-01T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-01T10:00:00.000Z',
|
||||||
|
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
||||||
|
historyEvents: [],
|
||||||
|
reviewState: 'none',
|
||||||
|
changePresence: 'unknown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'task-2',
|
||||||
|
owner: 'alice',
|
||||||
|
status: 'in_progress',
|
||||||
|
createdAt: '2026-03-01T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-01T10:00:00.000Z',
|
||||||
|
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
||||||
|
historyEvents: [],
|
||||||
|
reviewState: 'none',
|
||||||
|
changePresence: 'unknown',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paneLayout: {
|
||||||
|
focusedPaneId: 'p1',
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
widthFraction: 1,
|
||||||
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
||||||
|
activeTabId: 'g1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
invalidateTaskChangePresence,
|
||||||
|
checkTaskHasChanges,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'my-team',
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(checkTaskHasChanges).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'my-team',
|
||||||
|
'task-2',
|
||||||
|
expect.objectContaining({ status: 'in_progress', owner: 'alice' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('per-team throttling: busy team does not block another visible team', async () => {
|
it('per-team throttling: busy team does not block another visible team', async () => {
|
||||||
// Add a second visible team tab
|
// Add a second visible team tab
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
|
|
@ -374,6 +530,41 @@ describe('team change throttling', () => {
|
||||||
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
|
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tracks visible graph tabs for tool activity and disables tracking when graph tab disappears', async () => {
|
||||||
|
const setToolActivityTrackingSpy = vi.mocked(api.teams.setToolActivityTracking);
|
||||||
|
setToolActivityTrackingSpy.mockClear();
|
||||||
|
|
||||||
|
useStore.setState({
|
||||||
|
paneLayout: {
|
||||||
|
focusedPaneId: 'p1',
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
widthFraction: 1,
|
||||||
|
tabs: [{ id: 'g1', type: 'graph', teamName: 'my-team', label: 'My Team Graph' }],
|
||||||
|
activeTabId: 'g1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
cleanup?.();
|
||||||
|
cleanup = initializeNotificationListeners();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', true);
|
||||||
|
|
||||||
|
useStore.setState({
|
||||||
|
paneLayout: {
|
||||||
|
focusedPaneId: 'p1',
|
||||||
|
panes: [{ id: 'p1', widthFraction: 1, tabs: [], activeTabId: null }],
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(setToolActivityTrackingSpy).toHaveBeenCalledWith('my-team', false);
|
||||||
|
});
|
||||||
|
|
||||||
it('applies targeted tool resets without clearing sibling tools', async () => {
|
it('applies targeted tool resets without clearing sibling tools', async () => {
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
activeToolsByTeam: {
|
activeToolsByTeam: {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
__resetTeamSliceModuleStateForTests,
|
||||||
createTeamSlice,
|
createTeamSlice,
|
||||||
getCurrentProvisioningProgressForTeam,
|
getCurrentProvisioningProgressForTeam,
|
||||||
} from '../../../src/renderer/store/slices/teamSlice';
|
} from '../../../src/renderer/store/slices/teamSlice';
|
||||||
|
|
@ -13,6 +14,9 @@ const hoisted = vi.hoisted(() => ({
|
||||||
getProvisioningStatus: vi.fn(),
|
getProvisioningStatus: vi.fn(),
|
||||||
getMemberSpawnStatuses: vi.fn(),
|
getMemberSpawnStatuses: vi.fn(),
|
||||||
cancelProvisioning: vi.fn(),
|
cancelProvisioning: vi.fn(),
|
||||||
|
deleteTeam: vi.fn(),
|
||||||
|
restoreTeam: vi.fn(),
|
||||||
|
permanentlyDeleteTeam: vi.fn(),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
requestReview: vi.fn(),
|
requestReview: vi.fn(),
|
||||||
updateKanban: vi.fn(),
|
updateKanban: vi.fn(),
|
||||||
|
|
@ -29,6 +33,9 @@ vi.mock('@renderer/api', () => ({
|
||||||
getProvisioningStatus: hoisted.getProvisioningStatus,
|
getProvisioningStatus: hoisted.getProvisioningStatus,
|
||||||
getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses,
|
getMemberSpawnStatuses: hoisted.getMemberSpawnStatuses,
|
||||||
cancelProvisioning: hoisted.cancelProvisioning,
|
cancelProvisioning: hoisted.cancelProvisioning,
|
||||||
|
deleteTeam: hoisted.deleteTeam,
|
||||||
|
restoreTeam: hoisted.restoreTeam,
|
||||||
|
permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam,
|
||||||
sendMessage: hoisted.sendMessage,
|
sendMessage: hoisted.sendMessage,
|
||||||
requestReview: hoisted.requestReview,
|
requestReview: hoisted.requestReview,
|
||||||
updateKanban: hoisted.updateKanban,
|
updateKanban: hoisted.updateKanban,
|
||||||
|
|
@ -74,6 +81,8 @@ function createSliceStore() {
|
||||||
getAllPaneTabs: vi.fn(() => []),
|
getAllPaneTabs: vi.fn(() => []),
|
||||||
warmTaskChangeSummaries: vi.fn(async () => undefined),
|
warmTaskChangeSummaries: vi.fn(async () => undefined),
|
||||||
invalidateTaskChangePresence: vi.fn(),
|
invalidateTaskChangePresence: vi.fn(),
|
||||||
|
fetchTeams: vi.fn(async () => undefined),
|
||||||
|
fetchAllTasks: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +127,7 @@ function createMemberSpawnSnapshot(overrides: Record<string, unknown> = {}) {
|
||||||
describe('teamSlice actions', () => {
|
describe('teamSlice actions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
__resetTeamSliceModuleStateForTests();
|
||||||
hoisted.list.mockResolvedValue([]);
|
hoisted.list.mockResolvedValue([]);
|
||||||
hoisted.getData.mockResolvedValue({
|
hoisted.getData.mockResolvedValue({
|
||||||
teamName: 'my-team',
|
teamName: 'my-team',
|
||||||
|
|
@ -143,6 +153,9 @@ describe('teamSlice actions', () => {
|
||||||
});
|
});
|
||||||
hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null });
|
hoisted.getMemberSpawnStatuses.mockResolvedValue({ statuses: {}, runId: null });
|
||||||
hoisted.cancelProvisioning.mockResolvedValue(undefined);
|
hoisted.cancelProvisioning.mockResolvedValue(undefined);
|
||||||
|
hoisted.deleteTeam.mockResolvedValue(undefined);
|
||||||
|
hoisted.restoreTeam.mockResolvedValue(undefined);
|
||||||
|
hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps inbox verify failure to user-friendly text', async () => {
|
it('maps inbox verify failure to user-friendly text', async () => {
|
||||||
|
|
@ -207,6 +220,104 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
|
expect(store.getState().warmTaskChangeSummaries).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes non-selected team cache entries on permanent delete', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
'other-team': {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().permanentlyDeleteTeam('my-team');
|
||||||
|
|
||||||
|
expect(hoisted.permanentlyDeleteTeam).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
|
||||||
|
expect(store.getState().teamDataCacheByName['other-team']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears selected team state and cache on soft delete', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: 'my-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().deleteTeam('my-team');
|
||||||
|
|
||||||
|
expect(hoisted.deleteTeam).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(store.getState().selectedTeamName).toBeNull();
|
||||||
|
expect(store.getState().selectedTeamData).toBeNull();
|
||||||
|
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops stale cache on restore so the next open refetches fresh data', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.getState().restoreTeam('my-team');
|
||||||
|
|
||||||
|
expect(hoisted.restoreTeam).toHaveBeenCalledWith('my-team');
|
||||||
|
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
describe('refreshTeamData provisioning safety', () => {
|
describe('refreshTeamData provisioning safety', () => {
|
||||||
it('does not set fatal error on TEAM_PROVISIONING', async () => {
|
it('does not set fatal error on TEAM_PROVISIONING', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
|
|
@ -261,6 +372,74 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().selectedTeamData).toEqual(existingData);
|
expect(store.getState().selectedTeamData).toEqual(existingData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears non-selected cache on TEAM_DRAFT refresh failure', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hoisted.getData.mockRejectedValue(new Error('TEAM_DRAFT'));
|
||||||
|
|
||||||
|
await store.getState().refreshTeamData('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
|
||||||
|
expect(store.getState().selectedTeamData?.teamName).toBe('other-team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears non-selected cache when the team no longer exists', async () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
teamDataCacheByName: {
|
||||||
|
'my-team': {
|
||||||
|
teamName: 'my-team',
|
||||||
|
config: { name: 'My Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
hoisted.getData.mockRejectedValue(new Error('Team not found: my-team'));
|
||||||
|
|
||||||
|
await store.getState().refreshTeamData('my-team');
|
||||||
|
|
||||||
|
expect(store.getState().teamDataCacheByName['my-team']).toBeUndefined();
|
||||||
|
expect(store.getState().selectedTeamData?.teamName).toBe('other-team');
|
||||||
|
});
|
||||||
|
|
||||||
it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => {
|
it('clears stale selectedTeamError when TEAM_PROVISIONING with existing data', async () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
store.setState({
|
store.setState({
|
||||||
|
|
@ -512,6 +691,97 @@ describe('teamSlice actions', () => {
|
||||||
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed');
|
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates visible non-selected graph tabs when config becomes ready', () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
paneLayout: {
|
||||||
|
focusedPaneId: 'pane-default',
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'pane-default',
|
||||||
|
widthFraction: 1,
|
||||||
|
tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }],
|
||||||
|
activeTabId: 'graph-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
currentProvisioningRunIdByTeam: {
|
||||||
|
'my-team': 'run-current',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData');
|
||||||
|
const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam');
|
||||||
|
|
||||||
|
store.getState().onProvisioningProgress({
|
||||||
|
runId: 'run-current',
|
||||||
|
teamName: 'my-team',
|
||||||
|
state: 'assembling',
|
||||||
|
configReady: true,
|
||||||
|
message: 'Config written',
|
||||||
|
startedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-12T10:00:01.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||||
|
expect(selectTeamSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes visible non-selected graph tabs when the canonical run reaches ready', () => {
|
||||||
|
const store = createSliceStore();
|
||||||
|
store.setState({
|
||||||
|
selectedTeamName: 'other-team',
|
||||||
|
selectedTeamData: {
|
||||||
|
teamName: 'other-team',
|
||||||
|
config: { name: 'Other Team' },
|
||||||
|
tasks: [],
|
||||||
|
members: [],
|
||||||
|
messages: [],
|
||||||
|
kanbanState: { teamName: 'other-team', reviewers: [], tasks: {} },
|
||||||
|
processes: [],
|
||||||
|
},
|
||||||
|
paneLayout: {
|
||||||
|
focusedPaneId: 'pane-default',
|
||||||
|
panes: [
|
||||||
|
{
|
||||||
|
id: 'pane-default',
|
||||||
|
widthFraction: 1,
|
||||||
|
tabs: [{ id: 'graph-1', type: 'graph', teamName: 'my-team', label: 'My Team' }],
|
||||||
|
activeTabId: 'graph-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
currentProvisioningRunIdByTeam: {
|
||||||
|
'my-team': 'run-current',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTeamDataSpy = vi.spyOn(store.getState(), 'refreshTeamData');
|
||||||
|
const selectTeamSpy = vi.spyOn(store.getState(), 'selectTeam');
|
||||||
|
|
||||||
|
store.getState().onProvisioningProgress({
|
||||||
|
runId: 'run-current',
|
||||||
|
teamName: 'my-team',
|
||||||
|
state: 'ready',
|
||||||
|
message: 'Ready',
|
||||||
|
startedAt: '2026-03-12T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-03-12T10:00:02.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true });
|
||||||
|
expect(selectTeamSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps the current run pinned when stale progress from another run arrives', () => {
|
it('keeps the current run pinned when stale progress from another run arrives', () => {
|
||||||
const store = createSliceStore();
|
const store = createSliceStore();
|
||||||
const startedAt = '2026-03-12T10:00:00.000Z';
|
const startedAt = '2026-03-12T10:00:00.000Z';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue