feat(graph): refine runtime lanes and model compatibility
This commit is contained in:
parent
3e52008c7a
commit
2d06442ce0
26 changed files with 499 additions and 194 deletions
|
|
@ -3,13 +3,15 @@
|
|||
* NEW — not from agent-flow. Custom renderer for our task nodes.
|
||||
*/
|
||||
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors';
|
||||
import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants';
|
||||
import { truncateText, wrapTextLines } from './draw-misc';
|
||||
import { drawPillShell, drawPillStackLayer } from './draw-pill-shell';
|
||||
import { ANIM, KANBAN_ZONE, MIN_VISIBLE_OPACITY, TASK_PILL } from '../constants/canvas-constants';
|
||||
import { COLORS, getReviewStateColor, getTaskStatusColor } from '../constants/colors';
|
||||
|
||||
import { wrapTextLines } from './draw-misc';
|
||||
import { drawPillShell } from './draw-pill-shell';
|
||||
import { hexWithAlpha } from './render-cache';
|
||||
|
||||
import type { KanbanZoneInfo } from '../layout/kanbanLayout';
|
||||
import type { GraphNode } from '../ports/types';
|
||||
|
||||
const KANBAN_HEADER_FONT = '600 10px monospace';
|
||||
const KANBAN_HEADER_ALPHA = 0.92;
|
||||
|
|
@ -82,7 +84,7 @@ function drawTaskPill(
|
|||
ctx.translate(x, y);
|
||||
|
||||
if (node.isOverflowStack) {
|
||||
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
|
||||
drawOverflowStack(ctx, halfW, r, node, time, isSelected, isHovered);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
|
@ -200,8 +202,8 @@ function drawTaskPill(
|
|||
|
||||
// Comment count badge — on the bottom-right border edge, 1.5x bigger
|
||||
if (node.totalCommentCount && node.totalCommentCount > 0) {
|
||||
const badgeX = halfW - 6;
|
||||
const badgeY = halfH;
|
||||
const badgeX = halfW - 36;
|
||||
const badgeY = halfH - 30;
|
||||
|
||||
// Speech bubble background
|
||||
const bw = 20;
|
||||
|
|
@ -265,7 +267,7 @@ function drawTaskPillLod(
|
|||
ctx.translate(x, y);
|
||||
|
||||
if (node.isOverflowStack) {
|
||||
drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered);
|
||||
drawOverflowStack(ctx, halfW, r, node, time, isSelected, isHovered);
|
||||
ctx.restore();
|
||||
return;
|
||||
}
|
||||
|
|
@ -336,52 +338,41 @@ function drawLiveTaskLogIndicator(
|
|||
function drawOverflowStack(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
halfW: number,
|
||||
halfH: number,
|
||||
r: number,
|
||||
node: GraphNode,
|
||||
time: number,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean
|
||||
): void {
|
||||
for (const [offset, alpha] of [
|
||||
[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,
|
||||
});
|
||||
}
|
||||
const footerHeight = KANBAN_ZONE.overflowHeight;
|
||||
|
||||
drawPillShell(ctx, {
|
||||
width: TASK_PILL.width,
|
||||
height: TASK_PILL.height,
|
||||
radius: r,
|
||||
height: footerHeight,
|
||||
radius: Math.min(r, footerHeight / 2),
|
||||
fillStyle: isSelected
|
||||
? COLORS.cardBgSelected
|
||||
: isHovered
|
||||
? 'rgba(15, 20, 40, 0.78)'
|
||||
: COLORS.cardBg,
|
||||
? 'rgba(12, 20, 40, 0.78)'
|
||||
: 'rgba(8, 14, 28, 0.64)',
|
||||
borderColor: node.isBlocked
|
||||
? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65)
|
||||
: hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55),
|
||||
borderWidth: node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1,
|
||||
: isSelected
|
||||
? 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,
|
||||
});
|
||||
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.font = '600 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = COLORS.textPrimary;
|
||||
ctx.fillText(node.label, -halfW + 14, -8);
|
||||
ctx.fillStyle = '#cbd5e1';
|
||||
ctx.fillText(node.label, 0, 0.5);
|
||||
|
||||
ctx.font = '10px monospace';
|
||||
ctx.fillStyle = COLORS.textDim;
|
||||
ctx.fillText('more tasks', -halfW + 14, 12);
|
||||
if (node.hasLiveTaskLogs) {
|
||||
drawLiveTaskLogIndicator(ctx, -halfW + 16, 0, time, true);
|
||||
}
|
||||
}
|
||||
|
||||
function drawReviewChip(
|
||||
|
|
@ -451,6 +442,24 @@ function drawCenteredSpacedText(
|
|||
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.
|
||||
*/
|
||||
|
|
@ -470,41 +479,21 @@ export function drawColumnHeaders(
|
|||
const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10;
|
||||
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;
|
||||
}
|
||||
|
||||
for (const header of zone.headers) {
|
||||
ctx.font = KANBAN_HEADER_FONT;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA);
|
||||
drawCenteredSpacedText(
|
||||
drawLeftSpacedText(
|
||||
ctx,
|
||||
header.label,
|
||||
header.x,
|
||||
header.y + 10,
|
||||
header.x - TASK_PILL.width / 2 + 4,
|
||||
header.y + 4,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
* Adapted from agent-flow's hit-detection.ts (Apache 2.0).
|
||||
*/
|
||||
|
||||
import type { GraphEdge, GraphNode } from '../ports/types';
|
||||
import { BEAM, NODE, TASK_PILL, HIT_DETECTION } from '../constants/canvas-constants';
|
||||
import { BEAM, HIT_DETECTION, KANBAN_ZONE, NODE, TASK_PILL } from '../constants/canvas-constants';
|
||||
|
||||
import { bezierPoint, computeControlPoints } from './draw-edges';
|
||||
|
||||
import type { GraphEdge, GraphNode } from '../ports/types';
|
||||
|
||||
/**
|
||||
* Find the node at the given world-space coordinates.
|
||||
* Returns node ID or null.
|
||||
|
|
@ -39,7 +41,8 @@ export function findNodeAt(
|
|||
}
|
||||
case 'task': {
|
||||
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 (
|
||||
worldX >= x - halfW &&
|
||||
worldX <= x + halfW &&
|
||||
|
|
|
|||
|
|
@ -258,17 +258,22 @@ export const BACKGROUND = {
|
|||
|
||||
// ─── 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 = {
|
||||
/** Column width: task card (260) + gap (20) */
|
||||
columnWidth: 280,
|
||||
/** Row height: task card (72) + gap (8) */
|
||||
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 */
|
||||
headerHeight: 60,
|
||||
/** Zone starts this far below member node center */
|
||||
offsetY: 70,
|
||||
/** Column sequence: pending → wip → done → review → approved */
|
||||
columns: ['todo', 'wip', 'done', 'review', 'approved'] as const,
|
||||
/** Max tasks shown per column (overflow hidden) */
|
||||
maxVisibleRows: STABLE_SLOT_GEOMETRY.taskMaxVisibleRows,
|
||||
/** Max task rows shown per column. Includes the overflow stack row when present. */
|
||||
maxVisibleRows: TASK_COLUMN_MAX_VISIBLE_ROWS,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -7,31 +7,32 @@
|
|||
*/
|
||||
|
||||
// ─── Components ──────────────────────────────────────────────────────────────
|
||||
export { GraphView } from './ui/GraphView';
|
||||
export type { GraphViewProps } from './ui/GraphView';
|
||||
export { TASK_COLUMN_MAX_VISIBLE_ROWS } from './constants/canvas-constants';
|
||||
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) ─────────────────────────
|
||||
export type { GraphConfigPort } from './ports/GraphConfigPort';
|
||||
export type { GraphDataPort } from './ports/GraphDataPort';
|
||||
export type { GraphEventPort } from './ports/GraphEventPort';
|
||||
export type { GraphConfigPort } from './ports/GraphConfigPort';
|
||||
|
||||
// ─── Port Types ──────────────────────────────────────────────────────────────
|
||||
export type {
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
GraphParticle,
|
||||
GraphActivityItem,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphLayoutPort,
|
||||
GraphDomainRef,
|
||||
GraphEdge,
|
||||
GraphEdgeType,
|
||||
GraphLaunchVisualState,
|
||||
GraphLayoutMode,
|
||||
GraphLayoutPort,
|
||||
GraphLayoutVersion,
|
||||
GraphNode,
|
||||
GraphNodeKind,
|
||||
GraphNodeState,
|
||||
GraphLaunchVisualState,
|
||||
GraphEdgeType,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphParticle,
|
||||
GraphParticleKind,
|
||||
GraphDomainRef,
|
||||
} 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';
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@
|
|||
* 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 { COLORS } from '../constants/colors';
|
||||
|
||||
import type { GraphNode } from '../ports/types';
|
||||
import type { SlotFrame, StableRect } from './stableSlots';
|
||||
|
||||
/** Column header info for rendering */
|
||||
|
|
@ -18,10 +19,6 @@ export interface KanbanColumnHeader {
|
|||
x: number;
|
||||
y: number;
|
||||
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 */
|
||||
|
|
@ -41,6 +38,18 @@ const COLUMN_LABELS: Record<string, { label: string; color: string }> = {
|
|||
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: {
|
||||
ownerX: number;
|
||||
ownerKind: GraphNode['kind'];
|
||||
|
|
@ -200,8 +209,6 @@ export class KanbanLayoutEngine {
|
|||
for (const [colIdx, col] of activeColumns.entries()) {
|
||||
const colX = baseX + colIdx * columnWidth;
|
||||
const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' };
|
||||
const overflow = col.tasks.find((task) => task.isOverflowStack)?.overflowCount ?? 0;
|
||||
const visibleCount = col.tasks.length;
|
||||
|
||||
// Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y)
|
||||
headers.push({
|
||||
|
|
@ -209,14 +216,15 @@ export class KanbanLayoutEngine {
|
|||
x: colX, // pill center = task.x = colX
|
||||
y: baseY,
|
||||
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()) {
|
||||
const targetX = colX;
|
||||
const targetY = baseY + headerHeight + rowIdx * rowHeight;
|
||||
const targetY = task.isOverflowStack
|
||||
? getOverflowFooterCenterY(baseY)
|
||||
: baseY + headerHeight + rowIdx * rowHeight;
|
||||
task.x = slotFrame
|
||||
? targetX
|
||||
: task.x != null
|
||||
|
|
@ -264,7 +272,6 @@ export class KanbanLayoutEngine {
|
|||
const baseX = unassignedTaskRect.left + TASK_PILL.width / 2;
|
||||
const headerY = unassignedTaskRect.top;
|
||||
const baseY = headerY + KANBAN_ZONE.headerHeight;
|
||||
const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
|
||||
|
||||
this.zones.push({
|
||||
ownerId: '__unassigned__',
|
||||
|
|
@ -276,8 +283,6 @@ export class KanbanLayoutEngine {
|
|||
x: 0,
|
||||
y: headerY,
|
||||
color: COLORS.taskPending,
|
||||
overflowCount,
|
||||
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -286,7 +291,7 @@ export class KanbanLayoutEngine {
|
|||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
const targetX = baseX + col * columnWidth;
|
||||
const targetY = baseY + row * rowHeight;
|
||||
const targetY = task.isOverflowStack ? getOverflowFooterCenterY(headerY) : baseY + row * rowHeight;
|
||||
task.x = targetX;
|
||||
task.y = targetY;
|
||||
task.fx = targetX;
|
||||
|
|
@ -322,7 +327,6 @@ export class KanbanLayoutEngine {
|
|||
|
||||
// Add zone header for unassigned section
|
||||
if (tasks.length > 0) {
|
||||
const overflowCount = tasks.reduce((sum, task) => sum + (task.overflowCount ?? 0), 0);
|
||||
this.zones.push({
|
||||
ownerId: '__unassigned__',
|
||||
ownerX: centerX,
|
||||
|
|
@ -333,8 +337,6 @@ export class KanbanLayoutEngine {
|
|||
x: centerX,
|
||||
y: headerY,
|
||||
color: COLORS.taskPending,
|
||||
overflowCount,
|
||||
overflowY: baseY + KANBAN_ZONE.maxVisibleRows * rowHeight,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -344,7 +346,7 @@ export class KanbanLayoutEngine {
|
|||
const col = idx % cols;
|
||||
const row = Math.floor(idx / cols);
|
||||
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.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
|
||||
task.fx = task.x;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants';
|
||||
import type { GraphLayoutPort, GraphNode, GraphOwnerSlotAssignment } from '../ports/types';
|
||||
|
||||
import { ACTIVITY_LANE } from './activityLane';
|
||||
import type { WorldBounds } from './launchAnchor';
|
||||
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 interface StableRect {
|
||||
|
|
@ -138,7 +140,11 @@ const SLOT_GEOMETRY = {
|
|||
boardColumnGap: 24,
|
||||
processRailMinWidth: STABLE_SLOT_GEOMETRY.processRailWidth,
|
||||
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,
|
||||
} as const;
|
||||
|
||||
|
|
@ -1455,7 +1461,7 @@ function buildRowOrbitSlotFrames(
|
|||
const rowTop = rowTopByIndex.get(row[0]!.rowIndex) ?? 0;
|
||||
const columnCount = rowCounts[row[0]!.rowIndex] ?? row.length;
|
||||
const columnWidths = resolveRowOrbitColumnWidths(row, columnCount, fallbackColumnWidth);
|
||||
let nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
|
||||
const nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
|
||||
for (const config of row) {
|
||||
const ownerX =
|
||||
nextLeft +
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
* 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 { 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 {
|
||||
readonly kind = 'task' as const;
|
||||
|
|
@ -24,7 +25,8 @@ export class TaskStrategy implements NodeRenderStrategy {
|
|||
const x = node.x ?? 0;
|
||||
const y = node.y ?? 0;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ export function collapseOverflowStacksWithMeta(
|
|||
continue;
|
||||
}
|
||||
|
||||
const keptTasks = groupTasks.slice(0, maxVisibleRows - 1);
|
||||
const hiddenTasks = groupTasks.slice(maxVisibleRows - 1);
|
||||
const keptTasks = groupTasks.slice(0, maxVisibleRows);
|
||||
const hiddenTasks = groupTasks.slice(maxVisibleRows);
|
||||
const representative = hiddenTasks[0] ?? groupTasks[groupTasks.length - 1];
|
||||
const columnKey = resolveOverflowColumnKey(representative);
|
||||
const ownerMemberName = extractOwnerMemberName(representative, teamName);
|
||||
|
|
@ -96,10 +96,10 @@ export function collapseOverflowStacksWithMeta(
|
|||
visibleTasks.push({
|
||||
id: `task:${teamName}:overflow:${groupKey}`,
|
||||
kind: 'task',
|
||||
label: `+${hiddenTasks.length}`,
|
||||
label: `+${hiddenTasks.length} more`,
|
||||
state: representative.state,
|
||||
displayId: `+${hiddenTasks.length}`,
|
||||
sublabel: `${hiddenTasks.length} more tasks`,
|
||||
displayId: `+${hiddenTasks.length} more`,
|
||||
sublabel: undefined,
|
||||
ownerId: representative.ownerId ?? null,
|
||||
taskStatus: representative.taskStatus,
|
||||
reviewState: representative.reviewState,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,17 @@
|
|||
* 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 {
|
||||
agentAvatarUrl,
|
||||
|
|
@ -50,20 +61,11 @@ import {
|
|||
resolveTaskReviewer,
|
||||
} from '../../core/domain/taskGraphSemantics';
|
||||
|
||||
import type {
|
||||
GraphDataPort,
|
||||
GraphEdge,
|
||||
GraphLayoutMode,
|
||||
GraphLayoutPort,
|
||||
GraphNode,
|
||||
GraphNodeState,
|
||||
GraphOwnerSlotAssignment,
|
||||
GraphParticle,
|
||||
} from '@claude-teams/agent-graph';
|
||||
import type {
|
||||
ActiveToolCall,
|
||||
InboxMessage,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
MemberSpawnStatusEntry,
|
||||
MemberSpawnStatusesSnapshot,
|
||||
ResolvedTeamMember,
|
||||
|
|
@ -72,7 +74,6 @@ import type {
|
|||
TeamProvisioningProgress,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types/team';
|
||||
import type { LeadContextUsage } from '@shared/types/team';
|
||||
|
||||
export interface TeamGraphData extends TeamViewSnapshot {
|
||||
members: ResolvedTeamMember[];
|
||||
|
|
@ -759,7 +760,7 @@ export class TeamGraphAdapter {
|
|||
}
|
||||
|
||||
const { visibleNodes: visibleTaskNodes, visibleNodeIdByTaskId } =
|
||||
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, 5);
|
||||
collapseOverflowStacksWithMeta(rawTaskNodes, teamName, TASK_COLUMN_MAX_VISIBLE_ROWS);
|
||||
const visibleTaskIds = new Set(
|
||||
visibleTaskNodes.flatMap((taskNode) =>
|
||||
taskNode.domainRef.kind === 'task' ? [taskNode.domainRef.taskId] : []
|
||||
|
|
|
|||
|
|
@ -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-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" />
|
||||
Logs
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ describe('planTeamRuntimeLanes', () => {
|
|||
ok: false,
|
||||
reason: 'unsupported_opencode_led_mixed_team',
|
||||
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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ export function planTeamRuntimeLanes(params: {
|
|||
ok: false,
|
||||
reason: 'unsupported_opencode_led_mixed_team',
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export function buildTmuxEffectiveAvailability(
|
|||
version: input.host.version,
|
||||
binaryPath: input.host.binaryPath,
|
||||
runtimeReady: input.nativeSupported,
|
||||
detail: 'tmux is available as an optional pane transport for teammate sessions.',
|
||||
detail: 'tmux is available.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ export class TmuxInstallStrategyResolver {
|
|||
if (input.effective.available) {
|
||||
return input.effective.location === 'wsl'
|
||||
? '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') {
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
version: 'tmux 3.6a',
|
||||
binaryPath: '/opt/homebrew/bin/tmux',
|
||||
runtimeReady: true,
|
||||
detail: 'tmux is available as an optional pane transport.',
|
||||
detail: 'tmux is available.',
|
||||
},
|
||||
},
|
||||
snapshot: {
|
||||
|
|
|
|||
|
|
@ -1697,7 +1697,7 @@ function isPureOpenCodeProvisioningRequest(request: {
|
|||
export function getOpenCodeMixedProviderProvisioningError(): string {
|
||||
return (
|
||||
'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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -683,23 +683,6 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
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(() => {
|
||||
runtimeBackendSummaryByProviderRef.current = runtimeBackendSummaryByProvider;
|
||||
}, [runtimeBackendSummaryByProvider]);
|
||||
|
|
@ -1366,6 +1349,23 @@ export const CreateTeamDialog = ({
|
|||
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(
|
||||
() =>
|
||||
selectedProviderId === 'anthropic'
|
||||
|
|
@ -1895,17 +1895,19 @@ export const CreateTeamDialog = ({
|
|||
|
||||
const rosterHeaderBottom = useMemo(
|
||||
() =>
|
||||
teammateRuntimeCompatibility.visible ||
|
||||
showRosterTeammateRuntimeCompatibility ||
|
||||
soloTeam ||
|
||||
(canCreate && hasSelectedWorktreeIsolation) ? (
|
||||
<div className="space-y-2">
|
||||
<TeammateRuntimeCompatibilityNotice
|
||||
analysis={teammateRuntimeCompatibility}
|
||||
onOpenDashboard={() => {
|
||||
onClose();
|
||||
openDashboard();
|
||||
}}
|
||||
/>
|
||||
{showRosterTeammateRuntimeCompatibility ? (
|
||||
<TeammateRuntimeCompatibilityNotice
|
||||
analysis={teammateRuntimeCompatibility}
|
||||
onOpenDashboard={() => {
|
||||
onClose();
|
||||
openDashboard();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{soloTeam ? (
|
||||
<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" />
|
||||
|
|
@ -1928,6 +1930,7 @@ export const CreateTeamDialog = ({
|
|||
hasSelectedWorktreeIsolation,
|
||||
onClose,
|
||||
openDashboard,
|
||||
showRosterTeammateRuntimeCompatibility,
|
||||
soloTeam,
|
||||
teammateRuntimeCompatibility,
|
||||
worktreeGitReadiness,
|
||||
|
|
@ -2065,6 +2068,7 @@ export const CreateTeamDialog = ({
|
|||
model={selectedModel}
|
||||
effort={(selectedEffort as EffortLevel) || undefined}
|
||||
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||
leadProviderNoticeById={teammateRuntimeProviderNoticeById}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
|
|
|
|||
|
|
@ -955,6 +955,23 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
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(
|
||||
() =>
|
||||
selectedProviderId === 'anthropic'
|
||||
|
|
@ -2613,6 +2630,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
model={selectedModel}
|
||||
effort={(selectedEffort as EffortLevel) || undefined}
|
||||
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||
leadProviderNoticeById={teammateRuntimeProviderNoticeById}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
|
|
@ -2640,15 +2658,17 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
softDeleteMembers
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
headerBottom={
|
||||
teammateRuntimeCompatibility.visible || hasSelectedWorktreeIsolation ? (
|
||||
showRosterTeammateRuntimeCompatibility || hasSelectedWorktreeIsolation ? (
|
||||
<div className="space-y-2">
|
||||
<TeammateRuntimeCompatibilityNotice
|
||||
analysis={teammateRuntimeCompatibility}
|
||||
onOpenDashboard={() => {
|
||||
closeDialog();
|
||||
openDashboard();
|
||||
}}
|
||||
/>
|
||||
{showRosterTeammateRuntimeCompatibility ? (
|
||||
<TeammateRuntimeCompatibilityNotice
|
||||
analysis={teammateRuntimeCompatibility}
|
||||
onOpenDashboard={() => {
|
||||
closeDialog();
|
||||
openDashboard();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{hasSelectedWorktreeIsolation ? (
|
||||
<WorktreeGitReadinessBanner state={worktreeGitReadiness} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -135,8 +135,8 @@ const OPENCODE_MODEL_ROW_ESTIMATE_PX = 92;
|
|||
const PROVIDERS: ProviderDef[] = [
|
||||
{ id: 'anthropic', label: 'Anthropic', comingSoon: false },
|
||||
{ id: 'codex', label: 'Codex', comingSoon: false },
|
||||
{ id: 'gemini', label: 'Gemini', comingSoon: false },
|
||||
{ id: 'opencode', label: 'OpenCode', comingSoon: false },
|
||||
{ id: 'gemini', label: 'Gemini', comingSoon: false },
|
||||
];
|
||||
|
||||
function getOpenCodeSourceInfo(model: string): OpenCodeSourceInfo | null {
|
||||
|
|
@ -330,7 +330,7 @@ function getOpenCodeModelPricingInfo(
|
|||
|
||||
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
|
||||
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';
|
||||
|
||||
function getOpenCodeReadinessBadgeLabel(
|
||||
|
|
@ -610,6 +610,7 @@ export interface TeamModelSelectorProps {
|
|||
onValueChange: (value: string) => void;
|
||||
id?: string;
|
||||
disableGeminiOption?: boolean;
|
||||
providerNoticeById?: Partial<Record<TeamProviderId, React.ReactNode>>;
|
||||
providerDisabledReasonById?: Partial<Record<TeamProviderId, string | null | undefined>>;
|
||||
providerDisabledBadgeLabelById?: Partial<Record<TeamProviderId, string | null | undefined>>;
|
||||
modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
|
|
@ -624,6 +625,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
onValueChange,
|
||||
id,
|
||||
disableGeminiOption = false,
|
||||
providerNoticeById,
|
||||
providerDisabledReasonById,
|
||||
providerDisabledBadgeLabelById,
|
||||
modelAdvisoryReasonByValue,
|
||||
|
|
@ -1022,7 +1024,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
if (!normalizedModelQuery) {
|
||||
return true;
|
||||
}
|
||||
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
|
||||
const modelRecommendation =
|
||||
effectiveProviderId === 'opencode'
|
||||
? getTeamModelRecommendation(effectiveProviderId, option.value)
|
||||
: null;
|
||||
return [
|
||||
option.value,
|
||||
option.label,
|
||||
|
|
@ -1110,6 +1115,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
actionLabel: 'Use OpenCode',
|
||||
}
|
||||
: null;
|
||||
const activeProviderNotice = providerNoticeById?.[effectiveProviderId] ?? null;
|
||||
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
|
||||
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
|
||||
const renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => {
|
||||
|
|
@ -1156,9 +1162,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
null;
|
||||
const openCodeMetadata =
|
||||
effectiveProviderId === 'opencode' ? openCodeModelMetadataByValue.get(opt.value) : null;
|
||||
const modelRecommendation =
|
||||
openCodeMetadata?.recommendation ??
|
||||
getTeamModelRecommendation(effectiveProviderId, opt.value);
|
||||
let modelRecommendation: ReturnType<typeof getTeamModelRecommendation> = null;
|
||||
if (effectiveProviderId === 'opencode') {
|
||||
modelRecommendation =
|
||||
openCodeMetadata?.recommendation ??
|
||||
getTeamModelRecommendation(effectiveProviderId, opt.value);
|
||||
}
|
||||
const openCodePricingInfo =
|
||||
effectiveProviderId === 'opencode' ? (openCodeMetadata?.pricingInfo ?? null) : null;
|
||||
const modelButtonTitle =
|
||||
|
|
@ -1333,6 +1342,10 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
if (!isTeamProviderId(nextValue)) {
|
||||
return;
|
||||
}
|
||||
if (isInspectingInactiveProvider && nextValue === selectedProviderId) {
|
||||
setInspectedProviderId(null);
|
||||
return;
|
||||
}
|
||||
if (isProviderSelectable(nextValue)) {
|
||||
setInspectedProviderId(null);
|
||||
onProviderChange(nextValue);
|
||||
|
|
@ -1408,6 +1421,11 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
) : null}
|
||||
|
||||
<div className="p-3">
|
||||
{activeProviderNotice ? (
|
||||
<div data-testid="team-model-selector-provider-notice" className="mb-3">
|
||||
{activeProviderNotice}
|
||||
</div>
|
||||
) : null}
|
||||
{activeProviderStatusPanel ? (
|
||||
<div
|
||||
data-testid="team-model-selector-provider-status"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface TeammateRuntimeCompatibility {
|
|||
visible: boolean;
|
||||
blocksSubmission: boolean;
|
||||
checking: boolean;
|
||||
providerNoticeProviderId: TeamProviderId | null;
|
||||
title: string;
|
||||
message: string;
|
||||
details: string[];
|
||||
|
|
@ -189,6 +190,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
visible: false,
|
||||
blocksSubmission: false,
|
||||
checking: false,
|
||||
providerNoticeProviderId: null,
|
||||
title: '',
|
||||
message: '',
|
||||
details: [],
|
||||
|
|
@ -208,6 +210,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
visible: false,
|
||||
blocksSubmission: false,
|
||||
checking: false,
|
||||
providerNoticeProviderId: null,
|
||||
title: '',
|
||||
message: '',
|
||||
details: [],
|
||||
|
|
@ -221,6 +224,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
visible: false,
|
||||
blocksSubmission: false,
|
||||
checking: false,
|
||||
providerNoticeProviderId: null,
|
||||
title: '',
|
||||
message: '',
|
||||
details: [],
|
||||
|
|
@ -273,7 +277,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
}
|
||||
if (hasOpenCodeLeadMixedUnsupported) {
|
||||
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) {
|
||||
details.push(
|
||||
|
|
@ -307,6 +311,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
visible: blocksSubmission || checking,
|
||||
blocksSubmission,
|
||||
checking,
|
||||
providerNoticeProviderId: hasOpenCodeLeadMixedUnsupported ? 'opencode' : null,
|
||||
title: checking
|
||||
? 'Checking tmux runtime for explicit teammate mode'
|
||||
: hasOpenCodeLeadMixedUnsupported
|
||||
|
|
@ -317,12 +322,12 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
message: checking
|
||||
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
|
||||
: 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
|
||||
? '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.',
|
||||
details,
|
||||
tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError),
|
||||
tmuxDetail: hasOpenCodeLeadMixedUnsupported ? null : getTmuxDetail(tmuxStatus, tmuxStatusError),
|
||||
memberWarningById,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import {
|
||||
|
|
@ -45,6 +45,7 @@ interface LeadModelRowProps {
|
|||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
warningText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
providerNoticeById?: Partial<Record<TeamProviderId, React.ReactNode>>;
|
||||
modelIssueText?: string | null;
|
||||
modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
|
|
@ -66,6 +67,7 @@ export const LeadModelRow = ({
|
|||
onSyncModelsWithTeammatesChange,
|
||||
warningText,
|
||||
disableGeminiOption = false,
|
||||
providerNoticeById,
|
||||
modelIssueText,
|
||||
modelAdvisoryReasonByValue,
|
||||
modelIssueReasonByValue,
|
||||
|
|
@ -74,7 +76,8 @@ export const LeadModelRow = ({
|
|||
disableAnthropicContextLimit,
|
||||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
const { isLight } = useTheme();
|
||||
const [modelExpanded, setModelExpanded] = useState(false);
|
||||
const hasActiveProviderNotice = Boolean(providerNoticeById?.[providerId]);
|
||||
const [modelExpanded, setModelExpanded] = useState(hasActiveProviderNotice);
|
||||
const leadColorSet = getTeamColorSet(resolveTeamLeadColorName());
|
||||
const modelButtonLabel = model.trim()
|
||||
? getProviderScopedTeamModelLabel(providerId, model.trim())
|
||||
|
|
@ -109,6 +112,12 @@ export const LeadModelRow = ({
|
|||
disableAnthropicContextLimit ??
|
||||
(providerId === 'anthropic' && isAnthropicHaikuTeamModel(model));
|
||||
|
||||
useEffect(() => {
|
||||
if (hasActiveProviderNotice && !modelExpanded) {
|
||||
setModelExpanded(true);
|
||||
}
|
||||
}, [hasActiveProviderNotice, modelExpanded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
id="lead-model"
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
providerNoticeById={providerNoticeById}
|
||||
modelAdvisoryReasonByValue={modelAdvisoryReasonByValue}
|
||||
modelIssueReasonByValue={{
|
||||
...(modelIssueReasonByValue ?? {}),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ interface TeamRosterEditorSectionProps {
|
|||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
headerTop?: React.ReactNode;
|
||||
headerBottom?: React.ReactNode;
|
||||
leadProviderNoticeById?: Partial<Record<TeamProviderId, React.ReactNode>>;
|
||||
softDeleteMembers?: boolean;
|
||||
leadWarningText?: string | null;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
|
|
@ -97,6 +98,7 @@ const TeamRosterEditorSectionImpl = ({
|
|||
onSyncModelsWithTeammatesChange,
|
||||
headerTop,
|
||||
headerBottom,
|
||||
leadProviderNoticeById,
|
||||
softDeleteMembers = false,
|
||||
leadWarningText,
|
||||
memberWarningById,
|
||||
|
|
@ -188,6 +190,7 @@ const TeamRosterEditorSectionImpl = ({
|
|||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||
warningText={leadWarningText}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
providerNoticeById={leadProviderNoticeById}
|
||||
modelIssueText={leadModelIssueText}
|
||||
modelAdvisoryReasonByValue={modelAdvisoryReasonByProvider?.[providerId]}
|
||||
modelIssueReasonByValue={modelIssueReasonByProvider?.[providerId]}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
|
|
@ -899,7 +958,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
const activeButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('5.2')
|
||||
);
|
||||
expect(activeButton?.textContent).toContain('Recommended');
|
||||
expect(activeButton?.textContent).not.toContain('Recommended');
|
||||
expect(activeButton?.getAttribute('aria-disabled')).toBe('false');
|
||||
|
||||
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 () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
|
|
@ -1624,7 +1821,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
onValueChange: () => undefined,
|
||||
providerDisabledReasonById: {
|
||||
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: {
|
||||
opencode: 'team only',
|
||||
|
|
@ -1639,7 +1836,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
);
|
||||
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
||||
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');
|
||||
|
||||
|
|
@ -1729,9 +1926,12 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
|
||||
const buttons = Array.from(host.querySelectorAll('button'));
|
||||
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(host.textContent).toContain('Anthropic');
|
||||
expect(host.textContent).toContain('Codex');
|
||||
expect(providerTabIndex('OpenCode')).toBeLessThan(providerTabIndex('Gemini'));
|
||||
|
||||
await act(async () => {
|
||||
codexTab?.click();
|
||||
|
|
|
|||
|
|
@ -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 { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const openDashboard = vi.fn();
|
||||
|
|
@ -238,10 +240,18 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({
|
||||
TeamRosterEditorSection: (props: any) => {
|
||||
teamRosterEditorSectionMock.lastProps = props;
|
||||
const leadProviderNotice = props.leadProviderNoticeById?.[props.providerId] ?? null;
|
||||
return React.createElement(
|
||||
'div',
|
||||
null,
|
||||
props.headerTop,
|
||||
leadProviderNotice
|
||||
? React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'mock-lead-provider-notice' },
|
||||
leadProviderNotice
|
||||
)
|
||||
: null,
|
||||
'team-roster-editor',
|
||||
props.headerBottom
|
||||
);
|
||||
|
|
@ -463,7 +473,7 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
|||
[providerId, model, effort].filter(Boolean).join(' '),
|
||||
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only',
|
||||
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', () => ({
|
||||
|
|
@ -1314,6 +1324,11 @@ describe('LaunchTeamDialog', () => {
|
|||
});
|
||||
|
||||
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(
|
||||
(button) => button.textContent === 'Launch team'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { analyzeTeammateRuntimeCompatibility } from '@renderer/components/team/dialogs/teammateRuntimeCompatibility';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { TmuxStatus } from '@features/tmux-installer/contracts';
|
||||
|
||||
|
|
@ -52,6 +51,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
|||
|
||||
expect(result.blocksSubmission).toBe(false);
|
||||
expect(result.visible).toBe(false);
|
||||
expect(result.providerNoticeProviderId).toBeNull();
|
||||
expect(result.memberWarningById).toEqual({});
|
||||
});
|
||||
|
||||
|
|
@ -93,8 +93,12 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
|||
});
|
||||
|
||||
expect(result.blocksSubmission).toBe(true);
|
||||
expect(result.providerNoticeProviderId).toBe('opencode');
|
||||
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('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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type GraphNode, TASK_COLUMN_MAX_VISIBLE_ROWS } from '@claude-teams/agent-graph';
|
||||
import {
|
||||
collapseOverflowStacks,
|
||||
collapseOverflowStacksWithMeta,
|
||||
} from '@features/agent-graph/core/domain/collapseOverflowStacks';
|
||||
|
||||
import type { GraphNode } from '@claude-teams/agent-graph';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeTaskNode(taskId: string, ownerName: string | null = 'alice'): GraphNode {
|
||||
return {
|
||||
|
|
@ -33,22 +31,23 @@ describe('collapseOverflowStacks', () => {
|
|||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result.slice(0, 5).map((node) => node.domainRef.kind === 'task' && node.domainRef.taskId)).toEqual([
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result.slice(0, 6).map((node) => node.domainRef.kind === 'task' && node.domainRef.taskId)).toEqual([
|
||||
'task-1',
|
||||
'task-2',
|
||||
'task-3',
|
||||
'task-4',
|
||||
'task-5',
|
||||
'task-6',
|
||||
]);
|
||||
expect(result[5]).toMatchObject({
|
||||
expect(result[6]).toMatchObject({
|
||||
isOverflowStack: true,
|
||||
overflowCount: 2,
|
||||
overflowTaskIds: ['task-6', 'task-7'],
|
||||
overflowTaskIds: ['task-7', 'task-8'],
|
||||
domainRef: {
|
||||
kind: 'task_overflow',
|
||||
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', () => {
|
||||
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 stack = result.find((node) => node.isOverflowStack);
|
||||
|
||||
expect(stack).toMatchObject({
|
||||
overflowCount: 2,
|
||||
overflowTaskIds: ['task-6', 'task-7'],
|
||||
overflowTaskIds: ['task-7', 'task-8'],
|
||||
ownerId: null,
|
||||
domainRef: {
|
||||
kind: 'task_overflow',
|
||||
|
|
@ -78,14 +94,15 @@ describe('collapseOverflowStacks', () => {
|
|||
});
|
||||
|
||||
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 stackNode = result.visibleNodes.find((node) => node.isOverflowStack);
|
||||
|
||||
expect(stackNode).toBeDefined();
|
||||
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-8')).toBe(stackNode?.id);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue