diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 1df21cac2..1cf4d15e3 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -311,7 +311,7 @@ import type { SettingsMeasurement } from './settings/SettingsPanel'; import AddProjectIndexDialog from './projects/AddProjectIndexDialog'; import ProjectSwitcher from './projects/ProjectSwitcher'; import type { ProjectsSource } from './projects/ProjectsPanel'; -import { ProjectWindows, type ProjectWindow } from './app/project-windows'; +import { ProjectWindows, useParamInAddress, type ProjectWindow } from './app/project-windows'; import { MODEL_SUGGESTIONS, fetchCommand } from './settings/model-catalog'; import { modelKey, readModelPreference, recordModelPreference } from './settings/model-preference'; import { @@ -758,10 +758,12 @@ interface WindowCarry { /** * Jedes Projekt bekommt ein frisches Fenster in derselben Seite (K24, Kopf von * src/app/project-windows.tsx). Der Agent reicht beim Wechsel sein geladenes - * Modell an das Fenster des neuen Projekts weiter. + * Modell an das Fenster des neuen Projekts weiter. Die Adresse behaelt dabei + * den Arbeitsbereich und die Grenzen des Walks, nichts vom alten Projekt (A5). */ +const PAGE_PARAMS = ['workspace', CLOSURE_DEPTH_PARAM, CLOSURE_CAP_PARAM] as const; export default function App(): JSX.Element { - return around={switchKeepingAgent}> + return around={switchKeepingAgent} keepParams={PAGE_PARAMS}> {shown => } ; } @@ -783,11 +785,10 @@ function ProjectWindowView({ projectWindow }: { projectWindow: ProjectWindow { try { return localStorage.getItem('cbm.workspace.setup') !== 'done'; } catch { return false; } }); diff --git a/graph-ui/src/app/project-windows.test.tsx b/graph-ui/src/app/project-windows.test.tsx index 9580aa5f7..89de6c002 100644 --- a/graph-ui/src/app/project-windows.test.tsx +++ b/graph-ui/src/app/project-windows.test.tsx @@ -2,7 +2,7 @@ import { act, useEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ProjectWindows, type ProjectWindow } from './project-windows'; +import { ProjectWindows, useParamInAddress, type ProjectWindow } from './project-windows'; /* * K24: a project switch stays in the page. Each project gets a fresh window (fresh state in @@ -77,6 +77,34 @@ describe('an in-page project switch (K24)', () => { expect(mounted).toHaveLength(3); }); + /* Hand test 2026-10-04 (A5): the switcher turned "?project=django-demo&workspace=galaxy" into "?project=cbm". */ + it('keeps the page-wide parameters it is given, the workspace among them, and nothing project specific', async () => { + window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py'); + await act(async () => root.render( keepParams={['workspace', 'codeatlasClosureDepth', 'codeatlasClosureCap']}> + {shown => })); + await act(async () => current!.open('atlas sample')); + expect(window.location.search).toBe('?project=atlas%20sample&workspace=galaxy&codeatlasClosureDepth=4'); + expect(shownProject()).toBe('atlas sample'); + // Back and Forward of the browser still switch between both projects, each with its own address. + const popped = () => new Promise(resolve => window.addEventListener('popstate', () => resolve(), { once: true })); + await act(async () => { const done = popped(); window.history.back(); await done; }); + expect(window.location.search).toBe('?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py'); + expect(shownProject()).toBe('django-demo'); + await act(async () => { const done = popped(); window.history.forward(); await done; }); + expect(shownProject()).toBe('atlas sample'); + }); + + it('writes the workspace in view into the address without a new history entry', async () => { + window.history.replaceState(null, '', '/?project=django-demo'); + const length = window.history.length; + function Workspace({ name }: { name: string }) { useParamInAddress('workspace', name); return null; } + await act(async () => root.render()); + expect(window.location.search).toBe('?project=django-demo&workspace=architecture'); + await act(async () => root.render()); + expect(window.location.search).toBe('?project=django-demo&workspace=galaxy'); + expect(window.history.length).toBe(length); + }); + it('commits the switch inside the frame it is given, so the old window is gone and the new one there when it ends', async () => { const seen: string[] = []; const around = (commit: () => void) => { seen.push(`before:${mounted.join(',')}`); commit(); seen.push(`after:${mounted.join(',')}|${unmounted.join(',')}`); }; diff --git a/graph-ui/src/app/project-windows.tsx b/graph-ui/src/app/project-windows.tsx index 5669e8165..73d1175af 100644 --- a/graph-ui/src/app/project-windows.tsx +++ b/graph-ui/src/app/project-windows.tsx @@ -33,6 +33,30 @@ export function projectOfLocation(search = window.location.search): string { return new URLSearchParams(search).get('project') ?? ''; } +/* + * Handtest vom 2026-10-04 (A5): der Wechsel machte aus + * "?project=django-demo&workspace=galaxy" die Adresse "?project=cbm", und ein + * Neuladen danach konnte einen anderen Arbeitsbereich zeigen. Die Adresse des + * neuen Projekts behaelt nun die Parameter der ganzen Seite, die der Aufrufer + * nennt (den Arbeitsbereich, die Grenzen des Walks), und nichts, was zum alten + * Projekt gehoert. + */ +export function projectSwitchHref(project: string, search: string, keep: readonly string[]): string { + const from = new URLSearchParams(search); + const kept = keep.flatMap(name => { const value = from.get(name); return value === null ? [] : [`&${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; }); + return `${projectHref(project)}${kept.join('')}`; +} + +/** Haelt einen Parameter der Adresse auf dem Wert, den die Seite zeigt, ohne neuen Eintrag im Verlauf (A5: der Arbeitsbereich). */ +export function useParamInAddress(name: string, value: string): void { + useEffect(() => { + const url = new URL(window.location.href); + if (url.searchParams.get(name) === value) return; + url.searchParams.set(name, value); + window.history.replaceState(window.history.state, '', url); + }, [name, value]); +} + export interface ProjectWindow { /** Wechselt mit jedem Projekt; als React-Schluessel des Fensters gedacht. */ key: number; @@ -50,9 +74,11 @@ export interface ProjectWindow { interface Shown { key: number; project: string; controller: AbortController; carried?: C } -export function ProjectWindows({ children, around = commit => commit() }: { +export function ProjectWindows({ children, around = commit => commit(), keepParams = [] }: { children: (window: ProjectWindow) => ReactNode; around?: (commit: () => void) => void; + /** Parameter der ganzen Seite, die ein Wechsel in die neue Adresse mitnimmt (A5). */ + keepParams?: readonly string[]; }): JSX.Element { const [shown, setShown] = useState>(() => ({ key: 0, project: projectOfLocation(), controller: new AbortController() })); const current = useRef(shown); current.current = shown; @@ -65,9 +91,10 @@ export function ProjectWindows({ children, around = commit => commit() }: { around(() => flushSync(() => setShown(next))); previous.controller.abort(); }, [around]); + const keep = useRef(keepParams); keep.current = keepParams; const open = useCallback((project: string) => { if (project === current.current.project) return; - window.history.pushState(null, '', projectHref(project)); + window.history.pushState(null, '', projectSwitchHref(project, window.location.search, keep.current)); show(project); }, [show]); const report = useCallback((value: C) => { carry.current = value; }, []); diff --git a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx index 4791f2c4c..fc9d60dd4 100644 --- a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx +++ b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx @@ -9,6 +9,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ArchitecturePanel, { type ArchitecturePanelProps } from './ArchitecturePanel'; +import { fitToolbar } from '../galaxy/toolbar-fit'; import type { ArchitectureSceneProps } from './ArchitectureScene'; import type { ArchitectureOverviewDto } from '../core/intelligence-provider'; import type { GraphData, GraphNode } from '../galaxy/types'; @@ -126,8 +127,8 @@ const filter = () => container.querySelector('input[type="sear const focus = () => container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-focus'); const heading = () => container.querySelector('.behavior-heading h2')?.textContent; const sceneLabels = () => [...container.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent); -/** The "← Back" System structure and Behavior had in their own toolbars before K27. */ -const inViewBack = () => buttons().find(button => button.textContent === '← Back'); +/** The "← Back" System structure and Behavior had in their own toolbars before K27; the shared one carries the same words. */ +const inViewBack = () => buttons().find(button => button.textContent === '← Back' && !history()?.contains(button)); const systemCamera = () => container.querySelector('[aria-label="System camera"] button[aria-pressed="true"]')?.textContent; const behaviorCamera = () => container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent; const chainPosition = () => container.querySelector('[aria-label="Walk the call chain"] span')?.textContent; @@ -495,3 +496,61 @@ describe('Architecture back and forward (K27)', () => { expect(container.querySelector('details.atlas-arch-recent')).toBeNull(); }); }); + +/* + * Hand test 2026-10-04: Back, Forward and Recent stood as bare arrows at the right end of the subtab row, + * while Galaxy has "← Back", "Forward →" and "▾" at the start of its toolbar (A1), and the Recent menu stayed + * open over the content through several steps (A2). + */ +describe('Architecture history controls as in Galaxy (hand test 2026-10-04)', () => { + const recentMenu = () => container.querySelector('details.atlas-arch-recent'); + async function withRecent(): Promise { + await render(); + await press(container.querySelector('[data-view="routes"]')); + await press(container.querySelector('[data-view="hotspots"]')); + const menu = recentMenu(); + expect(menu).not.toBeNull(); + await act(async () => { menu!.open = true; }); + return menu!; + } + + it('A1: puts Back, Forward and Recent at the start of the subtab row, worded as in Galaxy', async () => { + await render(); + await press(container.querySelector('[data-view="routes"]')); + const row = container.querySelector('.atlas-arch-tabrow')!; + expect([...row.children].map(item => item.getAttribute('aria-label'))).toEqual(['Architecture history', 'Architecture views']); + expect(back()?.textContent).toBe('← Back'); + expect(back()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('←'); + expect(forward()?.textContent).toBe('Forward →'); + expect(forward()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('→'); + expect(back()?.title).toBe('Back to Overview (Alt+Left)'); + expect(recentMenu()?.querySelector('summary')?.textContent).toBe('▾'); + // The row measures itself like Galaxy's toolbar: words while the tabs fit, the glyphs once they would scroll. + expect(row.dataset.fit).toBe('full'); + const tabs = row.querySelector('nav')!; + Object.defineProperties(tabs, { clientWidth: { configurable: true, value: 300 }, scrollWidth: { configurable: true, value: 520 } }); + expect(fitToolbar(row)).toBe('compact'); + }); + + it('A2: closes the Recent menu on a press elsewhere and on Escape, which stays with the menu', async () => { + const menu = await withRecent(); + await act(async () => { container.querySelector('.atlas-arch-tabs')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); }); + expect(menu.open).toBe(false); + await act(async () => { menu.open = true; }); + expect(key({ key: 'Escape' }, menu.querySelector('summary')!).defaultPrevented).toBe(true); + expect(menu.open).toBe(false); + expect(view()).toBe('hotspots'); + }); + + it('A2: closes the Recent menu when Back or Alt+Right change the place', async () => { + const menu = await withRecent(); + await goBack(); + expect(view()).toBe('routes'); + expect(menu.open).toBe(false); + await act(async () => { menu.open = true; }); + expect(key({ key: 'ArrowRight', altKey: true }).defaultPrevented).toBe(true); + await settle(); + expect(view()).toBe('hotspots'); + expect(menu.open).toBe(false); + }); +}); diff --git a/graph-ui/src/architecture/ArchitecturePanel.tsx b/graph-ui/src/architecture/ArchitecturePanel.tsx index f91103022..5eb740d3e 100644 --- a/graph-ui/src/architecture/ArchitecturePanel.tsx +++ b/graph-ui/src/architecture/ArchitecturePanel.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { JSX, ReactNode } from 'react'; import type { ArchitectureHotspot, ArchitectureOverviewDto } from '../core/intelligence-provider'; import { @@ -7,6 +7,8 @@ import { import type { ArchitectureView, ConfigStorage } from './architecture-model'; import { architectureHistoryText as historyText, architectureText as text } from './strings'; import { peekNavigation, type NavigationHistory } from '../graph/navigation-history'; +import { useDismissibleMenu } from '../graph/use-dismissible-menu'; +import { FitLabel, useToolbarFit } from '../galaxy/toolbar-fit'; import { architectureEntryDetail, architectureEntryLabel, architectureEntryName, architectureHistoryOptions, initialArchitecturePlace, type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SpatialPlace, type SystemPlace, @@ -125,6 +127,9 @@ const paletteStyle = scenePaletteStyle(); * Back, Forward and the Recent list for the whole workspace (K27). The * tooltips name the target ("Back to Overview · django"); the list holds the * last distinct places, newest first, and the current one is not a target. + * They stand at the start of the subtab row and read as in Galaxy, "← Back", + * "Forward →" and "▾", with the glyphs alone once the tabs would scroll + * (hand test 2026-10-04, A1). The menu closes as Galaxy's does (A2). */ function HistoryControls({ history, place, onGo, onJump }: { history: NavigationHistory; place: ArchitectureHistoryEntry; @@ -132,23 +137,23 @@ function HistoryControls({ history, place, onGo, onJump }: { }): JSX.Element { const back = peekNavigation(history, -1), forward = peekNavigation(history, 1); const here = architectureHistoryOptions.recentKey?.(place); + const menu = useDismissibleMenu(architectureHistoryOptions.key(place)); return
+ title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}> + - {history.recent.length > 1 &&
{ - if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; } - }}> + title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}> + + {history.recent.length > 1 &&
{historyText.recentGlyph}
    {history.recent.map(entry => { const id = architectureHistoryOptions.recentKey?.(entry); const current = id === here; const detail = architectureEntryDetail(entry); return
  • -
  • ; @@ -164,7 +169,7 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error, * route perspective and filter, System structure focus and groups, Behavior * start, destination and position, and Plan or 3D of each scene. The views * below read their part of it and report changes back, so Back and Forward - * restore every part at once; the one Back for all of them sits beside the + * restore every part at once; the one Back for all of them sits before the * subtabs. A filter is a search for this visit: one saved by an earlier * session would silently hide routes, so only the subtab is read back. */ @@ -172,6 +177,9 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error, active, escapeTaken, onRestore: (from, to) => { if (from.view !== to.view) onSelectionEvidence?.(undefined); }, }); const { place, navigate } = navigation; + // The row measures itself as Galaxy's toolbar does (toolbar-fit.tsx): it counts as narrow once the tabs would scroll. + const tabRow = useRef(null); + useToolbarFit(tabRow, true); useEffect(() => { saveArchitectureConfig(storage, projectName, { view: place.view, filter: place.filter }); }, [place.view, place.filter, projectName, storage]); const setView = useCallback((view: ArchitectureView) => navigate(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view })), [navigate]); const changeSpatial = useCallback>((change, automatic) => @@ -198,11 +206,11 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error, } : undefined; // Every scene below draws with the same palette; the DOM reads it as CSS variables. return
    -
    -
    }> { expect(compact.edges.map(edge => edge.pathEdge!.id)).toEqual([14, 15]); }); }); + +/* Hand test 2026-10-04 (A4): the "Start" list had no recognizable order, and "handle · …/loaddata.py" could not be found. */ +describe('Behavior start field', () => { + const LOADDATA = 'django/core/management/commands/loaddata.py'; + const entries = [symbol(1), { ...symbol(21), name: 'handle', file_path: 'django/core/management/commands/makemigrations.py' }, + { ...symbol(22), name: 'handle', file_path: LOADDATA }, { ...symbol(23), name: 'as_sql', file_path: 'django/db/models/fields/json.py' }]; + const select = () => container.querySelector('select[aria-label="Behavior entry point"]')!; + const filterField = () => container.querySelector('input[aria-label="Filter operations"]')!; + const group = (index: number) => [...select().querySelectorAll('optgroup')][index]; + const options = (index: number) => [...group(index).querySelectorAll('option')].map(option => option.textContent); + async function type(value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(filterField(), value); + filterField().dispatchEvent(new Event('input', { bubbles: true })); + }); + } + + it('A4: offers the suggestions first, then every operation alphabetically, behind a filter field', async () => { + await render(fixture(), { entries, suggestedEntries: [1, 22] }); + expect(filterField().placeholder).toBe('Filter operations…'); + expect(filterField().compareDocumentPosition(select()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect([...select().querySelectorAll('optgroup')].map(item => item.label)).toEqual(['Suggested', 'All operations · 4']); + expect(options(0)).toEqual(['operation1 · src/part1.ts', `handle · ${LOADDATA}`]); + expect(options(1)).toEqual(['as_sql · django/db/models/fields/json.py', `handle · ${LOADDATA}`, + 'handle · django/core/management/commands/makemigrations.py', 'operation1 · src/part1.ts']); + expect(select().value).toBe('1'); + expect(select().selectedOptions[0]?.textContent).toBe('operation1 · src/part1.ts'); + }); + + it('A4: narrows the list to what the filter names, keeps the chosen start, and starts the operation picked there', async () => { + const props = await render(fixture(), { entries, suggestedEntries: [1, 21] }); + await type('loaddata'); + expect(group(1).label).toBe('Matching operations · 1 of 4'); + expect(options(1)).toEqual([`handle · ${LOADDATA}`, 'operation1 · src/part1.ts']); + expect(options(0)).toEqual(['operation1 · src/part1.ts']); + expect(select().value).toBe('1'); + await act(async () => { select().value = '22'; select().dispatchEvent(new Event('change', { bubbles: true })); }); + expect(props.onRequest).toHaveBeenLastCalledWith(expect.objectContaining({ id: 22, file_path: LOADDATA }), undefined, {}); + await type('no such operation'); + expect(options(1)).toEqual(['operation1 · src/part1.ts', 'No operation matches "no such operation"']); + expect(group(1).querySelector('option:last-child')?.hasAttribute('disabled')).toBe(true); + }); +}); diff --git a/graph-ui/src/architecture/BehaviorJourney.tsx b/graph-ui/src/architecture/BehaviorJourney.tsx index 2437c444b..dba8a15dc 100644 --- a/graph-ui/src/architecture/BehaviorJourney.tsx +++ b/graph-ui/src/architecture/BehaviorJourney.tsx @@ -7,8 +7,12 @@ import { useLiftedPlace, useOnIdentityChange } from './lifted-place'; import type { SystemProjection, SystemSymbol } from './system-architecture-source'; import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence'; import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; +import { RefreshControl, type RefreshFeedback } from './refresh-feedback'; +import { operationChoices } from './operation-choices'; import './behavior-journey.css'; +const startText = text.behaviorStart; + const Scene = lazy(() => import('./SystemArchitectureScene')); export interface BehaviorJourneyProps { project: string; generation?: string; data?: SystemProjection; entries: SystemSymbol[]; @@ -28,6 +32,10 @@ export interface BehaviorJourneyProps { onClearSelection?: () => void; onSelectionEvidence?: SelectionEvidenceListener; onRefresh: () => void; onSelectSymbol: (symbol: SystemSymbol) => void; + /** What the last Refresh did, shown beside its button (hand test 2026-10-04, A3). */ + refresh?: RefreshFeedback; + /** The automatic start and the ranked suggestions, in their order, offered first in "Start" (hand test 2026-10-04, A4). */ + suggestedEntries?: number[]; onNavigate: (path: string, line?: number, name?: string) => void; } @@ -53,7 +61,7 @@ export function journeyPage(scene: SystemSceneModel, step: number, choices = fal } export default function BehaviorJourney({ project, generation, data, entries, targets, entryId, targetId, active, pending, error, filter, - onRequest, onRefresh, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) { + onRequest, onRefresh, refresh, suggestedEntries, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) { const [place, changePlace] = useLiftedPlace(liftedPlace, onPlace, () => ({})); const { path: pathIndex = 0, step = 0, page: branchPage = 0, planar = false } = place; const [overview, setOverview] = useState(false); @@ -94,7 +102,10 @@ export default function BehaviorJourney({ project, generation, data, entries, ta const targetOptions = [...new Map([...targets, ...(journey?.choices ?? []).map(item => ({ ...item, distance: item.distance ?? 1 }))] .filter(item => item.id !== (entryId ?? journey?.entry?.id)).map(item => [item.id, item])).values()]; const entry = journey?.entry ?? entries.find(item => item.id === entryId); - const availableEntries = entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries; + const availableEntries = useMemo(() => entry && !entries.some(item => item.id === entry.id) ? [entry, ...entries] : entries, [entry, entries]); + const [startQuery, setStartQuery] = useState(''); + const startChoices = useMemo(() => operationChoices(availableEntries, { suggested: suggestedEntries, query: startQuery, keep: entry?.id }), + [availableEntries, suggestedEntries, startQuery, entry?.id]); const reportShown = useRef(onShownStart); reportShown.current = onShownStart; const shownStart = entryId === undefined && !pending ? entry?.name : undefined; @@ -160,12 +171,22 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
    Behavior · source-guided exploration

    {targetId !== undefined ? `${entry?.name ?? 'Operation'} → ${selectedTarget?.name ?? 'destination'}` : `What can ${entry?.name ?? 'this operation'} call?`}

    {path ? 'Follow one recorded call chain across the parts it touches.' : 'Choose a starting operation. Explore its calls, or follow a path to a destination.'}

    -
    +
    - {/* The field names the operation the journey shows, also when the projection chose it. */} - + {/* + * The field names the operation the journey shows, also when the projection chose it. Suggestions come + * first, then every operation alphabetically; the filter before it narrows both (hand test 2026-10-04, A4). + */} + diff --git a/graph-ui/src/architecture/ContainerMap.test.tsx b/graph-ui/src/architecture/ContainerMap.test.tsx index 39f1ad1d3..7a02a6031 100644 --- a/graph-ui/src/architecture/ContainerMap.test.tsx +++ b/graph-ui/src/architecture/ContainerMap.test.tsx @@ -75,3 +75,41 @@ describe('service map interactions', () => { expect(loadContainerTopology).not.toHaveBeenCalled(); expect(loadContainerInventory).not.toHaveBeenCalled(); }); }); + +/* Hand test 2026-10-04 (A3): a Refresh that reads everything again says so, and says what it found. */ +describe('service map refresh feedback', () => { + const refresh = () => [...container.querySelectorAll('button')].find(element => /^Refresh(ing…)?$/.test(element.textContent ?? ''))!; + const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent; + afterEach(() => { vi.useRealTimers(); }); + + it('A3: reads the declared services again, busy meanwhile, then names the time and whether anything changed', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 9, 4, 9, 5, 7)); + await act(async () => root.render()); + let finish!: (value: ContainerReading) => void; + vi.mocked(loadContainerTopology).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await act(async () => refresh().click()); + expect(refresh().textContent).toBe('Refreshing…'); + expect(refresh().getAttribute('aria-disabled')).toBe('true'); + await act(async () => finish(sample())); + expect(refresh().textContent).toBe('Refresh'); + expect(status()).toBe('Up to date at 09:05:07: no changes since the last load'); + const changed = sample(); + changed.topology.services.pop(); + vi.mocked(loadContainerTopology).mockResolvedValueOnce(changed); + await act(async () => refresh().click()); + expect(status()).toBe('Service map refreshed at 09:05:07'); + }); + + it('A3: also answers for a project without deployment files', async () => { + vi.mocked(loadContainerInventory).mockResolvedValue({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] }); + await act(async () => root.render()); + let finish!: (value: Awaited>) => void; + vi.mocked(loadContainerInventory).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await act(async () => refresh().click()); + expect(refresh().textContent).toBe('Refreshing…'); + await act(async () => finish({ project: 'p', rootPath: '/p', files: new Map(), manifests: [], warnings: [] })); + expect(refresh().textContent).toBe('Refresh'); + expect(status()).toMatch(/^Up to date at \d\d:\d\d:\d\d: no changes since the last load$/); + }); +}); diff --git a/graph-ui/src/architecture/ContainerMap.tsx b/graph-ui/src/architecture/ContainerMap.tsx index 8d67d33b4..65685f488 100644 --- a/graph-ui/src/architecture/ContainerMap.tsx +++ b/graph-ui/src/architecture/ContainerMap.tsx @@ -7,6 +7,7 @@ import { loadContainerInventory, loadContainerTopology, type ContainerReading, t import type { ServiceEvidence } from './container-topology'; import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; import { architectureText as text } from './strings'; +import { RefreshControl, useRefreshFeedback } from './refresh-feedback'; import './spatial-architecture.css'; import './container-map.css'; @@ -32,6 +33,8 @@ export default function ContainerMap({ project, generation, active, filter, onNa const [available, setAvailable] = useState([]); const [selections, setSelections] = useState([]); const [discovery, setDiscovery] = useState('reading'); + /** The `discoveryRevision` the current `discovery` answers, so a refresh can tell its own answer from the earlier one. */ + const [discovered, setDiscovered] = useState(); const [reading, setReading] = useState<{ key: string; result?: ContainerReading; error?: string }>(); const [revision, setRevision] = useState(0); const [discoveryRevision, setDiscoveryRevision] = useState(0); @@ -55,12 +58,21 @@ export default function ContainerMap({ project, generation, active, filter, onNa const inventory = await loadContainerInventory(current, client); if (controller.signal.aborted) return; setAvailable(projects); setSelections([{ inventory, manifest: inventory.manifests[0] ?? '' }]); - setDiscovery(inventory.manifests.length ? undefined : 'none'); - }).catch(() => { if (!controller.signal.aborted) setDiscovery('failed'); }); + setDiscovery(inventory.manifests.length ? undefined : 'none'); setDiscovered(discoveryRevision); + }).catch(() => { if (!controller.signal.aborted) { setDiscovery('failed'); setDiscovered(discoveryRevision); } }); return () => controller.abort(); }, [project, active, generation, discoveryRevision]); const selectionKey = JSON.stringify(selections.map(item => [item.inventory.project, item.manifest])); const key = `${project}:${generation ?? ''}:${selectionKey}:${revision}`; + // Settled once the inventory answered this revision and, when a Compose file is chosen, its services were read. + const readsTopology = selections.some(item => item.manifest); + const topologyReading = reading?.key === key ? reading : undefined; + const shownTopology = topologyReading?.result?.topology; + const shown = useMemo(() => [discovery, shownTopology], [discovery, shownTopology]); + const refresh = useRefreshFeedback({ key: `${key}:${discoveryRevision}`, + settled: discovered === discoveryRevision && discovery !== 'reading' && (!readsTopology || Boolean(topologyReading?.result || topologyReading?.error)), + value: shown, + error: discovery === 'failed' ? text.serviceMap.failed : topologyReading?.error }); useEffect(() => { if (!active || !selections.length || selections[0].inventory.project !== project || !selections.some(item => item.manifest)) return; const controller = new AbortController(), client = new RpcIntelligenceClient({ signal: controller.signal }); @@ -137,10 +149,11 @@ export default function ContainerMap({ project, generation, active, filter, onNa
    Routes / Services

    How the services connect

    One square per declared service. Follow a connection to see the configuration and code behind it.

    - { + refresh.begin(); setRevision(value => value + 1); if (!selections.length || !selections[0].inventory.manifests.length) setDiscoveryRevision(value => value + 1); - }}>Refresh
    + }} />
    {selections.map((selection, index) => } {view === 'hotspots' && hotspotArea && } - {view === 'routes' && } + {view === 'routes' && { routeRefresh.begin(); setRouteRevision(value => value + 1); }} />} {view === 'routes' && } {filePath && (view === 'overview' || view === 'dependencies') && } {(view === 'overview' || view === 'dependencies') &&
    diff --git a/graph-ui/src/architecture/SystemArchitecture.test.tsx b/graph-ui/src/architecture/SystemArchitecture.test.tsx index edbe55504..a19f79dc8 100644 --- a/graph-ui/src/architecture/SystemArchitecture.test.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.test.tsx @@ -561,8 +561,10 @@ describe('system architecture workspace', () => { expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample'); expect(loader.mock.calls.at(-1)?.[0]).toEqual({ project: 'sample', entryNodeId: 36163, expectedGeneration: 'g1', includeBehaviorEvidence: true }); const start = container.querySelector('[aria-label="Behavior entry point"]')!; - expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…', 'run_tests · tests/runtests.py', - 'handle · django/core/management/commands/runserver.py']); + // Alphabetical (hand test 2026-10-04, A4); with every operation suggested a Suggested group would only repeat the list. + expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…', + 'handle · django/core/management/commands/runserver.py', 'run_tests · tests/runtests.py']); + expect(start.querySelectorAll('optgroup')).toHaveLength(1); expect(start.value).toBe('36163'); const state = container.querySelector('.behavior-loading[role="status"]')!; expect(state.textContent).toContain('The analysis returned no call evidence.'); @@ -570,7 +572,9 @@ describe('system architecture workspace', () => { expect(state.textContent).toContain('Optional behavior evidence was omitted entirely'); await choose('Behavior entry point', '9'); expect(loader.mock.calls.at(-1)?.[0].entryNodeId).toBe(9); - expect([...start.options].map(option => option.value)).toEqual(['', '9', '36163']); + // The chosen start keeps its place in the alphabetical list. + expect([...start.options].map(option => option.value)).toEqual(['', '36163', '9']); + expect(start.value).toBe('9'); }); it('does not ask for flows once the projection classifies enough entry points', async () => { const flowsLoader = vi.fn().mockResolvedValue([]); @@ -586,8 +590,46 @@ describe('system architecture workspace', () => { await render(vi.fn().mockResolvedValue(response()), { view: 'behavior', flowsLoader }); expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample'); const start = container.querySelector('[aria-label="Behavior entry point"]')!; - // The two classified entry points come first; the flow that repeats one of them is not listed twice. - expect([...start.options].map(option => option.value)).toEqual(['', '1', '2', '36163']); + // Every operation alphabetically (hand test 2026-10-04, A4), the flow that repeats a classified entry point once. + // The journey's start and the two ranked flows would suggest all three, so no Suggested group repeats them. + expect([...start.querySelectorAll('optgroup')].map(group => [group.label, [...group.querySelectorAll('option')].map(option => option.value)])) + .toEqual([['All operations · 3', ['36163', '2', '1']]]); + }); + it('A4: suggests the automatic start and the top ranked flows, and names look-alike flow starts by their class', async () => { + const MODELS = 'django/db/migrations/operations/models.py'; + const flow = (id: number, name: string, filePath: string) => ({ id, label: name, entry: { id, name, filePath }, terminal: { id: 1, name: 'x' }, steps: 3 }); + const flowsLoader = vi.fn().mockResolvedValue([flow(41, 'database_backwards', MODELS), flow(42, 'database_backwards', MODELS), + flow(43, 'handle', 'django/core/management/commands/loaddata.py'), flow(44, 'b4', 'b.py'), flow(45, 'b5', 'b.py'), flow(46, 'b6', 'b.py')]); + const operationNames = vi.fn(async (_project: string, entries: SystemSymbol[]) => entries.map(entry => entry.id === 41 + ? { ...entry, qualified_name: 'p.ops.models.CreateModel.database_backwards' } : entry.id === 42 ? { ...entry, qualified_name: 'p.ops.models.DeleteModel.database_backwards' } : entry)); + const data = overviewFixture(); + data.entrypoints = [{ ...data.entrypoints[0], name: 'main' }, data.entrypoints[1]]; + await render(vi.fn().mockResolvedValue(response(data)), { view: 'behavior', flowsLoader, operationNames }); + expect(operationNames).toHaveBeenCalledOnce(); + expect(operationNames.mock.calls[0]?.[1].map(entry => entry.id)).toEqual([41, 42, 43, 44, 45, 46]); + const start = container.querySelector('[aria-label="Behavior entry point"]')!; + const suggested = [...start.querySelectorAll('optgroup')[0]!.querySelectorAll('option')].map(option => option.textContent); + // The automatic start, then the first five ranked flows in their order. + expect(suggested).toEqual(['main · src/api.ts', `database_backwards (CreateModel) · ${MODELS}`, `database_backwards (DeleteModel) · ${MODELS}`, + 'handle · django/core/management/commands/loaddata.py', 'b4 · b.py', 'b5 · b.py']); + expect(start.selectedOptions[0]?.textContent).toBe('main · src/api.ts'); + }); + it('A4: suggests the start the projection chose itself, and keeps suggesting it after another start is picked', async () => { + // django-demo: "main · …/manage.py-tpl" is no conventional entry for the page, the projection picks it on its own. + const flow = (id: number, name: string, filePath: string) => ({ id, label: name, entry: { id, name, filePath }, terminal: { id: 1, name: 'x' }, steps: 3 }); + const flowsLoader = vi.fn().mockResolvedValue([flow(43, 'handle', 'loaddata.py'), flow(44, 'b4', 'b.py')]); + const data = overviewFixture(); + data.entrypoints = [{ ...data.entrypoints[0], name: 'main', file_path: 'conf/manage.py-tpl' }, data.entrypoints[1]]; + // No operation requested: the server names none (0), and the journey starts at the first classified entry point. + data.behavior = { ...data.behavior!, source_id: 0 }; + await render(vi.fn().mockResolvedValue(response(data)), { view: 'behavior', flowsLoader }); + const start = container.querySelector('[aria-label="Behavior entry point"]')!; + const suggested = () => [...start.querySelectorAll('optgroup')[0]!.querySelectorAll('option')].map(option => option.value); + expect(start.value).toBe('1'); + expect(suggested()).toEqual(['1', '43', '44']); + await choose('Behavior entry point', '43'); + expect(start.value).toBe('43'); + expect(suggested()).toEqual(['1', '43', '44']); }); it('shows failures without retrying continuously', async () => { vi.useFakeTimers(); const loader = vi.fn().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'Analysis budget exceeded.' }); @@ -595,3 +637,49 @@ describe('system architecture workspace', () => { expect(container.querySelector('[role="alert"]')?.textContent).toContain('Analysis budget exceeded'); expect(loader).toHaveBeenCalledTimes(1); }); }); + +/* Hand test 2026-10-04 (A3): "Refresh analysis" and the Behavior "Refresh" read again and said nothing. */ +describe('system architecture refresh feedback', () => { + const refresh = (pattern: RegExp) => [...container.querySelectorAll('button')].find(element => pattern.test(element.textContent ?? ''))!; + const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent; + afterEach(() => { vi.useRealTimers(); }); + + it('A3: Refresh analysis is busy while it runs, then names the time and whether the index changed', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 9, 4, 18, 0, 9)); + const loader = vi.fn().mockResolvedValue(response(overviewFixture())); + await render(loader); + let finish!: (value: SystemArchitectureResponse) => void; + loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await act(async () => refresh(/^Refresh(ing)? analysis/).click()); + expect(refresh(/^Refresh(ing)? analysis/).textContent).toBe('Refreshing analysis…'); + expect(refresh(/^Refresh(ing)? analysis/).getAttribute('aria-disabled')).toBe('true'); + // The structure stays on screen meanwhile. + expect(container.querySelector('[data-testid="system-scene"]')).not.toBeNull(); + await act(async () => finish(response(overviewFixture()))); + expect(refresh(/^Refresh(ing)? analysis/).textContent).toBe('Refresh analysis'); + expect(status()).toBe('Up to date at 18:00:09: no changes since the last load'); + loader.mockResolvedValueOnce({ ...response(overviewFixture()), generation: 'g2' }); + await act(async () => refresh(/^Refresh(ing)? analysis/).click()); + expect(status()).toBe('Analysis refreshed at 18:00:09'); + loader.mockRejectedValueOnce(new Error('Daemon stopped.')); + await act(async () => refresh(/^Refresh(ing)? analysis/).click()); + expect(status()).toBe('Refresh failed at 18:00:09: Daemon stopped.'); + }); + + it('A3: the Behavior Refresh says the same beside its button', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 9, 4, 18, 1, 0)); + const loader = vi.fn().mockResolvedValue(response(overviewFixture())); + await render(loader, { view: 'behavior' }); + const button = () => container.querySelector('.behavior-heading .atlas-arch-refresh button')!; + expect(button()?.textContent).toBe('Refresh'); + let finish!: (value: SystemArchitectureResponse) => void; + loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await act(async () => button().click()); + expect(button().textContent).toBe('Refreshing…'); + await act(async () => finish(response(overviewFixture()))); + expect(button().textContent).toBe('Refresh'); + expect(container.querySelector('.behavior-heading [role="status"]')?.textContent).toBe('Up to date at 18:01:00: no changes since the last load'); + }); +}); diff --git a/graph-ui/src/architecture/SystemArchitecture.tsx b/graph-ui/src/architecture/SystemArchitecture.tsx index 8a6afe080..c08b4fdb9 100644 --- a/graph-ui/src/architecture/SystemArchitecture.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.tsx @@ -8,8 +8,12 @@ import { connectionLoad } from '../graph/connection-load'; import { AtlasApi } from '../app/atlas-api'; import type { FlowSummary } from '../traces/trace-schemas'; import BehaviorJourney from './BehaviorJourney'; +import { defaultJourneyStart } from './behavior-journey-model'; import type { BehaviorPlace, JourneyPlace, PlaceChange, SystemPlace } from './architecture-history'; import { useLiftedPlace, useOnIdentityChange } from './lifted-place'; +import { RefreshControl, useRefreshFeedback } from './refresh-feedback'; +import { nameOperations } from './operation-choices'; +import { RpcIntelligenceClient } from '../provider/rpc-client'; import './system-architecture.css'; const Scene = lazy(() => import('./SystemArchitectureScene')); @@ -20,6 +24,8 @@ export interface SystemArchitectureProps { onNavigate: (path: string, line?: number, name?: string) => void; loader?: SystemArchitectureLoader; flowsLoader?: FlowsLoader; + /** Fills in the qualified names of look-alike flow starts, so "Start" can tell them apart (hand test 2026-10-04, A4). */ + operationNames?: OperationNamer; /** Focus, expanded groups, the Behavior start and position, and both cameras, lifted to the workspace for Back and Forward (K27). */ place?: SystemPlace; onPlace?: PlaceChange; @@ -27,8 +33,12 @@ export interface SystemArchitectureProps { /** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */ export type FlowsLoader = (project: string) => Promise; const loadFlows: FlowsLoader = project => new AtlasApi().flows(project); +export type OperationNamer = (project: string, entries: SystemSymbol[]) => Promise; +const nameFlowStarts: OperationNamer = (project, entries) => nameOperations(project, entries, new RpcIntelligenceClient()); /** Below this many classified entry points the Behavior start list also offers ranked flows. */ const FEW_ENTRY_POINTS = 8; +/** How many of the ranked flows "Start" suggests after the automatic start. */ +const RANKED_SUGGESTIONS = 5; /** Starting operations from flows, once each. The projection supplies qualified name and component on request. */ export function flowEntries(flows: FlowSummary[]): SystemSymbol[] { @@ -70,7 +80,7 @@ const connectionAllowed = (type: string, view: ConnectionView) => view === 'all' || (view === 'calls' ? ['CALLS', 'IMPORTS', 'HTTP_CALLS', 'ASYNC_CALLS'] : ['INHERITS', 'IMPLEMENTS']).includes(type); /** Poll only while this view is active. A new request key hides stale results before its effect runs. */ -export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, place: liftedPlace, onPlace }: SystemArchitectureProps) { +export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, operationNames = nameFlowStarts, place: liftedPlace, onPlace }: SystemArchitectureProps) { const [place, changePlace] = useLiftedPlace(liftedPlace, onPlace, () => ({ expanded: [] })); const entryChoice = place.behavior; /** A start the page picks itself (the suggestion, a reset) completes the current step instead of adding one. */ @@ -159,6 +169,9 @@ export default function SystemArchitecture({ project, generation, view, filter, }); const structureData = useMemo(() => data ? { ...data, dependencies: data.dependencies.filter(edge => connectionAllowed(edge.type, connectionView)) } : undefined, [data, connectionView]); const error = current?.error ?? (current?.response?.status === 'failed' ? current.response.error ?? 'Architecture analysis failed.' : undefined); + /* Refresh analysis and the Behavior Refresh say what they did (hand test 2026-10-04, A3). The same index answers with the same generation. */ + const refresh = useRefreshFeedback({ key: requestKey, settled: Boolean(current) && !queryPending, value: current?.response?.generation, error }); + const refreshAnalysis = () => { refresh.begin(); setRevision(value => value + 1); }; const chosenEntries = entryChoices?.project === project && entryChoices.generation === generation ? entryChoices.entries : data?.entrypoints ?? []; // With no or only a handful of classified entry points (limited projections, Python without main, a few // route handlers) the ranked flows add their starts after the classified ones. @@ -167,9 +180,11 @@ export default function SystemArchitecture({ project, generation, view, filter, useEffect(() => { if (!needsFlows || (flows?.project === project && flows.generation === generation)) return; let live = true; - void flowsLoader(project).then(flowEntries, () => []).then(entries => { if (live) setFlows({ project, generation, entries }); }); + // Look-alike flow starts get their qualified names first, so the field never relabels them later (A4). + void flowsLoader(project).then(flowEntries, () => []).then(entries => operationNames(project, entries).catch(() => entries)) + .then(entries => { if (live) setFlows({ project, generation, entries }); }); return () => { live = false; }; - }, [needsFlows, flows, project, generation, flowsLoader]); + }, [needsFlows, flows, project, generation, flowsLoader, operationNames]); const flowStarts = useMemo(() => flows?.project === project && flows.generation === generation ? flows.entries : [], [flows, project, generation]); const entries = useMemo(() => { if (!fewEntries || !flowStarts.length) return chosenEntries; @@ -177,11 +192,24 @@ export default function SystemArchitecture({ project, generation, view, filter, for (const item of flowStarts) if (!merged.has(item.id)) merged.set(item.id, item); return [...merged.values()]; }, [fewEntries, flowStarts, chosenEntries]); + const automaticStart = useMemo(() => data ? data.entrypoints.length ? suggestedBehaviorEntry(data.entrypoints) : suggestedBehaviorEntry(flowStarts, true) : undefined, [data, flowStarts]); useEffect(() => { if (view !== 'behavior' || !data || (entryChoice?.project === project && entryChoice.generation === generation)) return; - const suggested = data.entrypoints.length ? suggestedBehaviorEntry(data.entrypoints) : suggestedBehaviorEntry(flowStarts, true); - if (suggested) setEntryChoice({ project, generation, expectedGeneration: current?.response?.generation ?? snapshot?.analysisGeneration, id: suggested.id, name: suggested.name }, true); - }, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration, flowStarts]); + if (automaticStart) setEntryChoice({ project, generation, expectedGeneration: current?.response?.generation ?? snapshot?.analysisGeneration, id: automaticStart.id, name: automaticStart.name }, true); + }, [view, data, project, generation, entryChoice, current?.response?.generation, snapshot?.analysisGeneration, automaticStart]); + /* + * "Start" offers the automatic start first, then the best ranked flows in the server's order (hand test + * 2026-10-04, A4). Without a conventional entry the journey starts where the projection points + * (django-demo: "main · …/manage.py-tpl"); that start stays a suggestion after another one is chosen. + */ + const [projectionStart, setProjectionStart] = useState<{ project: string; generation?: string; id: number }>(); + const pickedByProjection = requestedEntry === undefined && view === 'behavior' && queryData ? defaultJourneyStart(queryData) : undefined; + useEffect(() => { + if (pickedByProjection !== undefined) setProjectionStart({ project, generation, id: pickedByProjection }); + }, [pickedByProjection, project, generation]); + const shownByProjection = projectionStart?.project === project && projectionStart.generation === generation ? projectionStart.id : undefined; + const suggestedStarts = useMemo(() => [...new Set([...automaticStart ? [automaticStart.id] : shownByProjection !== undefined ? [shownByProjection] : [], + ...flowStarts.slice(0, RANKED_SUGGESTIONS).map(item => item.id)])], [automaticStart, shownByProjection, flowStarts]); const returnedPaths = useMemo(() => { const valid = queryData?.paths.filter(path => isContiguousPath(path)) ?? []; if (queryData?.behavior && requestedTarget === undefined) return []; @@ -318,7 +346,7 @@ export default function SystemArchitecture({ project, generation, view, filter, }, automatic); const behaviorPage = view === 'behavior' ? setRevision(value => value + 1)} onNavigate={onNavigate} onSelectSymbol={selectSymbol} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence} + error={error} filter={filter} onRefresh={refreshAnalysis} refresh={refresh.feedback} suggestedEntries={suggestedStarts} onNavigate={onNavigate} onSelectSymbol={selectSymbol} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence} from={requestedEntry !== undefined ? entryChoice?.from : undefined} place={journeyPlace} onPlace={changeJourney} onShownStart={name => { if (name !== place.shown) changePlace({ shown: name }, true); }} onRequest={(entry, targetId, detail) => { @@ -332,7 +360,8 @@ export default function SystemArchitecture({ project, generation, view, filter, return

    {view === 'structure' ? 'Inspect the parts and the code that connects them.' : 'Follow a question through the same system map.'}

    - + { refreshAnalysis(); setResetKey(value => value + 1); }} />
    {view === 'structure' &&
    ; +} +async function setup(place = 'a') { + const pick = vi.fn(); + await act(async () => root.render()); + const details = host.querySelector('details')!; + await act(async () => { details.open = true; }); + return { pick, details }; +} +const fire = (target: EventTarget, type: string) => act(async () => { target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); }); + +it('closes on a pointer press outside the menu and stays open for one inside it', async () => { + const { details } = await setup(); + await fire(details.querySelector('summary')!, 'pointerdown'); + await fire(details.querySelector('li button')!, 'pointerdown'); + expect(details.open).toBe(true); + await fire(host.querySelector('p')!, 'pointerdown'); + expect(details.open).toBe(false); +}); + +it('closes when the page scrolls under it or focus moves elsewhere', async () => { + const { details } = await setup(); + await act(async () => { host.querySelector('p')!.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 })); }); + expect(details.open).toBe(false); + await act(async () => { details.open = true; }); + // The list scrolls itself without closing. + await act(async () => { details.querySelector('ul')!.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 })); }); + expect(details.open).toBe(true); + await act(async () => { host.querySelector('input')!.focus(); }); + expect(details.open).toBe(false); +}); + +it('closes on Escape, takes the key from the page and returns focus to its summary', async () => { + const { details } = await setup(); + const entry = details.querySelector('li button')!; + entry.focus(); + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + const page = vi.fn(); + window.addEventListener('keydown', page); + await act(async () => { entry.dispatchEvent(event); }); + window.removeEventListener('keydown', page); + expect(details.open).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(page).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(details.querySelector('summary')); + // Closed, Escape belongs to the page again. + const later = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + await act(async () => { document.body.dispatchEvent(later); }); + expect(later.defaultPrevented).toBe(false); +}); + +it('closes when an entry is chosen and whenever the place changes', async () => { + const { pick, details } = await setup(); + await act(async () => details.querySelector('li button')!.click()); + expect(pick).toHaveBeenCalledOnce(); + expect(details.open).toBe(false); + await act(async () => { details.open = true; }); + await act(async () => root.render()); + expect(details.open).toBe(true); + // Back, Forward, Alt+Left or any other step leads to another place. + await act(async () => root.render()); + expect(details.open).toBe(false); +}); diff --git a/graph-ui/src/graph/use-dismissible-menu.ts b/graph-ui/src/graph/use-dismissible-menu.ts new file mode 100644 index 000000000..6c0b961c1 --- /dev/null +++ b/graph-ui/src/graph/use-dismissible-menu.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * A `
    ` menu that closes once the reader is done with it, as the + * Recent menus of Galaxy and Architecture do (hand test 2026-10-04, A2: the + * menu stayed open over the content through several steps and while + * scrolling). + * + * It closes on a pointer press, a wheel turn or a focus outside it, on Escape + * (which then belongs to the menu alone: the page neither leaves its scope nor + * clears its selection), when the caller closes it after an entry was chosen, + * and whenever `place` changes: Back, Forward, Alt+Left and every other step + * lead somewhere else, and the list would name the wrong current place. + * + * The listeners stay on the document for the life of the menu and do nothing + * while it is closed, so opening it needs no React state and no render. + */ +export function useDismissibleMenu(place: string) { + const ref = useRef(null); + const close = useCallback(() => { + const menu = ref.current; + if (menu?.open) menu.open = false; + }, []); + useEffect(() => { + const outside = (event: Event) => { + const menu = ref.current; + if (menu?.open && !(event.target instanceof Node && menu.contains(event.target))) menu.open = false; + }; + const escape = (event: KeyboardEvent) => { + const menu = ref.current; + if (event.key !== 'Escape' || !menu?.open) return; + event.preventDefault(); + event.stopPropagation(); + const inside = document.activeElement instanceof Node && menu.contains(document.activeElement); + menu.open = false; + // An entry that had focus is hidden now; the summary keeps the reader in place. + if (inside) menu.querySelector('summary')?.focus(); + }; + document.addEventListener('pointerdown', outside, true); + document.addEventListener('wheel', outside, { capture: true, passive: true }); + document.addEventListener('focusin', outside, true); + document.addEventListener('keydown', escape, true); + return () => { + document.removeEventListener('pointerdown', outside, true); + document.removeEventListener('wheel', outside, { capture: true }); + document.removeEventListener('focusin', outside, true); + document.removeEventListener('keydown', escape, true); + }; + }, []); + const shown = useRef(place); + useEffect(() => { + if (shown.current === place) return; + shown.current = place; + close(); + }, [place, close]); + return { ref, close }; +} diff --git a/graph-ui/tools/handtest-fixes-k27.mjs b/graph-ui/tools/handtest-fixes-k27.mjs index bb82db99b..e6bb7d046 100644 --- a/graph-ui/tools/handtest-fixes-k27.mjs +++ b/graph-ui/tools/handtest-fixes-k27.mjs @@ -100,8 +100,11 @@ async function state(page) { behaviorCamera: text(root.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')), chain: text(root.querySelector('[aria-label="Walk the call chain"] span')), path: paths.length ? paths.findIndex((element) => element.getAttribute('aria-pressed') === 'true') + 1 : null, - /* Das "← Back", das System structure und Behavior frueher in der eigenen Leiste hatten. */ - inViewBack: [...root.querySelectorAll('button')].filter((element) => element.textContent?.trim() === '← Back').length, + /* + * Das "← Back", das System structure und Behavior frueher in der eigenen Leiste hatten. Das gemeinsame + * Zurueck traegt seit dem Handtest vom 2026-10-04 (A1) dieselben Worte wie in Galaxy und zaehlt nicht mit. + */ + inViewBack: [...root.querySelectorAll('button')].filter((element) => element.textContent?.trim() === '← Back' && !history?.contains(element)).length, back: button('Back'), forward: button('Forward'), position: history?.getAttribute('data-position') ?? null, recent: [...(history?.querySelectorAll('[aria-label="Recently visited places"] li button') ?? [])].map((element) => ({