From ebef3d75d60dd969672403f7e2903241e88473e1 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 19:37:08 +0200 Subject: [PATCH] fix(ui): drop a stale galaxy path and leave Escape to overlays and fields A path or call order selection was only hidden when the root, trace direction or edge filter changed. Setting the direction back, or selecting the same root again after "All graph", brought the old path back. The selection is now dropped on every new scope key, on a new selection and on reset. The window Escape handler for the path view ignored escapeTaken and typing targets, so one Escape could clear the path while Help, Settings or Projects stayed open, or take the key from a field. It now steps back like the fullscreen handler does. The picked target keeps its name, so removing a layer no longer shows it as a raw node id. Signed-off-by: Bernhard Jackiewicz --- .../src/galaxy/GalaxyPanel.stability.test.tsx | 86 +++++++++++++++++++ graph-ui/src/galaxy/GalaxyPanel.tsx | 39 ++++++--- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx index 08f05fcc0..be0518fc0 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx @@ -159,3 +159,89 @@ it('highlights a path and the call order of the root inside the loaded scope, an await direction('outbound'); expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull(); }); + +const panel = () => host.querySelector('[data-testid="atlas-galaxy-path-panel"]'); +const traceDirection = (value: string) => act(async () => { + const select = host.querySelector('select[aria-label="Trace direction"]')!; + select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); +}); +const pickPath = (name: string) => act(async () => { + host.querySelector('.atlas-graph-path-picker')!.open = true; + [...host.querySelectorAll('.atlas-graph-path-menu li button')] + .find(entry => entry.querySelector('strong')?.textContent === name)!.click(); +}); + +it('drops a path on a new scope key, so returning to the old key does not bring it back', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await pickPath('n2'); + expect(heading()).toBe('Path to n2 · 1 hop'); + + // Incoming and back to both directions: the same key as before, but the path is gone. + await traceDirection('inbound'); + await settle(() => expect(seam().nodes).toBe(2)); + expect(panel()).toBeNull(); + await traceDirection('both'); + await settle(() => expect(seam().nodes).toBe(4)); + expect(panel()).toBeNull(); + expect(scene.path).toBeUndefined(); + + // The whole graph and the same root again: a fresh picture without the old path. + await act(async () => button('Call order').click()); + expect(heading()).toBe('Calls of n1 · 2 calls'); + await act(async () => button('All graph').click()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + expect(panel()).toBeNull(); + expect(button('Call order').getAttribute('aria-pressed')).toBe('false'); +}); + +it('leaves Escape to an overlay above the galaxy and to a field being typed in', async () => { + const fetch = graphFetch(); + const panelWith = (escapeTaken: boolean) => ; + await act(async () => root.render(panelWith(false))); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await pickPath('n2'); + expect(panel()).not.toBeNull(); + const escape = (target: EventTarget) => { + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + act(() => { target.dispatchEvent(event); }); + return event; + }; + + // Help, settings or projects lie above: the key is theirs. + await act(async () => root.render(panelWith(true))); + expect(escape(window).defaultPrevented).toBe(false); + expect(panel()).not.toBeNull(); + + // Typing in a field: Escape leaves the field, not the path. + await act(async () => root.render(panelWith(false))); + const field = host.querySelector('input[aria-label="Find a graph node"]')!; + expect(escape(field).defaultPrevented).toBe(false); + expect(panel()).not.toBeNull(); + + expect(escape(window).defaultPrevented).toBe(true); + expect(panel()).toBeNull(); +}); + +it('keeps the picked name when a removed layer takes the path target out of the picture', async () => { + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + await act(async () => button('Expand +1').click()); + await settle(() => expect(seam().nodes).toBe(5)); + await pickPath('n4'); + expect(heading()).toBe('Path to n4 · 2 hops'); + + await act(async () => host.querySelector('button[aria-label="Remove graph layer"]')!.click()); + await settle(() => expect(seam().nodes).toBe(4)); + expect(heading()).toBe('Path to n4 · 0 hops'); + expect(panel()?.textContent).toContain('No path to n4 over the loaded relationships'); + expect(panel()?.textContent).not.toContain('#4'); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 5b5f90a35..dc98d4584 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -215,6 +215,7 @@ import type { AgentsRuntime } from '../agents/agent-source'; import { loadAgentsPreference, saveAgentsPreference } from '../agents/agent-preference'; import type { AgentsPreference } from '../agents/agent-preference'; import { fullscreenIsolationRequired, isolateFullscreenBackground } from './fullscreen-isolation'; +import { isTypingTarget } from '../app/keyboard'; /** * Wie lange die Ereigniszeile des FOLLOW-Modus stehen bleibt. @@ -1207,10 +1208,17 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { * nicht". Der Abstand ist hier die Zahl der Hops und nur hier (G6); die * Wolke selbst behauptet keinen. Die Wahl haengt am Scope (`organicKey`): * eine neue Wurzel, Richtung oder Kantenart verwirft sie, ein Expand - * rechnet sie auf dem groesseren Bild neu. + * rechnet sie auf dem groesseren Bild neu. Verworfen heisst verworfen: wer + * die Richtung zurueckstellt oder dieselbe Wurzel neu waehlt, bekommt den + * alten Pfad nicht ungefragt wieder. Das Ziel behaelt seinen Namen, auch + * wenn ein entfernter Layer es aus dem Bild nimmt. */ - const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number } | { key: string; kind: 'calls' }>(); + const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number; name: string } | { key: string; kind: 'calls' }>(); const [trailStep, setTrailStep] = useState(0); + useEffect(() => { + setTrail(current => (current && current.key !== organicKey ? undefined : current)); + setTrailStep(0); + }, [organicKey]); const trailRoot = scope.result?.roots.size === 1 ? [...scope.result.roots][0] : undefined; const rootCalls = useMemo(() => (data && trailRoot !== undefined ? callOrder(data.edges, trailRoot) : []), [data, trailRoot]); const pathCandidates = useMemo(() => data?.nodes.filter(node => !scope.result?.roots.has(node.id)) ?? [], [data, scope.result]); @@ -1223,7 +1231,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { heading: galaxyPathText.callsHeading(trailRoot === undefined ? '' : nameOf(trailRoot), rootCalls.length) }; } const steps: ScopePathStep[] | undefined = shortestScopePath(data.edges, scope.result.roots, trail.target, scope.direction); - const target = nameOf(trail.target); + const target = names.get(trail.target) ?? trail.name; return { nameOf, lines: false, labels: 'all' as const, steps: steps ?? [], heading: galaxyPathText.pathHeading(target, steps?.length ?? 0), note: steps === undefined ? galaxyPathText.noPath(target) : steps.length === 0 ? galaxyPathText.isRoot(target) : undefined }; @@ -1232,16 +1240,24 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const trailIds = useMemo(() => trailView?.steps.length && scope.result ? pathNodes(trailView.steps, scope.result.roots) : undefined, [trailView, scope.result]); const clearTrail = useCallback(() => { setTrail(undefined); setTrailStep(0); }, []); + /* + * Escape gibt den Pfad frei, aber erst nach allen, die vorgehen: liegt eine + * Flaeche darueber (`escapeTaken`, dieselbe Reihenfolge wie beim + * Vollbild), gehoert die Taste ihr, und wer in einem Feld oder im Editor + * tippt, verlaesst mit Escape das Feld und nicht den Pfad. + */ + const escapeTaken = props.escapeTaken === true; useEffect(() => { - if (!trailView) return; + if (!trailView || escapeTaken) return; const onKey = (event: globalThis.KeyboardEvent): void => { if (event.key !== 'Escape' || event.defaultPrevented) return; + if (isTypingTarget(event.target instanceof Element ? event.target : null)) return; event.preventDefault(); clearTrail(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [trailView, clearTrail]); + }, [trailView, escapeTaken, clearTrail]); const index = useMemo( () => (picture === undefined ? new Map() : nodesByQualifiedName(picture.nodes)), @@ -1727,9 +1743,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { * die Oberflaeche legt, und zwar ZULETZT: liegt eine andere Flaeche darueber * (Hilfe, Einstiegsdialog, Suchfenster, Einstellungen), gehoert die Taste * ihr. Der Vollbildmodus reiht sich in die bestehende Reihenfolge ein, er - * draengt sich nicht vor. + * draengt sich nicht vor (`escapeTaken` steht beim Pfad weiter oben). */ - const escapeTaken = props.escapeTaken === true; useEffect(() => { const node = panel.current; if (node === null || !fullscreenIsolationRequired(fullscreen, escapeTaken)) { @@ -1810,6 +1825,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const selected = layoutNodeForSelection(layout, node) ?? layoutNodeForSelection(data, node) ?? node; props.onSelectNode?.(selected); if (props.workspaceExpanded) { + clearTrail(); scope.select({ kind: 'node', id: selected.id, name: selected.name, qualifiedName: selected.qualified_name }); setNote(''); return; @@ -1821,19 +1837,20 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { setNote(''); onOpenNode(selected); }, - [layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select], + [layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select, clearTrail], ); const handleBackgroundClick = useCallback(() => { setBackgroundCleared(true); if (props.workspaceExpanded) setChosenMode('galaxy'); + clearTrail(); scope.reset(); changeTraceTypes(undefined); setHighlighted(null); setNote(props.workspaceExpanded ? '' : mode === 'hierarchy' ? readerProjection?.message ?? HIERARCHY_NO_FOCUS_NOTE : GALAXY_NO_FOCUS_NOTE); refitNow(); props.onClearSelection?.(); - }, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes]); + }, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes, clearTrail]); /* * Die Vorgabe der Ansicht, mit der Wahl des Lesers darauf. @@ -2312,7 +2329,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )} {(props.workspaceExpanded || scope.scope) &&
- {props.workspaceExpanded && { props.onClearSelection?.(); setBackgroundCleared(false); scope.select(next); }} />} + {props.workspaceExpanded && { props.onClearSelection?.(); setBackgroundCleared(false); clearTrail(); scope.select(next); }} />} {scope.scope ? <> {props.workspaceExpanded && } {scope.scope.name} @@ -2328,7 +2345,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { {props.workspaceExpanded && mode === 'galaxy' && <> - { setTrail({ key: organicKey, kind: 'path', target: node.id }); setTrailStep(0); }} /> + { setTrail({ key: organicKey, kind: 'path', target: node.id, name: node.name }); setTrailStep(0); }} />