fix(agent-graph): harden pan and launch stepper visibility

This commit is contained in:
777genius 2026-04-16 21:26:15 +03:00
parent 82a0e3e6bb
commit 57ba5b57b5
5 changed files with 127 additions and 53 deletions

View file

@ -266,24 +266,49 @@ function drawLaunchStage(
ctx.save();
switch (visualState) {
case 'waiting': {
const ringR = r + 7 + Math.sin(time * 3.2) * 1.2;
const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2));
const ringR = r + 8 + Math.sin(time * 3.2) * 1.4;
const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2));
const dotOrbit = r + 11;
ctx.beginPath();
ctx.arc(x, y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha);
ctx.lineWidth = 2.2;
ctx.lineWidth = 2.5;
ctx.setLineDash([4, 5]);
ctx.stroke();
ctx.setLineDash([]);
for (let index = 0; index < 3; index += 1) {
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
ctx.beginPath();
ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2);
ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
ctx.fill();
}
break;
}
case 'spawning': {
const ringR = r + 7;
const rotation = time * 2.4;
const rotation = time * 2.7;
ctx.beginPath();
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15);
ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8);
ctx.lineWidth = 2.2;
ctx.lineWidth = 2.8;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, ringR + 4, rotation + Math.PI, rotation + Math.PI + Math.PI * 0.4);
ctx.strokeStyle = hexWithAlpha('#fbbf24', 0.65);
ctx.lineWidth = 1.8;
ctx.lineCap = 'round';
ctx.stroke();
const glow = ctx.createRadialGradient(x, y, r * 0.5, x, y, ringR + 12);
glow.addColorStop(0, hexWithAlpha('#f59e0b', 0.18));
glow.addColorStop(1, hexWithAlpha('#f59e0b', 0));
ctx.beginPath();
ctx.arc(x, y, ringR + 12, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
break;
}
case 'runtime_pending': {
@ -291,27 +316,37 @@ function drawLaunchStage(
ctx.beginPath();
ctx.arc(x, y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48);
ctx.lineWidth = 1.75;
ctx.lineWidth = 1.9;
ctx.setLineDash([5, 4]);
ctx.stroke();
ctx.setLineDash([]);
const orbit = time * 1.6;
const dotR = 2.2;
const dotX = x + Math.cos(orbit) * ringR;
const dotY = y + Math.sin(orbit) * ringR;
ctx.beginPath();
ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2);
ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9);
ctx.fill();
const orbit = time * 1.8;
for (let index = 0; index < 2; index += 1) {
const angle = orbit + Math.PI * index;
const dotX = x + Math.cos(angle) * ringR;
const dotY = y + Math.sin(angle) * ringR;
ctx.beginPath();
ctx.arc(dotX, dotY, 2.3, 0, Math.PI * 2);
ctx.fillStyle = hexWithAlpha(index === 0 ? '#67e8f9' : '#38bdf8', 0.92);
ctx.fill();
}
break;
}
case 'settling': {
const ringR = r + 6;
const arc = 0.65 + 0.08 * Math.sin(time * 2.2);
const arc = 0.72 + 0.08 * Math.sin(time * 2.2);
const rotation = time * 1.25;
ctx.beginPath();
ctx.arc(x, y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.18);
ctx.lineWidth = 1.4;
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc);
ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62);
ctx.lineWidth = 2;
ctx.lineWidth = 2.2;
ctx.lineCap = 'round';
ctx.stroke();
break;
@ -321,9 +356,14 @@ function drawLaunchStage(
ctx.beginPath();
ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15);
ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72);
ctx.lineWidth = 2.2;
ctx.lineWidth = 2.4;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(x + ringR * 0.52, y - ringR * 0.5, 2.2, 0, Math.PI * 2);
ctx.fillStyle = hexWithAlpha('#f87171', 0.92);
ctx.fill();
break;
}
}

View file

@ -49,6 +49,7 @@ export interface GraphControlsProps {
teamColor?: string;
isAlive?: boolean;
topToolbarContent?: React.ReactNode;
interactionLocked?: boolean;
}
const TOPBAR_BUTTON_SIZE = 25;
@ -69,6 +70,7 @@ export function GraphControls({
isSidebarVisible = true,
teamColor,
topToolbarContent,
interactionLocked = false,
}: GraphControlsProps): React.JSX.Element {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const settingsRef = useRef<HTMLDivElement>(null);
@ -104,6 +106,9 @@ export function GraphControls({
}, [isSettingsOpen]);
const nameColor = teamColor ?? '#aaeeff';
const chromeInteractivityClass = interactionLocked
? 'pointer-events-none select-none'
: 'pointer-events-auto';
return (
<>
@ -111,7 +116,7 @@ export function GraphControls({
<div className="absolute left-0 top-0 flex shrink-0 items-center gap-0.5">
{onToggleSidebar ? (
<div
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
@ -133,7 +138,7 @@ export function GraphControls({
) : null}
{onOpenTeamPage ? (
<div
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
@ -149,7 +154,7 @@ export function GraphControls({
) : null}
{onCreateTask ? (
<div
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: `1px solid ${nameColor}25`,
@ -167,7 +172,7 @@ export function GraphControls({
<div className="absolute left-1/2 top-0 w-[min(360px,38vw)] -translate-x-1/2 px-2">
{topToolbarContent ? (
<div className="pointer-events-auto min-w-0">
<div className={`${chromeInteractivityClass} min-w-0`}>
{topToolbarContent}
</div>
) : null}
@ -175,7 +180,7 @@ export function GraphControls({
<div className="absolute right-0 top-0 flex shrink-0 items-center gap-0.5">
<div
className="pointer-events-auto flex items-center rounded-md p-0 backdrop-blur-sm"
className={`${chromeInteractivityClass} flex items-center rounded-md p-0 backdrop-blur-sm`}
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',
@ -189,7 +194,7 @@ export function GraphControls({
/>
</div>
<div ref={settingsRef} className="relative pointer-events-auto">
<div ref={settingsRef} className={`relative ${chromeInteractivityClass}`}>
<div
className="flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
style={{
@ -240,7 +245,7 @@ export function GraphControls({
</div>
<div
className="pointer-events-auto flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm"
className={`${chromeInteractivityClass} flex items-center gap-0.5 rounded-md p-0 backdrop-blur-sm`}
style={{
background: 'rgba(8, 12, 24, 0.8)',
border: '1px solid rgba(100, 200, 255, 0.08)',

View file

@ -104,6 +104,7 @@ export function GraphView({
// ─── React state (user-facing only) ─────────────────────────────────────
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [interactionLocked, setInteractionLocked] = useState(false);
const [filters, setFilters] = useState<GraphFilterState>({
showTasks: config?.showTasks ?? true,
showProcesses: config?.showProcesses ?? true,
@ -136,6 +137,7 @@ export function GraphView({
color?: string | null;
} | null>(null);
const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null);
const activePrimaryInteractionRef = useRef(false);
// ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation();
@ -280,6 +282,15 @@ export function GraphView({
selectionLockRef.current = null;
}, []);
const setInteractionGuards = useCallback(
(active: boolean) => {
activePrimaryInteractionRef.current = active;
setInteractionLocked(active);
setInteractionSelectionDisabled(active);
},
[setInteractionSelectionDisabled]
);
const animate = useCallback(() => {
if (!runningRef.current) return;
@ -413,6 +424,7 @@ export function GraphView({
dragPreviewRef.current = null;
isPanningRef.current = false;
edgeMouseDownRef.current = null;
setInteractionGuards(false);
}, [interaction, isSurfaceActive, simulation]);
const handleWheel = useCallback(
@ -432,11 +444,11 @@ export function GraphView({
if (e.button !== 0) return; // only left click
e.preventDefault();
dragPreviewRef.current = null;
setInteractionSelectionDisabled(true);
setInteractionGuards(true);
const canvas = canvasHandle.current?.getCanvas();
if (!canvas) {
setInteractionSelectionDisabled(false);
setInteractionGuards(false);
return;
}
const rect = canvas.getBoundingClientRect();
@ -482,13 +494,14 @@ export function GraphView({
getVisibleNodes,
interaction,
markUserInteracted,
setInteractionGuards,
simulation.stateRef,
]
);
const processActivePointerMove = useCallback(
(clientX: number, clientY: number, buttons: number) => {
if ((buttons & 1) === 0) {
(clientX: number, clientY: number) => {
if (!activePrimaryInteractionRef.current) {
dragPreviewRef.current = null;
return false;
}
@ -545,7 +558,7 @@ export function GraphView({
if (isPanningRef.current) {
camera.handlePanEnd();
isPanningRef.current = false;
setInteractionSelectionDisabled(false);
setInteractionGuards(false);
dragPreviewRef.current = null;
setSelectedNodeId(null);
setSelectedEdgeId(null);
@ -556,7 +569,7 @@ export function GraphView({
const clickedId = interaction.handleMouseUp();
if (wasDragging && draggedNodeId) {
setInteractionSelectionDisabled(false);
setInteractionGuards(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(
@ -585,7 +598,7 @@ export function GraphView({
return;
}
setInteractionSelectionDisabled(false);
setInteractionGuards(false);
if (clickedId) {
setSelectedNodeId(clickedId);
setSelectedEdgeId(null);
@ -624,12 +637,12 @@ export function GraphView({
}
dragPreviewRef.current = null;
},
[camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation]
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) {
if (processActivePointerMove(e.clientX, e.clientY)) {
return;
}
@ -678,11 +691,8 @@ export function GraphView({
useEffect(() => {
const handleWindowMouseMove = (event: MouseEvent): void => {
if ((event.buttons & 1) === 0) {
setInteractionSelectionDisabled(false);
return;
}
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current &&
@ -691,30 +701,47 @@ export function GraphView({
return;
}
event.preventDefault();
processActivePointerMove(event.clientX, event.clientY, event.buttons);
processActivePointerMove(event.clientX, event.clientY);
};
const handleWindowMouseUp = (event: MouseEvent): void => {
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current &&
!edgeMouseDownRef.current
) {
setInteractionSelectionDisabled(false);
setInteractionGuards(false);
return;
}
completePointerInteraction(event.clientX, event.clientY);
};
const clearInteraction = (): void => {
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
return;
}
interaction.handleMouseUp();
camera.handlePanEnd();
isPanningRef.current = false;
edgeMouseDownRef.current = null;
dragPreviewRef.current = null;
setInteractionGuards(false);
};
window.addEventListener('mousemove', handleWindowMouseMove);
window.addEventListener('mouseup', handleWindowMouseUp);
window.addEventListener('blur', clearInteraction);
window.addEventListener('dragstart', clearInteraction);
return () => {
window.removeEventListener('mousemove', handleWindowMouseMove);
window.removeEventListener('mouseup', handleWindowMouseUp);
setInteractionSelectionDisabled(false);
window.removeEventListener('blur', clearInteraction);
window.removeEventListener('dragstart', clearInteraction);
setInteractionGuards(false);
};
}, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]);
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
@ -911,6 +938,7 @@ export function GraphView({
teamColor={data.teamColor}
isAlive={data.isAlive}
topToolbarContent={renderTopToolbarContent?.()}
interactionLocked={interactionLocked}
/>
{renderHud ? (

View file

@ -34,7 +34,7 @@ const HUD_STEPPER_STYLE: CSSProperties = {
};
function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean {
return presentation != null;
return presentation != null && (presentation.isActive || presentation.isFailed);
}
export interface GraphProvisioningHudProps {
@ -80,8 +80,10 @@ export const GraphProvisioningHud = ({
<>
<button
type="button"
className="focus-visible:ring-white/18 w-full rounded-xl bg-transparent px-1 py-0.5 text-left text-slate-100 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-1"
className="focus-visible:ring-white/18 w-full touch-none select-none rounded-xl bg-transparent px-1 py-0.5 text-left text-slate-100 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-1"
onClick={() => setDetailsOpen(true)}
onMouseDown={(event) => event.preventDefault()}
onDragStart={(event) => event.preventDefault()}
aria-label={ariaLabel}
>
<div className="px-1 py-0.5" style={HUD_STEPPER_STYLE}>

View file

@ -53,7 +53,7 @@ describe('GraphProvisioningHud', () => {
hookState.runInstanceKey = 'team:run-1:2026-04-13T10:00:00.000Z';
});
it('keeps successful ready launch summary visible until dismissed', async () => {
it('hides the graph launch hud once provisioning is ready', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hookState.presentation = {
isActive: false,
@ -80,9 +80,8 @@ describe('GraphProvisioningHud', () => {
await Promise.resolve();
});
expect(host.textContent).toContain('Team launched');
expect(host.textContent).toContain('All 3 teammates joined');
expect(host.querySelector('[data-testid="stepper"]')).not.toBeNull();
expect(host.textContent).toBe('');
expect(host.querySelector('[data-testid="stepper"]')).toBeNull();
expect(document.body.textContent).not.toContain('provisioning-panel');
await act(async () => {
@ -94,14 +93,14 @@ describe('GraphProvisioningHud', () => {
it('opens launch details in a separate dialog when the stepper is clicked', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hookState.presentation = {
isActive: false,
isActive: true,
isFailed: false,
hasMembersStillJoining: false,
hasMembersStillJoining: true,
failedSpawnCount: 0,
compactTone: 'success',
compactTitle: 'Team launched',
compactDetail: 'All 3 teammates joined',
currentStepIndex: 4,
compactTone: 'default',
compactTitle: 'Launching team',
compactDetail: '1 teammate still joining',
currentStepIndex: 2,
progress: { runId: 'run-3' },
};