feat(graph): refine runtime lanes and model compatibility

This commit is contained in:
777genius 2026-05-19 14:22:49 +03:00
parent 3e52008c7a
commit 2d06442ce0
26 changed files with 499 additions and 194 deletions

View file

@ -3,13 +3,15 @@
* NEW not from agent-flow. Custom renderer for our task nodes. * NEW not from agent-flow. Custom renderer for our task nodes.
*/ */
import type { GraphNode } from '../ports/types'; import { ANIM, KANBAN_ZONE, MIN_VISIBLE_OPACITY, TASK_PILL } from '../constants/canvas-constants';
import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors'; import { COLORS, getReviewStateColor, getTaskStatusColor } from '../constants/colors';
import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants';
import { truncateText, wrapTextLines } from './draw-misc'; import { wrapTextLines } from './draw-misc';
import { drawPillShell, drawPillStackLayer } from './draw-pill-shell'; import { drawPillShell } from './draw-pill-shell';
import { hexWithAlpha } from './render-cache'; import { hexWithAlpha } from './render-cache';
import type { KanbanZoneInfo } from '../layout/kanbanLayout'; import type { KanbanZoneInfo } from '../layout/kanbanLayout';
import type { GraphNode } from '../ports/types';
const KANBAN_HEADER_FONT = '600 10px monospace'; const KANBAN_HEADER_FONT = '600 10px monospace';
const KANBAN_HEADER_ALPHA = 0.92; const KANBAN_HEADER_ALPHA = 0.92;
@ -82,7 +84,7 @@ function drawTaskPill(
ctx.translate(x, y); ctx.translate(x, y);
if (node.isOverflowStack) { if (node.isOverflowStack) {
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered); drawOverflowStack(ctx, halfW, r, node, time, isSelected, isHovered);
ctx.restore(); ctx.restore();
return; return;
} }
@ -200,8 +202,8 @@ function drawTaskPill(
// 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 - 36;
const badgeY = halfH; const badgeY = halfH - 30;
// Speech bubble background // Speech bubble background
const bw = 20; const bw = 20;
@ -265,7 +267,7 @@ function drawTaskPillLod(
ctx.translate(x, y); ctx.translate(x, y);
if (node.isOverflowStack) { if (node.isOverflowStack) {
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered); drawOverflowStack(ctx, halfW, r, node, time, isSelected, isHovered);
ctx.restore(); ctx.restore();
return; return;
} }
@ -336,52 +338,41 @@ function drawLiveTaskLogIndicator(
function drawOverflowStack( function drawOverflowStack(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
halfW: number, halfW: number,
halfH: number,
r: number, r: number,
node: GraphNode, node: GraphNode,
time: number,
isSelected: boolean, isSelected: boolean,
isHovered: boolean isHovered: boolean
): void { ): void {
for (const [offset, alpha] of [ const footerHeight = KANBAN_ZONE.overflowHeight;
[6, 0.18],
[3, 0.28],
] as const) {
drawPillStackLayer(ctx, {
width: TASK_PILL.width,
height: TASK_PILL.height,
radius: r,
offsetX: offset,
offsetY: -offset,
fillColor: '#334155',
fillAlpha: alpha,
});
}
drawPillShell(ctx, { drawPillShell(ctx, {
width: TASK_PILL.width, width: TASK_PILL.width,
height: TASK_PILL.height, height: footerHeight,
radius: r, radius: Math.min(r, footerHeight / 2),
fillStyle: isSelected fillStyle: isSelected
? COLORS.cardBgSelected ? COLORS.cardBgSelected
: isHovered : isHovered
? 'rgba(15, 20, 40, 0.78)' ? 'rgba(12, 20, 40, 0.78)'
: COLORS.cardBg, : 'rgba(8, 14, 28, 0.64)',
borderColor: node.isBlocked borderColor: node.isBlocked
? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65) ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65)
: hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55), : isSelected
borderWidth: node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1, ? hexWithAlpha(COLORS.holoBright, 0.45)
: 'rgba(255, 255, 255, 0.10)',
borderWidth: node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 1.5 : 1,
accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined, accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined,
}); });
ctx.font = 'bold 12px sans-serif'; ctx.font = '600 11px sans-serif';
ctx.textAlign = 'left'; ctx.textAlign = 'center';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
ctx.fillStyle = COLORS.textPrimary; ctx.fillStyle = '#cbd5e1';
ctx.fillText(node.label, -halfW + 14, -8); ctx.fillText(node.label, 0, 0.5);
ctx.font = '10px monospace'; if (node.hasLiveTaskLogs) {
ctx.fillStyle = COLORS.textDim; drawLiveTaskLogIndicator(ctx, -halfW + 16, 0, time, true);
ctx.fillText('more tasks', -halfW + 14, 12); }
} }
function drawReviewChip( function drawReviewChip(
@ -451,6 +442,24 @@ function drawCenteredSpacedText(
ctx.textAlign = previousAlign; ctx.textAlign = previousAlign;
} }
function drawLeftSpacedText(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
letterSpacing: number
): void {
const chars = Array.from(text);
const previousAlign = ctx.textAlign;
ctx.textAlign = 'left';
let cursorX = x;
for (const char of chars) {
ctx.fillText(char, cursorX, y);
cursorX += ctx.measureText(char).width + letterSpacing;
}
ctx.textAlign = previousAlign;
}
/** /**
* Draw kanban column headers above task columns. * Draw kanban column headers above task columns.
*/ */
@ -470,41 +479,21 @@ export function drawColumnHeaders(
const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10; const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10;
drawCenteredSpacedText(ctx, 'Unassigned', zone.ownerX, labelY, KANBAN_HEADER_LETTER_SPACING); drawCenteredSpacedText(ctx, 'Unassigned', zone.ownerX, labelY, KANBAN_HEADER_LETTER_SPACING);
// Overflow badge
for (const header of zone.headers) {
if (header.overflowCount > 0) {
ctx.font = '7px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(header.color, 0.45);
ctx.fillText(`+${header.overflowCount} more`, header.x, header.overflowY + 4);
}
}
continue; continue;
} }
for (const header of zone.headers) { for (const header of zone.headers) {
ctx.font = KANBAN_HEADER_FONT; ctx.font = KANBAN_HEADER_FONT;
ctx.textAlign = 'center'; ctx.textAlign = 'left';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA); ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA);
drawCenteredSpacedText( drawLeftSpacedText(
ctx, ctx,
header.label, header.label,
header.x, header.x - TASK_PILL.width / 2 + 4,
header.y + 10, header.y + 4,
KANBAN_HEADER_LETTER_SPACING KANBAN_HEADER_LETTER_SPACING
); );
// Overflow badge: "+N more"
if (header.overflowCount > 0) {
const badgeText = `+${header.overflowCount} more`;
ctx.font = '10px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = hexWithAlpha(header.color, 0.45);
ctx.fillText(badgeText, header.x, header.overflowY + 4);
}
} }
} }
} }

View file

@ -3,10 +3,12 @@
* Adapted from agent-flow's hit-detection.ts (Apache 2.0). * Adapted from agent-flow's hit-detection.ts (Apache 2.0).
*/ */
import type { GraphEdge, GraphNode } from '../ports/types'; import { BEAM, HIT_DETECTION, KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants';
import { BEAM, NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants';
import { bezierPoint, computeControlPoints } from './draw-edges'; import { bezierPoint, computeControlPoints } from './draw-edges';
import type { GraphEdge, GraphNode } from '../ports/types';
/** /**
* Find the node at the given world-space coordinates. * Find the node at the given world-space coordinates.
* Returns node ID or null. * Returns node ID or null.
@ -39,7 +41,8 @@ export function findNodeAt(
} }
case 'task': { case 'task': {
const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding;
const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; const taskHeight = node.isOverflowStack ? KANBAN_ZONE.overflowHeight : TASK_PILL.height;
const halfH = taskHeight / 2 + HIT_DETECTION.taskPadding;
if ( if (
worldX >= x - halfW && worldX >= x - halfW &&
worldX <= x + halfW && worldX <= x + halfW &&

View file

@ -258,17 +258,22 @@ export const BACKGROUND = {
// ─── Kanban zone layout ───────────────────────────────────────────────────── // ─── Kanban zone layout ─────────────────────────────────────────────────────
/** Max visible task rows per column. Includes the overflow stack row when present. */
export const TASK_COLUMN_MAX_VISIBLE_ROWS = STABLE_SLOT_GEOMETRY.taskMaxVisibleRows;
export const KANBAN_ZONE = { export const KANBAN_ZONE = {
/** Column width: task card (260) + gap (20) */ /** Column width: task card (260) + gap (20) */
columnWidth: 280, columnWidth: 280,
/** Row height: task card (72) + gap (8) */ /** Row height: task card (72) + gap (8) */
rowHeight: 80, rowHeight: 80,
/** Compact overflow footer height, matching activity/log more buttons */
overflowHeight: 32,
/** Task center offset from band top: header (20) + gap (4) + half card */ /** Task center offset from band top: header (20) + gap (4) + half card */
headerHeight: 60, headerHeight: 60,
/** Zone starts this far below member node center */ /** Zone starts this far below member node center */
offsetY: 70, offsetY: 70,
/** Column sequence: pending → wip → done → review → approved */ /** Column sequence: pending → wip → done → review → approved */
columns: ['todo', 'wip', 'done', 'review', 'approved'] as const, columns: ['todo', 'wip', 'done', 'review', 'approved'] as const,
/** Max tasks shown per column (overflow hidden) */ /** Max task rows shown per column. Includes the overflow stack row when present. */
maxVisibleRows: STABLE_SLOT_GEOMETRY.taskMaxVisibleRows, maxVisibleRows: TASK_COLUMN_MAX_VISIBLE_ROWS,
} as const; } as const;

View file

@ -7,31 +7,32 @@
*/ */
// ─── Components ────────────────────────────────────────────────────────────── // ─── Components ──────────────────────────────────────────────────────────────
export { GraphView } from './ui/GraphView'; export { TASK_COLUMN_MAX_VISIBLE_ROWS } from './constants/canvas-constants';
export type { GraphViewProps } from './ui/GraphView';
export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane'; export { ACTIVITY_ANCHOR_LAYOUT, ACTIVITY_LANE } from './layout/activityLane';
export { getTransientHandoffCardAlpha } from './ui/transientHandoffs';
export type { TransientHandoffCard } from './ui/transientHandoffs';
// ─── Port Interfaces (for adapters in host project) ───────────────────────── // ─── Port Interfaces (for adapters in host project) ─────────────────────────
export type { GraphConfigPort } from './ports/GraphConfigPort';
export type { GraphDataPort } from './ports/GraphDataPort'; export type { GraphDataPort } from './ports/GraphDataPort';
export type { GraphEventPort } from './ports/GraphEventPort'; export type { GraphEventPort } from './ports/GraphEventPort';
export type { GraphConfigPort } from './ports/GraphConfigPort';
// ─── Port Types ────────────────────────────────────────────────────────────── // ─── Port Types ──────────────────────────────────────────────────────────────
export type { export type {
GraphNode,
GraphEdge,
GraphParticle,
GraphActivityItem, GraphActivityItem,
GraphOwnerSlotAssignment, GraphDomainRef,
GraphLayoutPort, GraphEdge,
GraphEdgeType,
GraphLaunchVisualState,
GraphLayoutMode, GraphLayoutMode,
GraphLayoutPort,
GraphLayoutVersion, GraphLayoutVersion,
GraphNode,
GraphNodeKind, GraphNodeKind,
GraphNodeState, GraphNodeState,
GraphLaunchVisualState, GraphOwnerSlotAssignment,
GraphEdgeType, GraphParticle,
GraphParticleKind, GraphParticleKind,
GraphDomainRef,
} from './ports/types'; } from './ports/types';
export type { GraphViewProps } from './ui/GraphView';
export { GraphView } from './ui/GraphView';
export type { TransientHandoffCard } from './ui/transientHandoffs';
export { getTransientHandoffCardAlpha } from './ui/transientHandoffs';

View file

@ -7,9 +7,10 @@
* Class with ES #private methods, single source of truth from KANBAN_ZONE constants. * Class with ES #private methods, single source of truth from KANBAN_ZONE constants.
*/ */
import type { GraphNode } from '../ports/types';
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import { COLORS } from '../constants/colors'; import { COLORS } from '../constants/colors';
import type { GraphNode } from '../ports/types';
import type { SlotFrame, StableRect } from './stableSlots'; import type { SlotFrame, StableRect } from './stableSlots';
/** Column header info for rendering */ /** Column header info for rendering */
@ -18,10 +19,6 @@ export interface KanbanColumnHeader {
x: number; x: number;
y: number; y: number;
color: string; color: string;
/** Number of hidden overflow tasks in this column */
overflowCount: number;
/** Y position for the overflow badge */
overflowY: number;
} }
/** Zone info per owner for rendering headers */ /** Zone info per owner for rendering headers */
@ -41,6 +38,18 @@ const COLUMN_LABELS: Record<string, { label: string; color: string }> = {
approved: { label: 'Approved', color: COLORS.reviewApproved }, approved: { label: 'Approved', color: COLORS.reviewApproved },
}; };
function getOverflowFooterCenterY(baseY: number): number {
const overflowGap = KANBAN_ZONE.rowHeight - TASK_PILL.height;
return (
baseY +
KANBAN_ZONE.headerHeight +
(KANBAN_ZONE.maxVisibleRows - 1) * KANBAN_ZONE.rowHeight +
TASK_PILL.height / 2 +
overflowGap +
KANBAN_ZONE.overflowHeight / 2
);
}
export function getOwnerKanbanBaseX(args: { export function getOwnerKanbanBaseX(args: {
ownerX: number; ownerX: number;
ownerKind: GraphNode['kind']; ownerKind: GraphNode['kind'];
@ -200,8 +209,6 @@ 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 = col.tasks.find((task) => task.isOverflowStack)?.overflowCount ?? 0;
const visibleCount = col.tasks.length;
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
headers.push({ headers.push({
@ -209,14 +216,15 @@ export class KanbanLayoutEngine {
x: colX, // pill center = task.x = colX x: colX, // pill center = task.x = colX
y: baseY, y: baseY,
color: config.color, color: config.color,
overflowCount: overflow,
overflowY: baseY + headerHeight + visibleCount * rowHeight,
}); });
// Position tasks below header // Position tasks below header. Overflow stacks render as a compact footer after
// the full visible task budget instead of consuming a task-card row.
for (const [rowIdx, task] of col.tasks.entries()) { for (const [rowIdx, task] of col.tasks.entries()) {
const targetX = colX; const targetX = colX;
const targetY = baseY + headerHeight + rowIdx * rowHeight; const targetY = task.isOverflowStack
? getOverflowFooterCenterY(baseY)
: baseY + headerHeight + rowIdx * rowHeight;
task.x = slotFrame task.x = slotFrame
? targetX ? targetX
: task.x != null : task.x != null
@ -264,7 +272,6 @@ export class KanbanLayoutEngine {
const baseX = unassignedTaskRect.left + TASK_PILL.width / 2; const baseX = unassignedTaskRect.left + TASK_PILL.width / 2;
const headerY = unassignedTaskRect.top; const headerY = unassignedTaskRect.top;
const baseY = headerY + KANBAN_ZONE.headerHeight; const baseY = headerY + KANBAN_ZONE.headerHeight;
const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
this.zones.push({ this.zones.push({
ownerId: '__unassigned__', ownerId: '__unassigned__',
@ -276,8 +283,6 @@ export class KanbanLayoutEngine {
x: 0, x: 0,
y: headerY, y: headerY,
color: COLORS.taskPending, color: COLORS.taskPending,
overflowCount,
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
}, },
], ],
}); });
@ -286,7 +291,7 @@ export class KanbanLayoutEngine {
const col = idx % cols; const col = idx % cols;
const row = Math.floor(idx / cols); const row = Math.floor(idx / cols);
const targetX = baseX + col * columnWidth; const targetX = baseX + col * columnWidth;
const targetY = baseY + row * rowHeight; const targetY = task.isOverflowStack ? getOverflowFooterCenterY(headerY) : baseY + row * rowHeight;
task.x = targetX; task.x = targetX;
task.y = targetY; task.y = targetY;
task.fx = targetX; task.fx = targetX;
@ -322,7 +327,6 @@ 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,
@ -333,8 +337,6 @@ export class KanbanLayoutEngine {
x: centerX, x: centerX,
y: headerY, y: headerY,
color: COLORS.taskPending, color: COLORS.taskPending,
overflowCount,
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
}, },
], ],
}); });
@ -344,7 +346,7 @@ export class KanbanLayoutEngine {
const col = idx % cols; const col = idx % cols;
const row = Math.floor(idx / cols); const row = Math.floor(idx / cols);
const targetX = baseX + col * columnWidth; const targetX = baseX + col * columnWidth;
const targetY = baseY + row * rowHeight; const targetY = task.isOverflowStack ? getOverflowFooterCenterY(headerY) : baseY + row * 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;
task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
task.fx = task.x; task.fx = task.x;

View file

@ -1,9 +1,11 @@
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
import { ACTIVITY_LANE } from './activityLane'; import { ACTIVITY_LANE } from './activityLane';
import type { WorldBounds } from './launchAnchor';
import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS } from './stableSlotGeometry'; import { STABLE_SLOT_GEOMETRY, STABLE_SLOT_SECTOR_VECTORS } from './stableSlotGeometry';
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
import type { WorldBounds } from './launchAnchor';
export type StableSlotWidthBucket = 'S' | 'M' | 'L'; export type StableSlotWidthBucket = 'S' | 'M' | 'L';
export interface StableRect { export interface StableRect {
@ -138,7 +140,11 @@ const SLOT_GEOMETRY = {
boardColumnGap: 24, boardColumnGap: 24,
processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth, processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth,
kanbanBandHeight: kanbanBandHeight:
KANBAN_ZONE.headerHeight + STABLE_SLOT_GEOMETRY.taskMaxVisibleRows * KANBAN_ZONE.rowHeight, KANBAN_ZONE.headerHeight +
(STABLE_SLOT_GEOMETRY.taskMaxVisibleRows - 1) * KANBAN_ZONE.rowHeight +
TASK_PILL.height / 2 +
(KANBAN_ZONE.rowHeight - TASK_PILL.height) +
KANBAN_ZONE.overflowHeight,
centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding, centralPadding: STABLE_SLOT_GEOMETRY.centralSafetyPadding,
} as const; } as const;
@ -1455,7 +1461,7 @@ function buildRowOrbitSlotFrames(
const rowTop = rowTopByIndex.get(row[0]!.rowIndex) ?? 0; const rowTop = rowTopByIndex.get(row[0]!.rowIndex) ?? 0;
const columnCount = rowCounts[row[0]!.rowIndex] ?? row.length; const columnCount = rowCounts[row[0]!.rowIndex] ?? row.length;
const columnWidths = resolveRowOrbitColumnWidths(row, columnCount, fallbackColumnWidth); const columnWidths = resolveRowOrbitColumnWidths(row, columnCount, fallbackColumnWidth);
let nextLeft = -getRowOrbitRowWidth(columnWidths) / 2; const nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
for (const config of row) { for (const config of row) {
const ownerX = const ownerX =
nextLeft + nextLeft +

View file

@ -2,10 +2,11 @@
* Render strategy for task pill nodes. * Render strategy for task pill nodes.
*/ */
import type { GraphNode } from '../ports/types';
import type { NodeRenderStrategy, NodeRenderState } from './types';
import { drawTasks } from '../canvas/draw-tasks'; import { drawTasks } from '../canvas/draw-tasks';
import { TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants'; import { HIT_DETECTION, KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
import type { GraphNode } from '../ports/types';
import type { NodeRenderState, NodeRenderStrategy } from './types';
export class TaskStrategy implements NodeRenderStrategy { export class TaskStrategy implements NodeRenderStrategy {
readonly kind = 'task' as const; readonly kind = 'task' as const;
@ -24,7 +25,8 @@ export class TaskStrategy implements NodeRenderStrategy {
const x = node.x ?? 0; const x = node.x ?? 0;
const y = node.y ?? 0; const y = node.y ?? 0;
const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding; const halfW = TASK_PILL.width / 2 + HIT_DETECTION.taskPadding;
const halfH = TASK_PILL.height / 2 + HIT_DETECTION.taskPadding; const taskHeight = node.isOverflowStack ? KANBAN_ZONE.overflowHeight : TASK_PILL.height;
const halfH = taskHeight / 2 + HIT_DETECTION.taskPadding;
return wx >= x - halfW && wx <= x + halfW && wy >= y - halfH && wy <= y + halfH; return wx >= x - halfW && wx <= x + halfW && wy >= y - halfH && wy <= y + halfH;
} }

View file

@ -72,8 +72,8 @@ export function collapseOverflowStacksWithMeta(
continue; continue;
} }
const keptTasks = groupTasks.slice(0, maxVisibleRows - 1); const keptTasks = groupTasks.slice(0, maxVisibleRows);
const hiddenTasks = groupTasks.slice(maxVisibleRows - 1); const hiddenTasks = groupTasks.slice(maxVisibleRows);
const representative = hiddenTasks[0] ?? groupTasks[groupTasks.length - 1]; const representative = hiddenTasks[0] ?? groupTasks[groupTasks.length - 1];
const columnKey = resolveOverflowColumnKey(representative); const columnKey = resolveOverflowColumnKey(representative);
const ownerMemberName = extractOwnerMemberName(representative, teamName); const ownerMemberName = extractOwnerMemberName(representative, teamName);
@ -96,10 +96,10 @@ export function collapseOverflowStacksWithMeta(
visibleTasks.push({ visibleTasks.push({
id: `task:${teamName}:overflow:${groupKey}`, id: `task:${teamName}:overflow:${groupKey}`,
kind: 'task', kind: 'task',
label: `+${hiddenTasks.length}`, label: `+${hiddenTasks.length} more`,
state: representative.state, state: representative.state,
displayId: `+${hiddenTasks.length}`, displayId: `+${hiddenTasks.length} more`,
sublabel: `${hiddenTasks.length} more tasks`, sublabel: undefined,
ownerId: representative.ownerId ?? null, ownerId: representative.ownerId ?? null,
taskStatus: representative.taskStatus, taskStatus: representative.taskStatus,
reviewState: representative.reviewState, reviewState: representative.reviewState,

View file

@ -8,6 +8,17 @@
* Class-based with ES #private fields and DI-ready constructor. * Class-based with ES #private fields and DI-ready constructor.
*/ */
import {
type GraphDataPort,
type GraphEdge,
type GraphLayoutMode,
type GraphLayoutPort,
type GraphNode,
type GraphNodeState,
type GraphOwnerSlotAssignment,
type GraphParticle,
TASK_COLUMN_MAX_VISIBLE_ROWS,
} from '@claude-teams/agent-graph';
import { getUnreadCount } from '@renderer/services/commentReadStorage'; import { getUnreadCount } from '@renderer/services/commentReadStorage';
import { import {
agentAvatarUrl, agentAvatarUrl,
@ -50,20 +61,11 @@ import {
resolveTaskReviewer, resolveTaskReviewer,
} from '../../core/domain/taskGraphSemantics'; } from '../../core/domain/taskGraphSemantics';
import type {
GraphDataPort,
GraphEdge,
GraphLayoutMode,
GraphLayoutPort,
GraphNode,
GraphNodeState,
GraphOwnerSlotAssignment,
GraphParticle,
} from '@claude-teams/agent-graph';
import type { import type {
ActiveToolCall, ActiveToolCall,
InboxMessage, InboxMessage,
LeadActivityState, LeadActivityState,
LeadContextUsage,
MemberSpawnStatusEntry, MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot, MemberSpawnStatusesSnapshot,
ResolvedTeamMember, ResolvedTeamMember,
@ -72,7 +74,6 @@ import type {
TeamProvisioningProgress, TeamProvisioningProgress,
TeamViewSnapshot, TeamViewSnapshot,
} from '@shared/types/team'; } from '@shared/types/team';
import type { LeadContextUsage } from '@shared/types/team';
export interface TeamGraphData extends TeamViewSnapshot { export interface TeamGraphData extends TeamViewSnapshot {
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
@ -759,7 +760,7 @@ export class TeamGraphAdapter {
} }
const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } = const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } =
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5); collapseOverflowStacksWithMeta(rawTaskNodes, teamName, TASK_COLUMN_MAX_VISIBLE_ROWS);
const visibleTaskIds = new Set( const visibleTaskIds = new Set(
visibleTaskNodes.flatMap((taskNode) => visibleTaskNodes.flatMap((taskNode) =>
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : [] taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []

View file

@ -605,7 +605,7 @@ export const GraphMemberLogPreviewHud = ({
}} }}
> >
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden"> <div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
<div className="flex h-4 min-h-4 items-center gap-1 px-1 text-[9px] font-semibold tracking-[0.18em] text-slate-400/70"> <div className="mb-1 flex h-4 min-h-4 items-center gap-1 px-1 text-[9px] font-semibold tracking-[0.18em] text-slate-400/70">
<Wrench className="size-2.5 text-slate-500" /> <Wrench className="size-2.5 text-slate-500" />
Logs Logs
</div> </div>

View file

@ -183,7 +183,7 @@ describe('planTeamRuntimeLanes', () => {
ok: false, ok: false,
reason: 'unsupported_opencode_led_mixed_team', reason: 'unsupported_opencode_led_mixed_team',
message: message:
'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic, Codex, or Gemini when you mix OpenCode with other providers.', 'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic or Codex when you mix OpenCode with other providers.',
}); });
}); });
}); });

View file

@ -126,7 +126,7 @@ export function planTeamRuntimeLanes(params: {
ok: false, ok: false,
reason: 'unsupported_opencode_led_mixed_team', reason: 'unsupported_opencode_led_mixed_team',
message: message:
'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic, Codex, or Gemini when you mix OpenCode with other providers.', 'Mixed teams with an OpenCode lead are not supported in this phase. Keep the team lead on Anthropic or Codex when you mix OpenCode with other providers.',
}; };
} }
return { return {

View file

@ -72,7 +72,7 @@ export function buildTmuxEffectiveAvailability(
version: input.host.version, version: input.host.version,
binaryPath: input.host.binaryPath, binaryPath: input.host.binaryPath,
runtimeReady: input.nativeSupported, runtimeReady: input.nativeSupported,
detail: 'tmux is available as an optional pane transport for teammate sessions.', detail: 'tmux is available.',
}; };
} }

View file

@ -171,7 +171,7 @@ export class TmuxInstallStrategyResolver {
if (input.effective.available) { if (input.effective.available) {
return input.effective.location === 'wsl' return input.effective.location === 'wsl'
? 'tmux is available inside WSL on Windows.' ? 'tmux is available inside WSL on Windows.'
: 'tmux is available as an optional pane transport for teammate sessions.'; : 'tmux is available.';
} }
if (input.platform === 'darwin') { if (input.platform === 'darwin') {

View file

@ -216,7 +216,7 @@ describe('TmuxInstallerBannerAdapter', () => {
version: 'tmux 3.6a', version: 'tmux 3.6a',
binaryPath: '/opt/homebrew/bin/tmux', binaryPath: '/opt/homebrew/bin/tmux',
runtimeReady: true, runtimeReady: true,
detail: 'tmux is available as an optional pane transport.', detail: 'tmux is available.',
}, },
}, },
snapshot: { snapshot: {

View file

@ -1697,7 +1697,7 @@ function isPureOpenCodeProvisioningRequest(request: {
export function getOpenCodeMixedProviderProvisioningError(): string { export function getOpenCodeMixedProviderProvisioningError(): string {
return ( return (
'This OpenCode mixed-team request is outside the current support scope. ' + 'This OpenCode mixed-team request is outside the current support scope. ' +
'Supported mixed teams keep the lead on Anthropic, Codex, or Gemini. OpenCode-led mixed teams still remain blocked in this phase.' 'Supported mixed teams keep the lead on Anthropic or Codex. OpenCode-led mixed teams still remain blocked in this phase.'
); );
} }

View file

@ -683,23 +683,6 @@ export const CreateTeamDialog = ({
); );
const lastPrepareRequestSignatureRef = useRef<string | null>(null); const lastPrepareRequestSignatureRef = useRef<string | null>(null);
useEffect(() => {
const generation = ++prepareUnmountGenerationRef.current;
return () => {
// React StrictMode replays effect cleanup/setup in development; defer
// invalidation so the replay does not cancel the live prepare request.
queueMicrotask(() => {
if (!isCurrentPrepareGeneration(prepareUnmountGenerationRef, generation)) {
return;
}
cancelScheduledIdle(prepareIdleHandleRef.current);
prepareIdleHandleRef.current = null;
prepareRequestSeqRef.current += 1;
lastPrepareRequestSignatureRef.current = null;
});
};
}, []);
useEffect(() => { useEffect(() => {
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider; runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
}, [runtimeBackendSummaryByProvider]); }, [runtimeBackendSummaryByProvider]);
@ -1366,6 +1349,23 @@ export const CreateTeamDialog = ({
tmuxRuntime.status, tmuxRuntime.status,
] ]
); );
const teammateRuntimeProviderNoticeById:
| Partial<Record<TeamProviderId, React.ReactNode>>
| undefined = teammateRuntimeCompatibility.providerNoticeProviderId
? {
[teammateRuntimeCompatibility.providerNoticeProviderId]: (
<TeammateRuntimeCompatibilityNotice
analysis={teammateRuntimeCompatibility}
onOpenDashboard={() => {
onClose();
openDashboard();
}}
/>
),
}
: undefined;
const showRosterTeammateRuntimeCompatibility =
teammateRuntimeCompatibility.visible && !teammateRuntimeCompatibility.providerNoticeProviderId;
const anthropicRuntimeSelection = useMemo( const anthropicRuntimeSelection = useMemo(
() => () =>
selectedProviderId === 'anthropic' selectedProviderId === 'anthropic'
@ -1895,17 +1895,19 @@ export const CreateTeamDialog = ({
const rosterHeaderBottom = useMemo( const rosterHeaderBottom = useMemo(
() => () =>
teammateRuntimeCompatibility.visible || showRosterTeammateRuntimeCompatibility ||
soloTeam || soloTeam ||
(canCreate && hasSelectedWorktreeIsolation) ? ( (canCreate && hasSelectedWorktreeIsolation) ? (
<div className="space-y-2"> <div className="space-y-2">
<TeammateRuntimeCompatibilityNotice {showRosterTeammateRuntimeCompatibility ? (
analysis={teammateRuntimeCompatibility} <TeammateRuntimeCompatibilityNotice
onOpenDashboard={() => { analysis={teammateRuntimeCompatibility}
onClose(); onOpenDashboard={() => {
openDashboard(); onClose();
}} openDashboard();
/> }}
/>
) : null}
{soloTeam ? ( {soloTeam ? (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2"> <div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" /> <Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
@ -1928,6 +1930,7 @@ export const CreateTeamDialog = ({
hasSelectedWorktreeIsolation, hasSelectedWorktreeIsolation,
onClose, onClose,
openDashboard, openDashboard,
showRosterTeammateRuntimeCompatibility,
soloTeam, soloTeam,
teammateRuntimeCompatibility, teammateRuntimeCompatibility,
worktreeGitReadiness, worktreeGitReadiness,
@ -2065,6 +2068,7 @@ export const CreateTeamDialog = ({
model={selectedModel} model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined} effort={(selectedEffort as EffortLevel) || undefined}
limitContext={effectiveAnthropicRuntimeLimitContext} limitContext={effectiveAnthropicRuntimeLimitContext}
leadProviderNoticeById={teammateRuntimeProviderNoticeById}
onProviderChange={setSelectedProviderId} onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort} onEffortChange={setSelectedEffort}

View file

@ -955,6 +955,23 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
tmuxRuntime.status, tmuxRuntime.status,
] ]
); );
const teammateRuntimeProviderNoticeById:
| Partial<Record<TeamProviderId, React.ReactNode>>
| undefined = teammateRuntimeCompatibility.providerNoticeProviderId
? {
[teammateRuntimeCompatibility.providerNoticeProviderId]: (
<TeammateRuntimeCompatibilityNotice
analysis={teammateRuntimeCompatibility}
onOpenDashboard={() => {
closeDialog();
openDashboard();
}}
/>
),
}
: undefined;
const showRosterTeammateRuntimeCompatibility =
teammateRuntimeCompatibility.visible && !teammateRuntimeCompatibility.providerNoticeProviderId;
const anthropicRuntimeSelection = useMemo( const anthropicRuntimeSelection = useMemo(
() => () =>
selectedProviderId === 'anthropic' selectedProviderId === 'anthropic'
@ -2613,6 +2630,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
model={selectedModel} model={selectedModel}
effort={(selectedEffort as EffortLevel) || undefined} effort={(selectedEffort as EffortLevel) || undefined}
limitContext={effectiveAnthropicRuntimeLimitContext} limitContext={effectiveAnthropicRuntimeLimitContext}
leadProviderNoticeById={teammateRuntimeProviderNoticeById}
onProviderChange={setSelectedProviderId} onProviderChange={setSelectedProviderId}
onModelChange={setSelectedModel} onModelChange={setSelectedModel}
onEffortChange={setSelectedEffort} onEffortChange={setSelectedEffort}
@ -2640,15 +2658,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
softDeleteMembers softDeleteMembers
disableGeminiOption={isGeminiUiFrozen()} disableGeminiOption={isGeminiUiFrozen()}
headerBottom={ headerBottom={
teammateRuntimeCompatibility.visible || hasSelectedWorktreeIsolation ? ( showRosterTeammateRuntimeCompatibility || hasSelectedWorktreeIsolation ? (
<div className="space-y-2"> <div className="space-y-2">
<TeammateRuntimeCompatibilityNotice {showRosterTeammateRuntimeCompatibility ? (
analysis={teammateRuntimeCompatibility} <TeammateRuntimeCompatibilityNotice
onOpenDashboard={() => { analysis={teammateRuntimeCompatibility}
closeDialog(); onOpenDashboard={() => {
openDashboard(); closeDialog();
}} openDashboard();
/> }}
/>
) : null}
{hasSelectedWorktreeIsolation ? ( {hasSelectedWorktreeIsolation ? (
<WorktreeGitReadinessBanner state={worktreeGitReadiness} /> <WorktreeGitReadinessBanner state={worktreeGitReadiness} />
) : null} ) : null}

View file

@ -135,8 +135,8 @@ const OPENCODE_MODEL_ROW_ESTIMATE_PX = 92;
const PROVIDERS: ProviderDef[] = [ const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', comingSoon: false }, { id: 'anthropic', label: 'Anthropic', comingSoon: false },
{ id: 'codex', label: 'Codex', comingSoon: false }, { id: 'codex', label: 'Codex', comingSoon: false },
{ id: 'gemini', label: 'Gemini', comingSoon: false },
{ id: 'opencode', label: 'OpenCode', comingSoon: false }, { id: 'opencode', label: 'OpenCode', comingSoon: false },
{ id: 'gemini', label: 'Gemini', comingSoon: false },
]; ];
function getOpenCodeSourceInfo(model: string): OpenCodeSourceInfo | null { function getOpenCodeSourceInfo(model: string): OpenCodeSourceInfo | null {
@ -330,7 +330,7 @@ function getOpenCodeModelPricingInfo(
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.'; const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
export const OPENCODE_ONE_SHOT_DISABLED_REASON = export const OPENCODE_ONE_SHOT_DISABLED_REASON =
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.'; 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic or Codex for one-shot schedules.';
export const OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only'; export const OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only';
function getOpenCodeReadinessBadgeLabel( function getOpenCodeReadinessBadgeLabel(
@ -610,6 +610,7 @@ export interface TeamModelSelectorProps {
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
id?: string; id?: string;
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
providerNoticeById?: Partial<Record<TeamProviderId, React.ReactNode>>;
providerDisabledReasonById?: Partial<Record<TeamProviderId, string | null | undefined>>; providerDisabledReasonById?: Partial<Record<TeamProviderId, string | null | undefined>>;
providerDisabledBadgeLabelById?: Partial<Record<TeamProviderId, string | null | undefined>>; providerDisabledBadgeLabelById?: Partial<Record<TeamProviderId, string | null | undefined>>;
modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>; modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>;
@ -624,6 +625,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
onValueChange, onValueChange,
id, id,
disableGeminiOption = false, disableGeminiOption = false,
providerNoticeById,
providerDisabledReasonById, providerDisabledReasonById,
providerDisabledBadgeLabelById, providerDisabledBadgeLabelById,
modelAdvisoryReasonByValue, modelAdvisoryReasonByValue,
@ -1022,7 +1024,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
if (!normalizedModelQuery) { if (!normalizedModelQuery) {
return true; return true;
} }
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, option.value); const modelRecommendation =
effectiveProviderId === 'opencode'
? getTeamModelRecommendation(effectiveProviderId, option.value)
: null;
return [ return [
option.value, option.value,
option.label, option.label,
@ -1110,6 +1115,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
actionLabel: 'Use OpenCode', actionLabel: 'Use OpenCode',
} }
: null; : null;
const activeProviderNotice = providerNoticeById?.[effectiveProviderId] ?? null;
const getModelAdvisoryBadgeLabel = (reason: string | null): string => const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note'; reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
const renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => { const renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => {
@ -1156,9 +1162,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
null; null;
const openCodeMetadata = const openCodeMetadata =
effectiveProviderId === 'opencode' ? openCodeModelMetadataByValue.get(opt.value) : null; effectiveProviderId === 'opencode' ? openCodeModelMetadataByValue.get(opt.value) : null;
const modelRecommendation = let modelRecommendation: ReturnType<typeof getTeamModelRecommendation> = null;
openCodeMetadata?.recommendation ?? if (effectiveProviderId === 'opencode') {
getTeamModelRecommendation(effectiveProviderId, opt.value); modelRecommendation =
openCodeMetadata?.recommendation ??
getTeamModelRecommendation(effectiveProviderId, opt.value);
}
const openCodePricingInfo = const openCodePricingInfo =
effectiveProviderId === 'opencode' ? (openCodeMetadata?.pricingInfo ?? null) : null; effectiveProviderId === 'opencode' ? (openCodeMetadata?.pricingInfo ?? null) : null;
const modelButtonTitle = const modelButtonTitle =
@ -1333,6 +1342,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
if (!isTeamProviderId(nextValue)) { if (!isTeamProviderId(nextValue)) {
return; return;
} }
if (isInspectingInactiveProvider && nextValue === selectedProviderId) {
setInspectedProviderId(null);
return;
}
if (isProviderSelectable(nextValue)) { if (isProviderSelectable(nextValue)) {
setInspectedProviderId(null); setInspectedProviderId(null);
onProviderChange(nextValue); onProviderChange(nextValue);
@ -1408,6 +1421,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
) : null} ) : null}
<div className="p-3"> <div className="p-3">
{activeProviderNotice ? (
<div data-testid="team-model-selector-provider-notice" className="mb-3">
{activeProviderNotice}
</div>
) : null}
{activeProviderStatusPanel ? ( {activeProviderStatusPanel ? (
<div <div
data-testid="team-model-selector-provider-status" data-testid="team-model-selector-provider-status"

View file

@ -34,6 +34,7 @@ export interface TeammateRuntimeCompatibility {
visible: boolean; visible: boolean;
blocksSubmission: boolean; blocksSubmission: boolean;
checking: boolean; checking: boolean;
providerNoticeProviderId: TeamProviderId | null;
title: string; title: string;
message: string; message: string;
details: string[]; details: string[];
@ -189,6 +190,7 @@ export function analyzeTeammateRuntimeCompatibility({
visible: false, visible: false,
blocksSubmission: false, blocksSubmission: false,
checking: false, checking: false,
providerNoticeProviderId: null,
title: '', title: '',
message: '', message: '',
details: [], details: [],
@ -208,6 +210,7 @@ export function analyzeTeammateRuntimeCompatibility({
visible: false, visible: false,
blocksSubmission: false, blocksSubmission: false,
checking: false, checking: false,
providerNoticeProviderId: null,
title: '', title: '',
message: '', message: '',
details: [], details: [],
@ -221,6 +224,7 @@ export function analyzeTeammateRuntimeCompatibility({
visible: false, visible: false,
blocksSubmission: false, blocksSubmission: false,
checking: false, checking: false,
providerNoticeProviderId: null,
title: '', title: '',
message: '', message: '',
details: [], details: [],
@ -273,7 +277,7 @@ export function analyzeTeammateRuntimeCompatibility({
} }
if (hasOpenCodeLeadMixedUnsupported) { if (hasOpenCodeLeadMixedUnsupported) {
details.push( details.push(
'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.' 'Fix: keep the team lead on Anthropic or Codex when mixing OpenCode with other providers.'
); );
} else if (hasExplicitInProcess) { } else if (hasExplicitInProcess) {
details.push( details.push(
@ -307,6 +311,7 @@ export function analyzeTeammateRuntimeCompatibility({
visible: blocksSubmission || checking, visible: blocksSubmission || checking,
blocksSubmission, blocksSubmission,
checking, checking,
providerNoticeProviderId: hasOpenCodeLeadMixedUnsupported ? 'opencode' : null,
title: checking title: checking
? 'Checking tmux runtime for explicit teammate mode' ? 'Checking tmux runtime for explicit teammate mode'
: hasOpenCodeLeadMixedUnsupported : hasOpenCodeLeadMixedUnsupported
@ -317,12 +322,12 @@ export function analyzeTeammateRuntimeCompatibility({
message: checking message: checking
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.' ? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
: hasOpenCodeLeadMixedUnsupported : hasOpenCodeLeadMixedUnsupported
? 'OpenCode can be added as a teammate under an Anthropic, Codex, or Gemini lead, but mixed teams cannot use OpenCode as the lead in this phase.' ? 'OpenCode can be added as a teammate under an Anthropic or Codex lead, but mixed teams cannot use OpenCode as the lead in this phase.'
: hasExplicitInProcess : hasExplicitInProcess
? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.' ? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.'
: 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.', : 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.',
details, details,
tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError), tmuxDetail: hasOpenCodeLeadMixedUnsupported ? null : getTmuxDetail(tmuxStatus, tmuxStatusError),
memberWarningById, memberWarningById,
}; };
} }

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { import {
@ -45,6 +45,7 @@ interface LeadModelRowProps {
onSyncModelsWithTeammatesChange: (value: boolean) => void; onSyncModelsWithTeammatesChange: (value: boolean) => void;
warningText?: string | null; warningText?: string | null;
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
providerNoticeById?: Partial<Record<TeamProviderId, React.ReactNode>>;
modelIssueText?: string | null; modelIssueText?: string | null;
modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>; modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>;
modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>; modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
@ -66,6 +67,7 @@ export const LeadModelRow = ({
onSyncModelsWithTeammatesChange, onSyncModelsWithTeammatesChange,
warningText, warningText,
disableGeminiOption = false, disableGeminiOption = false,
providerNoticeById,
modelIssueText, modelIssueText,
modelAdvisoryReasonByValue, modelAdvisoryReasonByValue,
modelIssueReasonByValue, modelIssueReasonByValue,
@ -74,7 +76,8 @@ export const LeadModelRow = ({
disableAnthropicContextLimit, disableAnthropicContextLimit,
}: LeadModelRowProps): React.JSX.Element => { }: LeadModelRowProps): React.JSX.Element => {
const { isLight } = useTheme(); const { isLight } = useTheme();
const [modelExpanded, setModelExpanded] = useState(false); const hasActiveProviderNotice = Boolean(providerNoticeById?.[providerId]);
const [modelExpanded, setModelExpanded] = useState(hasActiveProviderNotice);
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName()); const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
const modelButtonLabel = model.trim() const modelButtonLabel = model.trim()
? getProviderScopedTeamModelLabel(providerId, model.trim()) ? getProviderScopedTeamModelLabel(providerId, model.trim())
@ -109,6 +112,12 @@ export const LeadModelRow = ({
disableAnthropicContextLimit ?? disableAnthropicContextLimit ??
(providerId === 'anthropic' && isAnthropicHaikuTeamModel(model)); (providerId === 'anthropic' && isAnthropicHaikuTeamModel(model));
useEffect(() => {
if (hasActiveProviderNotice && !modelExpanded) {
setModelExpanded(true);
}
}, [hasActiveProviderNotice, modelExpanded]);
return ( return (
<div <div
className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[minmax(0,1fr)_auto_auto]" className="relative grid grid-cols-1 gap-2 rounded-md p-2 shadow-sm md:grid-cols-[minmax(0,1fr)_auto_auto]"
@ -204,6 +213,7 @@ export const LeadModelRow = ({
onValueChange={onModelChange} onValueChange={onModelChange}
id="lead-model" id="lead-model"
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
providerNoticeById={providerNoticeById}
modelAdvisoryReasonByValue={modelAdvisoryReasonByValue} modelAdvisoryReasonByValue={modelAdvisoryReasonByValue}
modelIssueReasonByValue={{ modelIssueReasonByValue={{
...(modelIssueReasonByValue ?? {}), ...(modelIssueReasonByValue ?? {}),

View file

@ -42,6 +42,7 @@ interface TeamRosterEditorSectionProps {
onSyncModelsWithTeammatesChange: (value: boolean) => void; onSyncModelsWithTeammatesChange: (value: boolean) => void;
headerTop?: React.ReactNode; headerTop?: React.ReactNode;
headerBottom?: React.ReactNode; headerBottom?: React.ReactNode;
leadProviderNoticeById?: Partial<Record<TeamProviderId, React.ReactNode>>;
softDeleteMembers?: boolean; softDeleteMembers?: boolean;
leadWarningText?: string | null; leadWarningText?: string | null;
memberWarningById?: Record<string, string | null | undefined>; memberWarningById?: Record<string, string | null | undefined>;
@ -97,6 +98,7 @@ const TeamRosterEditorSectionImpl = ({
onSyncModelsWithTeammatesChange, onSyncModelsWithTeammatesChange,
headerTop, headerTop,
headerBottom, headerBottom,
leadProviderNoticeById,
softDeleteMembers = false, softDeleteMembers = false,
leadWarningText, leadWarningText,
memberWarningById, memberWarningById,
@ -188,6 +190,7 @@ const TeamRosterEditorSectionImpl = ({
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange} onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
warningText={leadWarningText} warningText={leadWarningText}
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
providerNoticeById={leadProviderNoticeById}
modelIssueText={leadModelIssueText} modelIssueText={leadModelIssueText}
modelAdvisoryReasonByValue={modelAdvisoryReasonByProvider?.[providerId]} modelAdvisoryReasonByValue={modelAdvisoryReasonByProvider?.[providerId]}
modelIssueReasonByValue={modelIssueReasonByProvider?.[providerId]} modelIssueReasonByValue={modelIssueReasonByProvider?.[providerId]}

View file

@ -211,6 +211,65 @@ describe('TeamModelSelector disabled Codex models', () => {
}); });
}); });
it('hides recommendation badges for Anthropic and Codex model tiles', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
{
providerId: 'codex',
models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'],
modelVerificationState: 'idle',
modelAvailability: [],
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const anthropicButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Opus 4.6')
);
expect(anthropicButton).toBeDefined();
expect(anthropicButton?.textContent).not.toContain('Recommended');
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'codex',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const codexButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('5.2')
);
expect(codexButton).toBeDefined();
expect(codexButton?.textContent).not.toContain('Recommended');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => { it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = { storeState.cliStatus = {
@ -899,7 +958,7 @@ describe('TeamModelSelector disabled Codex models', () => {
const activeButton = Array.from(host.querySelectorAll('button')).find((button) => const activeButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('5.2') button.textContent?.includes('5.2')
); );
expect(activeButton?.textContent).toContain('Recommended'); expect(activeButton?.textContent).not.toContain('Recommended');
expect(activeButton?.getAttribute('aria-disabled')).toBe('false'); expect(activeButton?.getAttribute('aria-disabled')).toBe('false');
await act(async () => { await act(async () => {
@ -1593,6 +1652,144 @@ describe('TeamModelSelector disabled Codex models', () => {
}); });
}); });
it('returns from OpenCode diagnostics to the selected provider without reselecting it', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onProviderChange = vi.fn();
const onValueChange = vi.fn();
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange,
value: 'claude-opus-4-7[1m]',
onValueChange,
})
);
await Promise.resolve();
});
const getTab = (label: string): HTMLButtonElement | undefined =>
Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes(label)
);
await act(async () => {
getTab('OpenCode')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(getTab('OpenCode')?.getAttribute('data-state')).toBe('active');
await act(async () => {
getTab('Anthropic')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(getTab('Anthropic')?.getAttribute('data-state')).toBe('active');
expect(onProviderChange).not.toHaveBeenCalled();
expect(onValueChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('commits the Anthropic fallback when a frozen Gemini selection is corrected', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onProviderChange = vi.fn();
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'gemini',
onProviderChange,
value: '',
onValueChange: () => undefined,
disableGeminiOption: true,
})
);
await Promise.resolve();
});
const anthropicTab = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Anthropic')
);
expect(anthropicTab?.getAttribute('data-state')).toBe('active');
await act(async () => {
anthropicTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(onProviderChange).toHaveBeenCalledWith('anthropic');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders active provider notices inside the provider tab panel', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
{
providerId: 'opencode',
supported: true,
authenticated: true,
detailMessage: null,
statusMessage: null,
capabilities: {
teamLaunch: true,
},
models: ['opencode/minimax-m2.5-free'],
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'opencode',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
providerNoticeById: {
opencode: React.createElement('p', null, 'OpenCode cannot lead mixed-provider teams'),
},
})
);
await Promise.resolve();
});
const notice = host.querySelector('[data-testid="team-model-selector-provider-notice"]');
const modelGrid = host.querySelector('[data-testid="team-model-selector-model-grid"]');
expect(notice?.textContent).toContain('OpenCode cannot lead mixed-provider teams');
expect(modelGrid).not.toBeNull();
if (!notice || !modelGrid) {
throw new Error('Expected provider notice and model grid to render.');
}
expect(
Boolean(notice.compareDocumentPosition(modelGrid) & Node.DOCUMENT_POSITION_FOLLOWING)
).toBe(true);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses role-specific provider disabled copy before OpenCode readiness gating', async () => { it('uses role-specific provider disabled copy before OpenCode readiness gating', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = { storeState.cliStatus = {
@ -1624,7 +1821,7 @@ describe('TeamModelSelector disabled Codex models', () => {
onValueChange: () => undefined, onValueChange: () => undefined,
providerDisabledReasonById: { providerDisabledReasonById: {
opencode: opencode:
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.', 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic or Codex for one-shot schedules.',
}, },
providerDisabledBadgeLabelById: { providerDisabledBadgeLabelById: {
opencode: 'team only', opencode: 'team only',
@ -1639,7 +1836,7 @@ describe('TeamModelSelector disabled Codex models', () => {
); );
expect(openCodeButton?.hasAttribute('disabled')).toBe(true); expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
expect(openCodeButton?.getAttribute('title')).toBe( expect(openCodeButton?.getAttribute('title')).toBe(
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.' 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic or Codex for one-shot schedules.'
); );
expect(openCodeButton?.textContent).toContain('team only'); expect(openCodeButton?.textContent).toContain('team only');
@ -1729,9 +1926,12 @@ describe('TeamModelSelector disabled Codex models', () => {
const buttons = Array.from(host.querySelectorAll('button')); const buttons = Array.from(host.querySelectorAll('button'));
const codexTab = buttons.find((button) => button.textContent?.trim() === 'Codex'); const codexTab = buttons.find((button) => button.textContent?.trim() === 'Codex');
const providerTabIndex = (label: string): number =>
buttons.findIndex((button) => button.textContent?.includes(label));
expect(codexTab).not.toBeNull(); expect(codexTab).not.toBeNull();
expect(host.textContent).toContain('Anthropic'); expect(host.textContent).toContain('Anthropic');
expect(host.textContent).toContain('Codex'); expect(host.textContent).toContain('Codex');
expect(providerTabIndex('OpenCode')).toBeLessThan(providerTabIndex('Gemini'));
await act(async () => { await act(async () => {
codexTab?.click(); codexTab?.click();

View file

@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type -- Legacy dialog mocks use broad DTO shapes. */
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';
const openDashboard = vi.fn(); const openDashboard = vi.fn();
@ -238,10 +240,18 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({ vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({
TeamRosterEditorSection: (props: any) => { TeamRosterEditorSection: (props: any) => {
teamRosterEditorSectionMock.lastProps = props; teamRosterEditorSectionMock.lastProps = props;
const leadProviderNotice = props.leadProviderNoticeById?.[props.providerId] ?? null;
return React.createElement( return React.createElement(
'div', 'div',
null, null,
props.headerTop, props.headerTop,
leadProviderNotice
? React.createElement(
'div',
{ 'data-testid': 'mock-lead-provider-notice' },
leadProviderNotice
)
: null,
'team-roster-editor', 'team-roster-editor',
props.headerBottom props.headerBottom
); );
@ -463,7 +473,7 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
[providerId, model, effort].filter(Boolean).join(' '), [providerId, model, effort].filter(Boolean).join(' '),
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only', OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only',
OPENCODE_ONE_SHOT_DISABLED_REASON: OPENCODE_ONE_SHOT_DISABLED_REASON:
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.', 'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic or Codex for one-shot schedules.',
})); }));
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({ vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
@ -1314,6 +1324,11 @@ describe('LaunchTeamDialog', () => {
}); });
expect(host.textContent).toContain('OpenCode cannot lead mixed-provider teams'); expect(host.textContent).toContain('OpenCode cannot lead mixed-provider teams');
const providerNotice = host.querySelector('[data-testid="mock-lead-provider-notice"]');
expect(providerNotice?.textContent).toContain('OpenCode cannot lead mixed-provider teams');
expect(providerNotice?.textContent).toContain(
'OpenCode can be added as a teammate under an Anthropic or Codex lead'
);
const submitButton = Array.from(host.querySelectorAll('button')).find( const submitButton = Array.from(host.querySelectorAll('button')).find(
(button) => button.textContent === 'Launch team' (button) => button.textContent === 'Launch team'
); );

View file

@ -1,6 +1,5 @@
import { describe, expect, it } from 'vitest';
import { analyzeTeammateRuntimeCompatibility } from '@renderer/components/team/dialogs/teammateRuntimeCompatibility'; import { analyzeTeammateRuntimeCompatibility } from '@renderer/components/team/dialogs/teammateRuntimeCompatibility';
import { describe, expect, it } from 'vitest';
import type { TmuxStatus } from '@features/tmux-installer/contracts'; import type { TmuxStatus } from '@features/tmux-installer/contracts';
@ -52,6 +51,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
expect(result.blocksSubmission).toBe(false); expect(result.blocksSubmission).toBe(false);
expect(result.visible).toBe(false); expect(result.visible).toBe(false);
expect(result.providerNoticeProviderId).toBeNull();
expect(result.memberWarningById).toEqual({}); expect(result.memberWarningById).toEqual({});
}); });
@ -93,8 +93,12 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
}); });
expect(result.blocksSubmission).toBe(true); expect(result.blocksSubmission).toBe(true);
expect(result.providerNoticeProviderId).toBe('opencode');
expect(result.title).toBe('OpenCode cannot lead mixed-provider teams'); expect(result.title).toBe('OpenCode cannot lead mixed-provider teams');
expect(result.message).toContain('mixed teams cannot use OpenCode as the lead'); expect(result.message).toContain('mixed teams cannot use OpenCode as the lead');
expect(result.message).toContain('Anthropic or Codex lead');
expect(result.message).not.toContain('Gemini');
expect(result.tmuxDetail).toBeNull();
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead'); expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
}); });

View file

@ -1,11 +1,9 @@
import { describe, expect, it } from 'vitest'; import { type GraphNode, TASK_COLUMN_MAX_VISIBLE_ROWS } from '@claude-teams/agent-graph';
import { import {
collapseOverflowStacks, collapseOverflowStacks,
collapseOverflowStacksWithMeta, collapseOverflowStacksWithMeta,
} from '@features/agent-graph/core/domain/collapseOverflowStacks'; } from '@features/agent-graph/core/domain/collapseOverflowStacks';
import { describe, expect, it } from 'vitest';
import type { GraphNode } from '@claude-teams/agent-graph';
function makeTaskNode(taskId: string, ownerName: string | null = 'alice'): GraphNode { function makeTaskNode(taskId: string, ownerName: string | null = 'alice'): GraphNode {
return { return {
@ -33,22 +31,23 @@ describe('collapseOverflowStacks', () => {
}); });
it('replaces the hidden tail with a single overflow stack node while preserving visible order', () => { 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 nodes = Array.from({ length: 8 }, (_, index) => makeTaskNode(`task-${index + 1}`));
const result = collapseOverflowStacks(nodes, 'my-team', 6); const result = collapseOverflowStacks(nodes, 'my-team', 6);
expect(result).toHaveLength(6); expect(result).toHaveLength(7);
expect(result.slice(0, 5).map((node) => node.domainRef.kind === 'task' && node.domainRef.taskId)).toEqual([ expect(result.slice(0, 6).map((node) => node.domainRef.kind === 'task' && node.domainRef.taskId)).toEqual([
'task-1', 'task-1',
'task-2', 'task-2',
'task-3', 'task-3',
'task-4', 'task-4',
'task-5', 'task-5',
'task-6',
]); ]);
expect(result[5]).toMatchObject({ expect(result[6]).toMatchObject({
isOverflowStack: true, isOverflowStack: true,
overflowCount: 2, overflowCount: 2,
overflowTaskIds: ['task-6', 'task-7'], overflowTaskIds: ['task-7', 'task-8'],
domainRef: { domainRef: {
kind: 'task_overflow', kind: 'task_overflow',
teamName: 'my-team', teamName: 'my-team',
@ -58,15 +57,32 @@ describe('collapseOverflowStacks', () => {
}); });
}); });
it('uses the graph column row budget so task columns stay inside the stable slot', () => {
const nodes = Array.from({ length: 5 }, (_, index) => makeTaskNode(`task-${index + 1}`));
const result = collapseOverflowStacks(nodes, 'my-team', TASK_COLUMN_MAX_VISIBLE_ROWS);
expect(result).toHaveLength(TASK_COLUMN_MAX_VISIBLE_ROWS + 1);
expect(
result.slice(0, 3).map((node) => node.domainRef.kind === 'task' && node.domainRef.taskId)
).toEqual(['task-1', 'task-2', 'task-3']);
expect(result[3]).toMatchObject({
isOverflowStack: true,
label: '+2 more',
overflowCount: 2,
overflowTaskIds: ['task-4', 'task-5'],
});
});
it('applies the same stack rules to unassigned task columns', () => { it('applies the same stack rules to unassigned task columns', () => {
const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`, null)); const nodes = Array.from({ length: 8 }, (_, index) => makeTaskNode(`task-${index + 1}`, null));
const result = collapseOverflowStacks(nodes, 'my-team', 6); const result = collapseOverflowStacks(nodes, 'my-team', 6);
const stack = result.find((node) => node.isOverflowStack); const stack = result.find((node) => node.isOverflowStack);
expect(stack).toMatchObject({ expect(stack).toMatchObject({
overflowCount: 2, overflowCount: 2,
overflowTaskIds: ['task-6', 'task-7'], overflowTaskIds: ['task-7', 'task-8'],
ownerId: null, ownerId: null,
domainRef: { domainRef: {
kind: 'task_overflow', kind: 'task_overflow',
@ -78,14 +94,15 @@ describe('collapseOverflowStacks', () => {
}); });
it('returns a visible-node mapping for hidden tasks behind the stack', () => { it('returns a visible-node mapping for hidden tasks behind the stack', () => {
const nodes = Array.from({ length: 7 }, (_, index) => makeTaskNode(`task-${index + 1}`)); const nodes = Array.from({ length: 8 }, (_, index) => makeTaskNode(`task-${index + 1}`));
const result = collapseOverflowStacksWithMeta(nodes, 'my-team', 6); const result = collapseOverflowStacksWithMeta(nodes, 'my-team', 6);
const stackNode = result.visibleNodes.find((node) => node.isOverflowStack); const stackNode = result.visibleNodes.find((node) => node.isOverflowStack);
expect(stackNode).toBeDefined(); expect(stackNode).toBeDefined();
expect(result.visibleNodeIdByTaskId.get('task-1')).toBe('task:my-team:task-1'); expect(result.visibleNodeIdByTaskId.get('task-1')).toBe('task:my-team:task-1');
expect(result.visibleNodeIdByTaskId.get('task-6')).toBe(stackNode?.id); expect(result.visibleNodeIdByTaskId.get('task-6')).toBe('task:my-team:task-6');
expect(result.visibleNodeIdByTaskId.get('task-7')).toBe(stackNode?.id); expect(result.visibleNodeIdByTaskId.get('task-7')).toBe(stackNode?.id);
expect(result.visibleNodeIdByTaskId.get('task-8')).toBe(stackNode?.id);
}); });
}); });