fix(agent-graph): harden pan and launch stepper visibility
This commit is contained in:
parent
82a0e3e6bb
commit
57ba5b57b5
5 changed files with 127 additions and 53 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue