fix(agent-graph): add launch status labels and pan guards

This commit is contained in:
777genius 2026-04-16 20:58:40 +03:00
parent ac1c99ac1f
commit 53c4204d89
12 changed files with 261 additions and 63 deletions

View file

@ -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.
*/ */

View file

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

View file

@ -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 */

View file

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

View file

@ -411,6 +411,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,
@ -503,6 +504,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

View file

@ -389,7 +389,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}>
@ -422,12 +422,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">

View file

@ -320,11 +320,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'

View file

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

View file

@ -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 () => {

View file

@ -1074,7 +1074,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', () => {
@ -1121,7 +1124,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', () => {

View file

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

View file

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