feat(graph): refine runtime lanes and model compatibility

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

View file

@ -3,13 +3,15 @@
* NEW not from agent-flow. Custom renderer for our task nodes.
*/
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);
}
}
}
}

View file

@ -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 &&

View file

@ -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;

View file

@ -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';

View file

@ -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;

View file

@ -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 +

View file

@ -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;
}

View file

@ -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,

View file

@ -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] : []

View file

@ -605,7 +605,7 @@ export const GraphMemberLogPreviewHud = ({
}}
>
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
<div className="flex h-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>

View file

@ -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.',
});
});
});

View file

@ -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 {

View file

@ -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.',
};
}

View file

@ -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') {

View file

@ -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: {

View file

@ -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.'
);
}

View file

@ -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}

View file

@ -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}

View file

@ -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"

View file

@ -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,
};
}

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import {
@ -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 ?? {}),

View file

@ -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]}

View file

@ -211,6 +211,65 @@ describe('TeamModelSelector disabled Codex models', () => {
});
});
it('hides recommendation badges for Anthropic and Codex model tiles', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = {
providers: [
{
providerId: 'codex',
models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'gpt-5.2'],
modelVerificationState: 'idle',
modelAvailability: [],
},
],
};
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'anthropic',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const anthropicButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('Opus 4.6')
);
expect(anthropicButton).toBeDefined();
expect(anthropicButton?.textContent).not.toContain('Recommended');
await act(async () => {
root.render(
React.createElement(TeamModelSelector, {
providerId: 'codex',
onProviderChange: () => undefined,
value: '',
onValueChange: () => undefined,
})
);
await Promise.resolve();
});
const codexButton = Array.from(host.querySelectorAll('button')).find((button) =>
button.textContent?.includes('5.2')
);
expect(codexButton).toBeDefined();
expect(codexButton?.textContent).not.toContain('Recommended');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('uses the runtime-reported Codex list and clears stale unsupported selections', async () => {
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();

View file

@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type -- Legacy dialog mocks use broad DTO shapes. */
import React, { act } from 'react';
import { 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'
);

View file

@ -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');
});

View file

@ -1,11 +1,9 @@
import { describe, expect, it } from 'vitest';
import { type GraphNode, TASK_COLUMN_MAX_VISIBLE_ROWS } from '@claude-teams/agent-graph';
import {
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);
});
});