Merge second hand test fixes: Architecture navigation, Recent menus, Refresh feedback, Behavior start list, workspace in the address (K40 to K44)

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz committed 2026-10-04 14:00:51 +02:00
commit 6002e391fe
26 files changed
+1056 -75

No files matched your search

+7 -6
View File
@@ -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 <ProjectWindows<WindowCarry> around={switchKeepingAgent}>
return <ProjectWindows<WindowCarry> around={switchKeepingAgent} keepParams={PAGE_PARAMS}>
{shown => <ProjectWindowView key={shown.key} projectWindow={shown} />}
</ProjectWindows>;
}
@@ -783,11 +785,10 @@ function ProjectWindowView({ projectWindow }: { projectWindow: ProjectWindow<Win
setProactiveSelection(undefined); setChatGraphSelection(undefined); setChatAttachment(undefined);
}
setWorkspace(value);
const url = new URL(window.location.href);
url.searchParams.set('workspace', value);
window.history.replaceState(window.history.state, '', url);
try { localStorage.setItem('cbm.workspace', value); } catch { /* Session-only preference. */ }
};
// The address always names the workspace in view, also one carried over a project switch, so a reload keeps it (A5).
useParamInAddress('workspace', workspace);
const [welcomeOpen, setWelcomeOpen] = useState(() => {
try { return localStorage.getItem('cbm.workspace.setup') !== 'done'; } catch { return false; }
});
+29 -1
View File
@@ -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(<ProjectWindows<Carry> keepParams={['workspace', 'codeatlasClosureDepth', 'codeatlasClosureCap']}>
{shown => <Window key={shown.key} window={shown} />}</ProjectWindows>));
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<void>(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(<Workspace name="architecture" />));
expect(window.location.search).toBe('?project=django-demo&workspace=architecture');
await act(async () => root.render(<Workspace name="galaxy" />));
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(',')}`); };
+29 -2
View File
@@ -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<C> {
/** Wechselt mit jedem Projekt; als React-Schluessel des Fensters gedacht. */
key: number;
@@ -50,9 +74,11 @@ export interface ProjectWindow<C> {
interface Shown<C> { key: number; project: string; controller: AbortController; carried?: C }
export function ProjectWindows<C>({ children, around = commit => commit() }: {
export function ProjectWindows<C>({ children, around = commit => commit(), keepParams = [] }: {
children: (window: ProjectWindow<C>) => 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<Shown<C>>(() => ({ key: 0, project: projectOfLocation(), controller: new AbortController() }));
const current = useRef(shown); current.current = shown;
@@ -65,9 +91,10 @@ export function ProjectWindows<C>({ 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; }, []);
@@ -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<HTMLInputElement>('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<HTMLDetailsElement>('details.atlas-arch-recent');
async function withRecent(): Promise<HTMLDetailsElement> {
await render();
await press(container.querySelector<HTMLButtonElement>('[data-view="routes"]'));
await press(container.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[data-view="routes"]'));
const row = container.querySelector<HTMLElement>('.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<HTMLElement>('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);
});
});
+21 -13
View File
@@ -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<ArchitectureHistoryEntry>; 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 <div className="atlas-arch-history" role="group" aria-label={historyText.group} data-position={`${history.index + 1}/${history.entries.length}`}>
<button type="button" aria-label={historyText.back} disabled={!back} onClick={() => onGo(-1)}
title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}>{historyText.backGlyph}</button>
title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}>
<FitLabel wide={historyText.backWide} narrow={historyText.backGlyph} /></button>
<button type="button" aria-label={historyText.forward} disabled={!forward} onClick={() => onGo(1)}
title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}>{historyText.forwardGlyph}</button>
{history.recent.length > 1 && <details className="atlas-arch-recent" onKeyDown={event => {
if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; }
}}>
title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}>
<FitLabel wide={historyText.forwardWide} narrow={historyText.forwardGlyph} /></button>
{history.recent.length > 1 && <details className="atlas-arch-recent" ref={menu.ref}>
<summary title={historyText.recentTitle} aria-label={historyText.recent}>{historyText.recentGlyph}</summary>
<ul className="atlas-arch-recent-menu" aria-label={historyText.recentList}>{history.recent.map(entry => {
const id = architectureHistoryOptions.recentKey?.(entry);
const current = id === here;
const detail = architectureEntryDetail(entry);
return <li key={id}>
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} title={architectureEntryLabel(entry)} onClick={event => {
const details = event.currentTarget.closest('details');
if (details) details.open = false;
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} title={architectureEntryLabel(entry)} onClick={() => {
menu.close();
onJump(entry);
}}><strong>{architectureEntryName(entry)}</strong>{detail && <span>{detail}</span>}</button>
</li>;
@@ -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<HTMLDivElement>(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<PlaceChange<SpatialPlace>>((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 <section className="atlas-architecture" data-testid="atlas-architecture" data-system-view={systemView} aria-label={text.title} aria-busy={!systemView && loading} style={paletteStyle}>
<div className="atlas-arch-tabrow">
<nav className="atlas-arch-tabs" aria-label={text.navigation}>{ARCHITECTURE_VIEWS.map(view =>
<div className="atlas-arch-tabrow" ref={tabRow}>
<HistoryControls history={navigation.history} place={place} onGo={navigation.go} onJump={navigation.jump} />
<nav className="atlas-arch-tabs" aria-label={text.navigation} data-fit-whole="">{ARCHITECTURE_VIEWS.map(view =>
<button className="atlas-arch-tab" key={view} aria-pressed={place.view === view || (view === 'overview' && ['dependencies', 'entryPoints'].includes(place.view))} data-view={view}
onClick={() => { if (view !== place.view) onSelectionEvidence?.(undefined); setView(view); }}>{text.views[view]}</button>)}</nav>
<HistoryControls history={navigation.history} place={place} onGo={navigation.go} onJump={navigation.jump} />
</div>
{systemView && projectName ? <Suspense fallback={<div role="status"><Empty>Preparing system analysis…</Empty></div>}><SystemArchitecture
project={projectName} generation={graphGeneration} view={systemView} filter={filter} active={active}
@@ -182,3 +182,46 @@ describe('behavior journeys', () => {
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<HTMLSelectElement>('select[aria-label="Behavior entry point"]')!;
const filterField = () => container.querySelector<HTMLInputElement>('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);
});
});
+28 -7
View File
@@ -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<JourneyPlace>(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
<header className="behavior-heading"><div><span className="system-eyebrow">Behavior · source-guided exploration</span>
<h2>{targetId !== undefined ? `${entry?.name ?? 'Operation'} → ${selectedTarget?.name ?? 'destination'}` : `What can ${entry?.name ?? 'this operation'} call?`}</h2>
<p>{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.'}</p></div>
<button onClick={onRefresh}>Refresh</button></header>
<RefreshControl labels={text.refreshFeedback.behavior} feedback={refresh} onRefresh={onRefresh} /></header>
<div className="behavior-requests">
{/* The field names the operation the journey shows, also when the projection chose it. */}
<label>Start <select aria-label="Behavior entry point" value={entry?.id ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
<option value="">Choose an operation…</option>{availableEntries.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? item.qualified_name}</option>)}
</select></label>
{/*
* 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).
*/}
<label className="behavior-start">{startText.label}
<input type="search" aria-label={startText.filter} placeholder={startText.filterPlaceholder} value={startQuery} onChange={event => setStartQuery(event.target.value)} />
<select aria-label={startText.field} value={entry?.id ?? ''} onChange={event => request(availableEntries.find(item => item.id === Number(event.target.value)))}>
<option value="">{startText.choose}</option>
{startChoices.suggested.length > 0 && <optgroup label={startText.suggested}>{startChoices.suggested.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}</optgroup>}
<optgroup label={startQuery.trim() ? startText.matching(startChoices.matching, startChoices.total) : startText.all(startChoices.total)}>
{startChoices.all.map(item => <option key={item.id} value={item.id}>{item.label}</option>)}
{startQuery.trim() && !startChoices.matching && <option disabled value="-1">{startText.none(startQuery.trim())}</option>}
</optgroup>
</select></label>
<label>Reach <select aria-label="Behavior destination" value={targetId ?? ''} disabled={!entry || pending} onChange={event => request(entry, event.target.value ? Number(event.target.value) : undefined, from)}>
<option value="">Explore immediate calls</option>{targetOptions.map(item => <option key={item.id} value={item.id}>{item.name} · {item.file_path ?? ''}</option>)}
</select></label>
@@ -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(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
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(<ContainerMap project="p" active filter="" onNavigate={vi.fn()} />));
let finish!: (value: Awaited<ReturnType<typeof loadContainerInventory>>) => 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$/);
});
});
+17 -4
View File
@@ -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<ProjectEntry[]>([]);
const [selections, setSelections] = useState<ContainerSelection[]>([]);
const [discovery, setDiscovery] = useState<Discovery>('reading');
/** The `discoveryRevision` the current `discovery` answers, so a refresh can tell its own answer from the earlier one. */
const [discovered, setDiscovered] = useState<number>();
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
<header className="spatial-heading"><div><span className="spatial-eyebrow">Routes / Services</span><h2>How the services connect</h2>
<p>One square per declared service. Follow a connection to see the configuration and code behind it.</p></div>
<div className="spatial-camera-controls"><button aria-pressed={!planar} onClick={() => setPlanar(false)}>3D</button><button aria-pressed={planar} onClick={() => setPlanar(true)}>Plan</button>
<button onClick={() => setResetKey(value => value + 1)}>Fit map</button><button onClick={() => {
<button onClick={() => setResetKey(value => value + 1)}>Fit map</button><RefreshControl labels={text.refreshFeedback.services} feedback={refresh.feedback} onRefresh={() => {
refresh.begin();
setRevision(value => value + 1);
if (!selections.length || !selections[0].inventory.manifests.length) setDiscoveryRevision(value => value + 1);
}}>Refresh</button></div></header>
}} /></div></header>
<div className="container-projects" aria-label="Deployment sources">{selections.map((selection, index) => <label key={selection.inventory.project}><span>{selection.inventory.project}</span>
<select aria-label={`Compose file for ${selection.inventory.project}`} value={selection.manifest} onChange={event => setSelections(current => current.map((item, i) => i === index ? { ...item, manifest: event.target.value } : item))}>
<option value="">Source only</option>{selection.inventory.manifests.map(path => <option key={path}>{path}</option>)}</select>
@@ -198,3 +198,45 @@ it('focuses an aggregated hotspot area and restores all areas on empty backgroun
expect(host.querySelector('[aria-label="Hotspot area"]')).toBeNull();
expect(host.querySelector('input[type="search"]')).toBeNull();
});
/*
* Hand test 2026-10-04 (A3): "Refresh connections" fetched everything again and nothing on screen changed. Now the
* button says it runs, the map keeps the connections it shows meanwhile, and a status names the time and the result.
*/
it('A3: Refresh connections says that it runs and what it found, and keeps the map while it runs', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
try {
vi.setSystemTime(new Date(2026, 9, 4, 13, 45, 12));
const route: GraphNode = { id: 2, label: 'Route', name: '/api/', qualified_name: 'sample.route.2', file_path: 'app/urls.py', x: 0, y: 0, z: 0, size: 1, color: '' };
const input: GraphData = { nodes: [node, route], edges: [], total_nodes: 2 };
await act(async () => root.render(<SpatialArchitecture project="sample" graph={input} overview={overview} view="routes" filter="" active onNavigate={vi.fn()} onView={vi.fn()} />));
const refresh = () => [...host.querySelectorAll('button')].find(button => /^Refresh(ing)? connections/.test(button.textContent ?? ''))!;
const status = () => host.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent;
expect(refresh().textContent).toBe('Refresh connections');
expect(status()).toBe('');
let finish!: (value: Awaited<ReturnType<typeof loadRouteGraph>>) => void;
vi.mocked(loadRouteGraph).mockImplementationOnce(() => new Promise(resolve => { finish = resolve; }));
await act(async () => refresh().click());
expect(refresh().textContent).toBe('Refreshing connections…');
expect(refresh().getAttribute('aria-disabled')).toBe('true');
// The map keeps what it shows; it does not fall back to "reading" while the refresh runs.
expect(host.textContent).not.toContain('Reading indexed endpoint connections…');
expect(host.textContent).not.toContain('(checking…)');
// A second press while it runs starts nothing.
await act(async () => refresh().click());
expect(loadRouteGraph).toHaveBeenCalledTimes(2);
await act(async () => finish({ relationships: [], truncated: false, warnings: [] }));
expect(refresh().textContent).toBe('Refresh connections');
expect(refresh().getAttribute('aria-disabled')).toBeNull();
expect(status()).toBe('Up to date at 13:45:12: no changes since the last load');
vi.setSystemTime(new Date(2026, 9, 4, 13, 46, 3));
vi.mocked(loadRouteGraph).mockResolvedValueOnce({ relationships: [{ source: route, target: node, type: 'HANDLES', id: 7 }], truncated: false, warnings: [] });
await act(async () => refresh().click());
expect(status()).toBe('Connections refreshed at 13:46:03');
vi.mocked(loadRouteGraph).mockRejectedValueOnce(new Error('daemon unavailable'));
await act(async () => refresh().click());
expect(status()).toBe('Refresh failed at 13:46:03: daemon unavailable');
} finally { vi.useRealTimers(); }
});
@@ -15,6 +15,7 @@ import type { CoverageIndex } from '../app/tree-model';
import { buildHotspotGraph, collectHotspots, hotspotsForNode, hotspotSignals, hotspotIdentity } from './hotspot-map';
import { hotspotAreas } from './hotspot-areas';
import { useViewPreferences } from '../settings/view-preferences';
import { RefreshControl, useRefreshFeedback } from './refresh-feedback';
import './spatial-architecture.css';
interface Props {
@@ -67,29 +68,36 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
const [inventoryLimit, setInventoryLimit] = useState(24);
const [resetKey, setResetKey] = useState(0);
const [selection, setSelection] = useState<{ scope: string; node?: string; edge?: string }>();
const [routeReading, setRouteReading] = useState<{ key: string; snapshot?: RouteGraphSnapshot; error?: string }>();
const [routeReading, setRouteReading] = useState<{ key: string; base?: string; snapshot?: RouteGraphSnapshot; error?: string; refreshing?: boolean }>();
const [routeRevision, setRouteRevision] = useState(0);
const [memberLimit, setMemberLimit] = useState(5);
const [includeTestRoutes, setIncludeTestRoutes] = useState(false);
// A reindex clears the hotspot area; a view that mounts again keeps the place it is handed.
useOnIdentityChange(`${project}:${generation ?? ''}`, () => { if (place.hotspotArea) changePlace({ hotspotArea: undefined }, true); });
const routeKey = `${project}:${generation ?? ''}:${routeRevision}`;
const routeBase = `${project}:${generation ?? ''}`;
const routeKey = `${routeBase}:${routeRevision}`;
useEffect(() => {
if (view !== 'routes' || !active) return;
const controller = new AbortController();
setRouteReading({ key: routeKey });
// A refresh of the same index keeps the connections on screen until the new ones arrive (hand test 2026-10-04, A3).
const kept = (current?: typeof routeReading) => current?.base === routeBase && current.snapshot ? { snapshot: current.snapshot } : {};
setRouteReading(current => ({ key: routeKey, base: routeBase, ...kept(current), refreshing: true }));
void loadRouteGraph(project, { signal: controller.signal }).then(snapshot => {
if (!controller.signal.aborted) setRouteReading({ key: routeKey, snapshot });
if (!controller.signal.aborted) setRouteReading({ key: routeKey, base: routeBase, snapshot });
}).catch((error: unknown) => {
if (!controller.signal.aborted) setRouteReading({ key: routeKey, error: error instanceof Error ? error.message : String(error) });
const message = error instanceof Error ? error.message : String(error);
if (!controller.signal.aborted) setRouteReading(current => ({ key: routeKey, base: routeBase, ...kept(current), error: message }));
});
return () => controller.abort();
}, [project, routeKey, view, active]);
}, [project, routeKey, routeBase, view, active]);
const routeRefresh = useRefreshFeedback({ key: routeKey, settled: routeReading?.key === routeKey && !routeReading.refreshing,
value: routeReading?.key === routeKey ? routeReading.snapshot : undefined, error: routeReading?.key === routeKey ? routeReading.error : undefined });
const entries = useMemo(() => semanticEntryPoints(graph, overview.entryPoints.flatMap(entry => entry.qualifiedName ? [entry.qualifiedName] : [])), [graph, overview.entryPoints]);
const currentEntry = entries.find(node => entryChoice?.node.qualified_name
? node.qualified_name === entryChoice.node.qualified_name && node.file_path === entryChoice.node.file_path
: entryChoice?.generation === generation && node.id === entryChoice?.node.id && node.file_path === entryChoice.node.file_path) ?? entries[0];
const routeSnapshot = routeReading?.key === routeKey ? routeReading.snapshot : undefined;
// A refresh of the same index shows the earlier connections until its own arrive.
const routeSnapshot = routeReading?.base === routeBase ? routeReading.snapshot : undefined;
// Which routes are test code is decided by their registration, handler and caller evidence.
const routesChecking = view === 'routes' && !routeSnapshot && !(routeReading?.key === routeKey && routeReading.error);
const knownFiles = useMemo(() => [...new Set([...overview.files, ...[...(coverage?.records.values() ?? [])].filter(record => record.kind === 'file').map(record => record.path)])], [overview.files, coverage]);
@@ -188,7 +196,8 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
{!entries.length && <option value="">No indexed entry points</option>}{entries.map(node => <option key={node.id} value={node.id}>{node.name} · {node.file_path}</option>)}
</select></label><label>Call depth <select aria-label="Call depth" value={depth} onChange={event => setDepth(Number(event.target.value))}>{[1, 2, 3, 4].map(value => <option key={value}>{value}</option>)}</select></label></>}
{view === 'hotspots' && hotspotArea && <nav aria-label="Hotspot area"><button onClick={clearScope}>All hotspots</button><span>/ {hotspotArea}</span></nav>}
{view === 'routes' && <button onClick={() => setRouteRevision(value => value + 1)}>Refresh connections</button>}
{view === 'routes' && <RefreshControl labels={text.refreshFeedback.routes} feedback={routeRefresh.feedback}
onRefresh={() => { routeRefresh.begin(); setRouteRevision(value => value + 1); }} />}
{view === 'routes' && <label className="spatial-gravity-toggle"><input type="checkbox" checked={includeTestRoutes} onChange={event => { setIncludeTestRoutes(event.target.checked); setSelection(undefined); }} />{routesChecking ? text.includeTestRoutesChecking : text.includeTestRoutes(model.hiddenRoutes ?? 0)}</label>}
{filePath && (view === 'overview' || view === 'dependencies') && <button onClick={() => onNavigate(filePath, 1)}>Read this file</button>}
{(view === 'overview' || view === 'dependencies') && <div className="spatial-relations" role="group" aria-label="Relationship types">
@@ -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<HTMLSelectElement>('[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<FlowsLoader>().mockResolvedValue([]);
@@ -586,8 +590,46 @@ describe('system architecture workspace', () => {
await render(vi.fn<SystemArchitectureLoader>().mockResolvedValue(response()), { view: 'behavior', flowsLoader });
expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample');
const start = container.querySelector<HTMLSelectElement>('[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<FlowsLoader>().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<SystemArchitectureLoader>().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<HTMLSelectElement>('[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<FlowsLoader>().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<SystemArchitectureLoader>().mockResolvedValue(response(data)), { view: 'behavior', flowsLoader });
const start = container.querySelector<HTMLSelectElement>('[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<SystemArchitectureLoader>().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<SystemArchitectureLoader>().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<SystemArchitectureLoader>().mockResolvedValue(response(overviewFixture()));
await render(loader, { view: 'behavior' });
const button = () => container.querySelector<HTMLButtonElement>('.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');
});
});
@@ -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<SystemPlace>;
@@ -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<FlowSummary[]>;
const loadFlows: FlowsLoader = project => new AtlasApi().flows(project);
export type OperationNamer = (project: string, entries: SystemSymbol[]) => Promise<SystemSymbol[]>;
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<SystemPlace>(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' ? <BehaviorJourney project={project} generation={projectionGeneration} data={queryData}
entries={entries} targets={targets} entryId={requestedEntry} targetId={requestedTarget} active={active} pending={queryPending}
error={error} filter={filter} onRefresh={() => 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 <section className="system-architecture" data-testid="system-architecture" aria-label={view === 'structure' ? 'System structure' : 'Behavior'} aria-busy={active && pending}>
<div className="system-toolbar">
<p>{view === 'structure' ? 'Inspect the parts and the code that connects them.' : 'Follow a question through the same system map.'}</p>
<button className="atlas-arch-action system-refresh" onClick={() => { setRevision(value => value + 1); setResetKey(value => value + 1); }}>Refresh analysis</button>
<RefreshControl className="atlas-arch-action system-refresh" labels={text.refreshFeedback.structure} feedback={refresh.feedback}
onRefresh={() => { refreshAnalysis(); setResetKey(value => value + 1); }} />
</div><div className="system-controls">
<div className="system-scope-actions"><button disabled={!focusId} onClick={clearSelection}>Whole system</button></div>
{view === 'structure' && <label>Connections <select aria-label="Connection view" value={connectionView} onChange={event => { setConnectionView(event.target.value as ConnectionView); setCyclesOnly(false); }}>
+22 -7
View File
@@ -45,21 +45,30 @@
}
.atlas-arch-tab[aria-pressed="true"] { color: var(--atlas-fg); border-bottom-color: var(--atlas-phosphor); }
.atlas-arch-tab:hover { color: var(--atlas-fg); background: var(--atlas-panel); }
/* K27: the tabs and one Back, Forward and Recent for the whole workspace share a row. The menu sits outside the scrolling tab strip. */
.atlas-arch-tabrow { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; }
/*
* K27: one Back, Forward and Recent for the whole workspace and the tabs share a row. The menu sits outside the
* scrolling tab strip. Hand test 2026-10-04 (A1): the controls come first and read as in Galaxy, "← Back" and
* "Forward →" while the tabs fit, the glyphs alone once they would scroll (`data-fit`, galaxy/toolbar-fit.tsx).
*/
.atlas-arch-tabrow { display: flex; align-items: center; gap: 14px; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; }
.atlas-arch-tabrow > .atlas-arch-tabs { flex: 1 1 auto; min-width: 0; border-bottom: 0; margin-bottom: 0; }
.atlas-arch-history { display: flex; align-items: center; gap: 4px; flex: none; position: relative; }
.atlas-arch-history > button, .atlas-arch-recent > summary {
border: 1px solid var(--atlas-line); border-radius: 7px; background: var(--atlas-panel); color: var(--atlas-fg);
min-width: 32px; min-height: 30px; padding: 3px 8px; line-height: 1.4;
border: 1px solid var(--atlas-line); border-radius: 6px; background: var(--atlas-panel); color: var(--atlas-fg);
min-width: 30px; min-height: 30px; padding: 3px 10px; line-height: 1.4; white-space: nowrap;
}
.atlas-arch-history > button:hover:not(:disabled), .atlas-arch-recent > summary:hover { background: var(--atlas-panel-raised); border-color: var(--atlas-dim); }
.atlas-arch-history > button:disabled { opacity: 0.45; }
.atlas-arch-history > button:disabled { opacity: 0.4; }
.atlas-arch-history .atlas-fit-narrow { display: none; }
.atlas-arch-history .atlas-fit-narrow::before { content: attr(data-label); }
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history .atlas-fit-wide { display: none; }
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history .atlas-fit-narrow { display: inline; }
.atlas-arch-tabrow:is([data-fit=compact], [data-fit=tight], [data-fit=wrap]) .atlas-arch-history > button { padding-inline: 8px; }
.atlas-arch-recent { position: relative; }
.atlas-arch-recent > summary { display: block; cursor: pointer; list-style: none; text-align: center; white-space: nowrap; color: var(--atlas-dim); }
.atlas-arch-recent > summary { display: block; cursor: pointer; list-style: none; text-align: center; color: var(--atlas-dim); padding-inline: 8px; }
.atlas-arch-recent > summary::-webkit-details-marker { display: none; }
.atlas-arch-recent-menu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 30; width: min(24rem, calc(100vw - 3rem)); max-height: 20rem; overflow: auto;
position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; width: min(24rem, calc(100vw - 3rem)); max-height: 20rem; overflow: auto;
margin: 0; padding: 6px; list-style: none; border: 1px solid var(--atlas-line); border-radius: 8px; background: var(--atlas-panel); box-shadow: 0 8px 24px #0006;
}
.atlas-arch-recent-menu button {
@@ -69,6 +78,12 @@
.atlas-arch-recent-menu button:hover:not(:disabled) { background: var(--atlas-panel-raised); }
.atlas-arch-recent-menu button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--atlas-dim); }
.atlas-arch-recent-menu button[aria-current="true"] { opacity: 0.6; }
/* Hand test 2026-10-04 (A3): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */
.atlas-arch-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; }
.atlas-arch-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; }
.atlas-arch-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; }
.atlas-arch-refresh-status:not(:empty) { margin-left: 8px; }
.atlas-arch-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); }
.atlas-arch-toolbar { margin-bottom: 24px; gap: 10px; }
.atlas-arch-filter { position: relative; flex: 1; max-width: 370px; }
.atlas-arch-filter svg { color: var(--atlas-dim); left: 11px; position: absolute; top: 10px; pointer-events: none; }
@@ -106,11 +106,18 @@ function corridorPath(data: SystemProjection, source: number, target: number, hi
return { entrypoint_id: source, nodes: nodes.reverse(), edges: edges.reverse() };
}
/**
* The operation a journey starts from when none was requested: the server's pick, else the first classified entry
* point, else the first connected path. The server reports source_id 0 when no operation was requested; node IDs
* start at 1.
*/
export function defaultJourneyStart(data: SystemProjection): number | undefined {
return (data.behavior?.source_id || undefined) ?? data.entrypoints[0]?.id ?? data.paths.find(validPath)?.entrypoint_id;
}
/** A focused static journey: positions describe hops, never catalog distance or runtime order. */
export function behaviorJourney(data: SystemProjection, options: BehaviorJourneyOptions = {}): BehaviorJourney {
// The server reports source_id 0 when no operation was requested; node IDs start at 1.
const entryId = options.entryId ?? (data.behavior?.source_id || undefined) ?? data.entrypoints[0]?.id
?? data.paths.find(validPath)?.entrypoint_id;
const entryId = options.entryId ?? defaultJourneyStart(data);
const scopedBehavior = data.behavior?.source_id === entryId ? data.behavior : undefined;
const dependencies = [...data.dependencies, ...(data.overview?.connections ?? [])];
const symbols = new Map<number, SystemSymbol>();
@@ -3,13 +3,17 @@
.behavior-heading h2 { font-size: 25px; font-weight: 500; letter-spacing: -.6px; margin: 7px 0; overflow-wrap: anywhere; }
.behavior-heading p { color: var(--atlas-dim); font-size: 12px; margin: 0; }
.behavior-journey button { cursor: pointer; }
.behavior-heading > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
.behavior-heading > button, .behavior-heading > .atlas-arch-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; }
.behavior-journey button:disabled { opacity: .35; cursor: default; }
.behavior-journey button:focus-visible { outline: 2px solid var(--arch-label-focus); outline-offset: 3px; }
.behavior-requests { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--journey-line); border-radius: 10px; background: var(--journey-tint); }
.behavior-requests label { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--atlas-dim); flex: 1 1 270px; min-width: 0; }
.behavior-requests select { min-width: 0; width: 100%; max-width: 100%; }
.behavior-requests input { flex: 1 1 150px; min-width: 120px; background: transparent; color: var(--atlas-fg); border: 1px solid var(--journey-line); border-radius: 5px; padding: 6px 9px; font: inherit; font-size: 11px; }
/* Hand test 2026-10-04 (A4): "Start" holds the filter and the field; it takes the larger share of the row. */
.behavior-requests .behavior-start { flex: 2 1 460px; }
.behavior-requests .behavior-start input { flex: 0 1 190px; }
.behavior-requests .behavior-start select { flex: 1 1 240px; width: auto; }
.behavior-navigation { display: flex; align-items: center; flex-wrap: wrap; gap: 9px; margin: 12px 0; font-size: 11px; color: var(--atlas-dim); }
.behavior-navigation > span:first-of-type { margin-left: 4px; }
.behavior-static-badge { margin-left: auto; border: 1px solid var(--journey-line); border-radius: 20px; padding: 4px 9px; font-size: 10px; }
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest';
import { nameOperations, operationChoices, operationLabels } from './operation-choices';
import type { SystemSymbol } from './system-architecture-source';
/*
* Hand test 2026-10-04 (A4): the Behavior "Start" list of django-demo held 62 operations in the order the
* server returned them, with look-alikes ("database_backwards · django/db/migrations/operations/models.py" six
* times), and "handle · …/loaddata.py" could not be found.
*/
const operation = (id: number, name: string, file_path: string, more: Partial<SystemSymbol> = {}): SystemSymbol =>
({ id, name, qualified_name: '', label: '', file_path, component_id: '', ...more });
const MODELS = 'django/db/migrations/operations/models.py';
const scouted = [
operation(1, 'main', 'django/conf/project_template/manage.py-tpl', { qualified_name: 'django-demo.django.conf.project_template.manage.main', start_line: 7 }),
operation(2, 'delete_selected', 'django/contrib/admin/actions.py'),
operation(3, 'handle', 'django/core/management/commands/makemigrations.py'),
operation(4, 'handle', 'django/core/management/commands/loaddata.py'),
operation(5, 'handle', 'django/core/management/commands/migrate.py'),
operation(6, 'database_backwards', MODELS),
operation(7, 'database_backwards', MODELS),
operation(8, 'clean', 'django/forms/models.py'),
operation(9, 'clean', 'django/forms/models.py'),
operation(10, 'as_sql', 'django/db/models/fields/json.py'),
];
const labels = (list: { label: string }[]) => list.map(item => item.label);
describe('Behavior start choices (A4)', () => {
it('lists every operation alphabetically by name, then by path', () => {
const { all, total } = operationChoices(scouted, {});
expect(total).toBe(10);
expect(all.map(item => item.id)).toEqual([10, 8, 9, 6, 7, 2, 4, 3, 5, 1]);
expect(labels(all).slice(5, 9)).toEqual(['delete_selected · django/contrib/admin/actions.py', 'handle · django/core/management/commands/loaddata.py',
'handle · django/core/management/commands/makemigrations.py', 'handle · django/core/management/commands/migrate.py']);
});
it('puts the automatic start and the ranked suggestions first, in their order, and keeps them in the full list', () => {
const { suggested, all } = operationChoices(scouted, { suggested: [1, 3, 4, 99] });
expect(labels(suggested)).toEqual(['main · django/conf/project_template/manage.py-tpl', 'handle · django/core/management/commands/makemigrations.py',
'handle · django/core/management/commands/loaddata.py']);
expect(all).toHaveLength(10);
});
it('tells look-alikes apart by their class, by their line when the class is the same, and by position otherwise', () => {
const named = scouted.map(item => item.id === 6 ? { ...item, qualified_name: `django-demo.django.db.migrations.operations.models.CreateModel.database_backwards`, start_line: 110 }
: item.id === 7 ? { ...item, qualified_name: `django-demo.django.db.migrations.operations.models.DeleteModel.database_backwards`, start_line: 444 }
: item.id === 8 ? { ...item, qualified_name: 'django-demo.django.forms.models.BaseModelForm.clean', start_line: 437 }
: item.id === 9 ? { ...item, qualified_name: 'django-demo.django.forms.models.BaseModelForm.clean', start_line: 800 } : item);
const text = operationLabels(named);
// The name stays first, so the list still reads alphabetically.
expect(text.get(6)).toBe(`database_backwards (CreateModel) · ${MODELS}`);
expect(text.get(7)).toBe(`database_backwards (DeleteModel) · ${MODELS}`);
expect(text.get(8)).toBe('clean · django/forms/models.py:437');
expect(text.get(9)).toBe('clean · django/forms/models.py:800');
// Unique names keep their plain label.
expect(text.get(4)).toBe('handle · django/core/management/commands/loaddata.py');
// Without a qualified name or line the position still tells them apart.
const bare = operationLabels(scouted);
expect([bare.get(6), bare.get(7)]).toEqual([`database_backwards · ${MODELS} (1 of 2)`, `database_backwards · ${MODELS} (2 of 2)`]);
expect(new Set(bare.values()).size).toBe(bare.size);
// Sorted by name first, the class does not tear the look-alikes apart.
expect(operationChoices(named, {}).all.map(item => item.id).slice(3, 5)).toEqual([6, 7]);
});
it('narrows to the operations that match every word of the filter, and keeps the chosen one', () => {
expect(labels(operationChoices(scouted, { query: 'loaddata' }).all)).toEqual(['handle · django/core/management/commands/loaddata.py']);
expect(operationChoices(scouted, { query: 'handle MIGRATE' }).all.map(item => item.id)).toEqual([5]);
const kept = operationChoices(scouted, { query: 'loaddata', keep: 1, suggested: [1, 3] });
expect(kept.all.map(item => item.id)).toEqual([4, 1]);
expect(kept.suggested.map(item => item.id)).toEqual([1]);
expect(kept.matching).toBe(1);
// A chosen start that matches counts as a match (measured in the browser: "0 of 61" after picking loaddata).
expect(operationChoices(scouted, { query: 'loaddata', keep: 4 }).matching).toBe(1);
expect(operationChoices(scouted, { query: 'nothing like this' }).all).toEqual([]);
});
});
describe('qualified names for look-alike flow starts (A4)', () => {
it('asks the index once for the look-alikes only and fills in their qualified name and line', async () => {
const queryRows = vi.fn(async () => [
{ id: '6', qualified_name: 'django-demo.django.db.migrations.operations.models.CreateModel.database_backwards', start_line: '110' },
{ id: '7', qualified_name: 'django-demo.django.db.migrations.operations.models.DeleteModel.database_backwards', start_line: '444' },
{ id: '70', qualified_name: 'django-demo.django.db.migrations.operations.models.RenameModel.database_backwards', start_line: '538' },
{ id: '8', qualified_name: 'django-demo.django.forms.models.BaseModelForm.clean', start_line: '437' },
]);
const named = await nameOperations('django-demo', scouted, { queryRows });
expect(queryRows).toHaveBeenCalledOnce();
const [project, query] = queryRows.mock.calls[0] as unknown as [string, string];
expect(project).toBe('django-demo');
expect(query).toContain('n.name IN ["database_backwards", "clean"]');
expect(query).toContain(`n.file_path IN ["${MODELS}", "django/forms/models.py"]`);
expect(query).not.toContain('handle');
expect(named.find(item => item.id === 6)).toMatchObject({ qualified_name: 'django-demo.django.db.migrations.operations.models.CreateModel.database_backwards', start_line: 110 });
expect(named.find(item => item.id === 9)?.qualified_name).toBe('');
expect(named.find(item => item.id === 4)).toBe(scouted[3]);
});
it('needs no query without look-alikes, never puts quotes or backslashes into one, and keeps the list when it fails', async () => {
const queryRows = vi.fn(async () => { throw new Error('index busy'); });
expect(await nameOperations('p', scouted.slice(0, 5), { queryRows })).toEqual(scouted.slice(0, 5));
expect(queryRows).not.toHaveBeenCalled();
const odd = [operation(1, 'run"x', 'a.py'), operation(2, 'run"x', 'a.py')];
expect(await nameOperations('p', odd, { queryRows })).toEqual(odd);
expect(queryRows).not.toHaveBeenCalled();
expect(await nameOperations('p', scouted, { queryRows })).toEqual(scouted);
});
});
@@ -0,0 +1,117 @@
/**
* The operations the Behavior "Start" field offers (hand test 2026-10-04,
* A4). For django-demo that were 62 entries in the order the server sent
* them, with look-alikes ("database_backwards · …/models.py" six times), and
* "handle · …/loaddata.py" could not be found. Now:
*
* - the automatic start and the ranked suggestions come first, in a group
* of their own and in their order;
* - then every operation, alphabetically by name and then by path;
* - look-alikes carry their class ("database_backwards (CreateModel) · …",
* the name first so the list still reads alphabetically), their line when
* the class is the same too, and their position when the index names
* neither;
* - a filter keeps the operations whose name, class or path contain every
* word typed, and always the chosen one, so the field can still show it.
*/
import type { SystemSymbol } from './system-architecture-source';
import { architectureText } from './strings';
const text = architectureText.behaviorStart;
export interface OperationChoice { id: number; label: string; symbol: SystemSymbol }
export interface OperationChoices {
suggested: OperationChoice[];
all: OperationChoice[];
/** How many operations there are, and how many of them match the filter (the kept one only when it matches). */
total: number;
matching: number;
}
const place = (symbol: SystemSymbol) => symbol.file_path ?? symbol.qualified_name;
const lookAlike = (symbol: SystemSymbol) => `${symbol.name}\u0000${place(symbol) ?? ''}`;
/** The segment before the name in the qualified name: the class of a method, the module of a function. */
export function operationQualifier(symbol: SystemSymbol): string | undefined {
const name = symbol.qualified_name;
if (!name || !name.endsWith(`.${symbol.name}`)) return undefined;
return name.slice(0, -symbol.name.length - 1).split('.').at(-1) || undefined;
}
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true });
function compare(a: SystemSymbol, b: SystemSymbol): number {
return collator.compare(a.name, b.name) || collator.compare(place(a) ?? '', place(b) ?? '')
|| collator.compare(operationQualifier(a) ?? '', operationQualifier(b) ?? '') || (a.start_line ?? 0) - (b.start_line ?? 0) || a.id - b.id;
}
/** One label per operation, the same with or without a filter, so the field never renames what it shows. */
export function operationLabels(entries: SystemSymbol[]): Map<number, string> {
const groups = new Map<string, SystemSymbol[]>();
for (const entry of entries) groups.set(lookAlike(entry), [...groups.get(lookAlike(entry)) ?? [], entry]);
const labels = new Map<number, string>();
for (const group of groups.values()) {
const ordered = [...group].sort((a, b) => a.id - b.id);
const qualifiers = ordered.map(operationQualifier);
const distinct = (values: unknown[]) => values.every(value => value !== undefined) && new Set(values).size === values.length;
const byQualifier = group.length > 1 && distinct(qualifiers);
const byLine = group.length > 1 && !byQualifier && distinct(ordered.map(item => item.start_line));
ordered.forEach((entry, index) => {
const where = place(entry) ?? '';
labels.set(entry.id, group.length === 1 ? text.option(entry.name, where)
: byQualifier ? text.option(text.qualified(qualifiers[index]!, entry.name), where)
: byLine ? text.option(entry.name, text.line(where, entry.start_line!))
: `${text.option(entry.name, where)}${text.ordinal(index + 1, group.length)}`);
});
}
return labels;
}
export function operationChoices(entries: SystemSymbol[], { suggested = [], query = '', keep }: { suggested?: number[]; query?: string; keep?: number }): OperationChoices {
const labels = operationLabels(entries);
const words = query.toLocaleLowerCase().split(/\s+/).filter(Boolean);
const matches = (entry: SystemSymbol) => words.every(word => `${labels.get(entry.id)} ${entry.qualified_name}`.toLocaleLowerCase().includes(word));
const choice = (symbol: SystemSymbol): OperationChoice => ({ id: symbol.id, label: labels.get(symbol.id)!, symbol });
const shown = (entry: SystemSymbol) => entry.id === keep || matches(entry);
const byId = new Map(entries.map(entry => [entry.id, entry]));
const offered = [...new Set(suggested)].filter(id => byId.has(id));
return {
// Suggestions that name every operation would only repeat the list.
suggested: offered.length >= entries.length ? [] : offered.flatMap(id => shown(byId.get(id)!) ? [choice(byId.get(id)!)] : []),
all: entries.filter(shown).sort(compare).map(choice),
total: entries.length,
matching: entries.filter(matches).length,
};
}
/** What `nameOperations` needs of the RPC client. */
export interface OperationNameClient { queryRows: (project: string, query: string) => Promise<Record<string, string>[]> }
/* Only plain identifiers and paths enter the query text; anything with a quote or a backslash stays unnamed. */
const PLAIN = /^[\w$<>@.\/\- ]+$/;
/**
* Ranked flow starts carry no qualified name or line (/api/flows names only
* the node, its file and its id). For the look-alikes among them the index is
* asked once, by name and file, and the answer is matched by node id. Without
* an answer the list stays as it was: the position still tells them apart.
*/
export async function nameOperations(project: string, entries: SystemSymbol[], client: OperationNameClient): Promise<SystemSymbol[]> {
const groups = new Map<string, SystemSymbol[]>();
for (const entry of entries) groups.set(lookAlike(entry), [...groups.get(lookAlike(entry)) ?? [], entry]);
const unnamed = [...groups.values()].filter(group => group.length > 1).flat()
.filter(entry => !entry.qualified_name && entry.file_path && PLAIN.test(entry.name) && PLAIN.test(entry.file_path));
if (!unnamed.length) return entries;
const list = (values: string[]) => `[${[...new Set(values)].map(value => `"${value}"`).join(', ')}]`;
const query = `MATCH (n) WHERE n.name IN ${list(unnamed.map(entry => entry.name))} AND n.file_path IN ${list(unnamed.map(entry => entry.file_path!))} `
+ 'RETURN id(n) AS id, n.qualified_name AS qualified_name, n.start_line AS start_line LIMIT 1000';
let rows: Record<string, string>[];
try { rows = await client.queryRows(project, query); } catch { return entries; }
const found = new Map(rows.map(row => [Number(row.id), row]));
const wanted = new Set(unnamed.map(entry => entry.id));
return entries.map(entry => {
const row = wanted.has(entry.id) ? found.get(entry.id) : undefined;
if (!row?.qualified_name) return entry;
const line = Number(row.start_line);
return { ...entry, qualified_name: row.qualified_name, ...(Number.isSafeInteger(line) && line > 0 ? { start_line: line } : {}) };
});
}
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { clockTime, RefreshControl, useRefreshFeedback, type RefreshReading } from './refresh-feedback';
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
const labels = { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Refreshed at ${time}` };
let begin: () => void = () => {};
function Fixture({ reading }: { reading: RefreshReading }) {
const refresh = useRefreshFeedback(reading, () => new Date(2026, 9, 4, 7, 3, 9).getTime());
begin = refresh.begin;
return <RefreshControl labels={labels} feedback={refresh.feedback} onRefresh={refresh.begin} />;
}
const show = (reading: RefreshReading) => act(async () => root.render(<Fixture reading={reading} />));
const status = () => host.querySelector('[role="status"]')?.textContent;
it('names the wall clock with two digits each', () => {
expect(clockTime(new Date(2026, 9, 4, 7, 3, 9).getTime())).toBe('07:03:09');
});
it('waits for the reading the refresh started, not the one shown when it was pressed', async () => {
await show({ key: 'a', settled: true, value: { routes: 1 } });
await act(async () => begin());
// Still the earlier reading: nothing is decided yet.
await show({ key: 'a', settled: true, value: { routes: 1 } });
expect(host.querySelector('button')?.textContent).toBe('Refreshing…');
await show({ key: 'b', settled: false });
expect(status()).toBe('');
await show({ key: 'b', settled: true, value: { routes: 1 } });
expect(status()).toBe('Up to date at 07:03:09: no changes since the last load');
});
it('drops the status once another reading than the refreshed one is shown', async () => {
await show({ key: 'a', settled: true, value: 1 });
await act(async () => begin());
await show({ key: 'b', settled: true, value: 2 });
expect(status()).toBe('Refreshed at 07:03:09');
// Another start or a reindex: the status would speak about data no longer shown.
await show({ key: 'c', settled: true, value: 3 });
expect(status()).toBe('');
});
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState, type JSX } from 'react';
import { architectureText } from './strings';
const text = architectureText.refreshFeedback;
/**
* What a Refresh button of Architecture says it did (hand test 2026-10-04, A3:
* "Refresh connections" fetched everything again and nothing on screen
* changed). While the reading runs the button reads "Refreshing …"; then a
* status beside it names the time and whether anything changed, or the error.
*/
export type RefreshFeedback =
| { phase: 'running' }
| { phase: 'done'; at: number; changed: boolean }
| { phase: 'failed'; at: number; error: string };
/**
* The reading a view shows: its key (a refresh always leads to a new one),
* whether it has settled, and what it holds. `value` is compared as JSON
* before and after, so a view hands something small: the route snapshot, the
* service topology, the analysis generation.
*/
export interface RefreshReading { key: string; settled: boolean; value?: unknown; error?: string }
const signature = (value: unknown) => JSON.stringify(value) ?? '';
export function useRefreshFeedback(reading: RefreshReading, now: () => number = Date.now): { feedback?: RefreshFeedback; begin: () => void } {
const [feedback, setFeedback] = useState<RefreshFeedback>();
const started = useRef<{ key: string; before: string } | undefined>(undefined);
const described = useRef(reading.key);
const latest = useRef(reading);
latest.current = reading;
const { key, settled, value, error } = reading;
useEffect(() => {
const start = started.current;
if (start) {
if (key === start.key || !settled) return;
started.current = undefined;
described.current = key;
setFeedback(error !== undefined ? { phase: 'failed', at: now(), error } : { phase: 'done', at: now(), changed: signature(value) !== start.before });
return;
}
// Another reading than the refreshed one (another start, a reindex): the status no longer speaks about what is shown.
if (key !== described.current) { described.current = key; setFeedback(undefined); }
}, [key, settled, value, error, now]);
const begin = useCallback(() => {
const current = latest.current;
started.current = { key: current.key, before: signature(current.value) };
setFeedback({ phase: 'running' });
}, []);
return { feedback, begin };
}
const two = (value: number) => String(value).padStart(2, '0');
/** The wall clock as the status names it: 13:45:12. */
export function clockTime(at: number): string {
const date = new Date(at);
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
}
export function refreshStatus(feedback: RefreshFeedback | undefined, done: (time: string) => string): string {
if (!feedback || feedback.phase === 'running') return '';
const time = clockTime(feedback.at);
return feedback.phase === 'failed' ? text.failed(time, feedback.error) : feedback.changed ? done(time) : text.unchanged(time);
}
/**
* The button and its status. A running refresh keeps the button in the focus
* order (`aria-disabled`, not `disabled`), so a keyboard reader stays where
* they were; a second press while it runs does nothing.
*/
export function RefreshControl({ labels, feedback, onRefresh, className }: {
labels: { idle: string; busy: string; done: (time: string) => string };
feedback?: RefreshFeedback; onRefresh: () => void; className?: string;
}): JSX.Element {
const running = feedback?.phase === 'running';
return <span className="atlas-arch-refresh" data-phase={feedback?.phase}>
<button type="button" className={className} aria-disabled={running || undefined} aria-busy={running || undefined}
onClick={() => { if (!running) onRefresh(); }}>{running ? labels.busy : labels.idle}</button>
<span className="atlas-arch-refresh-status" role="status" data-phase={feedback?.phase}>{refreshStatus(feedback, labels.done)}</span>
</span>;
}
+29
View File
@@ -100,6 +100,32 @@ export const architectureText = {
openedFile: 'Opened file',
scopeMeasure: (files: number, lines?: number) => `${files.toLocaleString()} files · ${lines === undefined ? 'unknown' : lines.toLocaleString()} indexed lines`,
openedScopeHint: 'Select a part or connection to inspect it. The location above leads back to the repository.',
/** Behavior "Start": suggestions first, then every operation alphabetically, with a filter (hand test 2026-10-04, A4). */
behaviorStart: {
label: 'Start',
field: 'Behavior entry point',
choose: 'Choose an operation…',
filter: 'Filter operations',
filterPlaceholder: 'Filter operations…',
suggested: 'Suggested',
all: (total: number) => `All operations · ${total.toLocaleString()}`,
matching: (shown: number, total: number) => `Matching operations · ${shown.toLocaleString()} of ${total.toLocaleString()}`,
none: (query: string) => `No operation matches "${query}"`,
option: (name: string, place: string) => `${name} · ${place}`,
/** The name stays first, so the list still reads alphabetically: "database_backwards (CreateModel)". */
qualified: (qualifier: string, name: string) => `${name} (${qualifier})`,
line: (place: string, line: number) => `${place}:${line}`,
ordinal: (index: number, count: number) => ` (${index} of ${count})`,
},
/** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when, and whether anything changed. */
refreshFeedback: {
routes: { idle: 'Refresh connections', busy: 'Refreshing connections…', done: (time: string) => `Connections refreshed at ${time}` },
services: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Service map refreshed at ${time}` },
structure: { idle: 'Refresh analysis', busy: 'Refreshing analysis…', done: (time: string) => `Analysis refreshed at ${time}` },
behavior: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Journey refreshed at ${time}` },
unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`,
failed: (time: string, error: string) => `Refresh failed at ${time}: ${error}`,
},
};
/**
@@ -114,6 +140,9 @@ export const architectureHistoryText = {
forward: 'Forward',
backGlyph: '←',
forwardGlyph: '→',
/** The words while the subtab row has room, as in Galaxy; the glyphs alone once the tabs would scroll. */
backWide: '← Back',
forwardWide: 'Forward →',
backTo: (label: string) => `Back to ${label} (Alt+Left)`,
forwardTo: (label: string) => `Forward to ${label} (Alt+Right)`,
noBack: 'Nothing to go back to yet',
@@ -207,6 +207,27 @@ it('K2: the recent list jumps straight to an earlier root with its last depth',
expect(named('Back')?.title).toBe('Back to n5 · 1 layer (Alt+Left)');
});
/* Handtest 2026-10-04 (A2): die Liste blieb ueber der Szene offen, Schritt um Schritt. */
it('A2: the recent list closes on a press elsewhere, on Escape without leaving the scope, and when Back changes the root', async () => {
await openScope();
await act(async () => { seam().clickNode('sample.n2'); });
await settle(() => expect(scopeName()).toBe('n2'));
const recent = host.querySelector<HTMLDetailsElement>('details.atlas-graph-recent')!;
await act(async () => { recent.open = true; });
await act(async () => { button('Empty canvas')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); });
expect(recent.open).toBe(false);
await act(async () => { recent.open = true; });
expect(key({ key: 'Escape' }, recent.querySelector('summary')!).defaultPrevented).toBe(true);
expect(recent.open).toBe(false);
expect(scopeName()).toBe('n2');
await act(async () => { recent.open = true; });
await act(async () => named('Back')!.click());
await settle(() => expect(scopeName()).toBe('n1'));
expect(recent.open).toBe(false);
});
/*
* Wie App.tsx: die Auswahl lebt ausserhalb des Panels, "Selection details"
* steht nur, solange sie besteht, und `onClearSelection` loescht sie.
+6 -6
View File
@@ -143,6 +143,7 @@ import { galaxyHierarchyText, galaxyHistoryText, galaxyLayerText, galaxyPathText
import { HierarchyBandLabel, HierarchyEdgeLabels } from './HierarchyEdgeLabels';
import { FitLabel, useToolbarFit } from './toolbar-fit';
import { emptyNavigationHistory, moveNavigation, peekNavigation, pushNavigation } from '../graph/navigation-history';
import { useDismissibleMenu } from '../graph/use-dismissible-menu';
import { galaxyHistoryOptions, historyEntryDetail, historyEntryLabel, scopeIdentity, type GalaxyHistoryEntry, type ScopeTrail } from './scope-history';
import { useOrganicLayout } from './use-organic-layout';
import RenderProgress from './RenderProgress';
@@ -2033,6 +2034,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [props.workspaceExpanded, scope.scope, trailView, escapeTaken, leaveScope]);
/* Handtest 2026-10-04 (A2): die Liste schliesst bei einem Klick daneben, mit Escape und mit jedem neuen Ort. */
const recentMenu = useDismissibleMenu(historyKey);
const historyControls = props.workspaceExpanded ? <span className="atlas-graph-history" role="group" aria-label={galaxyHistoryText.group}>
<button type="button" aria-label={galaxyHistoryText.back} disabled={!historyBack} onClick={() => goHistory(-1)}
title={historyBack ? galaxyHistoryText.backTo(historyEntryLabel(historyBack)) : galaxyHistoryText.noBack}>
@@ -2040,16 +2043,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
<button type="button" aria-label={galaxyHistoryText.forward} disabled={!historyForward} onClick={() => goHistory(1)}
title={historyForward ? galaxyHistoryText.forwardTo(historyEntryLabel(historyForward)) : galaxyHistoryText.noForward}>
<FitLabel wide={galaxyHistoryText.forwardWide} narrow={galaxyHistoryText.forwardGlyph} /></button>
{history.recent.length > 1 && <details className="atlas-graph-recent" onKeyDown={event => {
if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; }
}}>
{history.recent.length > 1 && <details className="atlas-graph-recent" ref={recentMenu.ref}>
<summary title={galaxyHistoryText.recentTitle} aria-label={galaxyHistoryText.recent}>{galaxyHistoryText.recentGlyph}</summary>
<ul className="atlas-graph-recent-menu" aria-label={galaxyHistoryText.recentList}>{history.recent.map((entry) => {
const current = galaxyHistoryOptions.recentKey?.(entry) === (scope.scope ? scopeIdentity(scope.scope) : undefined);
return <li key={galaxyHistoryOptions.recentKey?.(entry)}>
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} onClick={(event) => {
const details = event.currentTarget.closest('details');
if (details) details.open = false;
<button type="button" disabled={current} aria-current={current ? 'true' : undefined} onClick={() => {
recentMenu.close();
jumpToRecent(entry);
}}><strong>{entry.scope?.name}</strong><span>{historyEntryDetail(entry)}</span></button>
</li>;
@@ -0,0 +1,86 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { useDismissibleMenu } from './use-dismissible-menu';
/*
* Hand test 2026-10-04 (A2): the Recent menus of Galaxy and Architecture stayed open over the content
* through several steps and while scrolling. Both use this hook.
*/
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
function Fixture({ place, onPick }: { place: string; onPick: () => void }) {
const menu = useDismissibleMenu(place);
return <div>
<details ref={menu.ref}><summary>Recent</summary>
<ul><li><button type="button" onClick={() => { menu.close(); onPick(); }}>Overview</button></li></ul></details>
<p>Content</p><input aria-label="Elsewhere" />
</div>;
}
async function setup(place = 'a') {
const pick = vi.fn();
await act(async () => root.render(<Fixture place={place} onPick={pick} />));
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<HTMLButtonElement>('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<HTMLButtonElement>('li button')!.click());
expect(pick).toHaveBeenCalledOnce();
expect(details.open).toBe(false);
await act(async () => { details.open = true; });
await act(async () => root.render(<Fixture place="a" onPick={pick} />));
expect(details.open).toBe(true);
// Back, Forward, Alt+Left or any other step leads to another place.
await act(async () => root.render(<Fixture place="b" onPick={pick} />));
expect(details.open).toBe(false);
});
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* A `<details>` 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<HTMLDetailsElement>(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 };
}
+5 -2
View File
@@ -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) => ({