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);
// 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.
*/

View file

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

View file

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

View file

@ -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 (
<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
ref={canvasHandle}
showHexGrid={config?.showHexGrid ?? true}

View file

@ -411,6 +411,7 @@ export class TeamGraphAdapter {
leadMember?.effort
),
launchVisualState: leadLaunchPresentation?.launchVisualState ?? undefined,
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: agentAvatarUrl(leadName, 64),
pendingApproval,
@ -503,6 +504,7 @@ export class TeamGraphAdapter {
),
spawnStatus: spawn?.status,
launchVisualState: launchPresentation.launchVisualState ?? undefined,
launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined,
avatarUrl: agentAvatarUrl(member.name, 64),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId

View file

@ -389,7 +389,7 @@ export const GraphActivityHud = ({
<>
<div
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) => (
<div key={lane.node.id}>
@ -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();
}}
>
<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">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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