diff --git a/docs/development/pr-2068-galaxy-history.md b/docs/development/pr-2068-galaxy-history.md
index 7d8cb00f7..dd4829cf1 100644
--- a/docs/development/pr-2068-galaxy-history.md
+++ b/docs/development/pr-2068-galaxy-history.md
@@ -110,14 +110,30 @@ has opened.
| `routes` | The Routes perspective: Service map or Endpoints. |
| `filter` | The Routes filter. An opened route group ("Show these N routes") is that filter. |
| `system.focus`, `system.expanded` | The System structure focus and the expanded groups. |
+| `system.structurePlanar`, `system.behaviorPlanar` | Plan or 3D in System structure and in Behavior. Each keeps its own camera, as before K27. |
| `system.behavior` | The Behavior start, the destination ("Reach") and, after a followed call (double-click, "Follow calls from here"), the start the hops began from. |
+| `system.behavior.position` | Where the journey stands: the indexed path to the destination ("Path 2"), the operation on that call chain, and the page of direct calls. |
| `system.shown` | The start Behavior shows when none was requested (the server picks one, or the first entry point), so a tooltip can name it. |
-The entry holds identities and names, never projections, scenes or cameras.
-Its key covers only what the current subtab shows: a field another subtab holds
-does not split a step, and the names are there for the tooltips only. The
-Behavior start is the numeric identity of one analysis snapshot and stays
-guarded by its generation, as before.
+The entry holds identities, names and a few small numbers, never projections,
+scenes or cameras. Its key covers only what the current subtab shows: a field
+another subtab holds does not split a step, and the names are there for the
+tooltips only. The Behavior start is the numeric identity of one analysis
+snapshot and stays guarded by its generation, as before.
+
+What another subtab holds stays out of that subtab entirely, not only out of
+the key. The map reads the opened area or file only in Overview and the hotspot
+area only in Hotspots, so Entry points and Endpoints neither draw nor report an
+area opened in Overview, and the chat context there names nothing that is not
+on screen. Back to Overview opens the area again.
+
+Every view that draws a scene makes Plan or 3D a step: the shared map, System
+structure and Behavior. Another path to a destination is a step too. Walking
+the call chain (Previous, Next, the slider, a click on an operation) and paging
+through direct calls are no steps, because Previous and Next already do that;
+the entry keeps where the journey stood, so Back and Forward return to the same
+operation. A new start, destination or followed call begins at the top of its
+journey.
### Rules
@@ -129,21 +145,25 @@ keys, and a fresh history per project (the workspace remounts per project).
Architecture adds:
1. **One Back.** Back, Forward and Recent sit beside the subtabs and serve the
- whole workspace. The "← Back" buttons in System structure and Behavior stay
- where users know them and take the same step, with the same tooltip; there
- is no second, local meaning of Back. "Whole system" stays as a way to the
- top, and the Overview location trail stays; both are ordinary steps.
+ whole workspace. The "← Back" buttons System structure and Behavior had in
+ their own toolbars are absorbed into it: there is one Back control with one
+ meaning, and a step in System structure or Behavior is one Back away like
+ any other. "Whole system" stays as a way to the top, and the Overview
+ location trail stays; both are ordinary steps.
2. **Separate from Galaxy.** Galaxy and Architecture each keep their own
history, and Alt+Left and Alt+Right act only in the active workspace.
3. **Typing is one step.** Typing in the Routes filter becomes a step once it
- pauses (600 ms), not one step per key. A navigation while typing first
- records what the field showed. Back or Forward while typing drops the text
- that is not a step yet.
+ pauses (600 ms), not one step per key. A navigation, Back, Forward or a
+ Recent jump while typing first records what the field showed, so typed text
+ is never lost: Back leaves it for the place before the typing and Forward
+ returns to it. Forward while typing finds no forward branch, because the
+ text is a new navigation; it records the text and stays.
4. **The page's own choices are no steps.** What the page sets by itself (the
suggested Behavior start such as `main`, a reset after reindexing) replaces
the current step (`replaceNavigation`). As a step of its own, Back would land
on an empty Behavior, the page would pick `main` again, and Back would never
- get past it.
+ get past it. A replaced step that equals the step before or after it merges
+ with that step, so Back and Forward never lead to the same place twice.
5. **Followed calls.** A followed call is a new start that remembers where the
hops began. Empty background returns there, as it did before, and that is a
step too.
@@ -169,15 +189,16 @@ Architecture adds:
`SpatialArchitecture`, `RoutesArchitecture`, `SystemArchitecture` and
`BehaviorJourney` read their part of the place and report changes back
(`lifted-place.ts`). Rendered on their own, as in their unit tests, they keep
- that part as their own state and offer no Back.
+ that part as their own state; none of them has a Back of its own.
- Tests: `architecture-history.test.ts` (entry, key, labels),
`ArchitecturePanel.history.test.tsx` (the wiring) and the browser run
`graph-ui/tools/handtest-fixes-k27.mjs` on django-demo and cbm.
### Not in scope
-- Not steps: the Plan or 3D switch in System structure and Behavior, the Entry
- points start and call depth, selections, the path and step within a call
- chain, paging, the relationship filters and the camera.
+- Neither steps nor kept in the entry: the Entry points start and call depth,
+ selections, the relationship filters, the System structure checkboxes, and
+ the camera position (zoom, pan, "Fit"). Plan or 3D is a step, the camera
+ position is not.
- No persistence across reloads. As before, only the subtab is remembered per
project.
diff --git a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx
index a705eb34d..4791f2c4c 100644
--- a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx
+++ b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx
@@ -22,9 +22,9 @@ vi.mock('./ContainerMap', () => ({ default: ({ filter }: { filter: string }) =>
vi.mock('./route-graph-source', () => ({ loadRouteGraph: vi.fn(async () => ({ relationships: [], truncated: false, warnings: [] })) }));
vi.mock('./BehaviorSourceEvidence', () => ({ default: () =>
}));
const graphNode = (id: number, name: string, file_path: string, label = 'Function'): GraphNode =>
@@ -54,6 +54,13 @@ function projection(): SystemProjection {
connections: dependencies.map(edge => ({ ...edge, source: `g:${edge.source}`, target: `g:${edge.target}` })), totals: { groups: 3, components: 3 }, limits: {} },
};
}
+/** main reaches save on two indexed paths: through handle, and directly. */
+function twoPaths(): SystemProjection {
+ const data = projection();
+ const [main, , save] = data.paths[0]!.nodes;
+ data.paths = [...data.paths, { entrypoint_id: 1, nodes: [main!, save!], edges: [{ id: 13, source_id: 1, target_id: 3, type: 'CALLS' }] }];
+ return data;
+}
let container: HTMLDivElement;
let root: Root;
@@ -119,6 +126,14 @@ const filter = () => container.querySelector('input[type="sear
const focus = () => container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-focus');
const heading = () => container.querySelector('.behavior-heading h2')?.textContent;
const sceneLabels = () => [...container.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent);
+/** The "← Back" System structure and Behavior had in their own toolbars before K27. */
+const inViewBack = () => buttons().find(button => button.textContent === '← Back');
+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;
+const pathButtons = () => [...container.querySelectorAll('[aria-label="Indexed paths"] button')];
+const chosenPath = () => pathButtons().findIndex(button => button.getAttribute('aria-pressed') === 'true') + 1;
+const lastEvidence = (listener: ReturnType) => listener.mock.lastCall?.[0] as { label: string; text: string } | undefined;
const key = (init: KeyboardEventInit, target: EventTarget = window) => {
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
act(() => { target.dispatchEvent(event); });
@@ -204,9 +219,9 @@ describe('Architecture back and forward (K27)', () => {
await render();
await press(container.querySelector('[data-view="structure"]'));
expect(focus()).toBe('');
- const oldBack = () => byText('← Back', container.querySelector('.system-scope-actions')!);
- expect(oldBack()?.disabled).toBe(false);
- expect(oldBack()?.title).toBe('Back to Overview (Alt+Left)');
+ // The old "← Back" is absorbed into the shared Back beside the subtabs: one Back control, one meaning.
+ expect(inViewBack()).toBeUndefined();
+ expect(back()?.title).toBe('Back to Overview (Alt+Left)');
await doubleClick('[data-system-node="g:api"]');
expect(focus()).toBe('g:api');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull();
@@ -214,15 +229,16 @@ describe('Architecture back and forward (K27)', () => {
await press(container.querySelector('[data-system-node="g:service"]'));
await clickText('Focus here');
expect(focus()).toBe('g:service');
- expect(oldBack()?.title).toBe('Back to System structure · api · 1 group open (Alt+Left)');
+ expect(back()?.title).toBe('Back to System structure · api · 1 group open (Alt+Left)');
+ expect(inViewBack()).toBeUndefined();
- await press(oldBack());
+ await goBack();
expect(focus()).toBe('g:api');
await goBack();
expect(focus()).toBe('');
expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull();
- // One meaning of Back: the old button now also leaves System structure.
- await press(oldBack());
+ // One meaning of Back: the same Back also leaves System structure.
+ await goBack();
expect(view()).toBe('overview');
await goForward(); await goForward();
expect([view(), focus()]).toEqual(['structure', 'g:api']);
@@ -246,11 +262,11 @@ describe('Architecture back and forward (K27)', () => {
expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
await doubleClick('[data-system-node="journey-choice:3"]');
expect(heading()).toBe('What can save call?');
- const oldBack = () => byText('← Back', container.querySelector('.behavior-navigation')!);
- expect(oldBack()?.title).toBe('Back to Behavior · handle (Alt+Left)');
+ expect(inViewBack()).toBeUndefined();
+ expect(back()?.title).toBe('Back to Behavior · handle (Alt+Left)');
expect(forward()?.disabled).toBe(true);
- await press(oldBack());
+ await goBack();
expect(heading()).toBe('What can handle call?');
expect(forward()?.title).toBe('Forward to Behavior · save · followed from handle (Alt+Right)');
await goBack();
@@ -289,6 +305,121 @@ describe('Architecture back and forward (K27)', () => {
expect(forward()?.title).toBe('Forward to Behavior · main (Alt+Right)');
});
+ it('makes Plan or 3D in System structure a step that Back restores', async () => {
+ await render();
+ await press(container.querySelector('[data-view="structure"]'));
+ expect(systemCamera()).toBe('3D');
+ expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('false');
+ await clickText('Plan', container.querySelector('[aria-label="System camera"]')!);
+ expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
+ expect(back()?.title).toBe('Back to System structure (Alt+Left)');
+ await press(container.querySelector('[data-view="overview"]'));
+ expect(back()?.title).toBe('Back to System structure · Plan (Alt+Left)');
+
+ await goBack();
+ expect([view(), systemCamera()]).toEqual(['structure', 'Plan']);
+ expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true');
+ await goBack();
+ expect([view(), systemCamera()]).toEqual(['structure', '3D']);
+ await goForward(); await goForward();
+ expect(view()).toBe('overview');
+ // Behavior keeps a camera of its own, as before.
+ await goBack();
+ await press(container.querySelector('[data-view="behavior"]'));
+ expect(behaviorCamera()).toBe('3D');
+ });
+
+ it('keeps the Behavior path, the operation on it and Plan or 3D in the entry, and only a path or camera change is a step', async () => {
+ loader.mockImplementation(async () => ({ status: 'ready', generation: 'g1', result: twoPaths() }));
+ await render();
+ await press(container.querySelector('[data-view="behavior"]'));
+ expect(heading()).toBe('What can main call?');
+ await choose('Behavior destination', '3');
+ expect(heading()).toBe('main → save');
+ expect([chosenPath(), chainPosition()]).toEqual([1, '1 / 3']);
+ await clickText('Next →');
+ expect(chainPosition()).toBe('2 / 3');
+ // Walking the chain is no step of its own: Previous and Next do that; the entry keeps where it stands.
+ expect(back()?.title).toBe('Back to Behavior · main (Alt+Left)');
+ await press(pathButtons()[1]);
+ expect([chosenPath(), chainPosition()]).toEqual([2, '1 / 2']);
+ expect(back()?.title).toBe('Back to Behavior · main → save (Alt+Left)');
+ await clickText('Plan', container.querySelector('[aria-label="Behavior camera"]')!);
+ expect(back()?.title).toBe('Back to Behavior · main → save · Path 2 (Alt+Left)');
+ await press(container.querySelector('[data-view="overview"]'));
+ expect(back()?.title).toBe('Back to Behavior · main → save · Path 2 · Plan (Alt+Left)');
+
+ await goBack();
+ expect([view(), behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['behavior', 'Plan', 2, '1 / 2']);
+ await goBack();
+ expect([behaviorCamera(), chosenPath()]).toEqual(['3D', 2]);
+ await goBack();
+ expect([heading(), chosenPath(), chainPosition()]).toEqual(['main → save', 1, '2 / 3']);
+ await goBack();
+ expect([heading(), container.querySelector('[aria-label="Walk the call chain"]')]).toEqual(['What can main call?', null]);
+ await goForward();
+ expect([chosenPath(), chainPosition()]).toEqual([1, '2 / 3']);
+ await goForward(); await goForward();
+ expect([behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['Plan', 2, '1 / 2']);
+ // System structure keeps a camera of its own, as before.
+ await press(container.querySelector('[data-view="structure"]'));
+ expect(systemCamera()).toBe('3D');
+ });
+
+ it('records typing that has not paused yet as a step when Back, Forward or Recent leaves it', async () => {
+ await render();
+ await press(container.querySelector('[data-view="routes"]'));
+ await type('/adm');
+ await goBack();
+ expect([view(), filter()]).toEqual(['routes', '']);
+ expect(forward()?.title).toBe('Forward to Routes · Service map · /adm (Alt+Right)');
+ await goForward();
+ expect([view(), filter()]).toEqual(['routes', '/adm']);
+ await act(async () => { await new Promise(resolve => setTimeout(resolve, 900)); });
+ expect(history()?.getAttribute('data-position')).toBe('3/3');
+ // A recent place while typing: the text stays a step before the jump.
+ await type('/home');
+ const recent = container.querySelector('details.atlas-arch-recent')!;
+ const overviewPlace = [...recent.querySelectorAll('[aria-label="Recently visited places"] li button')]
+ .find(item => item.querySelector('strong')?.textContent === 'Overview');
+ await press(overviewPlace);
+ expect(view()).toBe('overview');
+ expect(back()?.title).toBe('Back to Routes · Service map · /home (Alt+Left)');
+ await goBack();
+ expect([view(), filter()]).toEqual(['routes', '/home']);
+ // Forward while typing: the text is a new step and drops the forward branch, as after the pause.
+ await goBack();
+ expect(filter()).toBe('/adm');
+ await type('/admin');
+ await goForward();
+ expect(filter()).toBe('/admin');
+ expect(forward()?.disabled).toBe(true);
+ expect(back()?.title).toBe('Back to Routes · Service map · /adm (Alt+Left)');
+ });
+
+ it('reports an opened area only to the subtab that shows it, never to Routes or Hotspots', async () => {
+ const onSelectionEvidence = vi.fn();
+ await render({ onSelectionEvidence });
+ await openArea('django');
+ expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
+ await press(container.querySelector('[data-view="routes"]'));
+ await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!);
+ expect(sceneLabels()).toContain('/edit · 2');
+ // Nothing is selected on the Endpoints map, so the chat context names nothing from Overview.
+ expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
+ await press(container.querySelector('[data-view="hotspots"]'));
+ expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
+ await press(container.querySelector('[data-view="overview"]'));
+ expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
+ await press(container.querySelector('[data-view="routes"]'));
+ await goBack();
+ expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]);
+ expect(lastEvidence(onSelectionEvidence)?.label).toBe('django');
+ await goBack(); await goBack();
+ expect([view(), perspective()]).toEqual(['routes', 'Endpoints']);
+ expect(lastEvidence(onSelectionEvidence)).toBeUndefined();
+ });
+
it('drops the forward branch on a new navigation after Back and keeps at most 25 steps', async () => {
await render();
for (const tab of ['routes', 'hotspots', 'structure']) await press(container.querySelector(`[data-view="${tab}"]`));
diff --git a/graph-ui/src/architecture/ArchitecturePanel.tsx b/graph-ui/src/architecture/ArchitecturePanel.tsx
index 5c90b625a..f91103022 100644
--- a/graph-ui/src/architecture/ArchitecturePanel.tsx
+++ b/graph-ui/src/architecture/ArchitecturePanel.tsx
@@ -9,7 +9,7 @@ import { architectureHistoryText as historyText, architectureText as text } from
import { peekNavigation, type NavigationHistory } from '../graph/navigation-history';
import {
architectureEntryDetail, architectureEntryLabel, architectureEntryName, architectureHistoryOptions, initialArchitecturePlace,
- type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SharedBack, type SpatialPlace, type SystemPlace,
+ type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SpatialPlace, type SystemPlace,
} from './architecture-history';
import { useArchitectureHistory } from './use-architecture-history';
import './architecture.css';
@@ -161,11 +161,12 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
const storage = useMemo(browserStorage, []);
/*
* The place is the whole history entry (K27): subtab, opened area or file,
- * Plan or 3D, route perspective and filter, System structure focus and
- * groups, Behavior start. The views below read their part of it and report
- * changes back, so Back and Forward restore every part at once. 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.
+ * 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
+ * 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.
*/
const navigation = useArchitectureHistory(() => initialArchitecturePlace(readArchitectureConfig(storage, projectName).view), {
active, escapeTaken, onRestore: (from, to) => { if (from.view !== to.view) onSelectionEvidence?.(undefined); },
@@ -180,7 +181,6 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
const changePerspective = useCallback((routes: RoutesPerspective) => navigate(current => ({ ...current, routes })), [navigate]);
// An opened route group ("Show these N routes") is a step at once; typing waits for a pause.
const openRouteGroup = useCallback((filter: string) => navigate(current => ({ ...current, filter })), [navigate]);
- const sharedBack: SharedBack = { target: navigation.back ? architectureEntryLabel(navigation.back) : undefined, onBack: () => navigation.go(-1) };
// A cached summary from another project must never appear here.
const data = overview?.projectName && overview.projectName !== projectName ? undefined : overview;
const ready = Boolean(data && !loading && !error && projectName);
@@ -207,7 +207,7 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error,
{systemView && projectName ? Preparing system analysis…