From 397b5dc68d6cef5d0a70e5886ee0f42a6e27e975 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 13:24:00 +0200 Subject: [PATCH 1/4] fix(ui): put Architecture Back and Forward first, as in Galaxy, and close Recent menus (A1, A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture showed bare arrows at the right end of its subtab row while Galaxy has "← Back", "Forward →" and "▾" at the start of its toolbar. The controls now come first in the row with Galaxy's words and tooltips; the row measures itself with the shared toolbar fit and shows the glyphs alone once the tabs would scroll. The Recent menus of both workspaces stayed open over the content. One shared hook (graph/use-dismissible-menu.ts) closes them on a press, wheel or focus outside, on Escape (which no longer reaches the page), on a chosen entry and whenever the place changes. The K27 browser check counts an in-view "← Back" outside the shared history group only, since the shared one now carries the same words. Signed-off-by: Bernhard Jackiewicz --- .../ArchitecturePanel.history.test.tsx | 63 +++++++++++++- .../src/architecture/ArchitecturePanel.tsx | 34 +++++--- graph-ui/src/architecture/architecture.css | 23 +++-- graph-ui/src/architecture/strings.ts | 3 + .../src/galaxy/GalaxyPanel.history.test.tsx | 21 +++++ graph-ui/src/galaxy/GalaxyPanel.tsx | 12 +-- .../src/graph/use-dismissible-menu.test.tsx | 86 +++++++++++++++++++ graph-ui/src/graph/use-dismissible-menu.ts | 57 ++++++++++++ graph-ui/tools/handtest-fixes-k27.mjs | 7 +- 9 files changed, 276 insertions(+), 30 deletions(-) create mode 100644 graph-ui/src/graph/use-dismissible-menu.test.tsx create mode 100644 graph-ui/src/graph/use-dismissible-menu.ts diff --git a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx index 4791f2c4c..fc9d60dd4 100644 --- a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx +++ b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx @@ -9,6 +9,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import ArchitecturePanel, { type ArchitecturePanelProps } from './ArchitecturePanel'; +import { fitToolbar } from '../galaxy/toolbar-fit'; import type { ArchitectureSceneProps } from './ArchitectureScene'; import type { ArchitectureOverviewDto } from '../core/intelligence-provider'; import type { GraphData, GraphNode } from '../galaxy/types'; @@ -126,8 +127,8 @@ const filter = () => container.querySelector('input[type="sear const focus = () => container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-focus'); const heading = () => container.querySelector('.behavior-heading h2')?.textContent; const sceneLabels = () => [...container.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent); -/** The "← Back" System structure and Behavior had in their own toolbars before K27. */ -const inViewBack = () => buttons().find(button => button.textContent === '← Back'); +/** The "← Back" System structure and Behavior had in their own toolbars before K27; the shared one carries the same words. */ +const inViewBack = () => buttons().find(button => button.textContent === '← Back' && !history()?.contains(button)); const systemCamera = () => container.querySelector('[aria-label="System camera"] button[aria-pressed="true"]')?.textContent; const behaviorCamera = () => container.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')?.textContent; const chainPosition = () => container.querySelector('[aria-label="Walk the call chain"] span')?.textContent; @@ -495,3 +496,61 @@ describe('Architecture back and forward (K27)', () => { expect(container.querySelector('details.atlas-arch-recent')).toBeNull(); }); }); + +/* + * Hand test 2026-10-04: Back, Forward and Recent stood as bare arrows at the right end of the subtab row, + * while Galaxy has "← Back", "Forward →" and "▾" at the start of its toolbar (A1), and the Recent menu stayed + * open over the content through several steps (A2). + */ +describe('Architecture history controls as in Galaxy (hand test 2026-10-04)', () => { + const recentMenu = () => container.querySelector('details.atlas-arch-recent'); + async function withRecent(): Promise { + await render(); + await press(container.querySelector('[data-view="routes"]')); + await press(container.querySelector('[data-view="hotspots"]')); + const menu = recentMenu(); + expect(menu).not.toBeNull(); + await act(async () => { menu!.open = true; }); + return menu!; + } + + it('A1: puts Back, Forward and Recent at the start of the subtab row, worded as in Galaxy', async () => { + await render(); + await press(container.querySelector('[data-view="routes"]')); + const row = container.querySelector('.atlas-arch-tabrow')!; + expect([...row.children].map(item => item.getAttribute('aria-label'))).toEqual(['Architecture history', 'Architecture views']); + expect(back()?.textContent).toBe('← Back'); + expect(back()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('←'); + expect(forward()?.textContent).toBe('Forward →'); + expect(forward()?.querySelector('.atlas-fit-narrow')?.getAttribute('data-label')).toBe('→'); + expect(back()?.title).toBe('Back to Overview (Alt+Left)'); + expect(recentMenu()?.querySelector('summary')?.textContent).toBe('▾'); + // The row measures itself like Galaxy's toolbar: words while the tabs fit, the glyphs once they would scroll. + expect(row.dataset.fit).toBe('full'); + const tabs = row.querySelector('nav')!; + Object.defineProperties(tabs, { clientWidth: { configurable: true, value: 300 }, scrollWidth: { configurable: true, value: 520 } }); + expect(fitToolbar(row)).toBe('compact'); + }); + + it('A2: closes the Recent menu on a press elsewhere and on Escape, which stays with the menu', async () => { + const menu = await withRecent(); + await act(async () => { container.querySelector('.atlas-arch-tabs')!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); }); + expect(menu.open).toBe(false); + await act(async () => { menu.open = true; }); + expect(key({ key: 'Escape' }, menu.querySelector('summary')!).defaultPrevented).toBe(true); + expect(menu.open).toBe(false); + expect(view()).toBe('hotspots'); + }); + + it('A2: closes the Recent menu when Back or Alt+Right change the place', async () => { + const menu = await withRecent(); + await goBack(); + expect(view()).toBe('routes'); + expect(menu.open).toBe(false); + await act(async () => { menu.open = true; }); + expect(key({ key: 'ArrowRight', altKey: true }).defaultPrevented).toBe(true); + await settle(); + expect(view()).toBe('hotspots'); + expect(menu.open).toBe(false); + }); +}); diff --git a/graph-ui/src/architecture/ArchitecturePanel.tsx b/graph-ui/src/architecture/ArchitecturePanel.tsx index f91103022..5eb740d3e 100644 --- a/graph-ui/src/architecture/ArchitecturePanel.tsx +++ b/graph-ui/src/architecture/ArchitecturePanel.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { JSX, ReactNode } from 'react'; import type { ArchitectureHotspot, ArchitectureOverviewDto } from '../core/intelligence-provider'; import { @@ -7,6 +7,8 @@ import { import type { ArchitectureView, ConfigStorage } from './architecture-model'; import { architectureHistoryText as historyText, architectureText as text } from './strings'; import { peekNavigation, type NavigationHistory } from '../graph/navigation-history'; +import { useDismissibleMenu } from '../graph/use-dismissible-menu'; +import { FitLabel, useToolbarFit } from '../galaxy/toolbar-fit'; import { architectureEntryDetail, architectureEntryLabel, architectureEntryName, architectureHistoryOptions, initialArchitecturePlace, type ArchitectureHistoryEntry, type PlaceChange, type RoutesPerspective, type SpatialPlace, type SystemPlace, @@ -125,6 +127,9 @@ const paletteStyle = scenePaletteStyle(); * Back, Forward and the Recent list for the whole workspace (K27). The * tooltips name the target ("Back to Overview · django"); the list holds the * last distinct places, newest first, and the current one is not a target. + * They stand at the start of the subtab row and read as in Galaxy, "← Back", + * "Forward →" and "▾", with the glyphs alone once the tabs would scroll + * (hand test 2026-10-04, A1). The menu closes as Galaxy's does (A2). */ function HistoryControls({ history, place, onGo, onJump }: { history: NavigationHistory; place: ArchitectureHistoryEntry; @@ -132,23 +137,23 @@ function HistoryControls({ history, place, onGo, onJump }: { }): JSX.Element { const back = peekNavigation(history, -1), forward = peekNavigation(history, 1); const here = architectureHistoryOptions.recentKey?.(place); + const menu = useDismissibleMenu(architectureHistoryOptions.key(place)); return
+ title={back ? historyText.backTo(architectureEntryLabel(back)) : historyText.noBack}> + - {history.recent.length > 1 &&
{ - if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; } - }}> + title={forward ? historyText.forwardTo(architectureEntryLabel(forward)) : historyText.noForward}> + + {history.recent.length > 1 &&
{historyText.recentGlyph}
    {history.recent.map(entry => { const id = architectureHistoryOptions.recentKey?.(entry); const current = id === here; const detail = architectureEntryDetail(entry); return
  • -
  • ; @@ -164,7 +169,7 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error, * route perspective and filter, System structure focus and groups, Behavior * start, destination and position, and Plan or 3D of each scene. The views * below read their part of it and report changes back, so Back and Forward - * restore every part at once; the one Back for all of them sits beside the + * restore every part at once; the one Back for all of them sits before the * subtabs. A filter is a search for this visit: one saved by an earlier * session would silently hide routes, so only the subtab is read back. */ @@ -172,6 +177,9 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error, active, escapeTaken, onRestore: (from, to) => { if (from.view !== to.view) onSelectionEvidence?.(undefined); }, }); const { place, navigate } = navigation; + // The row measures itself as Galaxy's toolbar does (toolbar-fit.tsx): it counts as narrow once the tabs would scroll. + const tabRow = useRef(null); + useToolbarFit(tabRow, true); useEffect(() => { saveArchitectureConfig(storage, projectName, { view: place.view, filter: place.filter }); }, [place.view, place.filter, projectName, storage]); const setView = useCallback((view: ArchitectureView) => navigate(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view })), [navigate]); const changeSpatial = useCallback>((change, automatic) => @@ -198,11 +206,11 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error, } : undefined; // Every scene below draws with the same palette; the DOM reads it as CSS variables. return
    -
    -
    }> .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 { diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts index 867b173a4..e9b3bf937 100644 --- a/graph-ui/src/architecture/strings.ts +++ b/graph-ui/src/architecture/strings.ts @@ -114,6 +114,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', diff --git a/graph-ui/src/galaxy/GalaxyPanel.history.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.history.test.tsx index e705d193d..cb2eb57ff 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.history.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.history.test.tsx @@ -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('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. diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index be266df88..960f16a83 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -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 ? - {history.recent.length > 1 &&
    { - if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; } - }}> + {history.recent.length > 1 &&
    {galaxyHistoryText.recentGlyph}
      {history.recent.map((entry) => { const current = galaxyHistoryOptions.recentKey?.(entry) === (scope.scope ? scopeIdentity(scope.scope) : undefined); return
    • -
    • ; diff --git a/graph-ui/src/graph/use-dismissible-menu.test.tsx b/graph-ui/src/graph/use-dismissible-menu.test.tsx new file mode 100644 index 000000000..f898b45eb --- /dev/null +++ b/graph-ui/src/graph/use-dismissible-menu.test.tsx @@ -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).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
      +
      Recent +
      +

      Content

      +
      ; +} +async function setup(place = 'a') { + const pick = vi.fn(); + await act(async () => root.render()); + const details = host.querySelector('details')!; + await act(async () => { details.open = true; }); + return { pick, details }; +} +const fire = (target: EventTarget, type: string) => act(async () => { target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true })); }); + +it('closes on a pointer press outside the menu and stays open for one inside it', async () => { + const { details } = await setup(); + await fire(details.querySelector('summary')!, 'pointerdown'); + await fire(details.querySelector('li button')!, 'pointerdown'); + expect(details.open).toBe(true); + await fire(host.querySelector('p')!, 'pointerdown'); + expect(details.open).toBe(false); +}); + +it('closes when the page scrolls under it or focus moves elsewhere', async () => { + const { details } = await setup(); + await act(async () => { host.querySelector('p')!.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 })); }); + expect(details.open).toBe(false); + await act(async () => { details.open = true; }); + // The list scrolls itself without closing. + await act(async () => { details.querySelector('ul')!.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: 120 })); }); + expect(details.open).toBe(true); + await act(async () => { host.querySelector('input')!.focus(); }); + expect(details.open).toBe(false); +}); + +it('closes on Escape, takes the key from the page and returns focus to its summary', async () => { + const { details } = await setup(); + const entry = details.querySelector('li button')!; + entry.focus(); + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + const page = vi.fn(); + window.addEventListener('keydown', page); + await act(async () => { entry.dispatchEvent(event); }); + window.removeEventListener('keydown', page); + expect(details.open).toBe(false); + expect(event.defaultPrevented).toBe(true); + expect(page).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(details.querySelector('summary')); + // Closed, Escape belongs to the page again. + const later = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + await act(async () => { document.body.dispatchEvent(later); }); + expect(later.defaultPrevented).toBe(false); +}); + +it('closes when an entry is chosen and whenever the place changes', async () => { + const { pick, details } = await setup(); + await act(async () => details.querySelector('li button')!.click()); + expect(pick).toHaveBeenCalledOnce(); + expect(details.open).toBe(false); + await act(async () => { details.open = true; }); + await act(async () => root.render()); + expect(details.open).toBe(true); + // Back, Forward, Alt+Left or any other step leads to another place. + await act(async () => root.render()); + expect(details.open).toBe(false); +}); diff --git a/graph-ui/src/graph/use-dismissible-menu.ts b/graph-ui/src/graph/use-dismissible-menu.ts new file mode 100644 index 000000000..6c0b961c1 --- /dev/null +++ b/graph-ui/src/graph/use-dismissible-menu.ts @@ -0,0 +1,57 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * A `
      ` menu that closes once the reader is done with it, as the + * Recent menus of Galaxy and Architecture do (hand test 2026-10-04, A2: the + * menu stayed open over the content through several steps and while + * scrolling). + * + * It closes on a pointer press, a wheel turn or a focus outside it, on Escape + * (which then belongs to the menu alone: the page neither leaves its scope nor + * clears its selection), when the caller closes it after an entry was chosen, + * and whenever `place` changes: Back, Forward, Alt+Left and every other step + * lead somewhere else, and the list would name the wrong current place. + * + * The listeners stay on the document for the life of the menu and do nothing + * while it is closed, so opening it needs no React state and no render. + */ +export function useDismissibleMenu(place: string) { + const ref = useRef(null); + const close = useCallback(() => { + const menu = ref.current; + if (menu?.open) menu.open = false; + }, []); + useEffect(() => { + const outside = (event: Event) => { + const menu = ref.current; + if (menu?.open && !(event.target instanceof Node && menu.contains(event.target))) menu.open = false; + }; + const escape = (event: KeyboardEvent) => { + const menu = ref.current; + if (event.key !== 'Escape' || !menu?.open) return; + event.preventDefault(); + event.stopPropagation(); + const inside = document.activeElement instanceof Node && menu.contains(document.activeElement); + menu.open = false; + // An entry that had focus is hidden now; the summary keeps the reader in place. + if (inside) menu.querySelector('summary')?.focus(); + }; + document.addEventListener('pointerdown', outside, true); + document.addEventListener('wheel', outside, { capture: true, passive: true }); + document.addEventListener('focusin', outside, true); + document.addEventListener('keydown', escape, true); + return () => { + document.removeEventListener('pointerdown', outside, true); + document.removeEventListener('wheel', outside, { capture: true }); + document.removeEventListener('focusin', outside, true); + document.removeEventListener('keydown', escape, true); + }; + }, []); + const shown = useRef(place); + useEffect(() => { + if (shown.current === place) return; + shown.current = place; + close(); + }, [place, close]); + return { ref, close }; +} diff --git a/graph-ui/tools/handtest-fixes-k27.mjs b/graph-ui/tools/handtest-fixes-k27.mjs index bb82db99b..e6bb7d046 100644 --- a/graph-ui/tools/handtest-fixes-k27.mjs +++ b/graph-ui/tools/handtest-fixes-k27.mjs @@ -100,8 +100,11 @@ async function state(page) { behaviorCamera: text(root.querySelector('[aria-label="Behavior camera"] button[aria-pressed="true"]')), chain: text(root.querySelector('[aria-label="Walk the call chain"] span')), path: paths.length ? paths.findIndex((element) => element.getAttribute('aria-pressed') === 'true') + 1 : null, - /* Das "← Back", das System structure und Behavior frueher in der eigenen Leiste hatten. */ - inViewBack: [...root.querySelectorAll('button')].filter((element) => element.textContent?.trim() === '← Back').length, + /* + * Das "← Back", das System structure und Behavior frueher in der eigenen Leiste hatten. Das gemeinsame + * Zurueck traegt seit dem Handtest vom 2026-10-04 (A1) dieselben Worte wie in Galaxy und zaehlt nicht mit. + */ + inViewBack: [...root.querySelectorAll('button')].filter((element) => element.textContent?.trim() === '← Back' && !history?.contains(element)).length, back: button('Back'), forward: button('Forward'), position: history?.getAttribute('data-position') ?? null, recent: [...(history?.querySelectorAll('[aria-label="Recently visited places"] li button') ?? [])].map((element) => ({ From 91d47cb187ab48c3dd3c26e97fb9f6ea3235eac3 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 13:31:37 +0200 Subject: [PATCH 2/4] fix(ui): make the Architecture Refresh buttons say what they did (A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Refresh connections" read every route relationship again and nothing on screen changed; Service map "Refresh", "Refresh analysis" and the Behavior "Refresh" did the same. All four now share one control (architecture/refresh-feedback.tsx): while the reading runs the button reads "Refreshing …" and ignores a second press, afterwards a status beside it names the time and whether anything changed ("Up to date at 13:29:43: no changes since the last load") or the error. A refresh of the same index keeps the route connections on screen until the new ones arrive instead of dropping back to "Reading …". The strings live in architecture/strings.ts. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/architecture/BehaviorJourney.tsx | 7 +- .../src/architecture/ContainerMap.test.tsx | 38 +++++++++ graph-ui/src/architecture/ContainerMap.tsx | 21 ++++- .../architecture/SpatialArchitecture.test.tsx | 42 ++++++++++ .../src/architecture/SpatialArchitecture.tsx | 25 ++++-- .../architecture/SystemArchitecture.test.tsx | 46 +++++++++++ .../src/architecture/SystemArchitecture.tsx | 9 +- graph-ui/src/architecture/architecture.css | 6 ++ .../src/architecture/behavior-journey.css | 2 +- .../architecture/refresh-feedback.test.tsx | 48 +++++++++++ .../src/architecture/refresh-feedback.tsx | 82 +++++++++++++++++++ graph-ui/src/architecture/strings.ts | 9 ++ 12 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 graph-ui/src/architecture/refresh-feedback.test.tsx create mode 100644 graph-ui/src/architecture/refresh-feedback.tsx diff --git a/graph-ui/src/architecture/BehaviorJourney.tsx b/graph-ui/src/architecture/BehaviorJourney.tsx index 2437c444b..ae02f71d8 100644 --- a/graph-ui/src/architecture/BehaviorJourney.tsx +++ b/graph-ui/src/architecture/BehaviorJourney.tsx @@ -7,6 +7,7 @@ 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 './behavior-journey.css'; const Scene = lazy(() => import('./SystemArchitectureScene')); @@ -28,6 +29,8 @@ 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; onNavigate: (path: string, line?: number, name?: string) => void; } @@ -53,7 +56,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, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) { const [place, changePlace] = useLiftedPlace(liftedPlace, onPlace, () => ({})); const { path: pathIndex = 0, step = 0, page: branchPage = 0, planar = false } = place; const [overview, setOverview] = useState(false); @@ -160,7 +163,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta
      Behavior · source-guided exploration

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

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

      -
      +
      {/* The field names the operation the journey shows, also when the projection chose it. */} } {view === 'hotspots' && hotspotArea && } - {view === 'routes' && } + {view === 'routes' && { routeRefresh.begin(); setRouteRevision(value => value + 1); }} />} {view === 'routes' && } {filePath && (view === 'overview' || view === 'dependencies') && } {(view === 'overview' || view === 'dependencies') &&
      diff --git a/graph-ui/src/architecture/SystemArchitecture.test.tsx b/graph-ui/src/architecture/SystemArchitecture.test.tsx index edbe55504..c5b6dcbc8 100644 --- a/graph-ui/src/architecture/SystemArchitecture.test.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.test.tsx @@ -595,3 +595,49 @@ describe('system architecture workspace', () => { expect(container.querySelector('[role="alert"]')?.textContent).toContain('Analysis budget exceeded'); expect(loader).toHaveBeenCalledTimes(1); }); }); + +/* Hand test 2026-10-04 (A3): "Refresh analysis" and the Behavior "Refresh" read again and said nothing. */ +describe('system architecture refresh feedback', () => { + const refresh = (pattern: RegExp) => [...container.querySelectorAll('button')].find(element => pattern.test(element.textContent ?? ''))!; + const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent; + afterEach(() => { vi.useRealTimers(); }); + + it('A3: Refresh analysis is busy while it runs, then names the time and whether the index changed', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 9, 4, 18, 0, 9)); + const loader = vi.fn().mockResolvedValue(response(overviewFixture())); + await render(loader); + let finish!: (value: SystemArchitectureResponse) => void; + loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await act(async () => refresh(/^Refresh(ing)? analysis/).click()); + expect(refresh(/^Refresh(ing)? analysis/).textContent).toBe('Refreshing analysis…'); + expect(refresh(/^Refresh(ing)? analysis/).getAttribute('aria-disabled')).toBe('true'); + // The structure stays on screen meanwhile. + expect(container.querySelector('[data-testid="system-scene"]')).not.toBeNull(); + await act(async () => finish(response(overviewFixture()))); + expect(refresh(/^Refresh(ing)? analysis/).textContent).toBe('Refresh analysis'); + expect(status()).toBe('Up to date at 18:00:09: no changes since the last load'); + loader.mockResolvedValueOnce({ ...response(overviewFixture()), generation: 'g2' }); + await act(async () => refresh(/^Refresh(ing)? analysis/).click()); + expect(status()).toBe('Analysis refreshed at 18:00:09'); + loader.mockRejectedValueOnce(new Error('Daemon stopped.')); + await act(async () => refresh(/^Refresh(ing)? analysis/).click()); + expect(status()).toBe('Refresh failed at 18:00:09: Daemon stopped.'); + }); + + it('A3: the Behavior Refresh says the same beside its button', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 9, 4, 18, 1, 0)); + const loader = vi.fn().mockResolvedValue(response(overviewFixture())); + await render(loader, { view: 'behavior' }); + const button = () => container.querySelector('.behavior-heading .atlas-arch-refresh button')!; + expect(button()?.textContent).toBe('Refresh'); + let finish!: (value: SystemArchitectureResponse) => void; + loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + await act(async () => button().click()); + expect(button().textContent).toBe('Refreshing…'); + await act(async () => finish(response(overviewFixture()))); + expect(button().textContent).toBe('Refresh'); + expect(container.querySelector('.behavior-heading [role="status"]')?.textContent).toBe('Up to date at 18:01:00: no changes since the last load'); + }); +}); diff --git a/graph-ui/src/architecture/SystemArchitecture.tsx b/graph-ui/src/architecture/SystemArchitecture.tsx index 8a6afe080..0af327d75 100644 --- a/graph-ui/src/architecture/SystemArchitecture.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.tsx @@ -10,6 +10,7 @@ import type { FlowSummary } from '../traces/trace-schemas'; import BehaviorJourney from './BehaviorJourney'; import type { BehaviorPlace, JourneyPlace, PlaceChange, SystemPlace } from './architecture-history'; import { useLiftedPlace, useOnIdentityChange } from './lifted-place'; +import { RefreshControl, useRefreshFeedback } from './refresh-feedback'; import './system-architecture.css'; const Scene = lazy(() => import('./SystemArchitectureScene')); @@ -159,6 +160,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. @@ -318,7 +322,7 @@ export default function SystemArchitecture({ project, generation, view, filter, }, automatic); const behaviorPage = view === 'behavior' ? setRevision(value => value + 1)} onNavigate={onNavigate} onSelectSymbol={selectSymbol} onClearSelection={onClearSelection} onSelectionEvidence={onSelectionEvidence} + error={error} filter={filter} onRefresh={refreshAnalysis} refresh={refresh.feedback} 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 +336,8 @@ export default function SystemArchitecture({ project, generation, view, filter, return

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

      - + { refreshAnalysis(); setResetKey(value => value + 1); }} />
      {view === 'structure' && + {/* + * The field names the operation the journey shows, also when the projection chose it. Suggestions come + * first, then every operation alphabetically; the filter before it narrows both (hand test 2026-10-04, A4). + */} + diff --git a/graph-ui/src/architecture/SystemArchitecture.test.tsx b/graph-ui/src/architecture/SystemArchitecture.test.tsx index c5b6dcbc8..a19f79dc8 100644 --- a/graph-ui/src/architecture/SystemArchitecture.test.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.test.tsx @@ -561,8 +561,10 @@ describe('system architecture workspace', () => { expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample'); expect(loader.mock.calls.at(-1)?.[0]).toEqual({ project: 'sample', entryNodeId: 36163, expectedGeneration: 'g1', includeBehaviorEvidence: true }); const start = container.querySelector('[aria-label="Behavior entry point"]')!; - expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…', 'run_tests · tests/runtests.py', - 'handle · django/core/management/commands/runserver.py']); + // Alphabetical (hand test 2026-10-04, A4); with every operation suggested a Suggested group would only repeat the list. + expect([...start.options].map(option => option.textContent)).toEqual(['Choose an operation…', + 'handle · django/core/management/commands/runserver.py', 'run_tests · tests/runtests.py']); + expect(start.querySelectorAll('optgroup')).toHaveLength(1); expect(start.value).toBe('36163'); const state = container.querySelector('.behavior-loading[role="status"]')!; expect(state.textContent).toContain('The analysis returned no call evidence.'); @@ -570,7 +572,9 @@ describe('system architecture workspace', () => { expect(state.textContent).toContain('Optional behavior evidence was omitted entirely'); await choose('Behavior entry point', '9'); expect(loader.mock.calls.at(-1)?.[0].entryNodeId).toBe(9); - expect([...start.options].map(option => option.value)).toEqual(['', '9', '36163']); + // The chosen start keeps its place in the alphabetical list. + expect([...start.options].map(option => option.value)).toEqual(['', '36163', '9']); + expect(start.value).toBe('9'); }); it('does not ask for flows once the projection classifies enough entry points', async () => { const flowsLoader = vi.fn().mockResolvedValue([]); @@ -586,8 +590,46 @@ describe('system architecture workspace', () => { await render(vi.fn().mockResolvedValue(response()), { view: 'behavior', flowsLoader }); expect(flowsLoader).toHaveBeenCalledExactlyOnceWith('sample'); const start = container.querySelector('[aria-label="Behavior entry point"]')!; - // The two classified entry points come first; the flow that repeats one of them is not listed twice. - expect([...start.options].map(option => option.value)).toEqual(['', '1', '2', '36163']); + // Every operation alphabetically (hand test 2026-10-04, A4), the flow that repeats a classified entry point once. + // The journey's start and the two ranked flows would suggest all three, so no Suggested group repeats them. + expect([...start.querySelectorAll('optgroup')].map(group => [group.label, [...group.querySelectorAll('option')].map(option => option.value)])) + .toEqual([['All operations · 3', ['36163', '2', '1']]]); + }); + it('A4: suggests the automatic start and the top ranked flows, and names look-alike flow starts by their class', async () => { + const MODELS = 'django/db/migrations/operations/models.py'; + const flow = (id: number, name: string, filePath: string) => ({ id, label: name, entry: { id, name, filePath }, terminal: { id: 1, name: 'x' }, steps: 3 }); + const flowsLoader = vi.fn().mockResolvedValue([flow(41, 'database_backwards', MODELS), flow(42, 'database_backwards', MODELS), + flow(43, 'handle', 'django/core/management/commands/loaddata.py'), flow(44, 'b4', 'b.py'), flow(45, 'b5', 'b.py'), flow(46, 'b6', 'b.py')]); + const operationNames = vi.fn(async (_project: string, entries: SystemSymbol[]) => entries.map(entry => entry.id === 41 + ? { ...entry, qualified_name: 'p.ops.models.CreateModel.database_backwards' } : entry.id === 42 ? { ...entry, qualified_name: 'p.ops.models.DeleteModel.database_backwards' } : entry)); + const data = overviewFixture(); + data.entrypoints = [{ ...data.entrypoints[0], name: 'main' }, data.entrypoints[1]]; + await render(vi.fn().mockResolvedValue(response(data)), { view: 'behavior', flowsLoader, operationNames }); + expect(operationNames).toHaveBeenCalledOnce(); + expect(operationNames.mock.calls[0]?.[1].map(entry => entry.id)).toEqual([41, 42, 43, 44, 45, 46]); + const start = container.querySelector('[aria-label="Behavior entry point"]')!; + const suggested = [...start.querySelectorAll('optgroup')[0]!.querySelectorAll('option')].map(option => option.textContent); + // The automatic start, then the first five ranked flows in their order. + expect(suggested).toEqual(['main · src/api.ts', `database_backwards (CreateModel) · ${MODELS}`, `database_backwards (DeleteModel) · ${MODELS}`, + 'handle · django/core/management/commands/loaddata.py', 'b4 · b.py', 'b5 · b.py']); + expect(start.selectedOptions[0]?.textContent).toBe('main · src/api.ts'); + }); + it('A4: suggests the start the projection chose itself, and keeps suggesting it after another start is picked', async () => { + // django-demo: "main · …/manage.py-tpl" is no conventional entry for the page, the projection picks it on its own. + const flow = (id: number, name: string, filePath: string) => ({ id, label: name, entry: { id, name, filePath }, terminal: { id: 1, name: 'x' }, steps: 3 }); + const flowsLoader = vi.fn().mockResolvedValue([flow(43, 'handle', 'loaddata.py'), flow(44, 'b4', 'b.py')]); + const data = overviewFixture(); + data.entrypoints = [{ ...data.entrypoints[0], name: 'main', file_path: 'conf/manage.py-tpl' }, data.entrypoints[1]]; + // No operation requested: the server names none (0), and the journey starts at the first classified entry point. + data.behavior = { ...data.behavior!, source_id: 0 }; + await render(vi.fn().mockResolvedValue(response(data)), { view: 'behavior', flowsLoader }); + const start = container.querySelector('[aria-label="Behavior entry point"]')!; + const suggested = () => [...start.querySelectorAll('optgroup')[0]!.querySelectorAll('option')].map(option => option.value); + expect(start.value).toBe('1'); + expect(suggested()).toEqual(['1', '43', '44']); + await choose('Behavior entry point', '43'); + expect(start.value).toBe('43'); + expect(suggested()).toEqual(['1', '43', '44']); }); it('shows failures without retrying continuously', async () => { vi.useFakeTimers(); const loader = vi.fn().mockResolvedValue({ status: 'failed', generation: 'g1', error: 'Analysis budget exceeded.' }); diff --git a/graph-ui/src/architecture/SystemArchitecture.tsx b/graph-ui/src/architecture/SystemArchitecture.tsx index 0af327d75..c08b4fdb9 100644 --- a/graph-ui/src/architecture/SystemArchitecture.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.tsx @@ -8,9 +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')); @@ -21,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; @@ -28,8 +33,12 @@ export interface SystemArchitectureProps { /** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */ export type FlowsLoader = (project: string) => Promise; const loadFlows: FlowsLoader = project => new AtlasApi().flows(project); +export type OperationNamer = (project: string, entries: SystemSymbol[]) => Promise; +const nameFlowStarts: OperationNamer = (project, entries) => nameOperations(project, entries, new RpcIntelligenceClient()); /** Below this many classified entry points the Behavior start list also offers ranked flows. */ const FEW_ENTRY_POINTS = 8; +/** How many of the ranked flows "Start" suggests after the automatic start. */ +const RANKED_SUGGESTIONS = 5; /** Starting operations from flows, once each. The projection supplies qualified name and component on request. */ export function flowEntries(flows: FlowSummary[]): SystemSymbol[] { @@ -71,7 +80,7 @@ const connectionAllowed = (type: string, view: ConnectionView) => view === 'all' || (view === 'calls' ? ['CALLS', 'IMPORTS', 'HTTP_CALLS', 'ASYNC_CALLS'] : ['INHERITS', 'IMPLEMENTS']).includes(type); /** Poll only while this view is active. A new request key hides stale results before its effect runs. */ -export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, place: liftedPlace, onPlace }: SystemArchitectureProps) { +export default function SystemArchitecture({ project, generation, view, filter, active, graph, onSelect, onClearSelection, onSelectionEvidence, onNavigate, loader = loadSystemArchitecture, flowsLoader = loadFlows, operationNames = nameFlowStarts, place: liftedPlace, onPlace }: SystemArchitectureProps) { const [place, changePlace] = useLiftedPlace(liftedPlace, onPlace, () => ({ expanded: [] })); const entryChoice = place.behavior; /** A start the page picks itself (the suggestion, a reset) completes the current step instead of adding one. */ @@ -171,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; @@ -181,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 []; @@ -322,7 +346,7 @@ export default function SystemArchitecture({ project, generation, view, filter, }, automatic); const behaviorPage = view === 'behavior' ? { if (name !== place.shown) changePlace({ shown: name }, true); }} onRequest={(entry, targetId, detail) => { diff --git a/graph-ui/src/architecture/behavior-journey-model.ts b/graph-ui/src/architecture/behavior-journey-model.ts index b469f67a9..81e8c5b38 100644 --- a/graph-ui/src/architecture/behavior-journey-model.ts +++ b/graph-ui/src/architecture/behavior-journey-model.ts @@ -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(); diff --git a/graph-ui/src/architecture/behavior-journey.css b/graph-ui/src/architecture/behavior-journey.css index 6ed94e177..dca5aedc5 100644 --- a/graph-ui/src/architecture/behavior-journey.css +++ b/graph-ui/src/architecture/behavior-journey.css @@ -10,6 +10,10 @@ .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; } diff --git a/graph-ui/src/architecture/operation-choices.test.ts b/graph-ui/src/architecture/operation-choices.test.ts new file mode 100644 index 000000000..dca192edc --- /dev/null +++ b/graph-ui/src/architecture/operation-choices.test.ts @@ -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 => + ({ 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); + }); +}); diff --git a/graph-ui/src/architecture/operation-choices.ts b/graph-ui/src/architecture/operation-choices.ts new file mode 100644 index 000000000..8aa146efe --- /dev/null +++ b/graph-ui/src/architecture/operation-choices.ts @@ -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 { + const groups = new Map(); + for (const entry of entries) groups.set(lookAlike(entry), [...groups.get(lookAlike(entry)) ?? [], entry]); + const labels = new Map(); + 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[]> } + +/* 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 { + const groups = new Map(); + 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[]; + 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 } : {}) }; + }); +} diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts index 8a7410a8c..22acdf335 100644 --- a/graph-ui/src/architecture/strings.ts +++ b/graph-ui/src/architecture/strings.ts @@ -100,6 +100,23 @@ 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}` }, From 7c021147098acbfbd292d27ce4e7cdfe14a97ad4 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 13:47:12 +0200 Subject: [PATCH 4/4] fix(ui): keep the workspace in the address on a project switch (A5) The project switcher turned "?project=django-demo&workspace=galaxy" into "?project=cbm", so a reload could open another workspace. The new address now keeps the page-wide parameters App names (the workspace and the walk bounds) and nothing of the old project, and the address always names the workspace in view, also one carried over a switch or chosen from storage. Browser Back and Forward between projects work as before. Signed-off-by: Bernhard Jackiewicz --- graph-ui/src/App.tsx | 13 +++++----- graph-ui/src/app/project-windows.test.tsx | 30 +++++++++++++++++++++- graph-ui/src/app/project-windows.tsx | 31 +++++++++++++++++++++-- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 1df21cac2..1cf4d15e3 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -311,7 +311,7 @@ import type { SettingsMeasurement } from './settings/SettingsPanel'; import AddProjectIndexDialog from './projects/AddProjectIndexDialog'; import ProjectSwitcher from './projects/ProjectSwitcher'; import type { ProjectsSource } from './projects/ProjectsPanel'; -import { ProjectWindows, type ProjectWindow } from './app/project-windows'; +import { ProjectWindows, useParamInAddress, type ProjectWindow } from './app/project-windows'; import { MODEL_SUGGESTIONS, fetchCommand } from './settings/model-catalog'; import { modelKey, readModelPreference, recordModelPreference } from './settings/model-preference'; import { @@ -758,10 +758,12 @@ interface WindowCarry { /** * Jedes Projekt bekommt ein frisches Fenster in derselben Seite (K24, Kopf von * src/app/project-windows.tsx). Der Agent reicht beim Wechsel sein geladenes - * Modell an das Fenster des neuen Projekts weiter. + * Modell an das Fenster des neuen Projekts weiter. Die Adresse behaelt dabei + * den Arbeitsbereich und die Grenzen des Walks, nichts vom alten Projekt (A5). */ +const PAGE_PARAMS = ['workspace', CLOSURE_DEPTH_PARAM, CLOSURE_CAP_PARAM] as const; export default function App(): JSX.Element { - return around={switchKeepingAgent}> + return around={switchKeepingAgent} keepParams={PAGE_PARAMS}> {shown => } ; } @@ -783,11 +785,10 @@ function ProjectWindowView({ projectWindow }: { projectWindow: ProjectWindow { try { return localStorage.getItem('cbm.workspace.setup') !== 'done'; } catch { return false; } }); diff --git a/graph-ui/src/app/project-windows.test.tsx b/graph-ui/src/app/project-windows.test.tsx index 9580aa5f7..89de6c002 100644 --- a/graph-ui/src/app/project-windows.test.tsx +++ b/graph-ui/src/app/project-windows.test.tsx @@ -2,7 +2,7 @@ import { act, useEffect } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ProjectWindows, type ProjectWindow } from './project-windows'; +import { ProjectWindows, useParamInAddress, type ProjectWindow } from './project-windows'; /* * K24: a project switch stays in the page. Each project gets a fresh window (fresh state in @@ -77,6 +77,34 @@ describe('an in-page project switch (K24)', () => { expect(mounted).toHaveLength(3); }); + /* Hand test 2026-10-04 (A5): the switcher turned "?project=django-demo&workspace=galaxy" into "?project=cbm". */ + it('keeps the page-wide parameters it is given, the workspace among them, and nothing project specific', async () => { + window.history.replaceState(null, '', '/?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py'); + await act(async () => root.render( keepParams={['workspace', 'codeatlasClosureDepth', 'codeatlasClosureCap']}> + {shown => })); + await act(async () => current!.open('atlas sample')); + expect(window.location.search).toBe('?project=atlas%20sample&workspace=galaxy&codeatlasClosureDepth=4'); + expect(shownProject()).toBe('atlas sample'); + // Back and Forward of the browser still switch between both projects, each with its own address. + const popped = () => new Promise(resolve => window.addEventListener('popstate', () => resolve(), { once: true })); + await act(async () => { const done = popped(); window.history.back(); await done; }); + expect(window.location.search).toBe('?project=django-demo&workspace=galaxy&codeatlasClosureDepth=4&file=django%2Fshortcuts.py'); + expect(shownProject()).toBe('django-demo'); + await act(async () => { const done = popped(); window.history.forward(); await done; }); + expect(shownProject()).toBe('atlas sample'); + }); + + it('writes the workspace in view into the address without a new history entry', async () => { + window.history.replaceState(null, '', '/?project=django-demo'); + const length = window.history.length; + function Workspace({ name }: { name: string }) { useParamInAddress('workspace', name); return null; } + await act(async () => root.render()); + expect(window.location.search).toBe('?project=django-demo&workspace=architecture'); + await act(async () => root.render()); + expect(window.location.search).toBe('?project=django-demo&workspace=galaxy'); + expect(window.history.length).toBe(length); + }); + it('commits the switch inside the frame it is given, so the old window is gone and the new one there when it ends', async () => { const seen: string[] = []; const around = (commit: () => void) => { seen.push(`before:${mounted.join(',')}`); commit(); seen.push(`after:${mounted.join(',')}|${unmounted.join(',')}`); }; diff --git a/graph-ui/src/app/project-windows.tsx b/graph-ui/src/app/project-windows.tsx index 5669e8165..73d1175af 100644 --- a/graph-ui/src/app/project-windows.tsx +++ b/graph-ui/src/app/project-windows.tsx @@ -33,6 +33,30 @@ export function projectOfLocation(search = window.location.search): string { return new URLSearchParams(search).get('project') ?? ''; } +/* + * Handtest vom 2026-10-04 (A5): der Wechsel machte aus + * "?project=django-demo&workspace=galaxy" die Adresse "?project=cbm", und ein + * Neuladen danach konnte einen anderen Arbeitsbereich zeigen. Die Adresse des + * neuen Projekts behaelt nun die Parameter der ganzen Seite, die der Aufrufer + * nennt (den Arbeitsbereich, die Grenzen des Walks), und nichts, was zum alten + * Projekt gehoert. + */ +export function projectSwitchHref(project: string, search: string, keep: readonly string[]): string { + const from = new URLSearchParams(search); + const kept = keep.flatMap(name => { const value = from.get(name); return value === null ? [] : [`&${encodeURIComponent(name)}=${encodeURIComponent(value)}`]; }); + return `${projectHref(project)}${kept.join('')}`; +} + +/** Haelt einen Parameter der Adresse auf dem Wert, den die Seite zeigt, ohne neuen Eintrag im Verlauf (A5: der Arbeitsbereich). */ +export function useParamInAddress(name: string, value: string): void { + useEffect(() => { + const url = new URL(window.location.href); + if (url.searchParams.get(name) === value) return; + url.searchParams.set(name, value); + window.history.replaceState(window.history.state, '', url); + }, [name, value]); +} + export interface ProjectWindow { /** Wechselt mit jedem Projekt; als React-Schluessel des Fensters gedacht. */ key: number; @@ -50,9 +74,11 @@ export interface ProjectWindow { interface Shown { key: number; project: string; controller: AbortController; carried?: C } -export function ProjectWindows({ children, around = commit => commit() }: { +export function ProjectWindows({ children, around = commit => commit(), keepParams = [] }: { children: (window: ProjectWindow) => ReactNode; around?: (commit: () => void) => void; + /** Parameter der ganzen Seite, die ein Wechsel in die neue Adresse mitnimmt (A5). */ + keepParams?: readonly string[]; }): JSX.Element { const [shown, setShown] = useState>(() => ({ key: 0, project: projectOfLocation(), controller: new AbortController() })); const current = useRef(shown); current.current = shown; @@ -65,9 +91,10 @@ export function ProjectWindows({ children, around = commit => commit() }: { around(() => flushSync(() => setShown(next))); previous.controller.abort(); }, [around]); + const keep = useRef(keepParams); keep.current = keepParams; const open = useCallback((project: string) => { if (project === current.current.project) return; - window.history.pushState(null, '', projectHref(project)); + window.history.pushState(null, '', projectSwitchHref(project, window.location.search, keep.current)); show(project); }, [show]); const report = useCallback((value: C) => { carry.current = value; }, []);