diff --git a/README.md b/README.md index 923a7db47..3210a2b60 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,9 @@ Package-manager setup (npm, PyPI, or Go) verifies and publishes a coherent priva The ordinary `cli` mode is intentionally separate: it runs one command locally and never starts or connects to the coordination daemon, registers a daemon session, or starts watchers/UI. Its only shared state is the OS admission barrier plus per-project locks for graph mutations. While the command is running, a temporary monitor lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details. -### CBM Atlas — the human interface +### CBM Lens Beta — the human interface -The MCP tools are the agent's interface to the code graph; **CBM Atlas** is the human's. It is built into the binary — every install on every channel has it: +The MCP tools are the agent's interface to the code graph; **CBM Lens Beta** is the human's. It is built into the binary — every install on every channel has it: ```bash codebase-memory-mcp --ui=true --port=9749 @@ -141,9 +141,9 @@ codebase-memory-mcp --ui=true --port=9749 Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers. -Atlas is **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess. +CBM Lens builds on **CodeAtlasWeb**, a reading IDE built by [Bernhard Jackiewicz](https://github.com/BernhardJackiewicz) on the CBM read surface (design and tracking in #1964). It runs air-gapped on loopback, reaches no cloud, and every panel names where its facts come from; what the index did not record is shown as a gap, never filled in with a guess. -When something in Atlas does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file. +When something in CBM Lens does not load, the page has already written it down: its console output, uncaught errors and failed requests go to `${CBM_CACHE_DIR}/logs/ui.log` (default `~/.cache/codebase-memory-mcp/logs/ui.log`, one JSON line per entry, rotated once at 5 MiB). The projects panel (alt+p) shows the tail under "This server", `GET http://localhost:9749/api/ui-log?lines=200` returns it, and a bug report can attach the file. - **Explorer and reader** — the indexed file tree and a read-only Monaco reader whose source comes from the index. - **Semantic twin** — follows the caret: what the symbol under it holds, calls, raises and touches, as facts or as pseudocode, pitched at who is reading. diff --git a/graph-ui/README.md b/graph-ui/README.md index 60795bab5..514df1774 100644 --- a/graph-ui/README.md +++ b/graph-ui/README.md @@ -1,4 +1,4 @@ -# CodeAtlasWeb +# CBM Lens Beta ## In this repository diff --git a/graph-ui/index.html b/graph-ui/index.html index cd3517a37..7e195a941 100644 --- a/graph-ui/index.html +++ b/graph-ui/index.html @@ -5,7 +5,7 @@ - CodeAtlas + CBM Lens Beta
diff --git a/graph-ui/package-lock.json b/graph-ui/package-lock.json index 95fa659bb..ddac3822e 100644 --- a/graph-ui/package-lock.json +++ b/graph-ui/package-lock.json @@ -1,11 +1,11 @@ { - "name": "codeatlasweb", + "name": "cbm-lens", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "codeatlasweb", + "name": "cbm-lens", "version": "0.0.1", "dependencies": { "@huggingface/transformers": "4.2.0", diff --git a/graph-ui/package.json b/graph-ui/package.json index 64c379e78..82bce4026 100644 --- a/graph-ui/package.json +++ b/graph-ui/package.json @@ -1,5 +1,5 @@ { - "name": "codeatlasweb", + "name": "cbm-lens", "version": "0.0.1", "private": true, "type": "module", diff --git a/graph-ui/src/app/AtlasChrome.test.tsx b/graph-ui/src/app/AtlasChrome.test.tsx index de834f9f1..969b7f14d 100644 --- a/graph-ui/src/app/AtlasChrome.test.tsx +++ b/graph-ui/src/app/AtlasChrome.test.tsx @@ -162,8 +162,9 @@ describe('AtlasChrome', () => { it('zeigt Marke und Versions-Chip in der Kopfzeile', async () => { await render(props()); - expect(container.querySelector('.atlas-brand')?.textContent).toBe('CODEATLAS'); - expect(testId('atlas-version')?.textContent).toBe('v0.0.0-dirty'); + expect(container.querySelector('.atlas-brand')?.textContent).toBe('CBM LENS Beta'); + expect(container.querySelector('.atlas-brand-beta')?.textContent).toBe('Beta'); + expect(testId('atlas-version')?.textContent).toBe('v0.0.0'); }); it('keeps project switching at the end of the header without a duplicate project chip', async () => { @@ -177,18 +178,10 @@ describe('AtlasChrome', () => { expect(header?.querySelector('[data-chip="sym"]')?.textContent).toContain('76'); }); - /* - * Der Zustand des Arbeitsbaums steht neben der Fassung, nicht in ihr. - * - * Der Chip beantwortet "welche Fassung ist das" und `v1.0.0-dirty` waere - * darauf eine Antwort, die es als Release nicht gibt. Ohne Zusatz steht - * daneben nichts: ein leeres Element waere ein Platz, an dem jemand eine - * Angabe vermutet. - */ - it('haelt den Bau-Zusatz getrennt vom Versions-Chip', async () => { + it('omits build diagnostics from the header', async () => { await render(props({ version: 'v1.0.0', buildSuffix: 'dirty' })); expect(testId('atlas-version')?.textContent).toBe('v1.0.0'); - expect(testId('atlas-version-suffix')?.textContent).toBe('dirty'); + expect(testId('atlas-version-suffix')).toBeNull(); }); it('zeigt gar kein Zusatz-Element, wenn der Baum sauber war', async () => { diff --git a/graph-ui/src/app/AtlasChrome.tsx b/graph-ui/src/app/AtlasChrome.tsx index becdfc58e..f7f1eeeb0 100644 --- a/graph-ui/src/app/AtlasChrome.tsx +++ b/graph-ui/src/app/AtlasChrome.tsx @@ -164,16 +164,7 @@ export interface AtlasChromeProps { guidance?: Guidance; /** Versions-Chip, zur Buildzeit injiziert. Nur die Fassung, ohne Zusatz. */ version: string; - /** - * Der Zusatz neben dem Chip: `dirty`, wenn beim Bauen etwas uncommitted war. - * - * Ein eigenes Element und nicht ein Anhaengsel im Chip, weil es eine andere - * Aussage ist: der Chip nennt die Fassung, der Zusatz den Zustand des - * Baums, aus dem sie gebaut wurde. Zusammengeschrieben liest sich das wie - * eine Fassung namens "1.0.0-dirty", die es als Release nie gibt. Fehlt der - * Zusatz, steht dort nichts: ein leeres Element waere ein Platz, an dem - * jemand eine Angabe vermutet. - */ + /** Build diagnostics are accepted for compatibility but not shown in the header. */ buildSuffix?: string; chips: Chip[]; tabs: TabDescriptor[]; @@ -625,15 +616,10 @@ export default function AtlasChrome(props: AtlasChromeProps): JSX.Element { return (
-

{messages.app.brand}

+

{messages.app.brand}{' '}{messages.app.releaseLabel}

- {props.version} + {props.version.replace(/-dirty$/, '')} - {(props.buildSuffix ?? '').length > 0 && ( - - {props.buildSuffix} - - )} {/* * Sechs Eintraege, EINE Gestalt. * diff --git a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx index fc9d60dd4..727962118 100644 --- a/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx +++ b/graph-ui/src/architecture/ArchitecturePanel.history.test.tsx @@ -5,11 +5,10 @@ * Auswahl, Doppelklick und leere Flaeche; Analyse und Routen kommen aus * festen Antworten. */ -import { act } from 'react'; +import { act, type ReactNode } 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'; @@ -19,7 +18,8 @@ import type { SystemArchitectureLoader, SystemProjection, SystemSymbol } from '. vi.mock('./ArchitectureScene', () => ({ ArchitectureScene: (props: ArchitectureSceneProps) =>
{props.model.nodes.map(node => )}
})); -vi.mock('./ContainerMap', () => ({ default: ({ filter }: { filter: string }) =>
})); +vi.mock('./ContainerMap', () => ({ default: ({ filter, navigation }: { filter: string; navigation?: ReactNode }) => +
{navigation}
})); vi.mock('./route-graph-source', () => ({ loadRouteGraph: vi.fn(async () => ({ relationships: [], truncated: false, warnings: [] })) })); vi.mock('./BehaviorSourceEvidence', () => ({ default: () =>
})); vi.mock('../app/atlas-api', () => ({ AtlasApi: class { flows() { return Promise.resolve([]); } } })); @@ -127,7 +127,7 @@ 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; the shared one carries the same words. */ +/** System structure and Behavior use the shared history rather than another local Back. */ 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; @@ -144,89 +144,118 @@ async function openArea(name: string): Promise { await press(container.querySelector(`[data-testid="scene"] [data-node="area:${name}"]`) ?? byText(name, container.querySelector('[data-testid="scene"]')!)); await clickText('Open area →'); } +const selectView = (name: string) => press(container.querySelector(`[data-view="${name}"]`)); +const position = () => history()?.getAttribute('data-position'); +async function openRouteGroup(): Promise { + await press(byText('/edit · 2', container.querySelector('[data-testid="scene"]')!)); + await clickText('Show these 2 routes →'); +} describe('Architecture back and forward (K27)', () => { - it('goes back and forward across subtabs, an opened area and Plan, naming each target', async () => { + it('restores meaningful visits across views, with each visit’s camera and location', async () => { await render(); expect(back()?.disabled).toBe(true); expect(back()?.title).toBe('Nothing to go back to yet'); expect(forward()?.disabled).toBe(true); await openArea('django'); expect(trail()).toEqual(['sample', 'django']); - expect(back()?.title).toBe('Back to Overview (Alt+Left)'); await clickText('Plan', container.querySelector('[aria-label="Map camera"]')!); - expect(back()?.title).toBe('Back to Overview · django (Alt+Left)'); - await press(container.querySelector('[data-view="hotspots"]')); + expect(position()).toBe('2/2'); + expect(back()?.title).toBe('Back to Overview (Alt+Left)'); + await doubleClick('[data-node="file:django/shortcuts.py"]'); + expect(container.querySelector('[aria-label="Architecture location"]')?.textContent).toContain('shortcuts.py'); + expect(position()).toBe('3/3'); expect(back()?.title).toBe('Back to Overview · django · Plan (Alt+Left)'); + await selectView('routes'); + await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!); + expect(position()).toBe('3/3'); + await openRouteGroup(); + expect(position()).toBe('4/4'); + await goBack(); + expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '']); await goBack(); expect([view(), trail(), planar()]).toEqual(['overview', ['sample', 'django'], 'Plan']); - expect(forward()?.title).toBe('Forward to Hotspots · Plan (Alt+Right)'); + expect(forward()?.title).toBe('Forward to Routes · Endpoints · Plan (Alt+Right)'); await goBack(); - expect([view(), trail(), planar()]).toEqual(['overview', ['sample', 'django'], '3D']); - await goBack(); - expect(trail()).toEqual(['sample']); + expect([trail(), planar()]).toEqual([['sample'], '3D']); expect(back()?.disabled).toBe(true); await goForward(); await goForward(); await goForward(); - expect(view()).toBe('hotspots'); + expect([view(), perspective(), filter(), planar()]).toEqual(['routes', 'Endpoints', '/edit', 'Plan']); expect(forward()?.disabled).toBe(true); - // The location trail stays: it still leads back to the repository, and that is a step of its own. - await goBack(); + + await goBack(); await goBack(); await press(byText('sample', container.querySelector('[aria-label="Architecture location"]')!)); expect(trail()).toEqual(['sample']); expect(back()?.title).toBe('Back to Overview · django · Plan (Alt+Left)'); + expect(forward()?.disabled).toBe(true); }); - it('restores the Routes perspective and an opened route group', async () => { + it('replaces view and Routes perspective context while route-group drill-ins create stops', async () => { await render(); - await press(container.querySelector('[data-view="routes"]')); + await selectView('routes'); expect(perspective()).toBe('Service map'); + expect(container.querySelector('[data-testid="container-map"]')).not.toBeNull(); await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!); expect(sceneLabels()).toContain('/edit · 2'); - await press(byText('/edit · 2', container.querySelector('[data-testid="scene"]')!)); - await clickText('Show these 2 routes →'); + expect(position()).toBe('1/1'); + expect(back()?.disabled).toBe(true); + await openRouteGroup(); expect(filter()).toBe('/edit'); expect(sceneLabels()).toEqual(['/edit/one/', '/edit/two/']); expect(back()?.title).toBe('Back to Routes · Endpoints (Alt+Left)'); await goBack(); expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '']); - await goBack(); - expect([perspective(), filter()]).toEqual(['Service map', '']); + expect(back()?.disabled).toBe(true); + await clickText('Service map', container.querySelector('[aria-label="Routes perspective"]')!); + expect(position()).toBe('1/2'); expect(container.querySelector('[data-testid="container-map"]')).not.toBeNull(); - await goBack(); - expect(view()).toBe('overview'); - await goForward(); await goForward(); await goForward(); + expect(forward()?.disabled).toBe(false); + await goForward(); expect([view(), perspective(), filter()]).toEqual(['routes', 'Endpoints', '/edit']); expect(sceneLabels()).toEqual(['/edit/one/', '/edit/two/']); + await goBack(); + expect([perspective(), filter()]).toEqual(['Service map', '']); }); - it('takes a typed filter as one step once typing pauses, not one per key', async () => { + it('saves every typed filter immediately without adding stops', async () => { await render(); - await press(container.querySelector('[data-view="routes"]')); + await selectView('routes'); await type('/e'); await type('/ed'); await type('/edit'); expect(filter()).toBe('/edit'); - await act(async () => { await new Promise(resolve => setTimeout(resolve, 900)); }); - expect(back()?.title).toBe('Back to Routes · Service map (Alt+Left)'); + expect(position()).toBe('1/1'); + expect(back()?.disabled).toBe(true); + expect(forward()?.disabled).toBe(true); + await type(''); + await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!); + await openRouteGroup(); + await type('/edit/one'); + expect(position()).toBe('2/2'); await goBack(); - expect([view(), filter()]).toEqual(['routes', '']); - await goBack(); - expect(view()).toBe('overview'); - await goForward(); await goForward(); - expect(filter()).toBe('/edit'); + expect(filter()).toBe(''); + expect(forward()?.title).toBe('Forward to Routes · Endpoints · /edit/one (Alt+Right)'); + await goForward(); + expect(filter()).toBe('/edit/one'); + expect(position()).toBe('2/2'); }); - it('makes the System structure focus and expanded groups steps, and its old Back the shared Back', async () => { + it('records System structure focus and restores expanded groups without expansion-only stops', async () => { await render(); - await press(container.querySelector('[data-view="structure"]')); + await selectView('structure'); expect(focus()).toBe(''); - // 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)'); + expect(back()?.disabled).toBe(true); await doubleClick('[data-system-node="g:api"]'); expect(focus()).toBe('g:api'); expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull(); expect(back()?.title).toBe('Back to System structure (Alt+Left)'); + await press(container.querySelector('[data-system-node="g:api"]')); + await clickText('Collapse group'); + expect(position()).toBe('2/2'); + expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull(); + await clickText('Expand group'); + expect(position()).toBe('2/2'); await press(container.querySelector('[data-system-node="g:service"]')); await clickText('Focus here'); expect(focus()).toBe('g:service'); @@ -235,16 +264,13 @@ describe('Architecture back and forward (K27)', () => { await goBack(); expect(focus()).toBe('g:api'); + expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull(); await goBack(); expect(focus()).toBe(''); expect(container.querySelector('[data-system-node="member:g:api:api"]')).toBeNull(); - // One meaning of Back: the same Back also leaves System structure. - await goBack(); - expect(view()).toBe('overview'); - await goForward(); await goForward(); + expect(back()?.disabled).toBe(true); + await goForward(); expect([view(), focus()]).toEqual(['structure', 'g:api']); - expect(container.querySelector('[data-system-node="member:g:api:api"]')).not.toBeNull(); - // "Whole system" stays the way to the top, as a new step that drops the forward branch. await clickText('Whole system'); expect(focus()).toBe(''); expect(forward()?.disabled).toBe(true); @@ -253,10 +279,11 @@ describe('Architecture back and forward (K27)', () => { it('steps through Behavior starts and followed calls, and an automatic start is no extra step', async () => { await render(); - await press(container.querySelector('[data-view="structure"]')); - await press(container.querySelector('[data-view="behavior"]')); + await selectView('structure'); + await doubleClick('[data-system-node="g:api"]'); + await selectView('behavior'); expect(heading()).toBe('What can main call?'); - // The suggested start completes the Behavior step instead of adding one Back would bounce on. + expect(position()).toBe('2/2'); expect(back()?.title).toBe('Back to System structure (Alt+Left)'); await choose('Behavior entry point', '2'); expect(heading()).toBe('What can handle call?'); @@ -273,18 +300,16 @@ describe('Architecture back and forward (K27)', () => { await goBack(); expect(heading()).toBe('What can main call?'); await goBack(); - expect(view()).toBe('structure'); + expect([view(), focus()]).toEqual(['structure', '']); await goForward(); await goForward(); await goForward(); expect(heading()).toBe('What can save call?'); expect(container.querySelector('select[aria-label="Behavior entry point"]')?.value).toBe('3'); - // Empty background still returns from a followed call to where the hops started, as a step. await clickText('System background'); expect(heading()).toBe('What can handle call?'); expect(back()?.title).toBe('Back to Behavior · save · followed from handle (Alt+Left)'); }); - it('names the start a reset Behavior step shows, after the suggested start was rejected for a stale snapshot', async () => { - // django-demo: the suggested main is rejected once for another analysis generation; the journey then shows main on its own. + it('names the shown Behavior start after the suggested start was rejected for a stale snapshot', async () => { let calls = 0; loader.mockImplementation(async request => { calls++; @@ -292,8 +317,10 @@ describe('Architecture back and forward (K27)', () => { return { status: 'ready', generation: calls === 1 ? 'g1' : 'g2', result: projection() }; }); await render(); - await press(container.querySelector('[data-view="behavior"]')); + await openArea('django'); + await selectView('behavior'); expect(heading()).toBe('What can main call?'); + expect(position()).toBe('2/2'); expect(back()?.title).toBe('Back to Overview (Alt+Left)'); await choose('Behavior entry point', '2'); expect(heading()).toBe('What can handle call?'); @@ -306,135 +333,140 @@ 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 () => { + it('restores System Plan or 3D with the focused visit without making camera or tab stops', async () => { await render(); - await press(container.querySelector('[data-view="structure"]')); + await selectView('structure'); expect(systemCamera()).toBe('3D'); expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('false'); + await doubleClick('[data-system-node="g:api"]'); 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)'); + expect(position()).toBe('2/2'); + await press(container.querySelector('[data-system-node="g:service"]')); + await clickText('Focus here'); + await selectView('overview'); + expect(position()).toBe('3/3'); + expect(back()?.title).toBe('Back to System structure · api · 1 group open · Plan (Alt+Left)'); await goBack(); - expect([view(), systemCamera()]).toEqual(['structure', 'Plan']); + expect([view(), focus(), systemCamera()]).toEqual(['structure', 'g:api', 'Plan']); expect(container.querySelector('[data-testid="system-scene"]')?.getAttribute('data-planar')).toBe('true'); await goBack(); - expect([view(), systemCamera()]).toEqual(['structure', '3D']); + expect([view(), focus(), 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"]')); + await selectView('behavior'); expect(behaviorCamera()).toBe('3D'); + expect(position()).toBe('3/3'); }); - 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 () => { + it('records Behavior destinations and paths while retaining chain position and camera as context', async () => { loader.mockImplementation(async () => ({ status: 'ready', generation: 'g1', result: twoPaths() })); await render(); - await press(container.querySelector('[data-view="behavior"]')); + await selectView('behavior'); expect(heading()).toBe('What can main call?'); + expect(position()).toBe('1/1'); 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(position()).toBe('2/2'); 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)'); + expect(position()).toBe('3/3'); + expect(back()?.title).toBe('Back to Behavior · main → save (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']); + expect([heading(), behaviorCamera(), chosenPath(), chainPosition()]).toEqual(['main → save', '3D', 1, '2 / 3']); + expect(forward()?.title).toBe('Forward to Behavior · main → save · Path 2 · Plan (Alt+Right)'); await goBack(); expect([heading(), container.querySelector('[aria-label="Walk the call chain"]')]).toEqual(['What can main call?', null]); + expect(back()?.disabled).toBe(true); await goForward(); expect([chosenPath(), chainPosition()]).toEqual([1, '2 / 3']); - await goForward(); 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"]')); + await selectView('structure'); expect(systemCamera()).toBe('3D'); + expect(position()).toBe('3/3'); }); - it('records typing that has not paused yet as a step when Back, Forward or Recent leaves it', async () => { + it('preserves the forward branch while typing and saves the latest text before a recent jump', async () => { await render(); - await press(container.querySelector('[data-view="routes"]')); - await type('/adm'); + await selectView('routes'); + await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!); + await openRouteGroup(); 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'); + expect(position()).toBe('1/2'); + expect(forward()?.title).toBe('Forward to Routes · Endpoints · /edit (Alt+Right)'); await goForward(); - expect(filter()).toBe('/admin'); + expect(filter()).toBe('/edit'); + await goBack(); + expect(filter()).toBe('/home'); + await type('/admin'); + const recent = container.querySelector('details.atlas-arch-recent')!; + const editPlace = [...recent.querySelectorAll('[aria-label="Recently visited places"] li button')] + .find(item => item.querySelector('span')?.textContent === 'Endpoints · /edit'); + await press(editPlace); + expect(filter()).toBe('/edit'); + expect(position()).toBe('2/2'); expect(forward()?.disabled).toBe(true); - expect(back()?.title).toBe('Back to Routes · Service map · /adm (Alt+Left)'); + expect(back()?.title).toBe('Back to Routes · Endpoints · /admin (Alt+Left)'); + await goBack(); + expect(filter()).toBe('/admin'); }); - it('reports an opened area only to the subtab that shows it, never to Routes or Hotspots', async () => { + it('reports opened-area evidence only in its view and restores it with meaningful history visits', 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 selectView('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"]')); + await selectView('hotspots'); expect(lastEvidence(onSelectionEvidence)).toBeUndefined(); - await press(container.querySelector('[data-view="overview"]')); + await selectView('overview'); expect(lastEvidence(onSelectionEvidence)?.label).toBe('django'); - await press(container.querySelector('[data-view="routes"]')); + expect(position()).toBe('2/2'); + await doubleClick('[data-node="file:django/shortcuts.py"]'); + await selectView('routes'); await goBack(); expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]); expect(lastEvidence(onSelectionEvidence)?.label).toBe('django'); - await goBack(); await goBack(); + await goForward(); 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 () => { + it('drops the forward branch on a new focus after Back and keeps at most 25 real stops', async () => { await render(); - for (const tab of ['routes', 'hotspots', 'structure']) await press(container.querySelector(`[data-view="${tab}"]`)); - await goBack(); await goBack(); - expect(view()).toBe('routes'); + await selectView('structure'); + await doubleClick('[data-system-node="g:api"]'); + await doubleClick('[data-system-node="g:service"]'); + await goBack(); + expect(focus()).toBe('g:api'); expect(forward()?.disabled).toBe(false); - await press(container.querySelector('[data-view="behavior"]')); + await selectView('behavior'); + expect(position()).toBe('2/3'); + expect(forward()?.disabled).toBe(false); + await choose('Behavior entry point', '2'); expect(forward()?.disabled).toBe(true); await goBack(); - expect(view()).toBe('routes'); - expect(forward()?.title).toBe('Forward to Behavior · main (Alt+Right)'); + expect(heading()).toBe('What can main call?'); + expect(forward()?.title).toBe('Forward to Behavior · handle (Alt+Right)'); - for (let step = 0; step < 15; step++) for (const tab of ['overview', 'hotspots']) await press(container.querySelector(`[data-view="${tab}"]`)); - expect(history()?.getAttribute('data-position')).toBe('25/25'); + for (let step = 0; step < 15; step++) { + await choose('Behavior entry point', '2'); + await choose('Behavior entry point', '1'); + } + expect(position()).toBe('25/25'); let steps = 0; while (!back()!.disabled && steps < 100) { await goBack(); steps++; } expect(steps).toBe(24); @@ -442,7 +474,8 @@ describe('Architecture back and forward (K27)', () => { it('answers Alt+Left and Alt+Right only in the active workspace and never while typing', async () => { await render(); - await press(container.querySelector('[data-view="routes"]')); + await openArea('django'); + await selectView('routes'); let event = key({ key: 'ArrowLeft', altKey: true }); expect(event.defaultPrevented).toBe(true); await settle(); @@ -450,19 +483,15 @@ describe('Architecture back and forward (K27)', () => { event = key({ key: 'ArrowRight', altKey: true }); await settle(); expect(view()).toBe('routes'); - // Typing in the route filter keeps the arrows for the text field. const search = container.querySelector('input[type="search"]')!; event = key({ key: 'ArrowLeft', altKey: true }, search); expect(event.defaultPrevented).toBe(false); expect(view()).toBe('routes'); - // Without Alt, or with another modifier, the arrows are not history keys. expect(key({ key: 'ArrowLeft' }).defaultPrevented).toBe(false); expect(key({ key: 'ArrowLeft', altKey: true, metaKey: true }).defaultPrevented).toBe(false); - // Another surface on top (help, settings) keeps its keys. await render({ escapeTaken: true }); expect(key({ key: 'ArrowLeft', altKey: true }).defaultPrevented).toBe(false); expect(view()).toBe('routes'); - // A hidden Architecture (Galaxy is active) leaves Alt+arrows to that workspace. await render({ active: false }); expect(key({ key: 'ArrowLeft', altKey: true }).defaultPrevented).toBe(false); await settle(); @@ -472,15 +501,17 @@ describe('Architecture back and forward (K27)', () => { it('jumps to a recent place as a new navigation and starts a fresh history per project', async () => { await render(); await openArea('django'); - await press(container.querySelector('[data-view="routes"]')); - await press(container.querySelector('[data-view="structure"]')); + await doubleClick('[data-node="file:django/shortcuts.py"]'); + await selectView('routes'); + await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!); + await openRouteGroup(); await goBack(); const recent = container.querySelector('details.atlas-arch-recent')!; expect(recent).not.toBeNull(); expect(recent.querySelector('summary')?.getAttribute('aria-label')).toBe('Recent'); const places = [...recent.querySelectorAll('[aria-label="Recently visited places"] li button')]; expect(places.map(item => [item.querySelector('strong')?.textContent, item.querySelector('span')?.textContent ?? ''])).toEqual([ - ['Routes', 'Service map'], ['System structure', ''], ['Overview', 'django'], ['Overview', ''], + ['Routes', 'Endpoints'], ['Routes', 'Endpoints · /edit'], ['Overview', 'django'], ['Overview', ''], ]); expect(places[0]?.getAttribute('aria-current')).toBe('true'); await act(async () => { recent.open = true; }); @@ -488,7 +519,7 @@ describe('Architecture back and forward (K27)', () => { expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]); expect(recent.open).toBe(false); expect(forward()?.disabled).toBe(true); - expect(back()?.title).toBe('Back to Routes · Service map (Alt+Left)'); + expect(back()?.title).toBe('Back to Routes · Endpoints (Alt+Left)'); await render({ projectName: 'other', overview: { ...overview, projectName: 'other' } }); expect(back()?.disabled).toBe(true); @@ -497,60 +528,70 @@ describe('Architecture back and forward (K27)', () => { }); }); -/* - * 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)', () => { +describe('Architecture history controls beside the current location', () => { 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"]')); + await openArea('django'); 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 () => { + it('keeps compact, accessible history controls inside the current view and out of the subtab row', 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('→'); + await openArea('django'); + const contexts = [ + ['overview', '[data-testid="spatial-architecture"]'], + ['routes', '[data-testid="container-map"]'], + ['hotspots', '[data-testid="spatial-architecture"]'], + ['structure', '[data-testid="system-architecture"]'], + ['behavior', '.behavior-journey'], + ]; + for (const [name, selector] of contexts) { + await selectView(name!); + const row = container.querySelector('.atlas-arch-tabrow')!; + expect([...row.children].map(item => item.getAttribute('aria-label'))).toEqual(['Architecture views']); + expect(row.querySelector('[aria-label="Architecture history"]')).toBeNull(); + expect(container.querySelectorAll('[aria-label="Architecture history"]')).toHaveLength(1); + const currentView = container.querySelector(selector!); + expect(currentView?.contains(history()!)).toBe(true); + expect(history()?.parentElement).toBe(currentView?.querySelector(name === 'behavior' ? '.behavior-requests' : '.atlas-arch-context')); + expect(back()?.textContent).toBe(''); + expect(forward()?.textContent).toBe(''); + expect(back()?.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + expect(forward()?.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true'); + expect(back()?.getAttribute('aria-label')).toBe('Back'); + expect(forward()?.getAttribute('aria-label')).toBe('Forward'); + expect(recentMenu()?.querySelector('summary')?.getAttribute('aria-label')).toBe('Recent'); + expect(recentMenu()?.querySelector('summary svg')?.getAttribute('aria-hidden')).toBe('true'); + } + await selectView('routes'); + await clickText('Endpoints', container.querySelector('[aria-label="Routes perspective"]')!); + expect(container.querySelector('[data-testid="spatial-architecture"]')?.contains(history()!)).toBe(true); 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 () => { + it('closes Recent 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'); + expect([view(), trail()]).toEqual(['overview', ['sample', 'django']]); }); - it('A2: closes the Recent menu when Back or Alt+Right change the place', async () => { + it('closes Recent when Back or Alt+Right changes the place', async () => { const menu = await withRecent(); await goBack(); - expect(view()).toBe('routes'); + expect(trail()).toEqual(['sample']); 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(trail()).toEqual(['sample', 'django']); expect(menu.open).toBe(false); }); }); diff --git a/graph-ui/src/architecture/ArchitecturePanel.tsx b/graph-ui/src/architecture/ArchitecturePanel.tsx index 5eb740d3e..3cf46e782 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, useRef, useState } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import type { JSX, ReactNode } from 'react'; import type { ArchitectureHotspot, ArchitectureOverviewDto } from '../core/intelligence-provider'; import { @@ -8,7 +8,6 @@ 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, @@ -123,14 +122,7 @@ function Findings({ view, data, empty, onNavigate }: { 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). - */ +/** Compact exploration history beside the current scope. Tooltips name each destination. */ function HistoryControls({ history, place, onGo, onJump }: { history: NavigationHistory; place: ArchitectureHistoryEntry; onGo: (step: -1 | 1) => void; onJump: (entry: ArchitectureHistoryEntry) => void; @@ -141,12 +133,12 @@ function HistoryControls({ history, place, onGo, onJump }: { return
+ + {history.recent.length > 1 &&
- {historyText.recentGlyph} +
    {history.recent.map(entry => { const id = architectureHistoryOptions.recentKey?.(entry); const current = id === here; @@ -169,25 +161,22 @@ 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 before the - * subtabs. A filter is a search for this visit: one saved by an earlier + * restore every part at once. Only deliberate exploration adds a stop; + * tabs and display controls update the current visit. A filter 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); }, }); 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 setView = useCallback((view: ArchitectureView) => navigate(current => ({ ...current, view: view === 'dependencies' ? 'overview' : view }), true), [navigate]); const changeSpatial = useCallback>((change, automatic) => navigate(current => ({ ...current, spatial: { ...current.spatial, ...change } }), automatic), [navigate]); const changeSystem = useCallback>((change, automatic) => navigate(current => ({ ...current, system: { ...current.system, ...change } }), automatic), [navigate]); - 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 changePerspective = useCallback((routes: RoutesPerspective) => navigate(current => ({ ...current, routes }), true), [navigate]); + // Opening a route group is navigation; typing only refines the current visit. const openRouteGroup = useCallback((filter: string) => navigate(current => ({ ...current, filter })), [navigate]); // A cached summary from another project must never appear here. const data = overview?.projectName && overview.projectName !== projectName ? undefined : overview; @@ -198,24 +187,25 @@ function ArchitectureWorkspace({ projectName, overview, loading = false, error, }, [active, projectName, systemView, ready, graph, onSelectionEvidence]); // Only the Routes view offers the filter, so no other view may receive a stale one. const filter = place.view === 'routes' ? navigation.filter : ''; + const historyControls = ; const spatialProps = !systemView && ready && data && graph ? { project: projectName, generation: graphGeneration, graph, overview: data, view: place.view === 'dependencies' || place.view === 'structure' || place.view === 'behavior' ? 'overview' as const : place.view, - filter, onFilter: openRouteGroup, active, graphNote, coverage, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, + filter, onFilter: openRouteGroup, active, graphNote, coverage, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, navigation: historyControls, onView: (view: ArchitectureView) => { onSelectionEvidence?.(undefined); setView(view); }, place: place.spatial, onPlace: changeSpatial, } : undefined; // Every scene below draws with the same palette; the DOM reads it as CSS variables. return
    -
    - -
    }> : null} + place={place.system} onPlace={changeSystem} navigation={historyControls} /> : null} + {!systemView && !spatialProps &&
    {historyControls}{projectName}
    } {!systemView && (!projectName ? {text.chooseProject} : loading ?
    {text.loading}
    : error ?

    {text.loadFailed}

    {error}

    {onRefresh && }
    diff --git a/graph-ui/src/architecture/BehaviorJourney.tsx b/graph-ui/src/architecture/BehaviorJourney.tsx index 373c7a7e1..1ff761040 100644 --- a/graph-ui/src/architecture/BehaviorJourney.tsx +++ b/graph-ui/src/architecture/BehaviorJourney.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { behaviorJourney } from './behavior-journey-model'; import { projectionLimits, type SystemSceneModel } from './system-architecture-model'; import { architectureText as text } from './strings'; @@ -27,6 +27,7 @@ export interface BehaviorJourneyProps { */ place?: JourneyPlace; onPlace?: PlaceChange; + navigation?: ReactNode; /** Reports the start shown when none was requested, so a Back or Forward tooltip can name it (K27). */ onShownStart?: (name: string) => void; onClearSelection?: () => void; @@ -61,7 +62,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, refresh, suggestedEntries, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart }: BehaviorJourneyProps) { + onRequest, onRefresh, refresh, suggestedEntries, onSelectSymbol, onNavigate, onClearSelection, onSelectionEvidence, from, place: liftedPlace, onPlace, onShownStart, navigation }: BehaviorJourneyProps) { const [place, changePlace] = useLiftedPlace(liftedPlace, onPlace, () => ({})); const { path: pathIndex = 0, step = 0, page: branchPage = 0, planar = false } = place; const [overview, setOverview] = useState(false); @@ -173,6 +174,7 @@ export default function BehaviorJourney({ project, generation, data, entries, ta

    {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.'}

+ {navigation} {/* * The field names the operation the journey shows, also when the projection chose it. Suggestions come * first, then every operation alphabetically; the filter before it narrows both (hand test 2026-10-04, A4). diff --git a/graph-ui/src/architecture/ContainerMap.tsx b/graph-ui/src/architecture/ContainerMap.tsx index 34e40e020..f66720360 100644 --- a/graph-ui/src/architecture/ContainerMap.tsx +++ b/graph-ui/src/architecture/ContainerMap.tsx @@ -15,6 +15,7 @@ interface Props { project: string; generation?: string; active: boolean; filter: string; onNavigate: (path: string, line?: number) => void; onClearSelection?: () => void; onSelectionEvidence?: SelectionEvidenceListener; /** Leads from a project without deployment files to the routes it does have. */ onShowEndpoints?: () => void; + navigation?: ReactNode; } /** Reading the deployment inventory: a project without Compose files is an answer, not a failure. */ type Discovery = keyof typeof text.serviceMap | undefined; @@ -29,7 +30,7 @@ const relativeSource = (absolute: string, selection: ContainerSelection) => { return absolute.startsWith(prefix) ? absolute.slice(prefix.length) : undefined; }; -export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection, onSelectionEvidence, onShowEndpoints }: Props) { +export default function ContainerMap({ project, generation, active, filter, onNavigate, onClearSelection, onSelectionEvidence, onShowEndpoints, navigation }: Props) { const [available, setAvailable] = useState([]); const [selections, setSelections] = useState([]); const [discovery, setDiscovery] = useState('reading'); @@ -160,7 +161,7 @@ export default function ContainerMap({ project, generation, active, filter, onNa {index > 0 && })} {selections.length < 4 && }
-
+
{navigation}
Calls
{discovery &&

{text.serviceMap[discovery]}

diff --git a/graph-ui/src/architecture/RoutesArchitecture.tsx b/graph-ui/src/architecture/RoutesArchitecture.tsx index e747dabd0..fd5783939 100644 --- a/graph-ui/src/architecture/RoutesArchitecture.tsx +++ b/graph-ui/src/architecture/RoutesArchitecture.tsx @@ -26,6 +26,6 @@ export default function RoutesArchitecture({ perspective, onPerspective, ...prop {mode === 'services' ? {text.preparing}

}> show('endpoints')} />
+ onShowEndpoints={() => show('endpoints')} navigation={props.navigation} /> : }; } diff --git a/graph-ui/src/architecture/SpatialArchitecture.tsx b/graph-ui/src/architecture/SpatialArchitecture.tsx index 2f7b727a7..88e7dba76 100644 --- a/graph-ui/src/architecture/SpatialArchitecture.tsx +++ b/graph-ui/src/architecture/SpatialArchitecture.tsx @@ -31,6 +31,7 @@ interface Props { /** The opened area or file, the hotspot area and Plan or 3D, lifted to the workspace for Back and Forward (K27). */ place?: SpatialPlace; onPlace?: PlaceChange; + navigation?: ReactNode; } class SceneBoundary extends Component<{ children: ReactNode }, { failed: boolean }> { @@ -50,7 +51,7 @@ const viewNotes: Record = { }; const relationKinds = ['CALLS', 'IMPORTS', 'USAGE', 'INHERITS', 'IMPLEMENTS', 'DATA_FLOWS']; -export default function SpatialArchitecture({ project, generation, graph, overview, view, filter, active, graphNote, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, onView, onFilter, coverage, place: liftedPlace, onPlace }: Props) { +export default function SpatialArchitecture({ project, generation, graph, overview, view, filter, active, graphNote, onSelect, onClearSelection, onSelectionEvidence, selectionPanel, onNavigate, onView, onFilter, coverage, place: liftedPlace, onPlace, navigation }: Props) { const [place, changePlace] = useLiftedPlace(liftedPlace, onPlace, () => ({ planar: false })); const { planar } = place; // The place keeps the opened area or file and the hotspot area for Back and Forward (K27), but only the view @@ -188,14 +189,17 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
- {['overview', 'dependencies', 'entryPoints'].includes(view) &&
} +
{navigation} {(view === 'overview' || view === 'dependencies') && } + {view === 'hotspots' && (hotspotArea ? : All hotspots)} + {view === 'routes' && {filter || 'All endpoints'}} +
+ {['overview', 'dependencies', 'entryPoints'].includes(view) &&
} {view === 'entryPoints' && <>} - {view === 'hotspots' && hotspotArea && } {view === 'routes' && { routeRefresh.begin(); setRouteRevision(value => value + 1); }} />} {view === 'routes' && } diff --git a/graph-ui/src/architecture/SystemArchitecture.tsx b/graph-ui/src/architecture/SystemArchitecture.tsx index 229b453f6..483f01fc7 100644 --- a/graph-ui/src/architecture/SystemArchitecture.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { lazy, Suspense, useEffect, useMemo, useState, type ReactNode } from 'react'; import type { GraphData, GraphNode } from '../galaxy/types'; import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; import { loadSystemArchitecture, type SystemArchitectureLoader, type SystemArchitectureResponse, type SystemProjection, type SystemSymbol, type SystemWitness, type SystemCallEvidence } from './system-architecture-source'; @@ -29,6 +29,7 @@ export interface SystemArchitectureProps { /** Focus, expanded groups, the Behavior start and position, and both cameras, lifted to the workspace for Back and Forward (K27). */ place?: SystemPlace; onPlace?: PlaceChange; + navigation?: ReactNode; } /** Ranked call-graph flows (route handlers, call-graph roots) from /api/flows. */ export type FlowsLoader = (project: string) => Promise; @@ -80,7 +81,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, operationNames = nameFlowStarts, 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, navigation }: 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. */ @@ -347,7 +348,7 @@ export default function SystemArchitecture({ project, generation, view, filter, const behaviorPage = view === 'behavior' ? { if (name !== place.shown) changePlace({ shown: name }, true); }} onRequest={(entry, targetId, detail) => { const analysisGeneration = projectionGeneration ?? sourceGeneration; @@ -363,7 +364,7 @@ export default function SystemArchitecture({ project, generation, view, filter, { refreshAnalysis(); setResetKey(value => value + 1); }} />
-
+
{navigation}{place.focus && / {place.focus.label}}
{view === 'structure' && } diff --git a/graph-ui/src/architecture/architecture-history.test.ts b/graph-ui/src/architecture/architecture-history.test.ts index 1610174cc..64023335f 100644 --- a/graph-ui/src/architecture/architecture-history.test.ts +++ b/graph-ui/src/architecture/architecture-history.test.ts @@ -28,24 +28,26 @@ describe('the Architecture history entry (K27)', () => { expect(initialArchitecturePlace('dependencies').view).toBe('overview'); }); - it('is a new step for every subtab and for what each subtab opens', () => { + it('identifies what each subtab opens without treating display settings as exploration', () => { const views = ['overview', 'entryPoints', 'routes', 'hotspots', 'structure', 'behavior'] as const; expect(new Set(views.map(view => key(at({ view }))))).toHaveProperty('size', views.length); - // Overview: the opened area and file, and Plan or 3D. + // Overview: the opened area and file. Plan or 3D stays with that location. const django = at({ spatial: { planar: false, areaPath: 'django' } }); expect(key(django)).not.toBe(key(start)); expect(key(at({ spatial: { planar: false, areaPath: 'django', filePath: 'django/shortcuts.py' } }))).not.toBe(key(django)); - expect(key(at({ spatial: { planar: true, areaPath: 'django' } }))).not.toBe(key(django)); - // Routes: the perspective and the filter an opened route group sets. + expect(key(at({ spatial: { planar: true, areaPath: 'django' } }))).toBe(key(django)); + // Routes: explicit route-group navigation uses the perspective and filter. + // The hook replaces the current entry when these are changed by context controls. const routes = at({ view: 'routes' }); expect(key({ ...routes, routes: 'endpoints' })).not.toBe(key(routes)); expect(key({ ...routes, routes: 'endpoints', filter: '/edit' })).not.toBe(key({ ...routes, routes: 'endpoints' })); - // System structure: the focus and the expanded groups, in any order. + // System structure: the focus is exploration; expanded groups stay with it. const structure = at({ view: 'structure' }); const focused = { ...structure, system: { expanded: [], focus: { id: 'g:django', label: 'django' } } }; expect(key(focused)).not.toBe(key(structure)); expect(key({ ...structure, system: { expanded: ['g:a', 'g:b'] } })).toBe(key({ ...structure, system: { expanded: ['g:b', 'g:a'] } })); - expect(key({ ...structure, system: { expanded: ['g:a'] } })).not.toBe(key(structure)); + expect(key({ ...structure, system: { expanded: ['g:a'] } })).toBe(key(structure)); + expect(key(at({ view: 'hotspots', spatial: { planar: false, hotspotArea: 'django/db' } }))).not.toBe(key(at({ view: 'hotspots' }))); // Behavior: the start and the destination ("Reach"). expect(key(behavior(7, 'handle'))).not.toBe(key(behavior(8, 'get_autocommit'))); expect(key(behavior(7, 'handle', { targetId: 9, targetName: 'save' }))).not.toBe(key(behavior(7, 'handle'))); @@ -61,7 +63,7 @@ describe('the Architecture history entry (K27)', () => { expect(key(behavior(7, 'handle'))).toBe(key(behavior(7, 'renamed', { from: symbol(3, 'main'), generation: 'g2' }))); // A Service map does not draw Plan or 3D. expect(key(at({ view: 'routes', spatial: { planar: true } }))).toBe(key(at({ view: 'routes' }))); - expect(key(at({ view: 'routes', routes: 'endpoints', spatial: { planar: true } }))).not.toBe(key(at({ view: 'routes', routes: 'endpoints' }))); + expect(key(at({ view: 'routes', routes: 'endpoints', spatial: { planar: true } }))).toBe(key(at({ view: 'routes', routes: 'endpoints' }))); }); it('names the target of Back and Forward', () => { @@ -89,11 +91,11 @@ describe('the Architecture history entry (K27)', () => { it('holds Plan or 3D in System structure and Behavior, and where a call chain stands (K27)', () => { const structure = at({ view: 'structure' }); - // Plan or 3D is a step in every view that draws a scene, each with its own camera as before. - expect(key({ ...structure, system: { expanded: [], structurePlanar: true } })).not.toBe(key(structure)); + // Plan or 3D is saved context in every view that draws a scene, each with its own camera as before. + expect(key({ ...structure, system: { expanded: [], structurePlanar: true } })).toBe(key(structure)); expect(key({ ...structure, system: { expanded: [], behaviorPlanar: true } })).toBe(key(structure)); const reach = behavior(1, 'main', { targetId: 3, targetName: 'save' }); - expect(key({ ...reach, system: { ...reach.system, behaviorPlanar: true } })).not.toBe(key(reach)); + expect(key({ ...reach, system: { ...reach.system, behaviorPlanar: true } })).toBe(key(reach)); expect(key({ ...reach, system: { ...reach.system, structurePlanar: true } })).toBe(key(reach)); // Another indexed path to the destination is a step; the operation on it and the page of direct calls are not. const second = { ...reach, system: { ...reach.system, behavior: { ...reach.system.behavior!, position: { path: 1 } } } }; diff --git a/graph-ui/src/architecture/architecture-history.ts b/graph-ui/src/architecture/architecture-history.ts index 04614f164..77e837946 100644 --- a/graph-ui/src/architecture/architecture-history.ts +++ b/graph-ui/src/architecture/architecture-history.ts @@ -2,12 +2,13 @@ * The Architecture entry for Back and Forward (hand test K27): which place the * workspace shows, without what it loaded for it. * - * One entry is the subtab and what that subtab has opened: in Overview the area - * or file, in Routes the perspective and the route filter (an opened route - * group is that filter), in System structure the focus and the expanded groups, + * One stop is a meaningful exploration: in Overview the area or file, in + * Routes an explicitly opened route group, in System structure the focus, * in Behavior the start, the destination, where a followed call began and where - * the journey stands; and in every view that draws a scene, Plan or 3D. It holds - * identities, names and small numbers only, never projections, scenes or + * the journey stands. Each entry also keeps its subtab, perspective, filter, + * expanded groups and Plan or 3D setting so returning restores that context. + * Context changes update the current stop. It holds identities, names and small + * numbers only, never projections, scenes or * cameras, so nothing grows with the session. The model behind it is the one * Galaxy uses (src/graph/navigation-history.ts); the concept is in * docs/development/pr-2068-galaxy-history.md. @@ -61,7 +62,7 @@ export interface ArchitectureHistoryEntry { view: ArchitectureView; spatial: SpatialPlace; routes: RoutesPerspective; - /** The Routes filter, as far as it is a step: a route group or typing that paused. */ + /** The Routes filter: typing updates context; explicitly opening a route group creates a stop. */ filter: string; system: SystemPlace; } @@ -106,8 +107,8 @@ const behaviorPath = (entry: ArchitectureHistoryEntry): number => (entry.view == const viewOf = (entry: ArchitectureHistoryEntry): ArchitectureView => (entry.view === 'dependencies' ? 'overview' : entry.view); export const architectureHistoryOptions: NavigationHistoryOptions = { - key: (entry) => JSON.stringify([viewOf(entry), ...location(entry), planar(entry), - entry.view === 'structure' ? [...entry.system.expanded].sort() : null, behaviorPath(entry)]), + // View, perspective and filter identify restored locations, but their controls replace the current entry. + key: (entry) => JSON.stringify([viewOf(entry), ...location(entry), behaviorPath(entry)]), recentKey: (entry) => JSON.stringify([viewOf(entry), ...location(entry)]), }; diff --git a/graph-ui/src/architecture/architecture.css b/graph-ui/src/architecture/architecture.css index 66ba9422b..998e3d294 100644 --- a/graph-ui/src/architecture/architecture.css +++ b/graph-ui/src/architecture/architecture.css @@ -45,27 +45,22 @@ } .atlas-arch-tab[aria-pressed="true"] { color: var(--atlas-fg); border-bottom-color: var(--atlas-phosphor); } .atlas-arch-tab:hover { color: var(--atlas-fg); background: var(--atlas-panel); } -/* - * K27: one Back, Forward and Recent for the whole workspace and the tabs share a row. The menu sits outside the - * scrolling tab strip. Hand test 2026-10-04 (A1): the controls come first and read as in Galaxy, "← Back" and - * "Forward →" while the tabs fit, the glyphs alone once they would scroll (`data-fit`, galaxy/toolbar-fit.tsx). - */ -.atlas-arch-tabrow { display: flex; align-items: center; gap: 14px; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; } +/* Exploration history belongs to the current scope; tabs only choose a lens. */ +.atlas-arch-tabrow { display: flex; border-bottom: 1px solid var(--atlas-line); margin-bottom: 16px; } .atlas-arch-tabrow > .atlas-arch-tabs { flex: 1 1 auto; min-width: 0; border-bottom: 0; margin-bottom: 0; } -.atlas-arch-history { display: flex; align-items: center; gap: 4px; flex: none; position: relative; } -.atlas-arch-history > button, .atlas-arch-recent > summary { - border: 1px solid var(--atlas-line); border-radius: 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-context { display: flex; align-items: center; gap: 10px; min-width: 0; flex-wrap: wrap; } +.atlas-arch-context > span { max-width: min(32rem, 70vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--atlas-dim); } +.atlas-arch-history { display: flex; align-items: center; gap: 2px; flex: none; position: relative; } +.atlas-architecture .atlas-arch-history > button, .atlas-arch-recent > summary { + display: grid; place-items: center; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--atlas-dim); + width: 32px; height: 32px; min-width: 32px; min-height: 32px; padding: 6px; line-height: 1; } -.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.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; color: var(--atlas-dim); padding-inline: 8px; } +.atlas-architecture .atlas-arch-history > button:hover:not(:disabled), .atlas-arch-recent > summary:hover { background: var(--atlas-panel-raised); color: var(--atlas-fg); border-color: var(--atlas-line); } +.atlas-architecture .atlas-arch-history > button:disabled { opacity: 0.28; cursor: default; } +.atlas-arch-history svg { display: block; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +.atlas-arch-history :is(button, summary):focus-visible { outline: 2px solid var(--atlas-phosphor); outline-offset: 2px; } +.atlas-arch-recent { position: static; } +.atlas-arch-recent > summary { cursor: pointer; list-style: none; } .atlas-arch-recent > summary::-webkit-details-marker { display: none; } .atlas-arch-recent-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 30; width: min(24rem, calc(100vw - 3rem)); max-height: 20rem; overflow: auto; diff --git a/graph-ui/src/architecture/tab-row-position.test.ts b/graph-ui/src/architecture/tab-row-position.test.ts index 0125801a5..f37f43885 100644 --- a/graph-ui/src/architecture/tab-row-position.test.ts +++ b/graph-ui/src/architecture/tab-row-position.test.ts @@ -1,18 +1,15 @@ /* - * Review of K40: since A1 Back, Forward and the subtabs share one row, but the - * row jumped between Overview, Routes and Hotspots on one side and System - * structure and Behavior on the other: those two set the view's frame to 16 px - * instead of 28 and 32 and made the tabs narrower. "← Back" stood at x 32, y 98 - * in the first three and at x 16, y 86 in the other two. No rule for the - * system views may set the view's frame or the row differently, so the row - * stands alike in every subtab. + * Review of K40: the subtab row must stay in the same place across Overview, + * Routes, Hotspots, System structure and Behavior. History now lives beside + * each view's location, so those local controls may have their own layout. + * No system-view rule may change the workspace frame or the tabs themselves. */ import { readFileSync } from 'node:fs'; import { expect, it } from 'vitest'; const SHEETS = ['architecture.css', 'system-architecture.css', 'behavior-journey.css', 'spatial-architecture.css', 'container-map.css', 'architecture-scene.css', 'source-evidence.css'] .map((name) => ({ name, css: readFileSync(new URL(`./${name}`, import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '') })); -const ROW = /\.atlas-arch-(tabrow|tabs|tab|history|recent)\b/; +const ROW = /\.atlas-arch-(tabrow|tabs|tab)\b/; const BOX = /^(padding|margin)(-|$)/; it('K40: System structure and Behavior keep the subtab row where the other subtabs have it', () => { @@ -22,7 +19,7 @@ it('K40: System structure and Behavior keep the subtab row where the other subta for (const selector of match[1]!.split(',').map((item) => item.trim())) { if (!selector.includes('[data-system-view')) continue; const properties = match[2]!.split(';').map((part) => part.split(':')[0]!.trim()).filter(Boolean); - // The view's own frame, or anything about the row with the history buttons and the subtabs. + // Keep the workspace frame and the view-only tab row consistent. const frame = /^\.atlas-architecture\[data-system-view[^\]]*\]$/.test(selector) && properties.some((property) => BOX.test(property)); if (frame || ROW.test(selector)) offending.push(`${name}: ${selector} { ${properties.join(', ')} }`); } diff --git a/graph-ui/src/architecture/use-architecture-history.test.tsx b/graph-ui/src/architecture/use-architecture-history.test.tsx new file mode 100644 index 000000000..888d12f30 --- /dev/null +++ b/graph-ui/src/architecture/use-architecture-history.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { initialArchitecturePlace, type ArchitectureHistoryEntry } from './architecture-history'; +import { useArchitectureHistory, type ArchitectureHistory, type ArchitectureHistoryKeys } from './use-architecture-history'; + +let host: HTMLDivElement, root: Root; +let current: ArchitectureHistory; +let initial: ArchitectureHistoryEntry; +const restored = vi.fn(); +function Probe({ active = true, escapeTaken = false }: Partial) { + current = useArchitectureHistory(() => initial, { active, escapeTaken, onRestore: restored }); + return ; +} +const render = async (keys: Partial = {}) => { + await act(async () => { root.render(); }); +}; +const change = async (update: Parameters[0], context = false) => { + await act(async () => { current.navigate(update, context); }); +}; +const go = async (step: -1 | 1) => { await act(async () => { current.go(step); }); }; +const type = async (filter: string) => { await act(async () => { current.type(filter); }); }; +const area = (areaPath: string) => change(place => ({ ...place, spatial: { ...place.spatial, areaPath, filePath: undefined } })); +const file = (filePath: string) => change(place => ({ ...place, spatial: { ...place.spatial, filePath } })); +const keypress = async (key: string, target: EventTarget = window, modifiers: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key, altKey: true, bubbles: true, cancelable: true, ...modifiers }); + await act(async () => { target.dispatchEvent(event); }); + return event; +}; + +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + initial = initialArchitecturePlace('overview'); + restored.mockReset(); + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { await act(async () => { root.unmount(); }); host.remove(); }); + +describe('Architecture exploration history', () => { + it('updates tabs, perspective and scene settings in place while keeping Forward available', async () => { + await render(); + await area('src'); + await file('src/main.ts'); + await go(-1); + const forward = current.forward; + + await change(place => ({ ...place, view: 'routes' }), true); + await change(place => ({ ...place, routes: 'endpoints' }), true); + await change(place => ({ ...place, spatial: { ...place.spatial, planar: true } })); + await change(place => ({ ...place, system: { ...place.system, expanded: ['g:src'], structurePlanar: true, behaviorPlanar: true } })); + + expect(current.history.entries).toHaveLength(3); + expect(current.history.index).toBe(1); + expect(current.forward).toBe(forward); + expect(current.history.entries[1]).toBe(current.place); + const context = current.place; + await go(1); + expect(current.place).toBe(forward); + expect(current.place.view).toBe('overview'); + expect(current.place.spatial.planar).toBe(false); + await go(-1); + expect(current.place).toBe(context); + expect(current.place).toMatchObject({ view: 'routes', routes: 'endpoints', spatial: { planar: true }, system: { expanded: ['g:src'], structurePlanar: true, behaviorPlanar: true } }); + expect(current.history.entries).toHaveLength(3); + }); + + it('saves System structure expansion and Plan as context without inserting stops', async () => { + initial = initialArchitecturePlace('structure'); + await render(); + await change(place => ({ ...place, system: { ...place.system, focus: { id: 'g:src', label: 'src' } } })); + await change(place => ({ ...place, system: { ...place.system, expanded: ['g:src'], structurePlanar: true } })); + expect(current.history.entries).toHaveLength(2); + await change(place => ({ ...place, system: { ...place.system, focus: { id: 'g:api', label: 'api' }, expanded: [], structurePlanar: false } })); + await go(-1); + expect(current.place.system).toMatchObject({ focus: { id: 'g:src' }, expanded: ['g:src'], structurePlanar: true }); + expect(current.history.entries).toHaveLength(3); + }); + + it('drops the forward branch only when opening another exploration location', async () => { + await render(); + await area('src'); + await file('src/old.ts'); + await go(-1); + await change(place => ({ ...place, spatial: { ...place.spatial, planar: true } })); + expect(current.forward?.spatial.filePath).toBe('src/old.ts'); + await file('src/new.ts'); + expect(current.history.entries.map(entry => entry.spatial.filePath)).toEqual([undefined, undefined, 'src/new.ts']); + expect(current.forward).toBeUndefined(); + await go(-1); + expect(current.place.spatial).toMatchObject({ areaPath: 'src', planar: true }); + await go(1); + expect(current.place.spatial.filePath).toBe('src/new.ts'); + expect(current.history.entries).toHaveLength(3); + }); + + it('stores every filter edit immediately without a history stop', async () => { + initial = initialArchitecturePlace('routes'); + await render(); + for (const filter of ['/', '/a', '/api', '']) { + await type(filter); + expect(current.filter).toBe(filter); + expect(current.place.filter).toBe(filter); + expect(current.history.entries).toHaveLength(1); + expect(current.history.entries[0].filter).toBe(filter); + expect(current.back).toBeUndefined(); + } + }); + + it('restores typed filters through immediate Back and Forward while explicit route groups create stops', async () => { + initial = initialArchitecturePlace('routes'); + await render(); + await type('/typed-at-root'); + await change(place => ({ ...place, routes: 'endpoints', filter: '/users' })); + await type('/users/active'); + await change(place => ({ ...place, filter: '/orders' })); + expect(current.history.entries.map(entry => entry.filter)).toEqual(['/typed-at-root', '/users/active', '/orders']); + + await type('/orders/recent'); + await go(-1); + expect(current.filter).toBe('/users/active'); + await type('/users/new'); + expect(current.forward?.filter).toBe('/orders/recent'); + await go(1); + expect(current.filter).toBe('/orders/recent'); + await go(-1); + expect(current.filter).toBe('/users/new'); + expect(current.history.entries.map(entry => entry.filter)).toEqual(['/typed-at-root', '/users/new', '/orders/recent']); + expect(current.history.index).toBe(1); + }); + + it('replaces automatic starts and reindex resets, and restores them without pushing again', async () => { + initial = initialArchitecturePlace('behavior'); + await render(); + await change(place => ({ ...place, system: { ...place.system, behavior: { project: 'sample', id: 1, name: 'main' } } }), true); + expect(current.history.entries).toHaveLength(1); + await change(place => ({ ...place, system: { ...place.system, behavior: { project: 'sample', id: 2, name: 'save' } } })); + await go(-1); + await change(place => ({ ...place, system: { ...place.system, behavior: { project: 'sample', id: 3, name: 'reindexed main', generation: 'next' } } }), true); + expect(current.history.entries).toHaveLength(2); + expect(current.forward?.system.behavior?.id).toBe(2); + await go(1); + expect(current.place.system.behavior?.id).toBe(2); + await go(-1); + expect(current.place.system.behavior?.id).toBe(3); + expect(current.history.entries).toHaveLength(2); + expect(restored).toHaveBeenCalledTimes(3); + expect(restored.mock.calls.at(-1)?.map((entry: ArchitectureHistoryEntry) => entry.system.behavior?.id)).toEqual([2, 3]); + }); + + it('records another Behavior path while saving steps, pages and Plan with that path', async () => { + initial = { ...initialArchitecturePlace('behavior'), system: { expanded: [], behavior: { project: 'sample', id: 1, targetId: 2 } } }; + await render(); + await change(place => ({ ...place, system: { ...place.system, behaviorPlanar: true, behavior: { ...place.system.behavior!, position: { path: 0, step: 2, page: 1 } } } })); + expect(current.history.entries).toHaveLength(1); + await change(place => ({ ...place, system: { ...place.system, behavior: { ...place.system.behavior!, position: { path: 1 } } } })); + expect(current.history.entries).toHaveLength(2); + await go(-1); + expect(current.place.system.behavior?.position).toEqual({ path: 0, step: 2, page: 1 }); + expect(current.place.system.behaviorPlanar).toBe(true); + }); + + it('keeps Alt+Left and Alt+Right available for meaningful stops and leaves typing alone', async () => { + await render(); + await area('src'); + await file('src/main.ts'); + expect((await keypress('ArrowLeft')).defaultPrevented).toBe(true); + expect(current.place.spatial.filePath).toBeUndefined(); + expect(current.history.index).toBe(1); + expect((await keypress('ArrowRight', host.querySelector('input')!)).defaultPrevented).toBe(false); + expect(current.history.index).toBe(1); + expect((await keypress('ArrowRight', window, { ctrlKey: true })).defaultPrevented).toBe(false); + expect((await keypress('ArrowRight')).defaultPrevented).toBe(true); + expect(current.place.spatial.filePath).toBe('src/main.ts'); + expect(current.history.entries).toHaveLength(3); + expect((await keypress('ArrowRight')).defaultPrevented).toBe(false); + + await render({ active: false }); + expect((await keypress('ArrowLeft')).defaultPrevented).toBe(false); + await render({ escapeTaken: true }); + expect((await keypress('ArrowLeft')).defaultPrevented).toBe(false); + expect(current.history.index).toBe(2); + }); +}); diff --git a/graph-ui/src/architecture/use-architecture-history.ts b/graph-ui/src/architecture/use-architecture-history.ts index ed8f42fe2..0c05dbca8 100644 --- a/graph-ui/src/architecture/use-architecture-history.ts +++ b/graph-ui/src/architecture/use-architecture-history.ts @@ -1,17 +1,15 @@ /** * Back and Forward for the Architecture workspace (hand test K27), wired the - * way Galaxy wires K2: the current place is state, every change of its key is + * way Galaxy wires K2: the current place is state, meaningful exploration is * a step in the shared bounded model, and a restored entry pushes nothing. * * Three refinements Architecture needs and Galaxy does not: * - * - Typing in the Routes filter becomes one step once it pauses - * (`FILTER_STEP_MS`), not one step per key. A navigation, Back, Forward or - * a recent jump while typing first records what the field showed, so no - * typed text is lost. + * - Context controls (subtab, perspective and typed Routes filter) replace + * the current step immediately and preserve its forward branch. * - A change the page makes itself (the suggested Behavior start, a reset - * after reindexing) replaces the current step instead of adding one, so Back - * never lands on a state the page would immediately fill in again. + * after reindexing) also replaces the current step, so Back never lands on + * a state the page would immediately fill in again. * - Alt+Left and Alt+Right act only while this workspace is the active one, * never while typing and never while another surface takes the keys. * @@ -25,19 +23,16 @@ import { import { isTypingTarget } from '../app/keyboard'; import { architectureHistoryOptions as options, type ArchitectureHistoryEntry } from './architecture-history'; -/** Typing in the Routes filter becomes a step once it pauses this long. */ -export const FILTER_STEP_MS = 600; - type Update = (current: ArchitectureHistoryEntry) => ArchitectureHistoryEntry; export interface ArchitectureHistory { place: ArchitectureHistoryEntry; - /** What the filter field shows, including typing that is not a step yet. */ + /** The Routes filter saved with the current place, including the latest typing. */ filter: string; history: NavigationHistory; back?: ArchitectureHistoryEntry; forward?: ArchitectureHistoryEntry; - /** A navigation; `automatic` marks a change the page made itself. Stable across renders. */ + /** A navigation; `automatic` replaces the current stop for context controls and page-made changes. Stable across renders. */ navigate: (update: Update, automatic?: boolean) => void; type: (filter: string) => void; go: (step: -1 | 1) => void; @@ -54,11 +49,9 @@ export interface ArchitectureHistoryKeys { export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry, { active, escapeTaken, onRestore }: ArchitectureHistoryKeys): ArchitectureHistory { const [place, setPlace] = useState(initial); - const [draft, setDraft] = useState(); const [history, setHistory] = useState(() => emptyNavigationHistory()); const latest = useRef(place); latest.current = place; - const typed = useRef(undefined); const restoringKey = useRef(undefined); /** An automatic change: the key it was computed from and the key it leads to. */ const automaticStep = useRef<{ base: string; next: string } | undefined>(undefined); @@ -84,69 +77,32 @@ export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry, // The current step keeps the newest details of its place (a name known only later); after the push above, so a new key is never a refresh. useEffect(() => { setHistory((current) => refreshNavigation(current, place, options)); }, [place]); - const clearDraft = useCallback(() => { typed.current = undefined; setDraft(undefined); }, []); - const navigate = useCallback((update: Update, automatic = false) => { - const text = automatic ? undefined : typed.current; - if (text !== undefined) { - clearDraft(); - // What the field showed is a step of its own, before the navigation that leaves it. - setHistory((current) => pushNavigation(current, { ...latest.current, filter: text }, options)); - } setPlace((current) => { - const result = update(text === undefined ? current : { ...current, filter: text }); + const result = update(current); if (automatic) { const before = options.key(current), after = options.key(result); if (after !== before) automaticStep.current = { base: before, next: after }; } return result; }); - }, [clearDraft]); + }, []); - const type = useCallback((value: string) => { typed.current = value; setDraft(value); }, []); - useEffect(() => { - if (draft === undefined) return; - const timer = setTimeout(() => { - clearDraft(); - setPlace((current) => ({ ...current, filter: draft })); - }, FILTER_STEP_MS); - return () => clearTimeout(timer); - }, [draft, clearDraft]); + const type = useCallback((filter: string) => { navigate((current) => ({ ...current, filter }), true); }, [navigate]); const apply = useCallback((entry: ArchitectureHistoryEntry) => { restore.current?.(latest.current, entry); - clearDraft(); automaticStep.current = undefined; setPlace(entry); - }, [clearDraft]); - /** Typing that has not paused yet, as the step it is about to become; undefined while nothing is typed. */ - const typedStep = useCallback((): ArchitectureHistoryEntry | undefined => - (typed.current === undefined ? undefined : { ...latest.current, filter: typed.current }), []); - /** The history with that typing as its newest step, the way the pause would record it. */ - const withTyping = useCallback((): NavigationHistory => { - const entry = typedStep(); - return entry === undefined ? history : pushNavigation(history, entry, options); - }, [history, typedStep]); + }, []); const go = useCallback((step: -1 | 1) => { - const typedEntry = typedStep(); - const base = withTyping(); - const entry = peekNavigation(base, step); - if (entry === undefined) { - // Forward while typing: the text is a new step and drops the forward branch, as after the pause. - if (typedEntry === undefined) return; - clearDraft(); - setHistory(base); - setPlace(typedEntry); - return; - } + const entry = peekNavigation(history, step); + if (entry === undefined) return; restoringKey.current = options.key(entry); - setHistory(moveNavigation(base, step, options)); + setHistory(moveNavigation(history, step, options)); apply(entry); - }, [typedStep, withTyping, apply, clearDraft]); - const jump = useCallback((entry: ArchitectureHistoryEntry) => { - setHistory(withTyping()); - apply(entry); - }, [withTyping, apply]); + }, [history, apply]); + const jump = useCallback((entry: ArchitectureHistoryEntry) => { apply(entry); }, [apply]); useEffect(() => { if (!active || escapeTaken) return; @@ -155,13 +111,13 @@ export function useArchitectureHistory(initial: () => ArchitectureHistoryEntry, if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; if (isTypingTarget(event.target instanceof Element ? event.target : null)) return; const step = event.key === 'ArrowLeft' ? -1 : 1; - if (peekNavigation(withTyping(), step) === undefined) return; + if (peekNavigation(history, step) === undefined) return; event.preventDefault(); go(step); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [active, escapeTaken, withTyping, go]); + }, [active, escapeTaken, history, go]); - return { place, filter: draft ?? place.filter, history, back: peekNavigation(history, -1), forward: peekNavigation(history, 1), navigate, type, go, jump }; + return { place, filter: place.filter, history, back: peekNavigation(history, -1), forward: peekNavigation(history, 1), navigate, type, go, jump }; } diff --git a/graph-ui/src/chat/chat-strings.ts b/graph-ui/src/chat/chat-strings.ts index 37f2d727d..b42fffa1f 100644 --- a/graph-ui/src/chat/chat-strings.ts +++ b/graph-ui/src/chat/chat-strings.ts @@ -14,13 +14,13 @@ import { SIDECAR_PORT } from '../llm/sidecar'; /** The word over the panel. */ -export const CHAT_TITLE = 'ATLAS_CHAT'; +export const CHAT_TITLE = 'CBM LENS CHAT'; /** What the command line hint says when a question would be sent. */ -export const CHAT_HINT_READY = 'enter asks the atlas'; +export const CHAT_HINT_READY = 'enter asks CBM Lens'; /** What it says when the line looks like a question and the model is off. */ -export const CHAT_HINT_OFF = 'enter asks the atlas: the local model is off'; +export const CHAT_HINT_OFF = 'enter asks CBM Lens: the local model is off'; /** The sentence when the model is off and a question was typed. */ export const CHAT_OFF_MESSAGE = diff --git a/graph-ui/src/i18n/messages.ts b/graph-ui/src/i18n/messages.ts index 5a09fd89d..55ceccc3e 100644 --- a/graph-ui/src/i18n/messages.ts +++ b/graph-ui/src/i18n/messages.ts @@ -68,7 +68,8 @@ export const messages = { }, /** Die Kopfzeile und die Flaechen, die zu keinem Panel gehoeren. */ app: { - brand: 'CODEATLAS', + brand: 'CBM LENS', + releaseLabel: 'Beta', /** Steht in der Tab-Leiste und im Breadcrumb, wenn nichts offen ist. */ noFileOpen: 'no file open', /** Der Name, den eine Oberflaeche ohne Projekt traegt. */ @@ -147,14 +148,14 @@ export const messages = { * Repository. */ help: { - title: 'CODEATLAS HELP', + title: 'CBM LENS HELP', subtitle: 'a reading surface over an indexed repository', close: '[esc] close', closeLabel: CLOSE, whatTitle: 'What this is', what: [ - 'CodeAtlasWeb reads a repository that the analysis server has indexed and explains it: ' + 'CBM Lens reads a repository that the analysis server has indexed and explains it: ' + 'the tree and the source, a twin of the symbol under the caret, a walk along the calls, ' + 'the scope of a change, and the answers of a local model.', 'Everything on this screen comes from that index or from a file the server handed over, ' @@ -400,7 +401,7 @@ export const messages = { * Beispiele, wie man den Chat nutzt, sonst weiss niemand, wie man es * nutzt." */ - placeholder: 'type a command or ask the atlas...', + placeholder: 'type a command or ask CBM Lens...', examplesLabel: 'what you can type here', hintIdle: 'type 2 letters to search by meaning', hintOpen: 'up/down picks, enter opens, esc closes', @@ -422,7 +423,7 @@ export const messages = { * Schweigen. Jetzt steht der Satz an der Stelle, an der die Antwort * erwartet wird. */ - oneMoreLetter: 'one more letter, then the atlas searches by meaning', + oneMoreLetter: 'one more letter, then CBM Lens searches by meaning', /* * Die Sofort-Vorschlaege, und dass sie vorlaeufig sind. * @@ -648,7 +649,7 @@ export const messages = { /** Der Rahmen des Antwort-Panels. Seine Saetze stehen in chat-strings.ts. */ chat: { - panelLabel: 'atlas chat', + panelLabel: 'CBM Lens chat', depthGroupLabel: 'neighbourhood depth', /* * `clear` bleibt der einzige Knopf, der loescht, und sagt es. @@ -706,7 +707,7 @@ export const messages = { * das wurde am 2026-08-29 gemessen, und der Satz sagt es so. */ settings: { - title: 'CODEATLAS SETTINGS', + title: 'CBM LENS SETTINGS', subtitle: 'which model answers, and everything that costs computing time', panelLabel: 'settings', close: '[esc] close', @@ -974,7 +975,7 @@ export const messages = { * not about one subject area. */ projects: { - title: 'CODEATLAS PROJECTS', + title: 'CBM LENS PROJECTS', subtitle: 'what this server has indexed, and how to give it more', panelLabel: 'projects', close: '[esc] close', @@ -1100,7 +1101,7 @@ export const messages = { /** Der Rahmen des Twins. Seine Fakten-Saetze stehen in twin/strings.ts. */ twin: { /** Das Etikett des Leseprofils, mit dem dieses Produkt startet. */ - profileLabel: 'CodeAtlasWeb Twin', + profileLabel: 'CBM Lens Twin', /** Der Kopf des Panels, solange kein Symbol vor dem Leser steht. */ noSymbol: 'no symbol', }, diff --git a/graph-ui/src/impact/impact-strings.ts b/graph-ui/src/impact/impact-strings.ts index cb527a572..ce07e09cb 100644 --- a/graph-ui/src/impact/impact-strings.ts +++ b/graph-ui/src/impact/impact-strings.ts @@ -69,7 +69,7 @@ export function impactRiskTooltip(level: 'low' | 'medium' | 'high', reasons: str /** How a route was tied to the change. Two different strengths of claim. */ export const IMPACT_ENDPOINT_VIA_HANDLER = 'The registration names a handler this change reaches.'; export const IMPACT_ENDPOINT_VIA_FILE = - 'The registration is written in a file this change reaches. CodeAtlas cannot name the handler here, so this is the file and not the function.'; + 'The registration is written in a file this change reaches. CBM Lens cannot name the handler here, so this is the file and not the function.'; // Die Erzaehlung, ein Satz je Lesung ----------------------------------------- @@ -110,7 +110,7 @@ export function impactDownstreamSentence(count: number, distance: number): strin export function impactEndpointSentence(paths: string[]): string { if (paths.length === 0) { - return 'No HTTP endpoint CodeAtlas recovered is registered in a file this change reaches.'; + return 'No HTTP endpoint CBM Lens recovered is registered in a file this change reaches.'; } return `${countOf(paths.length, 'endpoint is', 'endpoints are')} registered in a file ` + `this change reaches: ${paths.join(', ')}.`; @@ -152,7 +152,7 @@ export const IMPACT_EVIDENCE_TITLE = 'What this rests on'; export const IMPACT_EVIDENCE_SOURCES: Readonly> = { detect_changes: 'the change set the analysis reported, plus the calls into it', - architecture: 'the whole-project summary, including the routes CodeAtlas recovered', + architecture: 'the whole-project summary, including the routes CBM Lens recovered', facts: 'the per-symbol readings: complexity, and callers flagged as test code', }; @@ -192,7 +192,7 @@ export const IMPACT_MENU_LABEL = '[c]hange scope'; /** Der Satz darunter: was gelesen wird und was das Wort am Ende bedeutet. */ export const IMPACT_SUBLINE = - 'Read from the change set the analysis reports, the calls into it, and the routes CodeAtlas recovered. ' + 'Read from the change set the analysis reports, the calls into it, and the routes CBM Lens recovered. ' + 'The word on the right is a rule applied to those readings, never a prediction.'; export const IMPACT_MODE_LABEL = 'Compare against'; @@ -244,7 +244,7 @@ export const IMPACT_DIRECT_EMPTY = export const IMPACT_DOWNSTREAM_EMPTY = 'Nothing in the indexed workspace calls the changed symbols.'; export const IMPACT_ENDPOINTS_EMPTY = - 'No route CodeAtlas recovered is registered in a file this change reaches.'; + 'No route CBM Lens recovered is registered in a file this change reaches.'; export const IMPACT_TESTS_EMPTY = 'No affected symbol could be checked for test callers.'; /** diff --git a/graph-ui/src/pseudocode/block-lead.test.ts b/graph-ui/src/pseudocode/block-lead.test.ts index 7e08a20af..c5c64b01f 100644 --- a/graph-ui/src/pseudocode/block-lead.test.ts +++ b/graph-ui/src/pseudocode/block-lead.test.ts @@ -85,7 +85,7 @@ describe('the sentence over the block', () => { it('leads with what cannot be checked when nothing is unused', () => { const lead = blockLeadOf(document, importsOf([USED, UNCHECKABLE]), 'getOrder'); expect(lead.kind).toBe('unchecked-imports'); - expect(lead.text).toContain('CodeAtlas cannot check either way'); + expect(lead.text).toContain('CBM Lens cannot check either way'); }); it('leads with what lies behind the calls when the imports check out', () => { diff --git a/graph-ui/src/pseudocode/imports-group.test.ts b/graph-ui/src/pseudocode/imports-group.test.ts index d0b9f0c45..271cadb58 100644 --- a/graph-ui/src/pseudocode/imports-group.test.ts +++ b/graph-ui/src/pseudocode/imports-group.test.ts @@ -252,7 +252,7 @@ describe('where a reader is sent, and what the group says about itself', () => { expect(built.used).toBe(5); expect(built.unused).toBe(2); expect(built.unknown).toBe(0); - expect(built.tally).toBe('5 used in this file, 2 not used in it, 0 that CodeAtlas cannot check.'); + expect(built.tally).toBe('5 used in this file, 2 not used in it, 0 that CBM Lens cannot check.'); }); it('says how many entries the cap left out rather than trailing off', () => { diff --git a/graph-ui/src/pseudocode/pseudocode-strings.ts b/graph-ui/src/pseudocode/pseudocode-strings.ts index 845c36e03..1966b6138 100644 --- a/graph-ui/src/pseudocode/pseudocode-strings.ts +++ b/graph-ui/src/pseudocode/pseudocode-strings.ts @@ -106,7 +106,7 @@ export function pseudocodeCappedNote(hidden: number): string { * the one way this feature could mislead without saying anything untrue. */ export const PSEUDOCODE_PARTIAL_SCOPE = - 'CodeAtlas sampled this range rather than reading every line of it, so a very short member may be missing.'; + 'CBM Lens sampled this range rather than reading every line of it, so a very short member may be missing.'; /** * The footer, and the sentence that keeps the whole block honest. @@ -169,7 +169,7 @@ export function pseudocodeLeadUnused(symbol: string, unused: number, total: numb export function pseudocodeLeadUnchecked(symbol: string, unknown: number): string { return `You cannot see this in the code: every name this file pulls in is one the index links to ` - + `${symbol}, except ${countOf(unknown, 'one', 'ones')} CodeAtlas cannot check either way.`; + + `${symbol}, except ${countOf(unknown, 'one', 'ones')} CBM Lens cannot check either way.`; } export function pseudocodeLeadBehind(steps: number): string { @@ -179,7 +179,7 @@ export function pseudocodeLeadBehind(steps: number): string { export function pseudocodeLeadNone(symbol: string, total: number): string { if (total === 0) { - return 'Nothing stands above the steps this time: CodeAtlas found no import in this file, and the ' + return 'Nothing stands above the steps this time: CBM Lens found no import in this file, and the ' + 'index records nothing behind the steps below.'; } return `Nothing stands above the steps this time: every name this file pulls in is one the index links ` @@ -293,7 +293,7 @@ export function importsFindingHeading(unused: number, unknown: number, total: nu + 'as far as the index shows'; } if (unknown > 0) { - return `${IMPORTS_GROUP_HEADING}: ${countOf(unknown, 'name', 'names')} CodeAtlas cannot check ` + return `${IMPORTS_GROUP_HEADING}: ${countOf(unknown, 'name', 'names')} CBM Lens cannot check ` + 'against this file'; } return `${IMPORTS_GROUP_HEADING}: every name here is linked to a symbol in this file`; @@ -318,7 +318,7 @@ export function importsUnusedLine(name: string, module: string): string { /** Said on an entry that could not be checked at all. */ export function importsUnknownLine(name: string, module: string): string { - return `${name} from ${module}: CodeAtlas cannot tell whether this file uses it`; + return `${name} from ${module}: CBM Lens cannot tell whether this file uses it`; } /** Said on `import * as db from ...` and its equivalents in the other languages. */ @@ -344,7 +344,7 @@ export const IMPORTS_NOTE_UNUSED = 'The index answered every checked symbol in this file, and none of their recorded facts mentions this name.'; export const IMPORTS_NOTE_UNKNOWN = - 'CodeAtlas does not have a complete answered reading for every callable in this file, so it will not claim the import is unused.'; + 'CBM Lens does not have a complete answered reading for every callable in this file, so it will not claim the import is unused.'; export const IMPORTS_NOTE_NAMESPACE = 'A namespace import binds every export of the module under one name, and the index records a call through it under the member\'s name. So nothing here can be matched against the binding.'; @@ -353,7 +353,7 @@ export const IMPORTS_NOTE_SIDE_EFFECT = 'This statement binds no name, so there is nothing for the index to match it against. What the module does when it loads is not something the analysis records.'; export const IMPORTS_NOTE_INDEX_ONLY = - 'This came from the dependency the index recorded rather than from the file\'s text, so CodeAtlas has the imported file and not the imported name.'; + 'This came from the dependency the index recorded rather than from the file\'s text, so CBM Lens has the imported file and not the imported name.'; /** Said at the end of a group the display cap cut short. */ export function importsCappedNote(hidden: number, shown: number): string { @@ -362,11 +362,11 @@ export function importsCappedNote(hidden: number, shown: number): string { /** Said when the file's own text could not be read at all. */ export const IMPORTS_SOURCE_UNREAD = - 'CodeAtlas could not read this file\'s text, so what follows is the dependencies the index recorded and not the import statements themselves.'; + 'CBM Lens could not read this file\'s text, so what follows is the dependencies the index recorded and not the import statements themselves.'; /** Said when the file pulls in nothing at all. */ export const IMPORTS_EMPTY = - 'The index records no dependency for this file and CodeAtlas found no import statement in it.'; + 'The index records no dependency for this file and CBM Lens found no import statement in it.'; /** * The standing sentence under the group, in both surfaces. @@ -377,7 +377,7 @@ export const IMPORTS_EMPTY = * the judgement about use comes from every callable declaration the file read. */ export const IMPORTS_SOURCE_NOTE = - 'The dependency comes from the index; the statement, its line and the name it binds are CodeAtlas reading ' + 'The dependency comes from the index; the statement, its line and the name it binds are CBM Lens reading ' + 'this file. Use is checked against the recorded facts of every callable symbol in the file.'; /** The three markers. Short enough to sit beside a name, and never the only carrier of meaning. */ @@ -395,7 +395,7 @@ export const IMPORTS_SECTION_NOTE = IMPORTS_SOURCE_NOTE; /** How the group's own tally reads, under the entries. */ export function importsTally(used: number, unused: number, unknown: number): string { - return `${used} used in this file, ${unused} not used in it, ${unknown} that CodeAtlas cannot check.`; + return `${used} used in this file, ${unused} not used in it, ${unknown} that CBM Lens cannot check.`; } // --------------------------------------------------------------------------- @@ -581,7 +581,7 @@ export function explainerWalkBound(depth: number, cap: number): string { * entworfen, muss sagen, dass sie aus einem Index gerechnet ist. */ export const EXPLAINER_GENERATED_NOTE = - 'Nobody drew this picture. CodeAtlas laid it out from what the index recorded for this walk, so it can ' + 'Nobody drew this picture. CBM Lens laid it out from what the index recorded for this walk, so it can ' + 'be incomplete about the code and exact about what was reported.'; /** Die Notiz im Bild, wenn eine Kante in eine schon besuchte Kette zurueckfuehrt. */ @@ -636,4 +636,4 @@ export const FLOW_LOADING = 'Reading what this symbol reaches.'; * hier nichts zu zeigen, und keiner davon heisst "dieses Symbol ruft nichts". */ export const FLOW_UNAVAILABLE = - 'CodeAtlas has no walk for this symbol right now. Place the caret in an indexed function, or reindex the workspace.'; + 'CBM Lens has no walk for this symbol right now. Place the caret in an indexed function, or reindex the workspace.'; diff --git a/graph-ui/src/search/command-examples.test.ts b/graph-ui/src/search/command-examples.test.ts index 72e0d45a7..9e2f94e14 100644 --- a/graph-ui/src/search/command-examples.test.ts +++ b/graph-ui/src/search/command-examples.test.ts @@ -93,8 +93,8 @@ describe('commandPlaceholderFor', () => { }); it('faellt auf die Gattung zurueck, statt einen Namen zu erfinden', () => { - expect(commandPlaceholderFor(undefined, 'type a command or ask the atlas...')) - .toBe('type a command or ask the atlas...'); + expect(commandPlaceholderFor(undefined, 'type a command or ask CBM Lens...')) + .toBe('type a command or ask CBM Lens...'); }); it('sagt ueber der Liste, worum es geht', () => { diff --git a/graph-ui/src/search/command-examples.ts b/graph-ui/src/search/command-examples.ts index 940d0e802..55c69a979 100644 --- a/graph-ui/src/search/command-examples.ts +++ b/graph-ui/src/search/command-examples.ts @@ -127,7 +127,7 @@ export function commandExamplesFor(symbol: string): CommandExample[] { { id: 'question', text: `Who calls ${symbol}?`, - note: 'ask the atlas', + note: 'ask CBM Lens', symbol, }, ]; diff --git a/graph-ui/src/styles/terminal.css b/graph-ui/src/styles/terminal.css index 6777c9387..c80646c8a 100644 --- a/graph-ui/src/styles/terminal.css +++ b/graph-ui/src/styles/terminal.css @@ -85,6 +85,9 @@ body { } .atlas-brand { + display: inline-flex; + align-items: center; + gap: 8px; margin: 0; font-size: 15px; font-weight: 700; @@ -92,22 +95,23 @@ body { color: var(--atlas-phosphor); } -.atlas-version { - color: var(--atlas-dim); +.atlas-brand-beta { + padding: 3px 6px; + border: 1px solid rgb(200 173 114 / 28%); + border-radius: 4px; + background: rgb(200 173 114 / 7%); + color: #c8ad72; + font-size: 10px; + font-weight: 600; + line-height: 1.2; letter-spacing: 0.06em; + text-shadow: none; } -/* - * Der Zustand des Arbeitsbaums, neben der Fassung und leiser als sie. - * - * Kleiner und gedimmt, weil er eine Randbemerkung ist: wer die Fassung sucht, - * liest den Chip, und wer wissen will, ob ein Bild zu einem Commit gehoert, - * findet daneben die Antwort. - */ -.atlas-version-suffix { +.atlas-version { color: var(--atlas-dim); - opacity: 0.7; - font-size: 10px; + opacity: 0.65; + font-size: 9px; letter-spacing: 0.06em; } diff --git a/graph-ui/src/styles/workspace.css b/graph-ui/src/styles/workspace.css index 35d8212b8..cb8645347 100644 --- a/graph-ui/src/styles/workspace.css +++ b/graph-ui/src/styles/workspace.css @@ -2,7 +2,7 @@ .atlas-shell[data-has-status=true] { grid-template-rows: auto auto minmax(0, 1fr) auto; } .atlas-header { gap: 12px; padding: 12px 18px; overflow: visible; position: relative; z-index: 20; border-bottom-color: var(--atlas-phosphor-mute); box-shadow: 0 2px 18px var(--atlas-glow); } .atlas-brand { font-size: 15px; font-weight: 700; letter-spacing: .22em; color: var(--atlas-phosphor); text-shadow: 0 0 16px var(--atlas-glow); } -.atlas-version, .atlas-version-suffix { font-size: 10px; color: var(--atlas-dim); } +.atlas-version { font-size: 9px; color: var(--atlas-dim); } .atlas-workspace-tabs { display: flex; gap: 4px; margin-left: 16px; } .atlas-workspace-tabs button, .atlas-guidance, .atlas-tools-menu > summary { border: 1px solid transparent; border-radius: 2px; background: transparent; color: var(--atlas-dim); padding: 7px 12px; font: inherit; cursor: pointer; } .atlas-workspace-tabs button[aria-selected=true] { background: var(--atlas-select); color: var(--atlas-phosphor); border-color: var(--atlas-phosphor-mute); box-shadow: inset 0 1px 0 var(--atlas-phosphor); } diff --git a/graph-ui/src/traces/bug-wizard-strings.ts b/graph-ui/src/traces/bug-wizard-strings.ts index a01197bc8..a9e8a00d3 100644 --- a/graph-ui/src/traces/bug-wizard-strings.ts +++ b/graph-ui/src/traces/bug-wizard-strings.ts @@ -80,7 +80,7 @@ export const BUG_WIZARD_ENTRY_TOOLTIP = * the first would be honest and useless. */ export const BUG_WIZARD_NO_TRACES = - 'No observed call came back for this symbol. CodeAtlas does not watch code while it runs; ' + 'No observed call came back for this symbol. CBM Lens does not watch code while it runs; ' + 'it reads back what somebody handed the analysis backend.'; export const BUG_WIZARD_NO_TRACES_HOW = @@ -114,7 +114,7 @@ export const BUG_WIZARD_NO_TRACES_WHERE = /** Said above the observed chains, so nobody reads them as a reading of the index. */ export const BUG_WIZARD_OBSERVED_NOTE = - 'Read back from the analysis backend, along the calls the index connects. CodeAtlas did not ' + 'Read back from the analysis backend, along the calls the index connects. CBM Lens did not ' + 'observe any of this itself.'; /** How many times an observed call was seen, and under which run. */ diff --git a/graph-ui/src/twin/TwinPanel.test.tsx b/graph-ui/src/twin/TwinPanel.test.tsx index 15db81380..3d4a3cf92 100644 --- a/graph-ui/src/twin/TwinPanel.test.tsx +++ b/graph-ui/src/twin/TwinPanel.test.tsx @@ -433,7 +433,7 @@ describe('wenn es nichts zu zeigen gibt', () => { ir: undefined, symbolName: 'nothing', message: 'This file is not indexed yet.', - hint: 'CodeAtlas is still reading this workspace.', + hint: 'CBM Lens is still reading this workspace.', }); const empty = q('[data-testid="atlas-twin-empty"]'); expect(empty?.textContent).toContain('not indexed yet'); @@ -627,7 +627,7 @@ describe('die Pseudocode-Ansicht', () => { expect(q('[data-testid="atlas-pseudocode-group"]')?.textContent) .toContain('not used anywhere in this file as far as the index shows'); expect(q('[data-testid="atlas-pseudocode-tally"]')?.textContent) - .toContain('that CodeAtlas cannot check'); + .toContain('that CBM Lens cannot check'); }); it('nennt an jeder Schrittzeile Datei und Zeile des aufgerufenen Symbols', async () => { diff --git a/graph-ui/src/twin/strings.ts b/graph-ui/src/twin/strings.ts index b4dc9e3e8..11b303646 100644 --- a/graph-ui/src/twin/strings.ts +++ b/graph-ui/src/twin/strings.ts @@ -79,7 +79,7 @@ export function countOf(count: number, singular: string, plural: string): string // --------------------------------------------------------------------------- export const TWIN_EMPTY_MESSAGE = 'Place the cursor inside a function, method, or class.'; -export const TWIN_EMPTY_HINT = 'CodeAtlas follows the caret and explains whatever it lands in.'; +export const TWIN_EMPTY_HINT = 'CBM Lens follows the caret and explains whatever it lands in.'; // --------------------------------------------------------------------------- // Twin identity and provenance @@ -94,13 +94,13 @@ export const TWIN_FILE_NOT_INDEXED = 'This file is not indexed yet.'; /** Says what would change the sentence above. */ export const TWIN_FILE_NOT_INDEXED_HINT = - 'CodeAtlas is still reading this workspace. Facts about this symbol appear as soon as it has.'; + 'CBM Lens is still reading this workspace. Facts about this symbol appear as soon as it has.'; /** Shown while the IR for a symbol is being built. */ export const TWIN_LOADING = 'Reading what the index knows about this symbol.'; /** Shown when the IR call itself failed, which is a different failure from an empty fact. */ -export const TWIN_LOAD_FAILED = 'CodeAtlas could not reach the analysis backend for this symbol.'; +export const TWIN_LOAD_FAILED = 'CBM Lens could not reach the analysis backend for this symbol.'; /** * What the flow switch in the twin head says, in both states. @@ -550,7 +550,7 @@ export function proseMoreParts(hidden: number): string { /** Said out loud, because the sentence above it was assembled rather than written. */ export const PROSE_GENERATED_NOTE = - 'Nobody wrote that description. CodeAtlas put it together from what the index recorded, so it can be wrong about intent and right about counts.'; + 'Nobody wrote that description. CBM Lens put it together from what the index recorded, so it can be wrong about intent and right about counts.'; export const PROSE_TESTS_NONE = 'Nothing that looks like a test calls this, so no automatic check would notice a change here.'; @@ -573,13 +573,13 @@ export const PLAIN_SUBJECT_ERRORS = 'How this can fail'; export function plainAbsence(state: KnowledgeState, subject: string): string { switch (state) { case 'notIndexed': - return `${subject} is unknown: CodeAtlas has not read this file yet.`; + return `${subject} is unknown: CBM Lens has not read this file yet.`; case 'unsupported': - return `${subject} is unknown: CodeAtlas cannot see this for this language.`; + return `${subject} is unknown: CBM Lens cannot see this for this language.`; case 'inferred': return `${subject} was worked out from what calls it, not read from the code itself.`; case 'unknown': - return `${subject} is unknown: nothing answered when CodeAtlas asked.`; + return `${subject} is unknown: nothing answered when CBM Lens asked.`; case 'ambiguous': case 'known': return ''; @@ -752,7 +752,7 @@ export const TERM_EXPLANATIONS: Readonly> = { 'a value this code takes from outside itself, such as a setting or a configuration key, rather than ' + 'from what it was handed.', 'test caller': - 'a caller that lives in test code. It is how CodeAtlas answers "is this checked", because this index ' + 'a caller that lives in test code. It is how CBM Lens answers "is this checked", because this index ' + 'records no test relation of its own for TypeScript.', 'entry point': 'a place the outside world can start this program: a route, a command, a handler. Work arrives here ' @@ -814,7 +814,7 @@ export const GROUND_LEAD_SITS_ON = export const GROUND_LEAD_CARRIED = 'What holds this up: the callers, and whether the outside world can reach it directly.'; export const GROUND_LEAD_DEBTS = - 'What is owed against this symbol, from the rules CodeAtlas applies to the recorded facts. Every line is a ' + 'What is owed against this symbol, from the rules CBM Lens applies to the recorded facts. Every line is a ' + 'rule over facts and not a fact, which is why they all wear the derived marker.'; export const GROUND_LEAD_LIMITS = 'What is not on this page, and why. This is part of the answer and not a footnote: an architect who cannot ' @@ -827,7 +827,7 @@ export const GROUND_EMPTY_CARRIED = 'Nothing carries this: no recorded caller, and nothing marking it as reachable from outside. A symbol with ' + 'no carrier is either an entry the index cannot see or a piece nobody uses.'; export const GROUND_EMPTY_DEBTS = - 'No rule fired against this symbol. That is not a clean bill: the rules CodeAtlas can apply are the ones ' + 'No rule fired against this symbol. That is not a clean bill: the rules CBM Lens can apply are the ones ' + 'listed under what this index stops at, and the ones it cannot apply stay silent either way.'; /** How many call sites reach into one module. */ @@ -877,7 +877,7 @@ export const LIMIT_NO_HANDLER_SITES = 'Raised error types are recorded; the places they are caught are not. A short error list is therefore not ' + 'evidence that the failure is contained.'; export const LIMIT_NO_ROUTES = - 'Routes for TypeScript are read out of the source text by CodeAtlas, not reported by the engine, so an ' + 'Routes for TypeScript are read out of the source text by CBM Lens, not reported by the engine, so an ' + 'entry point the engine did not mark can still exist.'; export const LIMIT_CALLS_DEDUPLICATED = 'Two calls to the same target inside one body are recorded once, with the last site. A count of call sites ' @@ -895,12 +895,12 @@ export const VOICE_TITLE = export const VOICE_RUNNING = 'The local model is rewording the sentences above.'; export const VOICE_RESTORE_LABEL = 'Back to the built text'; export const VOICE_RESTORE_TITLE = - 'Drop the reworded sentences and show the ones CodeAtlas assembled from the index.'; + 'Drop the reworded sentences and show the ones CBM Lens assembled from the index.'; /** The marker beside a sentence the model worded. Same idiom as the state markers. */ export const VOICE_MARKER = '[~]'; export const VOICE_MARKER_NOTE = - 'Worded by the local model from the sentence CodeAtlas built. The facts in it were checked against the ' + 'Worded by the local model from the sentence CBM Lens built. The facts in it were checked against the ' + 'original one by one; the wording is the model.'; export const VOICE_APPLIED = 'The sentences above were reworded by the local model. Every name, number, file and line in them was ' @@ -911,7 +911,7 @@ export function voiceRefused(reason: string): string { } export const VOICE_UNAVAILABLE = - 'The local model is off, so these sentences are the ones CodeAtlas assembled. Nothing here waits for it.'; + 'The local model is off, so these sentences are the ones CBM Lens assembled. Nothing here waits for it.'; export const FACET_LABELS: Readonly> = { [Facet.Logic]: 'Logic', @@ -1075,7 +1075,7 @@ export const RUNTIME_UNEXPECTED_TOOLTIP = /** Why the runtime section wears its marker: nothing is missing from the index here. */ export const RUNTIME_SECTION_NOTE = - 'Read from an imported recording, not from the index. CodeAtlas did not watch this run.'; + 'Read from an imported recording, not from the index. CBM Lens did not watch this run.'; // The runtime badge in the evidence popover --------------------------------- diff --git a/graph-ui/src/twin/twin-view-model.test.ts b/graph-ui/src/twin/twin-view-model.test.ts index 42bd39787..3a945288f 100644 --- a/graph-ui/src/twin/twin-view-model.test.ts +++ b/graph-ui/src/twin/twin-view-model.test.ts @@ -273,7 +273,7 @@ describe('importsSection', () => { }); it('schreibt die Bilanz ueber die Zeilen und die Herkunft darunter', () => { - expect(built.text).toBe('1 used in this file, 1 not used in it, 0 that CodeAtlas cannot check.'); + expect(built.text).toBe('1 used in this file, 1 not used in it, 0 that CBM Lens cannot check.'); expect(built.note).toContain('The dependency comes from the index'); expect(built.populated).toBe(true); }); diff --git a/graph-ui/tests/scaffold/w2a.test.mjs b/graph-ui/tests/scaffold/w2a.test.mjs index 7cb020aa5..2c9ef692f 100644 --- a/graph-ui/tests/scaffold/w2a.test.mjs +++ b/graph-ui/tests/scaffold/w2a.test.mjs @@ -24,7 +24,8 @@ test('AC1: design tokens and chrome are in place', () => { 'atlas-statusbar', 'atlas-tree', 'atlas-breadcrumb']) { assert.ok(smoke.testids.includes(id), `data-testid ${id} wurde im Smoke nicht gesehen`); } - assert.ok(/CODEATLAS/.test(smoke.brandText), 'Marke CODEATLAS fehlt in der Kopfzeile'); + // Archived W2 evidence predates the CBM Lens name; new smoke runs require the current brand. + assert.ok(/CBM LENS|CODEATLAS/.test(smoke.brandText), 'Frontend-Marke fehlt in der Kopfzeile'); assert.match(smoke.versionChip, /^v\d+\.\d+\.\d+/, 'Versions-Chip fehlt oder falsch'); // Spezifikations-Korrektur 2026-08-29 (Orchestrator, Nutzerauftrag): // Der Test nagelte [f]ile fest, also ausgerechnet einen der vier @@ -32,7 +33,7 @@ test('AC1: design tokens and chrome are in place', () => { // sein. Verlangt bleibt das Buchstaben-Menue als Form, belegt am // verdrahteten [a]tlas. assert.ok(/\[a\]tlas/.test(smoke.menuText), 'Buchstaben-Menue fehlt'); - assert.ok(/type a command or ask the atlas/.test(smoke.commandPlaceholder), + assert.ok(/type a command or ask (?:CBM Lens|the atlas)/.test(smoke.commandPlaceholder), 'Kommandozeilen-Platzhalter fehlt'); }); diff --git a/graph-ui/tools/smoke-w2a.mjs b/graph-ui/tools/smoke-w2a.mjs index 4ebb9fb4b..cadd25b17 100644 --- a/graph-ui/tools/smoke-w2a.mjs +++ b/graph-ui/tools/smoke-w2a.mjs @@ -534,14 +534,14 @@ async function main() { const ok = failure === null && missingTestids.length === 0 && - /CODEATLAS/.test(report.brandText) && + /CBM LENS/.test(report.brandText) && /^v\d+\.\d+\.\d+/.test(report.versionChip) && // Nachgezogen am 2026-08-29 (W7a, Nutzerauftrag): der Lauf nagelte // [f]ile fest, also ausgerechnet einen der vier Menuepunkte ohne // Verdrahtung. Eine Attrappe darf keine Zusicherung sein. Verlangt // bleibt das Buchstaben-Menue als Form, belegt am verdrahteten [a]tlas. /\[a\]tlas/.test(report.menuText) && - /type a command or ask the atlas/.test(report.commandPlaceholder) && + /type a command or ask CBM Lens/.test(report.commandPlaceholder) && report.treeEntries >= 10 && report.treeSource === '/api/tree' && report.openedFile === OPEN_FILE && diff --git a/graph-ui/tools/smoke-w8c.mjs b/graph-ui/tools/smoke-w8c.mjs index a2773fb83..d8d2ff302 100644 --- a/graph-ui/tools/smoke-w8c.mjs +++ b/graph-ui/tools/smoke-w8c.mjs @@ -543,7 +543,7 @@ async function main() { && short.imports.top < short.firstLineTop; report.importHonestyWordsKept = short.imports.entries .some((entry) => entry.text.includes('not used by this symbol as far as the index shows')) - && short.imports.tally.includes('that CodeAtlas cannot check') + && short.imports.tally.includes('that CBM Lens cannot check') && short.imports.heading.includes('as far as the index shows'); log(`Importgruppe: "${short.imports.heading}" / "${short.imports.tally}"`); @@ -681,7 +681,7 @@ async function main() { .filter((line) => !line.targetKnown) .every((line) => line.target.includes('the index records no place')); report.importHonestyWordsKept = report.importHonestyWordsKept - && long.imports.tally.includes('that CodeAtlas cannot check'); + && long.imports.tally.includes('that CBM Lens cannot check'); report.headSaysWhatTheFindingIs = report.headSaysWhatTheFindingIs && long.lead.text.length > 0 && long.lead.kind.length > 0;