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.
|
* 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 &&
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 +
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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] : []
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ?? {}),
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue