diff --git a/packages/agent-graph/src/canvas/draw-tasks.ts b/packages/agent-graph/src/canvas/draw-tasks.ts index edb0cb41..7527497e 100644 --- a/packages/agent-graph/src/canvas/draw-tasks.ts +++ b/packages/agent-graph/src/canvas/draw-tasks.ts @@ -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); - } } } } diff --git a/packages/agent-graph/src/canvas/hit-detection.ts b/packages/agent-graph/src/canvas/hit-detection.ts index 3d3577c3..470fb5bf 100644 --- a/packages/agent-graph/src/canvas/hit-detection.ts +++ b/packages/agent-graph/src/canvas/hit-detection.ts @@ -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 && diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 23c027eb..7e961cf1 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -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; diff --git a/packages/agent-graph/src/index.ts b/packages/agent-graph/src/index.ts index 4a675f71..2b8d5078 100644 --- a/packages/agent-graph/src/index.ts +++ b/packages/agent-graph/src/index.ts @@ -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'; diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index fe4b7911..72322280 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -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 = { 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; diff --git a/packages/agent-graph/src/layout/stableSlots.ts b/packages/agent-graph/src/layout/stableSlots.ts index dfddc4ce..cd96e380 100644 --- a/packages/agent-graph/src/layout/stableSlots.ts +++ b/packages/agent-graph/src/layout/stableSlots.ts @@ -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 + diff --git a/packages/agent-graph/src/strategies/taskStrategy.ts b/packages/agent-graph/src/strategies/taskStrategy.ts index 96a9a92b..e231eedf 100644 --- a/packages/agent-graph/src/strategies/taskStrategy.ts +++ b/packages/agent-graph/src/strategies/taskStrategy.ts @@ -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; } diff --git a/src/features/agent-graph/core/domain/collapseOverflowStacks.ts b/src/features/agent-graph/core/domain/collapseOverflowStacks.ts index 8d99dbc1..b2da40ae 100644 --- a/src/features/agent-graph/core/domain/collapseOverflowStacks.ts +++ b/src/features/agent-graph/core/domain/collapseOverflowStacks.ts @@ -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, diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 68de7115..469e117b 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -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] : [] diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index fd06994b..16c4e79a 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -605,7 +605,7 @@ export const GraphMemberLogPreviewHud = ({ }} >
-
+
Logs
diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts index 51704b72..272f28b0 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/planTeamRuntimeLanes.test.ts @@ -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.', }); }); }); diff --git a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts index 46f63a9b..63b1e011 100644 --- a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts +++ b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts @@ -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 { diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts index 78a674a4..4a26e476 100644 --- a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts @@ -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.', }; } diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts index 4ba5607b..c7864f80 100644 --- a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts @@ -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') { diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts index 0d3ab57e..756ea2ab 100644 --- a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -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: { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1f8fe493..5b8d97db 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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.' ); } diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 6fc1c8a1..e9ae59c2 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -683,23 +683,6 @@ export const CreateTeamDialog = ({ ); const lastPrepareRequestSignatureRef = useRef(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> + | undefined = teammateRuntimeCompatibility.providerNoticeProviderId + ? { + [teammateRuntimeCompatibility.providerNoticeProviderId]: ( + { + 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) ? (
- { - onClose(); - openDashboard(); - }} - /> + {showRosterTeammateRuntimeCompatibility ? ( + { + onClose(); + openDashboard(); + }} + /> + ) : null} {soloTeam ? (
@@ -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} diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index b44a1c46..838ee27f 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -955,6 +955,23 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen tmuxRuntime.status, ] ); + const teammateRuntimeProviderNoticeById: + | Partial> + | undefined = teammateRuntimeCompatibility.providerNoticeProviderId + ? { + [teammateRuntimeCompatibility.providerNoticeProviderId]: ( + { + 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 ? (
- { - closeDialog(); - openDashboard(); - }} - /> + {showRosterTeammateRuntimeCompatibility ? ( + { + closeDialog(); + openDashboard(); + }} + /> + ) : null} {hasSelectedWorktreeIsolation ? ( ) : null} diff --git a/src/renderer/components/team/dialogs/TeamModelSelector.tsx b/src/renderer/components/team/dialogs/TeamModelSelector.tsx index 3d8d281a..741aa188 100644 --- a/src/renderer/components/team/dialogs/TeamModelSelector.tsx +++ b/src/renderer/components/team/dialogs/TeamModelSelector.tsx @@ -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>; providerDisabledReasonById?: Partial>; providerDisabledBadgeLabelById?: Partial>; modelAdvisoryReasonByValue?: Partial>; @@ -624,6 +625,7 @@ export const TeamModelSelector: React.FC = ({ onValueChange, id, disableGeminiOption = false, + providerNoticeById, providerDisabledReasonById, providerDisabledBadgeLabelById, modelAdvisoryReasonByValue, @@ -1022,7 +1024,10 @@ export const TeamModelSelector: React.FC = ({ 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 = ({ 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 = ({ null; const openCodeMetadata = effectiveProviderId === 'opencode' ? openCodeModelMetadataByValue.get(opt.value) : null; - const modelRecommendation = - openCodeMetadata?.recommendation ?? - getTeamModelRecommendation(effectiveProviderId, opt.value); + let modelRecommendation: ReturnType = 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 = ({ 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 = ({ ) : null}
+ {activeProviderNotice ? ( +
+ {activeProviderNotice} +
+ ) : null} {activeProviderStatusPanel ? (
void; warningText?: string | null; disableGeminiOption?: boolean; + providerNoticeById?: Partial>; modelIssueText?: string | null; modelAdvisoryReasonByValue?: Partial>; modelIssueReasonByValue?: Partial>; @@ -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 (
void; headerTop?: React.ReactNode; headerBottom?: React.ReactNode; + leadProviderNoticeById?: Partial>; softDeleteMembers?: boolean; leadWarningText?: string | null; memberWarningById?: Record; @@ -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]} diff --git a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts index 220b2a0b..eba3443d 100644 --- a/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts +++ b/test/renderer/components/team/TeamModelSelectorDisabledState.test.ts @@ -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(); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 0a57af52..99bfa0a3 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -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' ); diff --git a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts index bd799141..9a3afcce 100644 --- a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts +++ b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts @@ -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'); }); diff --git a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts index d712659f..17c2d53a 100644 --- a/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts +++ b/test/renderer/features/agent-graph/collapseOverflowStacks.test.ts @@ -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); }); });