From 37fd34bb70b3dc24cf42a6f4373b0bcdc0896bd3 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 19:37:36 +0200 Subject: [PATCH] fix(ui): keep the placeholder picture out of galaxy filters and counts While the first scoped picture is arranged, the scene keeps the whole graph on screen. That placeholder skipped the reader's hidden edge kinds, and the trace edge filter and the selection evidence read their kinds and render counts from it, as if it were the new scope. The scene now draws the placeholder with the hidden kinds applied, and `shown` is the current picture only, so the filter and the counts never describe the placeholder. Signed-off-by: Bernhard Jackiewicz --- .../src/galaxy/GalaxyPanel.stability.test.tsx | 33 +++++++++++++++++-- graph-ui/src/galaxy/GalaxyPanel.tsx | 24 +++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx index be0518fc0..39966510b 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx @@ -5,20 +5,27 @@ * die mitzaehlt, wie oft sie auf- und abgebaut wird; die Beziehungen kommen * ueber dieselbe RPC-Strecke wie im Betrieb. */ -import { act, useEffect } from 'react'; +import { act, useEffect, useLayoutEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import GalaxyPanel from './GalaxyPanel'; import type { GraphData, GraphEdge, GraphNode } from './types'; import type { ScenePath } from './PathLayer'; +import { GALAXY_LEGEND_KEY } from './galaxy-legend'; const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet | undefined, - highlighted: null as Set | null, path: undefined as ScenePath | undefined })); + highlighted: null as Set | null, path: undefined as ScenePath | undefined, + /** Every committed picture: what the scene draws and what the edge type filter lists beside it. */ + frames: [] as { nodes: number; types: string[]; traceRows: number }[] })); vi.mock('./GraphScene', async importOriginal => ({ ...await importOriginal(), GraphScene: ({ data, rootIds, highlightedIds, path }: { data: GraphData; rootIds?: ReadonlySet; highlightedIds: Set | null; path?: ScenePath }) => { scene.renders += 1; scene.roots = rootIds; scene.highlighted = highlightedIds; scene.path = path; useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []); + useLayoutEffect(() => { + scene.frames.push({ nodes: data.nodes.length, types: [...new Set(data.edges.map(edge => edge.type))].sort(), + traceRows: document.querySelectorAll('.atlas-trace-edge-row').length }); + }); return {data.nodes.length}; }, })); @@ -61,7 +68,7 @@ function graphFetch() { let host: HTMLDivElement, root: Root; beforeEach(() => { (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; - scene.mounts = 0; scene.unmounts = 0; scene.renders = 0; + scene.mounts = 0; scene.unmounts = 0; scene.renders = 0; scene.frames = []; host = document.createElement('div'); document.body.append(host); root = createRoot(host); }); afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; }); @@ -245,3 +252,23 @@ it('keeps the picked name when a removed layer takes the path target out of the expect(panel()?.textContent).toContain('No path to n4 over the loaded relationships'); expect(panel()?.textContent).not.toContain('#4'); }); + +it('shows the whole graph with its hidden edge kinds until the first scoped picture, and lists only current kinds', async () => { + await act(async () => root.render( (key === GALAXY_LEGEND_KEY ? 'open' : null), setItem: vi.fn() } as unknown as Storage} />)); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => host.querySelector('[data-testid="atlas-galaxy-legend-swatch"][data-type="CALLS"]')!.click()); + expect(seam().hiddenKinds).toEqual(['CALLS']); + expect(seam().drawnEdges).toBe(1); + + scene.frames = []; + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(seam().nodes).toBe(4)); + const placeholder = scene.frames.filter(frame => frame.nodes === 5); + expect(placeholder.length).toBeGreaterThan(0); + // The whole graph that stays on screen keeps the reader's choice ... + expect(placeholder.every(frame => frame.types.join() === 'IMPORTS')).toBe(true); + // ... and the trace filter beside it never offers the whole graph's kinds as the scope's. + expect(placeholder.every(frame => frame.traceRows === 0)).toBe(true); + expect(scene.frames.at(-1)).toMatchObject({ nodes: 4, types: ['CALLS'], traceRows: 1 }); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index dc98d4584..67276925c 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -1139,19 +1139,27 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const hiddenHere = kinds.filter((kind) => hiddenKinds.has(kind.type)).length; const kindNote = edgeKindNote(kinds.length, hiddenHere); - // Die Szene bekommt in der Galaxie das stehende Bild (siehe `sceneData`). + /* + * Die Szene bekommt in der Galaxie das stehende Bild (siehe `sceneData`). + * Steht dort noch der ganze Graph, gelten auch seine ausgeblendeten + * Kantenarten weiter; erst ein Scope-Bild filtert ueber `traceTypes`. + * `shown` ist nur das AKTUELLE Bild: Kantenfilter, Zaehler und Auskunft an + * den Chat rechnen nie mit dem Platzhalter. + */ const scenePicture = mode === 'hierarchy' ? picture : sceneData; - const shown = useMemo( + const sceneShown = useMemo( () => { if (!scenePicture) return undefined; - const filtered = props.workspaceExpanded && scope.scope ? scenePicture : withoutEdgeKinds(scenePicture, hiddenKinds); + const traced = props.workspaceExpanded && scope.scope && (mode === 'hierarchy' || sceneScoped); + const filtered = traced ? scenePicture : withoutEdgeKinds(scenePicture, hiddenKinds); const requiredNames = new Set(data?.nodes.filter(node => scope.result?.roots.has(node.id)).map(node => node.qualified_name)); return props.workspaceExpanded ? limitGraphRender(filtered, nodeBudget, edgeBudget, mode === 'hierarchy' ? new Set(filtered.nodes.filter(node => requiredNames.has(node.qualified_name)).map(node => node.id)) : scope.result?.roots) : filtered; }, - [scenePicture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data], + [scenePicture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data, sceneScoped], ); + const shown = mode === 'hierarchy' || sceneData === data ? sceneShown : undefined; const traceKinds = useMemo(() => edgeKinds(shown), [shown]); const agentEvidence = useMemo(() => { @@ -2068,7 +2076,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { pulsedQn: pulsedNode?.qualified_name ?? pulsedNode?.name ?? '', edgeKinds: kinds.map((kind) => ({ ...kind, hidden: hiddenKinds.has(kind.type) })), hiddenKinds: [...hiddenKinds].sort(), - drawnEdges: shown?.edges.length ?? 0, + drawnEdges: sceneShown?.edges.length ?? 0, edgeNote, hierarchy: projection === undefined @@ -2522,14 +2530,14 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { )} - {shown !== undefined && everVisible.current && ( + {sceneShown !== undefined && everVisible.current && ( 1 ? null : highlighted)} path={trailIds && trailView ? { steps: trailView.steps, active: trailActive, labels: trailView.labels } : undefined} @@ -2568,7 +2576,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { */ showLabels={ mode === 'hierarchy' - ? shown.nodes.length <= HIERARCHY_LABEL_BUDGET + ? sceneShown.nodes.length <= HIERARCHY_LABEL_BUDGET : trailIds !== undefined || (highlighted !== null && highlighted.size > 0) } /*