Merge branch 'dev' into spike/team-snapshot-split-plan
This commit is contained in:
commit
82a0e3e6bb
23 changed files with 980 additions and 286 deletions
|
|
@ -71,7 +71,7 @@ export function drawAgents(
|
||||||
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
|
drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl);
|
||||||
|
|
||||||
// Breathing animation + launch-stage effects
|
// Breathing animation + launch-stage effects
|
||||||
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel);
|
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
|
||||||
drawLaunchStage(ctx, x, y, r, node.launchVisualState, time);
|
drawLaunchStage(ctx, x, y, r, node.launchVisualState, time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,7 +122,17 @@ export function drawAgents(
|
||||||
if (!simplify) {
|
if (!simplify) {
|
||||||
// Name + role label (single line: "jack · developer")
|
// Name + role label (single line: "jack · developer")
|
||||||
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
|
const labelText = node.role ? `${node.label} · ${node.role}` : node.label;
|
||||||
drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel);
|
drawLabel(
|
||||||
|
ctx,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
r,
|
||||||
|
labelText,
|
||||||
|
color,
|
||||||
|
node.runtimeLabel,
|
||||||
|
node.launchStatusLabel,
|
||||||
|
node.launchVisualState
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Context ring disabled — LeadContextUsage.percent is unreliable
|
// TODO: Context ring disabled — LeadContextUsage.percent is unreliable
|
||||||
|
|
@ -257,11 +267,11 @@ function drawLaunchStage(
|
||||||
switch (visualState) {
|
switch (visualState) {
|
||||||
case 'waiting': {
|
case 'waiting': {
|
||||||
const ringR = r + 7 + Math.sin(time * 3.2) * 1.2;
|
const ringR = r + 7 + Math.sin(time * 3.2) * 1.2;
|
||||||
const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2));
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
|
ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2.2;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -270,8 +280,8 @@ function drawLaunchStage(
|
||||||
const rotation = time * 2.4;
|
const rotation = time * 2.4;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
|
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
|
||||||
ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72);
|
ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2.2;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
break;
|
break;
|
||||||
|
|
@ -280,8 +290,8 @@ function drawLaunchStage(
|
||||||
const ringR = r + 8;
|
const ringR = r + 8;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
ctx.arc(x, y, ringR, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4);
|
ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48);
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.75;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
const orbit = time * 1.6;
|
const orbit = time * 1.6;
|
||||||
|
|
@ -300,8 +310,8 @@ function drawLaunchStage(
|
||||||
const rotation = time * 1.25;
|
const rotation = time * 1.25;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
|
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
|
||||||
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55);
|
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62);
|
||||||
ctx.lineWidth = 1.75;
|
ctx.lineWidth = 2;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
break;
|
break;
|
||||||
|
|
@ -310,8 +320,8 @@ function drawLaunchStage(
|
||||||
const ringR = r + 7 + Math.sin(time * 4) * 0.8;
|
const ringR = r + 7 + Math.sin(time * 4) * 0.8;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
|
ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
|
||||||
ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6);
|
ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72);
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2.2;
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
break;
|
break;
|
||||||
|
|
@ -467,12 +477,8 @@ function drawBreathing(
|
||||||
r: number,
|
r: number,
|
||||||
state: string,
|
state: string,
|
||||||
time: number,
|
time: number,
|
||||||
spawnStatus?: GraphNode['spawnStatus'],
|
spawnStatus?: GraphNode['spawnStatus']
|
||||||
runtimeLabel?: string
|
|
||||||
): void {
|
): void {
|
||||||
const hasRuntimeLabel = Boolean(runtimeLabel?.trim());
|
|
||||||
const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14);
|
|
||||||
|
|
||||||
// Spawning: bright animated double ring + radial glow
|
// Spawning: bright animated double ring + radial glow
|
||||||
if (spawnStatus === 'spawning') {
|
if (spawnStatus === 'spawning') {
|
||||||
const ringR = r + AGENT_DRAW.orbitParticleOffset;
|
const ringR = r + AGENT_DRAW.orbitParticleOffset;
|
||||||
|
|
@ -505,12 +511,6 @@ function drawBreathing(
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// "connecting" label below name
|
|
||||||
ctx.font = '7px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2));
|
|
||||||
ctx.fillText('connecting...', x, serviceLabelY);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,12 +532,6 @@ function drawBreathing(
|
||||||
ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse);
|
ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse);
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// "waiting" label
|
|
||||||
ctx.font = '7px monospace';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5));
|
|
||||||
ctx.fillText('waiting...', x, serviceLabelY);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -649,7 +643,9 @@ function drawLabel(
|
||||||
r: number,
|
r: number,
|
||||||
label: string,
|
label: string,
|
||||||
color: string,
|
color: string,
|
||||||
runtimeLabel?: string
|
runtimeLabel?: string,
|
||||||
|
launchStatusLabel?: string,
|
||||||
|
launchVisualState?: GraphNode['launchVisualState']
|
||||||
): void {
|
): void {
|
||||||
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
const labelY = y + r + AGENT_DRAW.labelYOffset;
|
||||||
ctx.font = '9px monospace';
|
ctx.font = '9px monospace';
|
||||||
|
|
@ -659,16 +655,27 @@ function drawLabel(
|
||||||
ctx.fillText(label, x, labelY);
|
ctx.fillText(label, x, labelY);
|
||||||
|
|
||||||
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
const trimmedRuntimeLabel = runtimeLabel?.trim();
|
||||||
if (!trimmedRuntimeLabel) {
|
const trimmedLaunchStatusLabel = launchStatusLabel?.trim();
|
||||||
|
if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.font = '8px monospace';
|
let nextLineY = labelY + 10;
|
||||||
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
|
if (trimmedRuntimeLabel) {
|
||||||
ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10);
|
ctx.font = '8px monospace';
|
||||||
|
ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72);
|
||||||
|
ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY);
|
||||||
|
nextLineY += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedLaunchStatusLabel) {
|
||||||
|
ctx.font = '7px monospace';
|
||||||
|
ctx.fillStyle = getLaunchStatusColor(launchVisualState);
|
||||||
|
ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
|
function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string {
|
||||||
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
|
const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2);
|
||||||
if (ctx.measureText(label).width <= maxWidth) return label;
|
if (ctx.measureText(label).width <= maxWidth) return label;
|
||||||
|
|
||||||
|
|
@ -679,6 +686,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n
|
||||||
return `${out}…`;
|
return `${out}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string {
|
||||||
|
switch (visualState) {
|
||||||
|
case 'waiting':
|
||||||
|
return hexWithAlpha('#d4d4d8', 0.8);
|
||||||
|
case 'spawning':
|
||||||
|
return hexWithAlpha('#f59e0b', 0.9);
|
||||||
|
case 'runtime_pending':
|
||||||
|
return hexWithAlpha('#67e8f9', 0.9);
|
||||||
|
case 'settling':
|
||||||
|
return hexWithAlpha('#22c55e', 0.9);
|
||||||
|
case 'error':
|
||||||
|
return hexWithAlpha('#ef4444', 0.92);
|
||||||
|
default:
|
||||||
|
return hexWithAlpha(COLORS.holoBright, 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw context usage ring around lead node.
|
* Draw context usage ring around lead node.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const STABLE_SLOT_GEOMETRY = {
|
||||||
unassignedGap: 72,
|
unassignedGap: 72,
|
||||||
maxGeneratedRings: 12,
|
maxGeneratedRings: 12,
|
||||||
ownerCollisionPadding: 28,
|
ownerCollisionPadding: 28,
|
||||||
ownerBandHeight: 72,
|
ownerBandHeight: 96,
|
||||||
ownerMinWidth: 200,
|
ownerMinWidth: 200,
|
||||||
processBandHeight: 32,
|
processBandHeight: 32,
|
||||||
processRailWidth: 220,
|
processRailWidth: 220,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ export interface GraphNode {
|
||||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||||
/** Shared launch-stage visual derived by the host app */
|
/** Shared launch-stage visual derived by the host app */
|
||||||
launchVisualState?: GraphLaunchVisualState;
|
launchVisualState?: GraphLaunchVisualState;
|
||||||
|
/** Shared launch-stage text shown beside the node during launch only */
|
||||||
|
launchStatusLabel?: string;
|
||||||
/** Context window usage ratio (0..1), available for lead only */
|
/** Context window usage ratio (0..1), available for lead only */
|
||||||
contextUsage?: number;
|
contextUsage?: number;
|
||||||
/** Current task ID this member is working on */
|
/** Current task ID this member is working on */
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ export function GraphView({
|
||||||
y: number;
|
y: number;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
|
||||||
|
|
||||||
// ─── Hooks ──────────────────────────────────────────────────────────────
|
// ─── Hooks ──────────────────────────────────────────────────────────────
|
||||||
const simulation = useGraphSimulation();
|
const simulation = useGraphSimulation();
|
||||||
|
|
@ -255,6 +256,30 @@ export function GraphView({
|
||||||
return { x: node.x, y: node.y };
|
return { x: node.x, y: node.y };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setInteractionSelectionDisabled = useCallback((disabled: boolean) => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bodyStyle = document.body.style;
|
||||||
|
if (disabled) {
|
||||||
|
if (!selectionLockRef.current) {
|
||||||
|
selectionLockRef.current = {
|
||||||
|
userSelect: bodyStyle.userSelect,
|
||||||
|
webkitUserSelect: bodyStyle.webkitUserSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bodyStyle.userSelect = 'none';
|
||||||
|
bodyStyle.webkitUserSelect = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectionLockRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bodyStyle.userSelect = selectionLockRef.current.userSelect;
|
||||||
|
bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect;
|
||||||
|
selectionLockRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const animate = useCallback(() => {
|
const animate = useCallback(() => {
|
||||||
if (!runningRef.current) return;
|
if (!runningRef.current) return;
|
||||||
|
|
||||||
|
|
@ -405,10 +430,15 @@ export function GraphView({
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (e.button !== 0) return; // only left click
|
if (e.button !== 0) return; // only left click
|
||||||
|
e.preventDefault();
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
|
setInteractionSelectionDisabled(true);
|
||||||
|
|
||||||
const canvas = canvasHandle.current?.getCanvas();
|
const canvas = canvasHandle.current?.getCanvas();
|
||||||
if (!canvas) return;
|
if (!canvas) {
|
||||||
|
setInteractionSelectionDisabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
|
||||||
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
const nodes = getVisibleNodes(simulation.stateRef.current.nodes);
|
||||||
|
|
@ -464,6 +494,9 @@ export function GraphView({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
camera.handlePanMove(clientX, clientY);
|
camera.handlePanMove(clientX, clientY);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -480,6 +513,9 @@ export function GraphView({
|
||||||
|
|
||||||
const draggedNodeId = interaction.dragNodeId.current;
|
const draggedNodeId = interaction.dragNodeId.current;
|
||||||
if (interaction.isDragging.current && draggedNodeId) {
|
if (interaction.isDragging.current && draggedNodeId) {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
||||||
if (draggedNode?.kind === 'member') {
|
if (draggedNode?.kind === 'member') {
|
||||||
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
|
const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y);
|
||||||
|
|
@ -509,6 +545,7 @@ export function GraphView({
|
||||||
if (isPanningRef.current) {
|
if (isPanningRef.current) {
|
||||||
camera.handlePanEnd();
|
camera.handlePanEnd();
|
||||||
isPanningRef.current = false;
|
isPanningRef.current = false;
|
||||||
|
setInteractionSelectionDisabled(false);
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
|
|
@ -519,6 +556,7 @@ export function GraphView({
|
||||||
|
|
||||||
const clickedId = interaction.handleMouseUp();
|
const clickedId = interaction.handleMouseUp();
|
||||||
if (wasDragging && draggedNodeId) {
|
if (wasDragging && draggedNodeId) {
|
||||||
|
setInteractionSelectionDisabled(false);
|
||||||
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId);
|
||||||
if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) {
|
if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) {
|
||||||
const nearest = simulation.resolveNearestOwnerSlot(
|
const nearest = simulation.resolveNearestOwnerSlot(
|
||||||
|
|
@ -547,6 +585,7 @@ export function GraphView({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInteractionSelectionDisabled(false);
|
||||||
if (clickedId) {
|
if (clickedId) {
|
||||||
setSelectedNodeId(clickedId);
|
setSelectedNodeId(clickedId);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
|
|
@ -585,7 +624,7 @@ export function GraphView({
|
||||||
}
|
}
|
||||||
dragPreviewRef.current = null;
|
dragPreviewRef.current = null;
|
||||||
},
|
},
|
||||||
[camera, events, interaction, onOwnerSlotDrop, simulation]
|
[camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
|
|
@ -640,6 +679,7 @@ export function GraphView({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWindowMouseMove = (event: MouseEvent): void => {
|
const handleWindowMouseMove = (event: MouseEvent): void => {
|
||||||
if ((event.buttons & 1) === 0) {
|
if ((event.buttons & 1) === 0) {
|
||||||
|
setInteractionSelectionDisabled(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
@ -650,6 +690,7 @@ export function GraphView({
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
event.preventDefault();
|
||||||
processActivePointerMove(event.clientX, event.clientY, event.buttons);
|
processActivePointerMove(event.clientX, event.clientY, event.buttons);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -660,6 +701,7 @@ export function GraphView({
|
||||||
!interaction.isDragging.current &&
|
!interaction.isDragging.current &&
|
||||||
!edgeMouseDownRef.current
|
!edgeMouseDownRef.current
|
||||||
) {
|
) {
|
||||||
|
setInteractionSelectionDisabled(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
completePointerInteraction(event.clientX, event.clientY);
|
completePointerInteraction(event.clientX, event.clientY);
|
||||||
|
|
@ -670,8 +712,9 @@ export function GraphView({
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', handleWindowMouseMove);
|
window.removeEventListener('mousemove', handleWindowMouseMove);
|
||||||
window.removeEventListener('mouseup', handleWindowMouseUp);
|
window.removeEventListener('mouseup', handleWindowMouseUp);
|
||||||
|
setInteractionSelectionDisabled(false);
|
||||||
};
|
};
|
||||||
}, [completePointerInteraction, interaction, processActivePointerMove]);
|
}, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]);
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
const handleDoubleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
|
@ -819,7 +862,10 @@ export function GraphView({
|
||||||
|
|
||||||
// ─── Render ─────────────────────────────────────────────────────────────
|
// ─── Render ─────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className ?? ''}`}>
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative h-full w-full overflow-hidden select-none ${className ?? ''}`}
|
||||||
|
>
|
||||||
<GraphCanvas
|
<GraphCanvas
|
||||||
ref={canvasHandle}
|
ref={canvasHandle}
|
||||||
showHexGrid={config?.showHexGrid ?? true}
|
showHexGrid={config?.showHexGrid ?? true}
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,7 @@ export class TeamGraphAdapter {
|
||||||
leadMember?.effort
|
leadMember?.effort
|
||||||
),
|
),
|
||||||
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
|
||||||
|
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
||||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||||
avatarUrl: agentAvatarUrl(leadName, 64),
|
avatarUrl: agentAvatarUrl(leadName, 64),
|
||||||
pendingApproval,
|
pendingApproval,
|
||||||
|
|
@ -509,6 +510,7 @@ export class TeamGraphAdapter {
|
||||||
),
|
),
|
||||||
spawnStatus: spawn?.status,
|
spawnStatus: spawn?.status,
|
||||||
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
launchVisualState: launchPresentation.launchVisualState ?? undefined,
|
||||||
|
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
|
||||||
avatarUrl: agentAvatarUrl(member.name, 64),
|
avatarUrl: agentAvatarUrl(member.name, 64),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: member.currentTaskId ?? undefined,
|
||||||
currentTaskSubject: member.currentTaskId
|
currentTaskSubject: member.currentTaskId
|
||||||
|
|
|
||||||
|
|
@ -398,7 +398,7 @@ export const GraphActivityHud = ({
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={worldLayerRef}
|
ref={worldLayerRef}
|
||||||
className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left"
|
className="pointer-events-none absolute left-0 top-0 z-[8] origin-top-left select-none"
|
||||||
>
|
>
|
||||||
{visibleLanes.map((lane) => (
|
{visibleLanes.map((lane) => (
|
||||||
<div key={lane.node.id}>
|
<div key={lane.node.id}>
|
||||||
|
|
@ -431,12 +431,15 @@ export const GraphActivityHud = ({
|
||||||
ref={(element) => {
|
ref={(element) => {
|
||||||
shellRefs.current.set(lane.node.id, element);
|
shellRefs.current.set(lane.node.id, element);
|
||||||
}}
|
}}
|
||||||
className="pointer-events-auto absolute z-10 origin-top-left opacity-0"
|
className="pointer-events-auto absolute z-10 origin-top-left select-none opacity-0"
|
||||||
style={{
|
style={{
|
||||||
width: `${laneWidth}px`,
|
width: `${laneWidth}px`,
|
||||||
maxWidth: `${laneWidth}px`,
|
maxWidth: `${laneWidth}px`,
|
||||||
height: `${laneHeight}px`,
|
height: `${laneHeight}px`,
|
||||||
}}
|
}}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||||
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
<div className="mb-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||||
|
|
|
||||||
|
|
@ -327,11 +327,17 @@ const MemberPopoverContent = ({
|
||||||
: null;
|
: null;
|
||||||
const fallbackSpawnStatusLabel =
|
const fallbackSpawnStatusLabel =
|
||||||
node.spawnStatus && node.spawnStatus !== 'online'
|
node.spawnStatus && node.spawnStatus !== 'online'
|
||||||
? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning'
|
? node.spawnStatus === 'waiting'
|
||||||
? 'starting'
|
? 'waiting to start'
|
||||||
: node.spawnStatus
|
: node.spawnStatus === 'spawning'
|
||||||
|
? 'starting'
|
||||||
|
: node.spawnStatus === 'error'
|
||||||
|
? 'failed'
|
||||||
|
: node.spawnStatus
|
||||||
: null;
|
: null;
|
||||||
const statusLabel =
|
const statusLabel =
|
||||||
|
launchPresentation?.launchStatusLabel ??
|
||||||
|
node.launchStatusLabel ??
|
||||||
launchPresentation?.presenceLabel ??
|
launchPresentation?.presenceLabel ??
|
||||||
fallbackSpawnStatusLabel ??
|
fallbackSpawnStatusLabel ??
|
||||||
(node.state === 'active'
|
(node.state === 'active'
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,36 @@ function createInitialMemberSpawnStatusEntry(): MemberSpawnStatusEntry {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LiveTeamAgentRuntimeMetadata {
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripWrappedCliFlagValue(raw: string | undefined): string | undefined {
|
||||||
|
const trimmed = raw?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||||
|
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||||
|
) {
|
||||||
|
const unwrapped = trimmed.slice(1, -1).trim();
|
||||||
|
return unwrapped.length > 0 ? unwrapped : undefined;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCliFlagValue(command: string, flagName: string): string | undefined {
|
||||||
|
const escapedFlag = flagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
const match = new RegExp(`(?:^|\\s)${escapedFlag}\\s+("([^"]*)"|'([^']*)'|([^\\s]+))`).exec(
|
||||||
|
command
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return stripWrappedCliFlagValue(match[2] ?? match[3] ?? match[4] ?? match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldAcceptDeterministicBootstrapEvent(params: {
|
export function shouldAcceptDeterministicBootstrapEvent(params: {
|
||||||
runId: string;
|
runId: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -911,11 +941,17 @@ function buildEffectiveTeamMemberSpec(
|
||||||
): TeamMemberInput {
|
): TeamMemberInput {
|
||||||
const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
|
const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
|
||||||
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
|
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
|
||||||
const model = member.model?.trim() || defaults.model?.trim() || undefined;
|
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
|
||||||
|
const model =
|
||||||
|
member.model?.trim() ||
|
||||||
|
(memberProviderId == null || memberProviderId === defaultProviderId
|
||||||
|
? defaults.model?.trim()
|
||||||
|
: undefined) ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...member,
|
...member,
|
||||||
providerId: memberProviderId ?? defaultProviderId ?? 'anthropic',
|
providerId: effectiveProviderId,
|
||||||
model,
|
model,
|
||||||
effort: member.effort ?? defaults.effort,
|
effort: member.effort ?? defaults.effort,
|
||||||
};
|
};
|
||||||
|
|
@ -3396,16 +3432,19 @@ export class TeamProvisioningService {
|
||||||
}> {
|
}> {
|
||||||
const runId = this.getTrackedRunId(teamName);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => ({
|
return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => {
|
||||||
statuses,
|
this.attachLiveRuntimeMetadataToStatuses(teamName, statuses);
|
||||||
runId: null,
|
return {
|
||||||
teamLaunchState: snapshot?.teamLaunchState,
|
statuses,
|
||||||
launchPhase: snapshot?.launchPhase,
|
runId: null,
|
||||||
expectedMembers: snapshot?.expectedMembers,
|
teamLaunchState: snapshot?.teamLaunchState,
|
||||||
updatedAt: snapshot?.updatedAt,
|
launchPhase: snapshot?.launchPhase,
|
||||||
summary: snapshot?.summary,
|
expectedMembers: snapshot?.expectedMembers,
|
||||||
source: snapshot ? 'persisted' : 'persisted',
|
updatedAt: snapshot?.updatedAt,
|
||||||
}));
|
summary: snapshot?.summary,
|
||||||
|
source: snapshot ? 'persisted' : 'persisted',
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const run = this.runs.get(runId);
|
const run = this.runs.get(runId);
|
||||||
if (!run) {
|
if (!run) {
|
||||||
|
|
@ -3426,6 +3465,7 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
const snapshot = persisted ?? liveSnapshot;
|
const snapshot = persisted ?? liveSnapshot;
|
||||||
const statuses = snapshotToMemberSpawnStatuses(snapshot);
|
const statuses = snapshotToMemberSpawnStatuses(snapshot);
|
||||||
|
this.attachLiveRuntimeMetadataToStatuses(teamName, statuses);
|
||||||
return {
|
return {
|
||||||
statuses,
|
statuses,
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -3552,7 +3592,6 @@ export class TeamProvisioningService {
|
||||||
!current ||
|
!current ||
|
||||||
current.launchState === 'failed_to_start' ||
|
current.launchState === 'failed_to_start' ||
|
||||||
current.launchState === 'confirmed_alive' ||
|
current.launchState === 'confirmed_alive' ||
|
||||||
current.runtimeAlive === true ||
|
|
||||||
current.hardFailure === true ||
|
current.hardFailure === true ||
|
||||||
current.agentToolAccepted !== true
|
current.agentToolAccepted !== true
|
||||||
) {
|
) {
|
||||||
|
|
@ -3902,6 +3941,89 @@ export class TeamProvisioningService {
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async materializeEffectiveTeamMemberSpecs(params: {
|
||||||
|
claudePath: string;
|
||||||
|
cwd: string;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
defaults: {
|
||||||
|
providerId?: TeamProviderId;
|
||||||
|
model?: string;
|
||||||
|
effort?: TeamCreateRequest['effort'];
|
||||||
|
};
|
||||||
|
primaryProviderId?: TeamProviderId;
|
||||||
|
primaryEnv?: ProvisioningEnvResolution;
|
||||||
|
limitContext?: boolean;
|
||||||
|
}): Promise<TeamCreateRequest['members']> {
|
||||||
|
const envByProvider = new Map<TeamProviderId, Promise<ProvisioningEnvResolution>>();
|
||||||
|
const defaultModelByProvider = new Map<TeamProviderId, Promise<string>>();
|
||||||
|
const normalizedPrimaryProviderId = resolveTeamProviderId(params.primaryProviderId);
|
||||||
|
|
||||||
|
const getProvisioningEnv = (providerId: TeamProviderId): Promise<ProvisioningEnvResolution> => {
|
||||||
|
if (normalizedPrimaryProviderId === providerId && params.primaryEnv != null) {
|
||||||
|
return Promise.resolve(params.primaryEnv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = envByProvider.get(providerId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = this.buildProvisioningEnv(providerId);
|
||||||
|
envByProvider.set(providerId, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResolvedDefaultModel = (providerId: TeamProviderId): Promise<string> => {
|
||||||
|
const cached = defaultModelByProvider.get(providerId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerLabel = getTeamProviderLabel(providerId);
|
||||||
|
const created = (async () => {
|
||||||
|
const envResolution = await getProvisioningEnv(providerId);
|
||||||
|
if (envResolution.warning) {
|
||||||
|
throw new Error(envResolution.warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedDefaultModel = await this.resolveProviderDefaultModel(
|
||||||
|
params.claudePath,
|
||||||
|
params.cwd,
|
||||||
|
providerId,
|
||||||
|
envResolution.env,
|
||||||
|
params.limitContext === true
|
||||||
|
);
|
||||||
|
const normalized = resolvedDefaultModel?.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not resolve the runtime default model for ${providerLabel} teammates. Select an explicit model and retry.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
})();
|
||||||
|
|
||||||
|
defaultModelByProvider.set(providerId, created);
|
||||||
|
return created;
|
||||||
|
};
|
||||||
|
|
||||||
|
const effectiveMembers: TeamCreateRequest['members'] = [];
|
||||||
|
for (const member of params.members) {
|
||||||
|
const effectiveMember = buildEffectiveTeamMemberSpec(member, params.defaults);
|
||||||
|
const providerId = normalizeTeamMemberProviderId(effectiveMember.providerId) ?? 'anthropic';
|
||||||
|
if (providerId === 'anthropic' || effectiveMember.model?.trim()) {
|
||||||
|
effectiveMembers.push(effectiveMember);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveMembers.push({
|
||||||
|
...effectiveMember,
|
||||||
|
model: await getResolvedDefaultModel(providerId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveMembers;
|
||||||
|
}
|
||||||
|
|
||||||
private getFreshCachedProbeResult(
|
private getFreshCachedProbeResult(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
providerId: TeamProviderId | undefined
|
providerId: TeamProviderId | undefined
|
||||||
|
|
@ -4756,10 +4878,23 @@ export class TeamProvisioningService {
|
||||||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
throw new Error('Claude CLI not found; install it or provide a valid path');
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(request.members, {
|
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
|
||||||
providerId: request.providerId,
|
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
|
||||||
model: request.model,
|
if (envWarning) {
|
||||||
effort: request.effort,
|
throw new Error(envWarning);
|
||||||
|
}
|
||||||
|
const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
||||||
|
claudePath,
|
||||||
|
cwd: request.cwd,
|
||||||
|
members: request.members,
|
||||||
|
defaults: {
|
||||||
|
providerId: request.providerId,
|
||||||
|
model: request.model,
|
||||||
|
effort: request.effort,
|
||||||
|
},
|
||||||
|
primaryProviderId: request.providerId,
|
||||||
|
primaryEnv: provisioningEnv,
|
||||||
|
limitContext: request.limitContext,
|
||||||
});
|
});
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const startedAt = nowIso();
|
const startedAt = nowIso();
|
||||||
|
|
@ -4864,14 +4999,6 @@ export class TeamProvisioningService {
|
||||||
const initialUserPrompt = request.prompt?.trim() ?? '';
|
const initialUserPrompt = request.prompt?.trim() ?? '';
|
||||||
const promptSize = getPromptSizeSummary(initialUserPrompt);
|
const promptSize = getPromptSizeSummary(initialUserPrompt);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const {
|
|
||||||
env: shellEnv,
|
|
||||||
geminiRuntimeAuth,
|
|
||||||
warning: envWarning,
|
|
||||||
} = await this.buildProvisioningEnv(request.providerId);
|
|
||||||
if (envWarning) {
|
|
||||||
throw new Error(envWarning);
|
|
||||||
}
|
|
||||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||||
if (teammateModeDecision.forceProcessTeammates) {
|
if (teammateModeDecision.forceProcessTeammates) {
|
||||||
|
|
@ -4963,10 +5090,16 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
await this.membersMetaStore.writeMembers(
|
await this.membersMetaStore.writeMembers(
|
||||||
request.teamName,
|
request.teamName,
|
||||||
request.members.map((m) => ({
|
effectiveMemberSpecs.map((m) => ({
|
||||||
name: m.name.trim(),
|
name: m.name.trim(),
|
||||||
role: m.role?.trim() || undefined,
|
role: m.role?.trim() || undefined,
|
||||||
workflow: m.workflow?.trim() || undefined,
|
workflow: m.workflow?.trim() || undefined,
|
||||||
|
providerId: normalizeOptionalTeamProviderId(m.providerId),
|
||||||
|
model: m.model?.trim() || undefined,
|
||||||
|
effort:
|
||||||
|
m.effort === 'low' || m.effort === 'medium' || m.effort === 'high'
|
||||||
|
? m.effort
|
||||||
|
: undefined,
|
||||||
agentType: 'general-purpose' as const,
|
agentType: 'general-purpose' as const,
|
||||||
color: getMemberColorByName(m.name.trim()),
|
color: getMemberColorByName(m.name.trim()),
|
||||||
joinedAt: Date.now(),
|
joinedAt: Date.now(),
|
||||||
|
|
@ -5300,16 +5433,30 @@ export class TeamProvisioningService {
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const startedAt = nowIso();
|
const startedAt = nowIso();
|
||||||
|
|
||||||
const effectiveMemberSpecs = buildEffectiveTeamMemberSpecs(expectedMemberSpecs, {
|
const provisioningEnv = await this.buildProvisioningEnv(request.providerId);
|
||||||
providerId: request.providerId,
|
const { env: shellEnv, geminiRuntimeAuth, warning: envWarning } = provisioningEnv;
|
||||||
model: request.model,
|
if (envWarning) {
|
||||||
effort: request.effort,
|
throw new Error(envWarning);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
||||||
|
claudePath,
|
||||||
|
cwd: request.cwd,
|
||||||
|
members: expectedMemberSpecs,
|
||||||
|
defaults: {
|
||||||
|
providerId: request.providerId,
|
||||||
|
model: request.model,
|
||||||
|
effort: request.effort,
|
||||||
|
},
|
||||||
|
primaryProviderId: request.providerId,
|
||||||
|
primaryEnv: provisioningEnv,
|
||||||
|
limitContext: request.limitContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
|
// Build a synthetic TeamCreateRequest for reuse by shared infrastructure
|
||||||
const syntheticRequest: TeamCreateRequest = {
|
const syntheticRequest: TeamCreateRequest = {
|
||||||
teamName: request.teamName,
|
teamName: request.teamName,
|
||||||
members: expectedMemberSpecs,
|
members: effectiveMemberSpecs,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
providerId: request.providerId,
|
providerId: request.providerId,
|
||||||
model: request.model,
|
model: request.model,
|
||||||
|
|
@ -5448,14 +5595,6 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
const promptSize = getPromptSizeSummary(prompt);
|
const promptSize = getPromptSizeSummary(prompt);
|
||||||
let child: ReturnType<typeof spawn>;
|
let child: ReturnType<typeof spawn>;
|
||||||
const {
|
|
||||||
env: shellEnv,
|
|
||||||
geminiRuntimeAuth,
|
|
||||||
warning: envWarning,
|
|
||||||
} = await this.buildProvisioningEnv(request.providerId);
|
|
||||||
if (envWarning) {
|
|
||||||
throw new Error(envWarning);
|
|
||||||
}
|
|
||||||
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1';
|
||||||
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs);
|
||||||
if (teammateModeDecision.forceProcessTeammates) {
|
if (teammateModeDecision.forceProcessTeammates) {
|
||||||
|
|
@ -6842,12 +6981,34 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean {
|
private hasLiveTeamAgentProcess(teamName: string, memberName: string): boolean {
|
||||||
return this.getLiveTeamAgentNames(teamName).has(memberName);
|
return this.getLiveTeamAgentRuntimeMetadata(teamName).has(memberName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachLiveRuntimeMetadataToStatuses(
|
||||||
|
teamName: string,
|
||||||
|
statuses: Record<string, MemberSpawnStatusEntry>
|
||||||
|
): void {
|
||||||
|
for (const [memberName, metadata] of this.getLiveTeamAgentRuntimeMetadata(teamName).entries()) {
|
||||||
|
const current = statuses[memberName];
|
||||||
|
if (!current || !metadata.model) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
statuses[memberName] = {
|
||||||
|
...current,
|
||||||
|
runtimeModel: metadata.model,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getLiveTeamAgentNames(teamName: string): Set<string> {
|
private getLiveTeamAgentNames(teamName: string): Set<string> {
|
||||||
|
return new Set(this.getLiveTeamAgentRuntimeMetadata(teamName).keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLiveTeamAgentRuntimeMetadata(
|
||||||
|
teamName: string
|
||||||
|
): Map<string, LiveTeamAgentRuntimeMetadata> {
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return new Set();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = '';
|
let output = '';
|
||||||
|
|
@ -6857,11 +7018,11 @@ export class TeamProvisioningService {
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
const teamMarker = `--team-name ${teamName}`;
|
const teamMarker = `--team-name ${teamName}`;
|
||||||
const names = new Set<string>();
|
const metadataByAgent = new Map<string, LiveTeamAgentRuntimeMetadata>();
|
||||||
for (const line of output.split('\n')) {
|
for (const line of output.split('\n')) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed.includes(teamMarker)) continue;
|
if (!trimmed.includes(teamMarker)) continue;
|
||||||
|
|
@ -6869,10 +7030,13 @@ export class TeamProvisioningService {
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
const agentName = match[1]?.trim();
|
const agentName = match[1]?.trim();
|
||||||
if (agentName) {
|
if (agentName) {
|
||||||
names.add(agentName);
|
const model = extractCliFlagValue(trimmed, '--model');
|
||||||
|
metadataByAgent.set(agentName, {
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return names;
|
return metadataByAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearPersistedLaunchState(teamName: string): Promise<void> {
|
private async clearPersistedLaunchState(teamName: string): Promise<void> {
|
||||||
|
|
@ -7107,7 +7271,7 @@ export class TeamProvisioningService {
|
||||||
current.hardFailure = false;
|
current.hardFailure = false;
|
||||||
current.hardFailureReason = undefined;
|
current.hardFailureReason = undefined;
|
||||||
}
|
}
|
||||||
if (!current.bootstrapConfirmed && !runtimeAlive && !current.hardFailure) {
|
if (!current.bootstrapConfirmed && !current.hardFailure) {
|
||||||
const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason(
|
const transcriptFailureReason = await this.findBootstrapTranscriptFailureReason(
|
||||||
teamName,
|
teamName,
|
||||||
expected,
|
expected,
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@ export const CreateTeamDialog = ({
|
||||||
selectedMemberProviders
|
selectedMemberProviders
|
||||||
);
|
);
|
||||||
setPrepareState('loading');
|
setPrepareState('loading');
|
||||||
setPrepareMessage('Checking selected providers...');
|
setPrepareMessage('Checking selected providers in parallel...');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
setPrepareChecks(initialChecks);
|
setPrepareChecks(initialChecks);
|
||||||
|
|
||||||
|
|
@ -601,118 +601,128 @@ export const CreateTeamDialog = ({
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let checks = initialChecks;
|
let checks = initialChecks;
|
||||||
let anyFailure = false;
|
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||||
let anyNotes = false;
|
const selectedModelChecks = (() => {
|
||||||
const collectedWarnings: string[] = [];
|
const next = new Set<string>();
|
||||||
|
let hasDefaultSelection = false;
|
||||||
try {
|
const supportsProviderDefaultCheck =
|
||||||
for (const providerId of selectedMemberProviders) {
|
providerId === 'codex' ||
|
||||||
const selectedModelChecks = (() => {
|
providerId === 'gemini' ||
|
||||||
const next = new Set<string>();
|
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
||||||
let hasDefaultSelection = false;
|
const leadModel = computeEffectiveTeamModel(
|
||||||
const supportsProviderDefaultCheck =
|
selectedModel,
|
||||||
providerId === 'codex' ||
|
limitContext,
|
||||||
providerId === 'gemini' ||
|
selectedProviderId
|
||||||
(providerId === 'anthropic' && selectedProviderId === 'anthropic');
|
);
|
||||||
const leadModel = computeEffectiveTeamModel(
|
if (selectedProviderId === providerId && selectedModel.trim()) {
|
||||||
selectedModel,
|
if (leadModel?.trim()) {
|
||||||
limitContext,
|
next.add(leadModel.trim());
|
||||||
selectedProviderId
|
}
|
||||||
);
|
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
||||||
if (selectedProviderId === providerId && selectedModel.trim()) {
|
hasDefaultSelection = true;
|
||||||
if (leadModel?.trim()) {
|
}
|
||||||
next.add(leadModel.trim());
|
for (const member of effectiveMemberDrafts) {
|
||||||
}
|
if (member.removedAt) {
|
||||||
} else if (selectedProviderId === providerId && supportsProviderDefaultCheck) {
|
continue;
|
||||||
|
}
|
||||||
|
const memberProviderId =
|
||||||
|
normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
||||||
|
if (memberProviderId !== providerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const memberModel = member.model?.trim();
|
||||||
|
if (memberModel) {
|
||||||
|
next.add(memberModel);
|
||||||
|
} else if (supportsProviderDefaultCheck) {
|
||||||
hasDefaultSelection = true;
|
hasDefaultSelection = true;
|
||||||
}
|
}
|
||||||
for (const member of effectiveMemberDrafts) {
|
|
||||||
if (member.removedAt) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const memberProviderId =
|
|
||||||
normalizeOptionalTeamProviderId(member.providerId) ?? selectedProviderId;
|
|
||||||
if (memberProviderId !== providerId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const memberModel = member.model?.trim();
|
|
||||||
if (memberModel) {
|
|
||||||
next.add(memberModel);
|
|
||||||
} else if (supportsProviderDefaultCheck) {
|
|
||||||
hasDefaultSelection = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
|
||||||
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
|
||||||
}
|
|
||||||
return Array.from(next);
|
|
||||||
})();
|
|
||||||
const backendSummary =
|
|
||||||
runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
|
||||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
|
||||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
|
||||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
|
||||||
providerId,
|
|
||||||
selectedModelIds: selectedModelChecks,
|
|
||||||
cachedModelResultsById,
|
|
||||||
});
|
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
|
||||||
status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
|
|
||||||
backendSummary,
|
|
||||||
details: cachedSnapshot.details,
|
|
||||||
});
|
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
|
||||||
setPrepareChecks(checks);
|
|
||||||
setPrepareMessage(
|
|
||||||
selectedModelChecks.length > 0
|
|
||||||
? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...`
|
|
||||||
: `Checking ${getProviderLabel(providerId)} runtime...`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
if (supportsProviderDefaultCheck && hasDefaultSelection) {
|
||||||
|
next.add(DEFAULT_PROVIDER_MODEL_SELECTION);
|
||||||
|
}
|
||||||
|
return Array.from(next);
|
||||||
|
})();
|
||||||
|
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||||
|
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
||||||
|
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||||
|
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||||
|
providerId,
|
||||||
|
selectedModelIds: selectedModelChecks,
|
||||||
|
cachedModelResultsById,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
selectedModelChecks,
|
||||||
|
backendSummary,
|
||||||
|
cacheKey,
|
||||||
|
cachedModelResultsById,
|
||||||
|
cachedSnapshot,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const prepResult = await runProviderPrepareDiagnostics({
|
try {
|
||||||
cwd: effectiveCwd,
|
for (const plan of providerPlans) {
|
||||||
providerId,
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
selectedModelIds: selectedModelChecks,
|
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||||
prepareProvisioning: api.teams.prepareProvisioning,
|
backendSummary: plan.backendSummary,
|
||||||
limitContext,
|
details: plan.cachedSnapshot.details,
|
||||||
cachedModelResultsById,
|
|
||||||
onModelProgress: ({ details, completedCount, totalCount }) => {
|
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
|
||||||
status: 'checking',
|
|
||||||
backendSummary,
|
|
||||||
details,
|
|
||||||
});
|
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
|
||||||
setPrepareChecks(checks);
|
|
||||||
setPrepareMessage(
|
|
||||||
`Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (prepResult.warnings.length > 0) {
|
}
|
||||||
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
setPrepareChecks(checks);
|
||||||
|
}
|
||||||
|
const providerResults = await Promise.all(
|
||||||
|
providerPlans.map(async (plan) => {
|
||||||
|
const prepResult = await runProviderPrepareDiagnostics({
|
||||||
|
cwd: effectiveCwd,
|
||||||
|
providerId: plan.providerId,
|
||||||
|
selectedModelIds: plan.selectedModelChecks,
|
||||||
|
prepareProvisioning: api.teams.prepareProvisioning,
|
||||||
|
limitContext,
|
||||||
|
cachedModelResultsById: plan.cachedModelResultsById,
|
||||||
|
onModelProgress: ({ details }) => {
|
||||||
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
|
status: 'checking',
|
||||||
|
backendSummary: plan.backendSummary,
|
||||||
|
details,
|
||||||
|
});
|
||||||
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
|
setPrepareChecks(checks);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...plan, prepResult };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let anyFailure = false;
|
||||||
|
let anyNotes = false;
|
||||||
|
const collectedWarnings: string[] = [];
|
||||||
|
for (const plan of providerResults) {
|
||||||
|
if (plan.prepResult.warnings.length > 0) {
|
||||||
anyNotes = true;
|
anyNotes = true;
|
||||||
collectedWarnings.push(
|
collectedWarnings.push(
|
||||||
...prepResult.warnings.map(
|
...plan.prepResult.warnings.map(
|
||||||
(warning) => `${getProviderLabel(providerId)}: ${warning}`
|
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (prepResult.status === 'failed') {
|
if (plan.prepResult.status === 'failed') {
|
||||||
anyFailure = true;
|
anyFailure = true;
|
||||||
} else if (prepResult.status === 'notes') {
|
} else if (plan.prepResult.status === 'notes') {
|
||||||
anyNotes = true;
|
anyNotes = true;
|
||||||
}
|
}
|
||||||
prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
|
prepareModelResultsCacheRef.current.set(
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
plan.cacheKey,
|
||||||
status: prepResult.status,
|
plan.prepResult.modelResultsById
|
||||||
backendSummary,
|
);
|
||||||
details: prepResult.details,
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
|
status: plan.prepResult.status,
|
||||||
|
backendSummary: plan.backendSummary,
|
||||||
|
details: plan.prepResult.details,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
}
|
||||||
setPrepareChecks(checks);
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
}
|
setPrepareChecks(checks);
|
||||||
}
|
}
|
||||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
const failureMessage =
|
const failureMessage =
|
||||||
|
|
|
||||||
|
|
@ -923,82 +923,92 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
selectedMemberProviders
|
selectedMemberProviders
|
||||||
);
|
);
|
||||||
setPrepareState('loading');
|
setPrepareState('loading');
|
||||||
setPrepareMessage('Checking selected providers...');
|
setPrepareMessage('Checking selected providers in parallel...');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
setPrepareChecks(initialChecks);
|
setPrepareChecks(initialChecks);
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let checks = initialChecks;
|
let checks = initialChecks;
|
||||||
let anyFailure = false;
|
const providerPlans = selectedMemberProviders.map((providerId) => {
|
||||||
let anyNotes = false;
|
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
||||||
const collectedWarnings: string[] = [];
|
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
||||||
|
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
||||||
|
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
||||||
|
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
||||||
|
providerId,
|
||||||
|
selectedModelIds: selectedModelChecks,
|
||||||
|
cachedModelResultsById,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
providerId,
|
||||||
|
selectedModelChecks,
|
||||||
|
backendSummary,
|
||||||
|
cacheKey,
|
||||||
|
cachedModelResultsById,
|
||||||
|
cachedSnapshot,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const providerId of selectedMemberProviders) {
|
for (const plan of providerPlans) {
|
||||||
const selectedModelChecks = selectedModelChecksByProvider.get(providerId) ?? [];
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
const backendSummary = runtimeBackendSummaryByProviderRef.current.get(providerId) ?? null;
|
status: plan.selectedModelChecks.length > 0 ? plan.cachedSnapshot.status : 'checking',
|
||||||
const cacheKey = buildPrepareModelCacheKey(effectiveCwd, providerId, backendSummary);
|
backendSummary: plan.backendSummary,
|
||||||
const cachedModelResultsById = prepareModelResultsCacheRef.current.get(cacheKey) ?? {};
|
details: plan.cachedSnapshot.details,
|
||||||
const cachedSnapshot = getProviderPrepareCachedSnapshot({
|
|
||||||
providerId,
|
|
||||||
selectedModelIds: selectedModelChecks,
|
|
||||||
cachedModelResultsById,
|
|
||||||
});
|
});
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
}
|
||||||
status: selectedModelChecks.length > 0 ? cachedSnapshot.status : 'checking',
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
backendSummary,
|
setPrepareChecks(checks);
|
||||||
details: cachedSnapshot.details,
|
}
|
||||||
});
|
const providerResults = await Promise.all(
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
providerPlans.map(async (plan) => {
|
||||||
setPrepareChecks(checks);
|
const prepResult = await runProviderPrepareDiagnostics({
|
||||||
setPrepareMessage(
|
cwd: effectiveCwd,
|
||||||
selectedModelChecks.length > 0
|
providerId: plan.providerId,
|
||||||
? `Checking ${getProviderLabel(providerId)} runtime and selected model checks ${cachedSnapshot.completedCount}/${cachedSnapshot.totalCount}...`
|
selectedModelIds: plan.selectedModelChecks,
|
||||||
: `Checking ${getProviderLabel(providerId)} runtime...`
|
prepareProvisioning: api.teams.prepareProvisioning,
|
||||||
);
|
limitContext,
|
||||||
}
|
cachedModelResultsById: plan.cachedModelResultsById,
|
||||||
|
onModelProgress: ({ details }) => {
|
||||||
const prepResult = await runProviderPrepareDiagnostics({
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
cwd: effectiveCwd,
|
status: 'checking',
|
||||||
providerId,
|
backendSummary: plan.backendSummary,
|
||||||
selectedModelIds: selectedModelChecks,
|
details,
|
||||||
prepareProvisioning: api.teams.prepareProvisioning,
|
});
|
||||||
limitContext,
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
cachedModelResultsById,
|
setPrepareChecks(checks);
|
||||||
onModelProgress: ({ details, completedCount, totalCount }) => {
|
}
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
},
|
||||||
status: 'checking',
|
});
|
||||||
backendSummary,
|
return { ...plan, prepResult };
|
||||||
details,
|
})
|
||||||
});
|
);
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
let anyFailure = false;
|
||||||
setPrepareChecks(checks);
|
let anyNotes = false;
|
||||||
setPrepareMessage(
|
const collectedWarnings: string[] = [];
|
||||||
`Checking ${getProviderLabel(providerId)} runtime and selected model checks ${completedCount}/${totalCount}...`
|
for (const plan of providerResults) {
|
||||||
);
|
if (plan.prepResult.warnings.length > 0) {
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (prepResult.warnings.length > 0) {
|
|
||||||
anyNotes = true;
|
anyNotes = true;
|
||||||
collectedWarnings.push(
|
collectedWarnings.push(
|
||||||
...prepResult.warnings.map((warning) => `${getProviderLabel(providerId)}: ${warning}`)
|
...plan.prepResult.warnings.map(
|
||||||
|
(warning) => `${getProviderLabel(plan.providerId)}: ${warning}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (prepResult.status === 'failed') {
|
if (plan.prepResult.status === 'failed') {
|
||||||
anyFailure = true;
|
anyFailure = true;
|
||||||
} else if (prepResult.status === 'notes') {
|
} else if (plan.prepResult.status === 'notes') {
|
||||||
anyNotes = true;
|
anyNotes = true;
|
||||||
}
|
}
|
||||||
prepareModelResultsCacheRef.current.set(cacheKey, prepResult.modelResultsById);
|
prepareModelResultsCacheRef.current.set(plan.cacheKey, plan.prepResult.modelResultsById);
|
||||||
checks = updateProviderCheck(checks, providerId, {
|
checks = updateProviderCheck(checks, plan.providerId, {
|
||||||
status: prepResult.status,
|
status: plan.prepResult.status,
|
||||||
backendSummary,
|
backendSummary: plan.backendSummary,
|
||||||
details: prepResult.details,
|
details: plan.prepResult.details,
|
||||||
});
|
});
|
||||||
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
}
|
||||||
setPrepareChecks(checks);
|
if (!cancelled && prepareRequestSeqRef.current === requestSeq) {
|
||||||
}
|
setPrepareChecks(checks);
|
||||||
}
|
}
|
||||||
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
if (cancelled || prepareRequestSeqRef.current !== requestSeq) return;
|
||||||
const failureMessage =
|
const failureMessage =
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,8 @@ export const MemberCard = ({
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
presenceLabel === 'starting' &&
|
presenceLabel === 'starting' &&
|
||||||
spawnLaunchState !== 'failed_to_start' &&
|
spawnLaunchState !== 'failed_to_start' &&
|
||||||
!activityTask;
|
!activityTask &&
|
||||||
|
!runtimeSummary;
|
||||||
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
const showStartingBadge = !isRemoved && presenceLabel === 'starting' && !activityTask;
|
||||||
const showRuntimeAdvisoryBadge =
|
const showRuntimeAdvisoryBadge =
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||||
formatTeamModelSummary,
|
|
||||||
getTeamEffortLabel,
|
|
||||||
getTeamModelLabel,
|
|
||||||
getTeamProviderLabel,
|
|
||||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
|
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
|
|
@ -152,6 +147,7 @@ function areMemberSpawnStatusesEquivalent(
|
||||||
leftEntry.launchState !== rightEntry.launchState ||
|
leftEntry.launchState !== rightEntry.launchState ||
|
||||||
leftEntry.error !== rightEntry.error ||
|
leftEntry.error !== rightEntry.error ||
|
||||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||||
|
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
|
||||||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive
|
leftEntry.runtimeAlive !== rightEntry.runtimeAlive
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -242,12 +238,11 @@ export const MemberList = memo(function MemberList({
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
||||||
const buildRuntimeSummary = useCallback(
|
const buildRuntimeSummary = useCallback(
|
||||||
(member: ResolvedTeamMember): string | undefined => {
|
(
|
||||||
const effectiveProvider = member.providerId ?? launchParams?.providerId ?? 'anthropic';
|
member: ResolvedTeamMember,
|
||||||
const effectiveModel = member.model?.trim() || launchParams?.model?.trim() || '';
|
spawnEntry: MemberSpawnStatusEntry | undefined
|
||||||
const effectiveEffort = member.effort ?? launchParams?.effort;
|
): string | undefined => {
|
||||||
|
return resolveMemberRuntimeSummary(member, launchParams, spawnEntry);
|
||||||
return formatTeamModelSummary(effectiveProvider, effectiveModel, effectiveEffort);
|
|
||||||
},
|
},
|
||||||
[launchParams]
|
[launchParams]
|
||||||
);
|
);
|
||||||
|
|
@ -293,7 +288,7 @@ export const MemberList = memo(function MemberList({
|
||||||
reviewTask={isRemoved ? null : reviewTask}
|
reviewTask={isRemoved ? null : reviewTask}
|
||||||
isAwaitingReply={isRemoved ? false : awaitingReply}
|
isAwaitingReply={isRemoved ? false : awaitingReply}
|
||||||
isRemoved={isRemoved}
|
isRemoved={isRemoved}
|
||||||
runtimeSummary={isRemoved ? buildRuntimeSummary(member) : buildRuntimeSummary(member)}
|
runtimeSummary={buildRuntimeSummary(member, isRemoved ? undefined : spawnEntry)}
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
||||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||||
|
|
|
||||||
|
|
@ -456,6 +456,7 @@ function areMemberSpawnStatusEntriesEqual(
|
||||||
left.error === right.error &&
|
left.error === right.error &&
|
||||||
left.livenessSource === right.livenessSource &&
|
left.livenessSource === right.livenessSource &&
|
||||||
left.runtimeAlive === right.runtimeAlive &&
|
left.runtimeAlive === right.runtimeAlive &&
|
||||||
|
left.runtimeModel === right.runtimeModel &&
|
||||||
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||||
left.hardFailure === right.hardFailure
|
left.hardFailure === right.hardFailure
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -423,9 +423,27 @@ export interface MemberLaunchPresentation {
|
||||||
runtimeAdvisoryLabel: string | null;
|
runtimeAdvisoryLabel: string | null;
|
||||||
runtimeAdvisoryTitle?: string;
|
runtimeAdvisoryTitle?: string;
|
||||||
launchVisualState: MemberLaunchVisualState;
|
launchVisualState: MemberLaunchVisualState;
|
||||||
|
launchStatusLabel: string | null;
|
||||||
spawnBadgeLabel: string | null;
|
spawnBadgeLabel: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null {
|
||||||
|
switch (visualState) {
|
||||||
|
case 'waiting':
|
||||||
|
return 'waiting to start';
|
||||||
|
case 'spawning':
|
||||||
|
return 'starting';
|
||||||
|
case 'runtime_pending':
|
||||||
|
return 'connecting';
|
||||||
|
case 'settling':
|
||||||
|
return 'joining team';
|
||||||
|
case 'error':
|
||||||
|
return 'failed';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildMemberLaunchPresentation({
|
export function buildMemberLaunchPresentation({
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -511,6 +529,7 @@ export function buildMemberLaunchPresentation({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
||||||
const spawnBadgeLabel =
|
const spawnBadgeLabel =
|
||||||
spawnStatus && spawnStatus !== 'online'
|
spawnStatus && spawnStatus !== 'online'
|
||||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||||
|
|
@ -525,6 +544,7 @@ export function buildMemberLaunchPresentation({
|
||||||
runtimeAdvisoryLabel,
|
runtimeAdvisoryLabel,
|
||||||
runtimeAdvisoryTitle,
|
runtimeAdvisoryTitle,
|
||||||
launchVisualState,
|
launchVisualState,
|
||||||
|
launchStatusLabel,
|
||||||
spawnBadgeLabel,
|
spawnBadgeLabel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
src/renderer/utils/memberRuntimeSummary.ts
Normal file
41
src/renderer/utils/memberRuntimeSummary.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||||
|
|
||||||
|
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||||
|
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamProviderId } from '@shared/types';
|
||||||
|
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
|
||||||
|
|
||||||
|
function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined): boolean {
|
||||||
|
if (!spawnEntry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
spawnEntry.launchState === 'starting' ||
|
||||||
|
spawnEntry.launchState === 'runtime_pending_bootstrap' ||
|
||||||
|
spawnEntry.status === 'waiting' ||
|
||||||
|
spawnEntry.status === 'spawning'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMemberRuntimeSummary(
|
||||||
|
member: ResolvedTeamMember,
|
||||||
|
launchParams: TeamLaunchParams | undefined,
|
||||||
|
spawnEntry: MemberSpawnStatusEntry | undefined
|
||||||
|
): string | undefined {
|
||||||
|
const configuredProvider: TeamProviderId =
|
||||||
|
member.providerId ?? launchParams?.providerId ?? 'anthropic';
|
||||||
|
const configuredModel = member.model?.trim() || launchParams?.model?.trim() || '';
|
||||||
|
const configuredEffort = member.effort ?? launchParams?.effort;
|
||||||
|
const runtimeModel = spawnEntry?.runtimeModel?.trim();
|
||||||
|
|
||||||
|
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
|
||||||
|
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
|
||||||
|
return formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMemberLaunchPending(spawnEntry)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
|
||||||
|
}
|
||||||
|
|
@ -945,6 +945,8 @@ export interface MemberSpawnStatusEntry {
|
||||||
firstSpawnAcceptedAt?: string;
|
firstSpawnAcceptedAt?: string;
|
||||||
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */
|
/** ISO timestamp of the latest confirmed heartbeat/bootstrap message. */
|
||||||
lastHeartbeatAt?: string;
|
lastHeartbeatAt?: string;
|
||||||
|
/** Live runtime model observed from the teammate process, when available. */
|
||||||
|
runtimeModel?: string;
|
||||||
/** ISO timestamp of the last status change. */
|
/** ISO timestamp of the last status change. */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -752,7 +752,7 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(result.teamLaunchState).toBe('partial_failure');
|
expect(result.teamLaunchState).toBe('partial_failure');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks a live teammate bootstrap as failed when transcript shows model unavailability', async () => {
|
it('marks an online teammate bootstrap as failed when transcript shows model unavailability', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-live-bootstrap-model-unavailable';
|
const teamName = 'zz-live-bootstrap-model-unavailable';
|
||||||
const leadSessionId = 'lead-session';
|
const leadSessionId = 'lead-session';
|
||||||
|
|
@ -816,8 +816,8 @@ describe('TeamProvisioningService', () => {
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
error: undefined,
|
error: undefined,
|
||||||
updatedAt: acceptedAt,
|
updatedAt: acceptedAt,
|
||||||
runtimeAlive: false,
|
runtimeAlive: true,
|
||||||
livenessSource: undefined,
|
livenessSource: 'process',
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
|
|
@ -846,4 +846,77 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available');
|
expect(run.provisioningOutputParts.join('\n')).toContain('requested model is not available');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks a persisted online teammate bootstrap as failed when transcript shows model unavailability', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const teamName = 'zz-persisted-live-bootstrap-model-unavailable';
|
||||||
|
const leadSessionId = 'lead-session';
|
||||||
|
const memberSessionId = 'jack-session';
|
||||||
|
const projectPath = '/Users/test/proj';
|
||||||
|
const projectId = '-Users-test-proj';
|
||||||
|
const acceptedAt = new Date(Date.now() - 5_000).toISOString();
|
||||||
|
const errorAt = new Date(Date.now() - 4_000).toISOString();
|
||||||
|
|
||||||
|
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
|
||||||
|
writeLaunchState(teamName, leadSessionId, {
|
||||||
|
jack: {
|
||||||
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
firstSpawnAcceptedAt: acceptedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectRoot = path.join(tempProjectsBase, projectId);
|
||||||
|
fs.mkdirSync(projectRoot, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, `${memberSessionId}.jsonl`),
|
||||||
|
[
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: acceptedAt,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
type: 'user',
|
||||||
|
message: {
|
||||||
|
role: 'user',
|
||||||
|
content: `You are bootstrapping into team "${teamName}" as member "jack".`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: errorAt,
|
||||||
|
teamName,
|
||||||
|
agentName: 'jack',
|
||||||
|
type: 'assistant',
|
||||||
|
isApiErrorMessage: true,
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: 'API Error: 400 {"detail":"The requested model is not available for your account."}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
].join('\n') + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack']));
|
||||||
|
|
||||||
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
expect(result.statuses.jack).toMatchObject({
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
runtimeAlive: true,
|
||||||
|
});
|
||||||
|
expect(result.statuses.jack?.error).toContain('requested model is not available');
|
||||||
|
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
|
||||||
|
expect(result.teamLaunchState).toBe('partial_failure');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,76 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createTeam materializes an explicit Codex default model for teammates before bootstrap spawn', async () => {
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
|
const { child } = createFakeChild();
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { PATH: '/usr/bin' },
|
||||||
|
authSource: 'codex_runtime',
|
||||||
|
geminiRuntimeAuth: null,
|
||||||
|
}));
|
||||||
|
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
|
const { runId } = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName: 'codex-default-team',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
providerId: 'codex',
|
||||||
|
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const bootstrapSpec = extractBootstrapSpec();
|
||||||
|
expect(bootstrapSpec.members).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'alice',
|
||||||
|
provider: 'codex',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createTeam fails fast when a Codex teammate default model cannot be resolved', async () => {
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
|
vi.mocked(spawnCli).mockReset();
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { PATH: '/usr/bin' },
|
||||||
|
authSource: 'codex_runtime',
|
||||||
|
geminiRuntimeAuth: null,
|
||||||
|
}));
|
||||||
|
(svc as any).resolveProviderDefaultModel = vi.fn(async () => null);
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName: 'codex-default-missing',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
providerId: 'codex',
|
||||||
|
members: [{ name: 'alice', providerId: 'codex' }],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
).rejects.toThrow(
|
||||||
|
'Could not resolve the runtime default model for Codex teammates. Select an explicit model and retry.'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spawnCli).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('add-member spawn prompt tells teammates to keep review on the same task', () => {
|
it('add-member spawn prompt tells teammates to keep review on the same task', () => {
|
||||||
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
|
const prompt = buildAddMemberSpawnMessage('my-team', 'My Team', 'team-lead', {
|
||||||
name: 'alice',
|
name: 'alice',
|
||||||
|
|
@ -429,4 +499,67 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () =>
|
||||||
|
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('launchTeam materializes an explicit Codex default model for launch teammates before bootstrap spawn', async () => {
|
||||||
|
const teamName = 'codex-default-launch';
|
||||||
|
const teamDir = path.join(tempTeamsBase, teamName);
|
||||||
|
fs.mkdirSync(teamDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: teamName,
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' },
|
||||||
|
{ name: 'alice', agentType: 'teammate', role: 'developer', providerId: 'codex' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/fake/claude');
|
||||||
|
const { child } = createFakeChild();
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(child as any);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
env: { PATH: '/usr/bin' },
|
||||||
|
authSource: 'codex_runtime',
|
||||||
|
geminiRuntimeAuth: null,
|
||||||
|
}));
|
||||||
|
(svc as any).resolveProviderDefaultModel = vi.fn(async () => 'gpt-5.4');
|
||||||
|
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
||||||
|
(svc as any).restorePrelaunchConfig = vi.fn(async () => {});
|
||||||
|
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
||||||
|
(svc as any).persistLaunchStateSnapshot = vi.fn(async () => {});
|
||||||
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
||||||
|
members: [{ name: 'alice', role: 'developer', providerId: 'codex' }],
|
||||||
|
source: 'config-fallback',
|
||||||
|
warning: undefined,
|
||||||
|
}));
|
||||||
|
(svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {});
|
||||||
|
(svc as any).pathExists = vi.fn(async () => false);
|
||||||
|
(svc as any).startFilesystemMonitor = vi.fn();
|
||||||
|
|
||||||
|
const { runId } = await svc.launchTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
providerId: 'codex',
|
||||||
|
clearContext: true,
|
||||||
|
} as any,
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const bootstrapSpec = extractBootstrapSpec();
|
||||||
|
expect(bootstrapSpec.members).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'alice',
|
||||||
|
provider: 'codex',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ describe('GraphNodePopover spawn badge labels', () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows human-facing starting for raw waiting/spawning spawn statuses', async () => {
|
it('shows human-readable launch-status labels for waiting and spawning spawn states', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -96,9 +96,9 @@ describe('GraphNodePopover spawn badge labels', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('waiting to start');
|
||||||
expect(host.textContent).toContain('starting');
|
expect(host.textContent).toContain('starting');
|
||||||
expect(host.textContent).toContain('Codex · GPT-5.4 Mini · Medium');
|
expect(host.textContent).toContain('Codex · GPT-5.4 Mini · Medium');
|
||||||
expect(host.textContent).not.toContain('waiting');
|
|
||||||
expect(host.textContent).not.toContain('spawning');
|
expect(host.textContent).not.toContain('spawning');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
@ -193,7 +193,7 @@ describe('GraphNodePopover spawn badge labels', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('online');
|
expect(host.textContent).toContain('connecting');
|
||||||
expect(host.textContent).not.toContain('Idle');
|
expect(host.textContent).not.toContain('Idle');
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
|
|
||||||
|
|
@ -1078,7 +1078,10 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
} as never
|
} as never
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('runtime_pending');
|
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
|
||||||
|
launchVisualState: 'runtime_pending',
|
||||||
|
launchStatusLabel: 'connecting',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps confirmed teammates in settling visuals while launch is still joining', () => {
|
it('keeps confirmed teammates in settling visuals while launch is still joining', () => {
|
||||||
|
|
@ -1125,7 +1128,10 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
} as never
|
} as never
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(findNode(graph, 'member:my-team:alice')?.launchVisualState).toBe('settling');
|
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
|
||||||
|
launchVisualState: 'settling',
|
||||||
|
launchStatusLabel: 'joining team',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scopes inbox particle ids by team name to avoid cross-team collisions', () => {
|
it('scopes inbox particle ids by team name to avoid cross-team collisions', () => {
|
||||||
|
|
|
||||||
|
|
@ -108,4 +108,36 @@ describe('drawAgents', () => {
|
||||||
expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y);
|
expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y);
|
||||||
expect(toolCall!.y).toBeLessThan(node.y!);
|
expect(toolCall!.y).toBeLessThan(node.y!);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders launch text as a third label line and removes old ad-hoc waiting text', () => {
|
||||||
|
const { ctx, fillTextCalls } = createMockContext();
|
||||||
|
const node: GraphNode = {
|
||||||
|
id: 'member:demo:alice',
|
||||||
|
kind: 'member',
|
||||||
|
label: 'alice',
|
||||||
|
state: 'idle',
|
||||||
|
color: '#60a5fa',
|
||||||
|
runtimeLabel: 'Codex · GPT-5.4 Mini · Medium',
|
||||||
|
launchVisualState: 'runtime_pending',
|
||||||
|
launchStatusLabel: 'connecting',
|
||||||
|
spawnStatus: 'online',
|
||||||
|
domainRef: { kind: 'member', teamName: 'demo', memberName: 'alice' },
|
||||||
|
x: 320,
|
||||||
|
y: 240,
|
||||||
|
};
|
||||||
|
|
||||||
|
drawAgents(ctx, [node], 0, null, null, null, 1);
|
||||||
|
|
||||||
|
const labelCall = fillTextCalls.find((call) => call.text === 'alice');
|
||||||
|
const runtimeCall = fillTextCalls.find((call) => call.text.includes('Codex'));
|
||||||
|
const launchCall = fillTextCalls.find((call) => call.text === 'connecting');
|
||||||
|
|
||||||
|
expect(labelCall).toBeDefined();
|
||||||
|
expect(runtimeCall).toBeDefined();
|
||||||
|
expect(launchCall).toBeDefined();
|
||||||
|
expect(runtimeCall!.y).toBeGreaterThan(labelCall!.y);
|
||||||
|
expect(launchCall!.y).toBeGreaterThan(runtimeCall!.y);
|
||||||
|
expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false);
|
||||||
|
expect(fillTextCalls.some((call) => call.text === 'connecting...')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -177,33 +177,90 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('derives runtime-pending and settling visual states from the same launch inputs', () => {
|
it('derives runtime-pending and settling visual states from the same launch inputs', () => {
|
||||||
|
const runtimePending = buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'runtime_pending_bootstrap',
|
||||||
|
spawnLivenessSource: 'process',
|
||||||
|
spawnRuntimeAlive: true,
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: false,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const settling = buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
spawnLivenessSource: 'heartbeat',
|
||||||
|
spawnRuntimeAlive: true,
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: true,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtimePending.launchVisualState).toBe('runtime_pending');
|
||||||
|
expect(runtimePending.launchStatusLabel).toBe('connecting');
|
||||||
|
expect(settling.launchVisualState).toBe('settling');
|
||||||
|
expect(settling.launchStatusLabel).toBe('joining team');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns shared launch status labels without changing generic presence labels', () => {
|
||||||
expect(
|
expect(
|
||||||
buildMemberLaunchPresentation({
|
buildMemberLaunchPresentation({
|
||||||
member,
|
member,
|
||||||
spawnStatus: 'online',
|
spawnStatus: 'waiting',
|
||||||
spawnLaunchState: 'runtime_pending_bootstrap',
|
spawnLaunchState: 'starting',
|
||||||
spawnLivenessSource: 'process',
|
spawnLivenessSource: undefined,
|
||||||
spawnRuntimeAlive: true,
|
spawnRuntimeAlive: false,
|
||||||
runtimeAdvisory: undefined,
|
runtimeAdvisory: undefined,
|
||||||
isLaunchSettling: false,
|
isLaunchSettling: false,
|
||||||
isTeamAlive: true,
|
isTeamAlive: true,
|
||||||
isTeamProvisioning: false,
|
isTeamProvisioning: false,
|
||||||
}).launchVisualState
|
})
|
||||||
).toBe('runtime_pending');
|
).toMatchObject({
|
||||||
|
presenceLabel: 'starting',
|
||||||
|
launchVisualState: 'waiting',
|
||||||
|
launchStatusLabel: 'waiting to start',
|
||||||
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildMemberLaunchPresentation({
|
buildMemberLaunchPresentation({
|
||||||
member,
|
member,
|
||||||
spawnStatus: 'online',
|
spawnStatus: 'spawning',
|
||||||
spawnLaunchState: 'confirmed_alive',
|
spawnLaunchState: 'starting',
|
||||||
spawnLivenessSource: 'heartbeat',
|
spawnLivenessSource: undefined,
|
||||||
spawnRuntimeAlive: true,
|
spawnRuntimeAlive: false,
|
||||||
runtimeAdvisory: undefined,
|
runtimeAdvisory: undefined,
|
||||||
isLaunchSettling: true,
|
isLaunchSettling: false,
|
||||||
isTeamAlive: true,
|
isTeamAlive: true,
|
||||||
isTeamProvisioning: false,
|
isTeamProvisioning: false,
|
||||||
}).launchVisualState
|
})
|
||||||
).toBe('settling');
|
).toMatchObject({
|
||||||
|
presenceLabel: 'starting',
|
||||||
|
launchVisualState: 'spawning',
|
||||||
|
launchStatusLabel: 'starting',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'error',
|
||||||
|
spawnLaunchState: 'failed_to_start',
|
||||||
|
spawnLivenessSource: undefined,
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: false,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'spawn failed',
|
||||||
|
launchVisualState: 'error',
|
||||||
|
launchStatusLabel: 'failed',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders unified retry advisory labels for provider retries', () => {
|
it('renders unified retry advisory labels for provider retries', () => {
|
||||||
|
|
|
||||||
66
test/renderer/utils/memberRuntimeSummary.test.ts
Normal file
66
test/renderer/utils/memberRuntimeSummary.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||||
|
|
||||||
|
import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
function createMember(overrides: Partial<ResolvedTeamMember> = {}): ResolvedTeamMember {
|
||||||
|
return {
|
||||||
|
name: 'alice',
|
||||||
|
agentId: 'alice@test-team',
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
role: 'developer',
|
||||||
|
providerId: 'codex',
|
||||||
|
effort: 'medium',
|
||||||
|
status: 'idle',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
color: 'blue',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSpawnEntry(overrides: Partial<MemberSpawnStatusEntry> = {}): MemberSpawnStatusEntry {
|
||||||
|
return {
|
||||||
|
status: 'waiting',
|
||||||
|
launchState: 'starting',
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: false,
|
||||||
|
agentToolAccepted: true,
|
||||||
|
updatedAt: '2026-04-16T17:10:48.646Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveMemberRuntimeSummary', () => {
|
||||||
|
it('shows the live runtime model for loading members when available', () => {
|
||||||
|
const member = createMember();
|
||||||
|
const spawnEntry = createSpawnEntry({ runtimeModel: 'claude-opus-4-6', runtimeAlive: true });
|
||||||
|
|
||||||
|
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe(
|
||||||
|
'Anthropic · Opus 4.6 · Medium'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the loading skeleton when a pending member has no live runtime model yet', () => {
|
||||||
|
const member = createMember();
|
||||||
|
const spawnEntry = createSpawnEntry();
|
||||||
|
|
||||||
|
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the live runtime model as a fallback when config has no explicit model', () => {
|
||||||
|
const member = createMember({ providerId: 'codex', model: undefined });
|
||||||
|
const spawnEntry = createSpawnEntry({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
runtimeModel: 'gpt-5.4-mini',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolveMemberRuntimeSummary(member, undefined, spawnEntry)).toBe('5.4 Mini · Medium');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue