From 53c4204d89e13f578760185d78becace35eef1a8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 20:58:40 +0300 Subject: [PATCH] fix(agent-graph): add launch status labels and pan guards --- .../agent-graph/src/canvas/draw-agents.ts | 94 ++++++++++++------- .../src/layout/stableSlotGeometry.ts | 2 +- packages/agent-graph/src/ports/types.ts | 2 + packages/agent-graph/src/ui/GraphView.tsx | 54 ++++++++++- .../renderer/adapters/TeamGraphAdapter.ts | 2 + .../renderer/ui/GraphActivityHud.tsx | 7 +- .../renderer/ui/GraphNodePopover.tsx | 12 ++- src/renderer/utils/memberHelpers.ts | 20 ++++ .../agent-graph/GraphNodePopover.test.ts | 6 +- .../agent-graph/TeamGraphAdapter.test.ts | 10 +- .../features/agent-graph/drawAgents.test.ts | 32 +++++++ test/renderer/utils/memberHelpers.test.ts | 83 +++++++++++++--- 12 files changed, 261 insertions(+), 63 deletions(-) diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 21a1d228..9a31800a 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -71,7 +71,7 @@ export function drawAgents( drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl); // 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); } @@ -122,7 +122,17 @@ export function drawAgents( if (!simplify) { // Name + role label (single line: "jack · developer") 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 @@ -257,11 +267,11 @@ function drawLaunchStage( switch (visualState) { case 'waiting': { 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.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.stroke(); break; } @@ -270,8 +280,8 @@ function drawLaunchStage( const rotation = time * 2.4; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -280,8 +290,8 @@ function drawLaunchStage( const ringR = r + 8; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); - ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4); - ctx.lineWidth = 1.5; + ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48); + ctx.lineWidth = 1.75; ctx.stroke(); const orbit = time * 1.6; @@ -300,8 +310,8 @@ function drawLaunchStage( const rotation = time * 1.25; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); - ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55); - ctx.lineWidth = 1.75; + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); + ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -310,8 +320,8 @@ function drawLaunchStage( const ringR = r + 7 + Math.sin(time * 4) * 0.8; ctx.beginPath(); ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -467,12 +477,8 @@ function drawBreathing( r: number, state: string, time: number, - spawnStatus?: GraphNode['spawnStatus'], - runtimeLabel?: string + spawnStatus?: GraphNode['spawnStatus'] ): void { - const hasRuntimeLabel = Boolean(runtimeLabel?.trim()); - const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14); - // Spawning: bright animated double ring + radial glow if (spawnStatus === 'spawning') { const ringR = r + AGENT_DRAW.orbitParticleOffset; @@ -505,12 +511,6 @@ function drawBreathing( ctx.stroke(); ctx.setLineDash([]); 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; } @@ -532,12 +532,6 @@ function drawBreathing( ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse); ctx.lineWidth = 1.5; 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; } @@ -649,7 +643,9 @@ function drawLabel( r: number, label: string, color: string, - runtimeLabel?: string + runtimeLabel?: string, + launchStatusLabel?: string, + launchVisualState?: GraphNode['launchVisualState'] ): void { const labelY = y + r + AGENT_DRAW.labelYOffset; ctx.font = '9px monospace'; @@ -659,16 +655,27 @@ function drawLabel( ctx.fillText(label, x, labelY); const trimmedRuntimeLabel = runtimeLabel?.trim(); - if (!trimmedRuntimeLabel) { + const trimmedLaunchStatusLabel = launchStatusLabel?.trim(); + if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) { return; } - ctx.font = '8px monospace'; - ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); - ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10); + let nextLineY = labelY + 10; + if (trimmedRuntimeLabel) { + 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); if (ctx.measureText(label).width <= maxWidth) return label; @@ -679,6 +686,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n 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. */ diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts index a9e58b13..d1e5265f 100644 --- a/packages/agent-graph/src/layout/stableSlotGeometry.ts +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -9,7 +9,7 @@ export const STABLE_SLOT_GEOMETRY = { unassignedGap: 72, maxGeneratedRings: 12, ownerCollisionPadding: 28, - ownerBandHeight: 72, + ownerBandHeight: 96, ownerMinWidth: 200, processBandHeight: 32, processRailWidth: 220, diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index df378f75..af439ae4 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -84,6 +84,8 @@ export interface GraphNode { spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; /** Shared launch-stage visual derived by the host app */ 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 */ contextUsage?: number; /** Current task ID this member is working on */ diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index a842bbf9..bb3a43af 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -135,6 +135,7 @@ export function GraphView({ y: number; color?: string | null; } | null>(null); + const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -255,6 +256,30 @@ export function GraphView({ 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(() => { if (!runningRef.current) return; @@ -405,10 +430,15 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + e.preventDefault(); dragPreviewRef.current = null; + setInteractionSelectionDisabled(true); const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; + if (!canvas) { + setInteractionSelectionDisabled(false); + return; + } const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const nodes = getVisibleNodes(simulation.stateRef.current.nodes); @@ -464,6 +494,9 @@ export function GraphView({ } if (isPanningRef.current) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } camera.handlePanMove(clientX, clientY); return true; } @@ -480,6 +513,9 @@ export function GraphView({ const draggedNodeId = interaction.dragNodeId.current; if (interaction.isDragging.current && draggedNodeId) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member') { const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y); @@ -509,6 +545,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; + setInteractionSelectionDisabled(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -519,6 +556,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { + setInteractionSelectionDisabled(false); const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { const nearest = simulation.resolveNearestOwnerSlot( @@ -547,6 +585,7 @@ export function GraphView({ return; } + setInteractionSelectionDisabled(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -585,7 +624,7 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] ); const handleMouseMove = useCallback( @@ -640,6 +679,7 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { if ((event.buttons & 1) === 0) { + setInteractionSelectionDisabled(false); return; } if ( @@ -650,6 +690,7 @@ export function GraphView({ ) { return; } + event.preventDefault(); processActivePointerMove(event.clientX, event.clientY, event.buttons); }; @@ -660,6 +701,7 @@ export function GraphView({ !interaction.isDragging.current && !edgeMouseDownRef.current ) { + setInteractionSelectionDisabled(false); return; } completePointerInteraction(event.clientX, event.clientY); @@ -670,8 +712,9 @@ export function GraphView({ return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); + setInteractionSelectionDisabled(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove]); + }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -819,7 +862,10 @@ export function GraphView({ // ─── Render ───────────────────────────────────────────────────────────── return ( -
+
{visibleLanes.map((lane) => (
@@ -422,12 +422,15 @@ export const GraphActivityHud = ({ ref={(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={{ width: `${laneWidth}px`, maxWidth: `${laneWidth}px`, height: `${laneHeight}px`, }} + onDragStart={(event) => { + event.preventDefault(); + }} >
diff --git a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx index afc454ac..a341ec4a 100644 --- a/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx +++ b/src/features/agent-graph/renderer/ui/GraphNodePopover.tsx @@ -320,11 +320,17 @@ const MemberPopoverContent = ({ : null; const fallbackSpawnStatusLabel = node.spawnStatus && node.spawnStatus !== 'online' - ? node.spawnStatus === 'waiting' || node.spawnStatus === 'spawning' - ? 'starting' - : node.spawnStatus + ? node.spawnStatus === 'waiting' + ? 'waiting to start' + : node.spawnStatus === 'spawning' + ? 'starting' + : node.spawnStatus === 'error' + ? 'failed' + : node.spawnStatus : null; const statusLabel = + launchPresentation?.launchStatusLabel ?? + node.launchStatusLabel ?? launchPresentation?.presenceLabel ?? fallbackSpawnStatusLabel ?? (node.state === 'active' diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index da447536..ce19b342 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -423,9 +423,27 @@ export interface MemberLaunchPresentation { runtimeAdvisoryLabel: string | null; runtimeAdvisoryTitle?: string; launchVisualState: MemberLaunchVisualState; + launchStatusLabel: 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({ member, spawnStatus, @@ -511,6 +529,7 @@ export function buildMemberLaunchPresentation({ } } + const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); const spawnBadgeLabel = spawnStatus && spawnStatus !== 'online' ? spawnStatus === 'waiting' || spawnStatus === 'spawning' @@ -525,6 +544,7 @@ export function buildMemberLaunchPresentation({ runtimeAdvisoryLabel, runtimeAdvisoryTitle, launchVisualState, + launchStatusLabel, spawnBadgeLabel, }; } diff --git a/test/renderer/features/agent-graph/GraphNodePopover.test.ts b/test/renderer/features/agent-graph/GraphNodePopover.test.ts index 7eaa65b5..e5d8ce6b 100644 --- a/test/renderer/features/agent-graph/GraphNodePopover.test.ts +++ b/test/renderer/features/agent-graph/GraphNodePopover.test.ts @@ -70,7 +70,7 @@ describe('GraphNodePopover spawn badge labels', () => { 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); const host = document.createElement('div'); document.body.appendChild(host); @@ -96,9 +96,9 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); + expect(host.textContent).toContain('waiting to start'); expect(host.textContent).toContain('starting'); expect(host.textContent).toContain('Codex · GPT-5.4 Mini · Medium'); - expect(host.textContent).not.toContain('waiting'); expect(host.textContent).not.toContain('spawning'); await act(async () => { @@ -193,7 +193,7 @@ describe('GraphNodePopover spawn badge labels', () => { await Promise.resolve(); }); - expect(host.textContent).toContain('online'); + expect(host.textContent).toContain('connecting'); expect(host.textContent).not.toContain('Idle'); await act(async () => { diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 41ebe1bf..77173f65 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1074,7 +1074,10 @@ describe('TeamGraphAdapter particles', () => { } 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', () => { @@ -1121,7 +1124,10 @@ describe('TeamGraphAdapter particles', () => { } 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', () => { diff --git a/test/renderer/features/agent-graph/drawAgents.test.ts b/test/renderer/features/agent-graph/drawAgents.test.ts index 4d5bcbb7..1208a2c6 100644 --- a/test/renderer/features/agent-graph/drawAgents.test.ts +++ b/test/renderer/features/agent-graph/drawAgents.test.ts @@ -108,4 +108,36 @@ describe('drawAgents', () => { expect(runtimeCall!.y).toBeGreaterThan(labelCall!.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); + }); }); diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 56a8dfe9..b626c0fe 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -177,33 +177,90 @@ describe('memberHelpers spawn-aware presence', () => { }); 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( buildMemberLaunchPresentation({ member, - spawnStatus: 'online', - spawnLaunchState: 'runtime_pending_bootstrap', - spawnLivenessSource: 'process', - spawnRuntimeAlive: true, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, runtimeAdvisory: undefined, isLaunchSettling: false, isTeamAlive: true, isTeamProvisioning: false, - }).launchVisualState - ).toBe('runtime_pending'); + }) + ).toMatchObject({ + presenceLabel: 'starting', + launchVisualState: 'waiting', + launchStatusLabel: 'waiting to start', + }); expect( buildMemberLaunchPresentation({ member, - spawnStatus: 'online', - spawnLaunchState: 'confirmed_alive', - spawnLivenessSource: 'heartbeat', - spawnRuntimeAlive: true, + spawnStatus: 'spawning', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, runtimeAdvisory: undefined, - isLaunchSettling: true, + isLaunchSettling: false, isTeamAlive: true, 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', () => {