fix(agent-graph): add launch status labels and pan guards
This commit is contained in:
parent
ac1c99ac1f
commit
53c4204d89
12 changed files with 261 additions and 63 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue