From 42e436157cac1fcb4dc81946b1b43faee2ad6ce8 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 15:06:07 +0200 Subject: [PATCH] fix(ui): make the other Refresh buttons say what they did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand test asked for every Refresh button to say what it did; only the four in Architecture did. Measured in the browser, three more showed nothing that lasted: Refresh in ADR changed nothing for a single frame, Refresh projects in the project picker showed "Loading projects..." for about 150 ms and then the same list, and Refresh in the file impact of Explore folded the details into "Reading dependencies…" for two seconds and brought back the same numbers. The control and its hook move from architecture/ to ui/refresh/, with the shared words "Up to date at …: no changes since the last load" and "Refresh failed at …", and these three buttons use it: they read "Refreshing…" while they run and then name the time and whether anything changed. Where the view already shows an error in its own words, the status names only the time. The file impact keeps its details on screen while a refresh of the same file runs. In the project picker the status stands on its own line under the buttons. The System buttons already show it ("Updated" and "Read" with the time), and the Refresh buttons of Settings, of the projects panel and of the daemon alerts are not mounted anywhere in the app, so they stay as they are. Signed-off-by: Bernhard Jackiewicz --- .../src/adr/AdrWorkspace.refresh.test.tsx | 60 ++++++++++++++ graph-ui/src/adr/AdrWorkspace.tsx | 17 +++- graph-ui/src/adr/adr-strings.ts | 3 +- graph-ui/src/architecture/BehaviorJourney.tsx | 2 +- .../src/architecture/ContainerMap.test.tsx | 2 +- graph-ui/src/architecture/ContainerMap.tsx | 2 +- .../architecture/SpatialArchitecture.test.tsx | 2 +- .../src/architecture/SpatialArchitecture.tsx | 2 +- .../architecture/SystemArchitecture.test.tsx | 4 +- .../src/architecture/SystemArchitecture.tsx | 2 +- graph-ui/src/architecture/architecture.css | 6 -- .../src/architecture/behavior-journey.css | 2 +- graph-ui/src/architecture/strings.ts | 4 +- .../impact/FileImpactSummary.refresh.test.tsx | 79 +++++++++++++++++++ graph-ui/src/impact/FileImpactSummary.tsx | 31 ++++++-- graph-ui/src/impact/impact-strings.ts | 5 ++ .../projects/ProjectSwitcher.refresh.test.tsx | 65 +++++++++++++++ graph-ui/src/projects/ProjectSwitcher.tsx | 15 +++- .../src/projects/project-switcher-strings.ts | 3 +- graph-ui/src/projects/project-switcher.css | 7 +- .../refresh}/refresh-feedback.test.tsx | 0 .../refresh}/refresh-feedback.tsx | 19 ++--- graph-ui/src/ui/refresh/refresh.css | 6 ++ graph-ui/src/ui/refresh/strings.ts | 6 ++ 24 files changed, 304 insertions(+), 40 deletions(-) create mode 100644 graph-ui/src/adr/AdrWorkspace.refresh.test.tsx create mode 100644 graph-ui/src/impact/FileImpactSummary.refresh.test.tsx create mode 100644 graph-ui/src/projects/ProjectSwitcher.refresh.test.tsx rename graph-ui/src/{architecture => ui/refresh}/refresh-feedback.test.tsx (100%) rename graph-ui/src/{architecture => ui/refresh}/refresh-feedback.tsx (82%) create mode 100644 graph-ui/src/ui/refresh/refresh.css create mode 100644 graph-ui/src/ui/refresh/strings.ts diff --git a/graph-ui/src/adr/AdrWorkspace.refresh.test.tsx b/graph-ui/src/adr/AdrWorkspace.refresh.test.tsx new file mode 100644 index 000000000..a56ef8aaf --- /dev/null +++ b/graph-ui/src/adr/AdrWorkspace.refresh.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +/* + * Review of K42 ("other Refresh buttons the same way"): Refresh in ADR read + * the decision record again and nothing on screen changed (measured in the + * browser: no visible state for a single frame). It now says that it runs and + * then when it ran and whether anything changed, as the Architecture + * Refresh buttons do. + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import AdrWorkspace from './AdrWorkspace'; +import type { AdrRecord } from '../projects/projects-model'; +import { clearAdrDraft } from './adr-model'; + +let host: HTMLDivElement, root: Root; +const project = 'adr-refresh-test'; +const record = (content: string): AdrRecord => ({ hasAdr: true, content, updatedAt: '2026-09-14T12:00:00Z' }); +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + clearAdrDraft(project); + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); clearAdrDraft(project); }); + +function deferred() { + let resolve!: (value: T) => void, reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const refresh = () => [...host.querySelectorAll('.adr-actions button')][0] as HTMLButtonElement; +const status = () => host.querySelector('.adr-actions .atlas-refresh-status')?.textContent; +const TIME = '\\d\\d:\\d\\d:\\d\\d'; + +it('K42: Refresh in ADR says that it runs, then when it ran and whether the record changed, or that it failed', async () => { + let next = deferred(); + const adr = vi.fn().mockResolvedValueOnce(record('# Decisions')).mockImplementation(() => next.promise); + await act(async () => root.render()); + expect(refresh().textContent).toBe('Refresh'); + expect(status()).toBe(''); + + await act(async () => refresh().click()); + expect(refresh().textContent).toBe('Refreshing…'); + expect(refresh().getAttribute('aria-disabled')).toBe('true'); + await act(async () => next.resolve(record('# Decisions'))); + expect(refresh().textContent).toBe('Refresh'); + expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`)); + + next = deferred(); + await act(async () => refresh().click()); + await act(async () => next.resolve(record('# Decisions\n\n## Storage'))); + expect(status()).toMatch(new RegExp(`^Decisions refreshed at ${TIME}$`)); + + next = deferred(); + await act(async () => refresh().click()); + await act(async () => next.reject(new Error('offline'))); + // The alert below the toolbar says what failed and offers Try again; the status beside the button only says when. + expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`)); + expect(host.querySelector('.adr-notice[role="alert"]')?.textContent).toContain('Could not load the decision record.'); +}); diff --git a/graph-ui/src/adr/AdrWorkspace.tsx b/graph-ui/src/adr/AdrWorkspace.tsx index d37dda435..a329e4ba6 100644 --- a/graph-ui/src/adr/AdrWorkspace.tsx +++ b/graph-ui/src/adr/AdrWorkspace.tsx @@ -5,6 +5,7 @@ import type { ProjectsSource } from '../projects/ProjectsPanel'; import type { AdrRecord } from '../projects/projects-model'; import { ADR_CONTENT_LIMIT, adrSize, appendDecision, clearAdrDraft, readAdrDraft, sameAdr, storeAdrDraft } from './adr-model'; import { adrStrings as s } from './adr-strings'; +import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback'; import './adr.css'; export interface AdrWorkspaceProps { @@ -27,13 +28,15 @@ interface EditorState { notice: string; conflict?: AdrRecord; reviewing: boolean; + /** How many readings of the record started; the Refresh status speaks about one of them (review of K42). */ + reads: number; } function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JSX.Element { const [state, setState] = useState(() => { const draft = readAdrDraft(project); return { base: draft?.base, content: draft?.content ?? '', editing: !!draft, preview: false, - phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false }; + phase: 'idle', error: '', notice: draft ? s.restored : '', reviewing: false, reads: 0 }; }); const [saving, setSaving] = useState(false); const [storageAvailable, setStorageAvailable] = useState(true); @@ -60,7 +63,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS lastReload.current = reload; if (!active || savingRef.current || (hasLoaded.current && current.phase === 'ready' && current.base && current.content !== current.base.content && !forced)) return; const ticket = ++loadTicket.current; - setState(value => ({ ...value, phase: 'loading', error: '' })); + setState(value => ({ ...value, phase: 'loading', error: '', reads: value.reads + 1 })); void source.adr(project).then(record => { if (!mounted.current || ticket !== loadTicket.current) return; hasLoaded.current = true; @@ -154,6 +157,14 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS editing: !discard, conflict: undefined, reviewing: false, phase: 'ready', error: '', notice: discard ? '' : s.rebased }) : value); const ready = state.phase === 'ready'; const documentVisible = !state.editing || state.preview; + /* + * Review of K42: Refresh read the record again and nothing on screen + * changed. Now it says that it runs, then when and whether the record + * changed; a failure only gets its time here, the alert below says what + * failed and offers Try again. + */ + const refresh = useRefreshFeedback({ key: String(state.reads), settled: state.phase === 'ready' || state.phase === 'error', + value: state.base && { content: state.base.content, updatedAt: state.base.updatedAt, hasAdr: state.base.hasAdr }, error: state.phase === 'error' ? '' : undefined }); return
@@ -164,7 +175,7 @@ function ProjectAdrWorkspace({ project, active, source }: AdrWorkspaceProps): JS : <> - + { refresh.begin(); setReload(value => value + 1); }} /> {ready && state.base?.hasAdr && } {ready && state.base?.hasAdr && } } diff --git a/graph-ui/src/adr/adr-strings.ts b/graph-ui/src/adr/adr-strings.ts index 0fb9a1bc0..c258ad955 100644 --- a/graph-ui/src/adr/adr-strings.ts +++ b/graph-ui/src/adr/adr-strings.ts @@ -2,7 +2,8 @@ export const adrStrings = { label: 'Architecture decisions', noProject: 'Select a project to read its architecture decisions.', loading: 'Loading decisions…', - refresh: 'Refresh', + /** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */ + refreshFeedback: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Decisions refreshed at ${time}` }, retry: 'Try again', edit: 'Edit', add: 'Add decision', diff --git a/graph-ui/src/architecture/BehaviorJourney.tsx b/graph-ui/src/architecture/BehaviorJourney.tsx index 8ef40ba7b..373c7a7e1 100644 --- a/graph-ui/src/architecture/BehaviorJourney.tsx +++ b/graph-ui/src/architecture/BehaviorJourney.tsx @@ -7,7 +7,7 @@ import { useLiftedPlace, useOnIdentityChange } from './lifted-place'; import type { SystemProjection, SystemSymbol } from './system-architecture-source'; import BehaviorSourceEvidence, { type BehaviorSourceSnapshot } from './BehaviorSourceEvidence'; import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; -import { RefreshControl, type RefreshFeedback } from './refresh-feedback'; +import { RefreshControl, type RefreshFeedback } from '../ui/refresh/refresh-feedback'; import { operationChoices } from './operation-choices'; import './behavior-journey.css'; diff --git a/graph-ui/src/architecture/ContainerMap.test.tsx b/graph-ui/src/architecture/ContainerMap.test.tsx index 7a02a6031..1c226d815 100644 --- a/graph-ui/src/architecture/ContainerMap.test.tsx +++ b/graph-ui/src/architecture/ContainerMap.test.tsx @@ -79,7 +79,7 @@ describe('service map interactions', () => { /* Hand test 2026-10-04 (A3): a Refresh that reads everything again says so, and says what it found. */ describe('service map refresh feedback', () => { const refresh = () => [...container.querySelectorAll('button')].find(element => /^Refresh(ing…)?$/.test(element.textContent ?? ''))!; - const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent; + const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent; afterEach(() => { vi.useRealTimers(); }); it('A3: reads the declared services again, busy meanwhile, then names the time and whether anything changed', async () => { diff --git a/graph-ui/src/architecture/ContainerMap.tsx b/graph-ui/src/architecture/ContainerMap.tsx index c270180a5..34e40e020 100644 --- a/graph-ui/src/architecture/ContainerMap.tsx +++ b/graph-ui/src/architecture/ContainerMap.tsx @@ -7,7 +7,7 @@ import { loadContainerInventory, loadContainerTopology, type ContainerReading, t import type { ServiceEvidence } from './container-topology'; import { useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; import { architectureText as text } from './strings'; -import { RefreshControl, useRefreshFeedback } from './refresh-feedback'; +import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback'; import './spatial-architecture.css'; import './container-map.css'; diff --git a/graph-ui/src/architecture/SpatialArchitecture.test.tsx b/graph-ui/src/architecture/SpatialArchitecture.test.tsx index cc3448b12..e0fc478b8 100644 --- a/graph-ui/src/architecture/SpatialArchitecture.test.tsx +++ b/graph-ui/src/architecture/SpatialArchitecture.test.tsx @@ -210,7 +210,7 @@ it('A3: Refresh connections says that it runs and what it found, and keeps the m const input: GraphData = { nodes: [node, route], edges: [], total_nodes: 2 }; await act(async () => root.render()); const refresh = () => [...host.querySelectorAll('button')].find(button => /^Refresh(ing)? connections/.test(button.textContent ?? ''))!; - const status = () => host.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent; + const status = () => host.querySelector('[role="status"].atlas-refresh-status')?.textContent; expect(refresh().textContent).toBe('Refresh connections'); expect(status()).toBe(''); diff --git a/graph-ui/src/architecture/SpatialArchitecture.tsx b/graph-ui/src/architecture/SpatialArchitecture.tsx index 4f713d316..2f7b727a7 100644 --- a/graph-ui/src/architecture/SpatialArchitecture.tsx +++ b/graph-ui/src/architecture/SpatialArchitecture.tsx @@ -15,7 +15,7 @@ import type { CoverageIndex } from '../app/tree-model'; import { buildHotspotGraph, collectHotspots, hotspotsForNode, hotspotSignals, hotspotIdentity } from './hotspot-map'; import { hotspotAreas } from './hotspot-areas'; import { useViewPreferences } from '../settings/view-preferences'; -import { RefreshControl, useRefreshFeedback } from './refresh-feedback'; +import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback'; import './spatial-architecture.css'; interface Props { diff --git a/graph-ui/src/architecture/SystemArchitecture.test.tsx b/graph-ui/src/architecture/SystemArchitecture.test.tsx index a19f79dc8..bd00f6896 100644 --- a/graph-ui/src/architecture/SystemArchitecture.test.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.test.tsx @@ -641,7 +641,7 @@ describe('system architecture workspace', () => { /* Hand test 2026-10-04 (A3): "Refresh analysis" and the Behavior "Refresh" read again and said nothing. */ describe('system architecture refresh feedback', () => { const refresh = (pattern: RegExp) => [...container.querySelectorAll('button')].find(element => pattern.test(element.textContent ?? ''))!; - const status = () => container.querySelector('[role="status"].atlas-arch-refresh-status')?.textContent; + const status = () => container.querySelector('[role="status"].atlas-refresh-status')?.textContent; afterEach(() => { vi.useRealTimers(); }); it('A3: Refresh analysis is busy while it runs, then names the time and whether the index changed', async () => { @@ -672,7 +672,7 @@ describe('system architecture refresh feedback', () => { vi.setSystemTime(new Date(2026, 9, 4, 18, 1, 0)); const loader = vi.fn().mockResolvedValue(response(overviewFixture())); await render(loader, { view: 'behavior' }); - const button = () => container.querySelector('.behavior-heading .atlas-arch-refresh button')!; + const button = () => container.querySelector('.behavior-heading .atlas-refresh button')!; expect(button()?.textContent).toBe('Refresh'); let finish!: (value: SystemArchitectureResponse) => void; loader.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); diff --git a/graph-ui/src/architecture/SystemArchitecture.tsx b/graph-ui/src/architecture/SystemArchitecture.tsx index c08b4fdb9..229b453f6 100644 --- a/graph-ui/src/architecture/SystemArchitecture.tsx +++ b/graph-ui/src/architecture/SystemArchitecture.tsx @@ -11,7 +11,7 @@ import BehaviorJourney from './BehaviorJourney'; import { defaultJourneyStart } from './behavior-journey-model'; import type { BehaviorPlace, JourneyPlace, PlaceChange, SystemPlace } from './architecture-history'; import { useLiftedPlace, useOnIdentityChange } from './lifted-place'; -import { RefreshControl, useRefreshFeedback } from './refresh-feedback'; +import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback'; import { nameOperations } from './operation-choices'; import { RpcIntelligenceClient } from '../provider/rpc-client'; import './system-architecture.css'; diff --git a/graph-ui/src/architecture/architecture.css b/graph-ui/src/architecture/architecture.css index ab6f8dcca..66ba9422b 100644 --- a/graph-ui/src/architecture/architecture.css +++ b/graph-ui/src/architecture/architecture.css @@ -78,12 +78,6 @@ .atlas-arch-recent-menu button:hover:not(:disabled) { background: var(--atlas-panel-raised); } .atlas-arch-recent-menu button span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: var(--atlas-dim); } .atlas-arch-recent-menu button[aria-current="true"] { opacity: 0.6; } -/* Hand test 2026-10-04 (A3): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */ -.atlas-arch-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; } -.atlas-arch-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; } -.atlas-arch-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; } -.atlas-arch-refresh-status:not(:empty) { margin-left: 8px; } -.atlas-arch-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); } .atlas-arch-toolbar { margin-bottom: 24px; gap: 10px; } .atlas-arch-filter { position: relative; flex: 1; max-width: 370px; } .atlas-arch-filter svg { color: var(--atlas-dim); left: 11px; position: absolute; top: 10px; pointer-events: none; } diff --git a/graph-ui/src/architecture/behavior-journey.css b/graph-ui/src/architecture/behavior-journey.css index dca5aedc5..572d09080 100644 --- a/graph-ui/src/architecture/behavior-journey.css +++ b/graph-ui/src/architecture/behavior-journey.css @@ -3,7 +3,7 @@ .behavior-heading h2 { font-size: 25px; font-weight: 500; letter-spacing: -.6px; margin: 7px 0; overflow-wrap: anywhere; } .behavior-heading p { color: var(--atlas-dim); font-size: 12px; margin: 0; } .behavior-journey button { cursor: pointer; } -.behavior-heading > button, .behavior-heading > .atlas-arch-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; } +.behavior-heading > button, .behavior-heading > .atlas-refresh > button, .behavior-navigation > button, .behavior-inspector-actions button, .behavior-walk button, .behavior-visual-toolbar > button, .behavior-source-heading button { background: var(--journey-tint); color: var(--arch-label-text); border: 1px solid var(--journey-line); border-radius: 6px; padding: 6px 10px; font: inherit; font-size: 11px; } .behavior-journey button:disabled { opacity: .35; cursor: default; } .behavior-journey button:focus-visible { outline: 2px solid var(--arch-label-focus); outline-offset: 3px; } .behavior-requests { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 12px; border: 1px solid var(--journey-line); border-radius: 10px; background: var(--journey-tint); } diff --git a/graph-ui/src/architecture/strings.ts b/graph-ui/src/architecture/strings.ts index b44e133a2..60d74f975 100644 --- a/graph-ui/src/architecture/strings.ts +++ b/graph-ui/src/architecture/strings.ts @@ -121,14 +121,12 @@ export const architectureText = { }, /** The camera button of the maps: back to the whole map. */ fitMap: 'Fit map', - /** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when, and whether anything changed. */ + /** Each Refresh button says what it did (hand test 2026-10-04, A3): busy while it runs, then when (shared words in ui/refresh/strings.ts). */ refreshFeedback: { routes: { idle: 'Refresh connections', busy: 'Refreshing connections…', done: (time: string) => `Connections refreshed at ${time}` }, services: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Service map refreshed at ${time}` }, structure: { idle: 'Refresh analysis', busy: 'Refreshing analysis…', done: (time: string) => `Analysis refreshed at ${time}` }, behavior: { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Journey refreshed at ${time}` }, - unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`, - failed: (time: string, error: string) => `Refresh failed at ${time}: ${error}`, }, }; diff --git a/graph-ui/src/impact/FileImpactSummary.refresh.test.tsx b/graph-ui/src/impact/FileImpactSummary.refresh.test.tsx new file mode 100644 index 000000000..8e7f6e8f5 --- /dev/null +++ b/graph-ui/src/impact/FileImpactSummary.refresh.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +/* + * Review of K42 ("other Refresh buttons the same way"): Refresh in the file + * impact details of Explore collapsed the details into "Reading + * dependencies…" for about two seconds and then showed the same numbers + * (measured in the browser), with nothing left to say that it had run. The + * details now stay on screen, the button says that it runs, and a status + * beside it says when it ran and whether anything changed. + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import FileImpactSummary from './FileImpactSummary'; +import type { fetchSelectionImpact, SelectionImpact, SelectionImpactReply } from './selection-impact'; + +function impact(direct: number, computed_at: number): SelectionImpact { + const node = (id: number, file: string) => ({ id, name: `symbol${id}`, qualified_name: `fixture.symbol${id}`, file_path: file, line: id }); + return { + status: 'ready', project: 'fixture', file_path: 'core.ts', scope: 'file', computed_at, cache_seconds: 30, + snapshot: { indexed_at: '2026-09-19T10:00:00Z', generation: 'generation-one', index_revision: 'abc', freshness: 'Current snapshot', coverage_recording: 'complete', coverage: [] }, + structural: { available: true, basis: 'CALLS and IMPORTS', max_depth: 4, visit_cap: 600, result_cap: 60, seed_count: 1, reachable: direct, direct, test_candidates: 0, truncated: false, + findings: Array.from({ length: direct }, (_, at) => ({ ...node(at + 2, `src/caller${at}.ts`), distance: 1, test_candidate: false, + path: [{ edge_id: at + 2, type: 'CALLS', from: node(at + 2, `src/caller${at}.ts`), to: node(1, 'core.ts') }] })) }, + history: { available: true, head: 'a'.repeat(40), shallow: false, worktree_status_known: true, selection_uncommitted: false, truncated: false, commit_cap: 200, window_days: 548, + mass_change_threshold: 30, commits_scanned: 20, commits_considered: 15, selection_commits: 3, merges_excluded: 0, mass_changes_excluded: 0, read_errors: 0, cochanges_omitted: 0, + commits: [], cochanges: [] }, + }; +} + +let container: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} +const refresh = () => container.querySelector('.file-impact-summary-details footer .atlas-refresh button'); +const status = () => container.querySelector('.file-impact-summary-details footer .atlas-refresh-status')?.textContent; +const TIME = '\\d\\d:\\d\\d:\\d\\d'; + +it('K42: Refresh in the file impact keeps the details, says that it runs, then when it ran and whether anything changed', async () => { + let next = deferred(); + const load = vi.fn().mockResolvedValueOnce(impact(2, 1)).mockImplementation(() => next.promise); + await act(async () => root.render()); + await act(async () => container.querySelector('.file-impact-summary-toggle')!.click()); + expect(refresh()?.textContent).toBe('Refresh'); + expect(status()).toBe(''); + + await act(async () => refresh()!.click()); + // While it runs the details stay, and the button says so. + expect(container.textContent).toContain('2 affected files'); + expect(refresh()?.textContent).toBe('Refreshing…'); + expect(refresh()?.getAttribute('aria-disabled')).toBe('true'); + // A new computation of the same evidence is no change, although its time stamp is another. + await act(async () => next.resolve(impact(2, 99))); + expect(refresh()?.textContent).toBe('Refresh'); + expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`)); + + next = deferred(); + await act(async () => refresh()!.click()); + await act(async () => next.resolve(impact(3, 120))); + expect(container.textContent).toContain('3 affected files'); + expect(status()).toMatch(new RegExp(`^Impact refreshed at ${TIME}$`)); +}); + +it('K42: another file never shows the evidence of the previous one while it loads', async () => { + const next = deferred(); + const load = vi.fn().mockResolvedValueOnce(impact(2, 1)).mockImplementation(() => next.promise); + await act(async () => root.render()); + expect(container.textContent).toContain('2 affected files'); + await act(async () => root.render()); + expect(container.textContent).not.toContain('affected files'); + expect(container.textContent).toContain('Reading dependencies…'); +}); diff --git a/graph-ui/src/impact/FileImpactSummary.tsx b/graph-ui/src/impact/FileImpactSummary.tsx index 82db8233d..bbeb96def 100644 --- a/graph-ui/src/impact/FileImpactSummary.tsx +++ b/graph-ui/src/impact/FileImpactSummary.tsx @@ -2,6 +2,8 @@ import { useEffect, useId, useMemo, useState } from 'react'; import type { JSX } from 'react'; import { evidenceTarget, fetchSelectionImpact } from './selection-impact'; import type { SelectionImpact, SelectionImpactFinding, SelectionImpactTarget } from './selection-impact'; +import { FILE_IMPACT_REFRESH } from './impact-strings'; +import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback'; import './file-impact-summary.css'; export interface FileImpactSummaryProps { @@ -63,6 +65,8 @@ function countLabel(count: number, noun: string, incomplete = false): string { export default function FileImpactSummary({ project, target, onOpen, expectedGeneration, load = fetchSelectionImpact }: FileImpactSummaryProps): JSX.Element { const [reading, setReading] = useState(); + // The last ready evidence and the selection it belongs to: a refresh of the same selection shows it until the new one is there. + const [lastReady, setLastReady] = useState<{ key: string; data: SelectionImpact }>(); const [retry, setRetry] = useState({ key: '', count: 0 }); const [expandedKey, setExpandedKey] = useState(''); const [limits, setLimits] = useState({ key: '', files: 6, history: 4 }); @@ -74,12 +78,21 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen // Bind every state (including errors) before effects run; a newly selected // file or symbol must never inherit the previous selection's evidence. const current = reading?.key === requestKey ? reading : undefined; - const data = current?.status === 'ready' ? current.data : undefined; + const waiting = !current || ['loading', 'pending', 'busy'].includes(current.status); + /* + * Review of K42: Refresh folded the details into "Reading dependencies…" + * and then showed the same numbers, with nothing left to say that it had + * run. A refresh of the same selection keeps the last evidence on screen, + * the button says that it runs, and the status beside it says when and + * whether anything changed. Another selection never shows it. + */ + const kept = waiting && lastReady?.key === selectionKey ? lastReady.data : undefined; + const data = current?.status === 'ready' ? current.data : kept; const aggregate = useMemo(() => data ? aggregateFileImpact(data) : undefined, [data]); const expanded = expandedKey === selectionKey; const fileLimit = limits.key === selectionKey ? limits.files : 6; const historyLimit = limits.key === selectionKey ? limits.history : 4; - const refresh = (): void => setRetry(value => ({ key: selectionKey, + const retryNow = (): void => setRetry(value => ({ key: selectionKey, count: value.key === selectionKey ? value.count + 1 : 1 })); useEffect(() => { @@ -98,6 +111,7 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen if (reply.project !== project || reply.file_path !== file) throw new Error('The response belongs to another selection.'); setReading({ key: requestKey, status: 'ready', data: reply }); + setLastReady({ key: selectionKey, data: reply }); } else if (reply.status === 'failed') { setReading({ key: requestKey, status: 'failed', error: reply.error }); } else if (attempts < 80) { @@ -111,7 +125,12 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen }; void read(); return () => { controller.abort(); if (timer !== undefined) clearTimeout(timer); }; - }, [project, file, qualifiedName, id, load, requestKey, retryCount]); + }, [project, file, qualifiedName, id, load, requestKey, retryCount, selectionKey]); + // What counts as a change: the evidence shown, not the time stamp of its computation. + const refresh = useRefreshFeedback({ key: requestKey, settled: !waiting, error: current?.status === 'failed' ? '' : undefined, + value: data && aggregate ? { generation: data.snapshot.generation, revision: data.snapshot.index_revision, direct: data.structural.direct, + files: aggregate.files.map(row => [row.filePath, row.distance, row.symbols, row.testCandidate]), cochanges: aggregate.cochanges.map(row => [row.file_path, row.shared_commits]), + commits: data.history.selection_commits } : undefined }); const notes: string[] = []; if (data && aggregate) { @@ -130,10 +149,10 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
{scope} impact {!file && Select a file to see its dependents.} - {file && (!current || ['loading', 'pending', 'busy'].includes(current.status)) + {file && waiting && !kept && {current?.status === 'busy' ? 'Waiting for local analysis…' : 'Reading dependencies…'}} {current?.status === 'failed' && <>Impact unavailable} + title={current.error}>Impact unavailable} {data && aggregate && <>
{data.structural.available ? <> @@ -174,7 +193,7 @@ export default function FileImpactSummary({ project, target, onOpen, expectedGen
Indexed calls/imports · up to {data.structural.max_depth} hops. Test files are candidates; Git co-changes are associations. -
+ { refresh.begin(); retryNow(); }} /> } ; } diff --git a/graph-ui/src/impact/impact-strings.ts b/graph-ui/src/impact/impact-strings.ts index fd85cd7b8..cb527a572 100644 --- a/graph-ui/src/impact/impact-strings.ts +++ b/graph-ui/src/impact/impact-strings.ts @@ -282,3 +282,8 @@ export function impactUntestedRule(count: number): string { export function impactSymbolRule(name: string, level: 'low' | 'medium' | 'high', reasons: string[]): string { return `${name} is ${RISK_LABELS[level]}: ${reasons.join('; ')}.`; } + +// Review zu K42: der Knopf Refresh der Dateiwirkung in Explore sagt, dass er laeuft, dann wann er lief --- +// (die gemeinsamen Saetze "Up to date at …" und "Refresh failed at …" stehen in ui/refresh/strings.ts). + +export const FILE_IMPACT_REFRESH = { idle: 'Refresh', busy: 'Refreshing…', done: (time: string) => `Impact refreshed at ${time}` }; diff --git a/graph-ui/src/projects/ProjectSwitcher.refresh.test.tsx b/graph-ui/src/projects/ProjectSwitcher.refresh.test.tsx new file mode 100644 index 000000000..245fd7007 --- /dev/null +++ b/graph-ui/src/projects/ProjectSwitcher.refresh.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +/* + * Review of K42 ("other Refresh buttons the same way"): "Refresh projects" + * in the project picker read the list again, showed "Loading projects..." + * for about 150 ms and then the same list (measured in the browser). It now + * says that it runs and then when it ran and whether the list changed. + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import ProjectSwitcher from './ProjectSwitcher'; +import type { ProjectEntry } from '../provider/rpc-schemas'; + +let container: HTMLDivElement, root: Root; +beforeEach(() => { + (globalThis as unknown as Record).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); +}); +afterEach(async () => { await act(async () => root.unmount()); container.remove(); }); + +function deferred() { + let resolve!: (value: T) => void, reject!: (reason: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const refresh = () => container.querySelector('.atlas-project-picker footer .atlas-refresh button')!; +const status = () => container.querySelector('.atlas-project-picker footer .atlas-refresh-status')?.textContent; +const TIME = '\\d\\d:\\d\\d:\\d\\d'; +const alpha: ProjectEntry = { name: 'alpha', root_path: '/repos/alpha' }; + +it('K42: Refresh projects says that it runs, then when it ran and whether the list changed, or that it failed', async () => { + let next = deferred(); + const list = vi.fn().mockResolvedValueOnce([alpha]).mockImplementation(() => next.promise); + await act(async () => root.render()); + await act(async () => container.querySelector('summary')!.click()); + expect(refresh().textContent).toBe('Refresh projects'); + expect(status()).toBe(''); + + await act(async () => refresh().click()); + expect(refresh().textContent).toBe('Refreshing projects…'); + expect(refresh().getAttribute('aria-disabled')).toBe('true'); + await act(async () => next.resolve([alpha])); + expect(refresh().textContent).toBe('Refresh projects'); + expect(status()).toMatch(new RegExp(`^Up to date at ${TIME}: no changes since the last load$`)); + + next = deferred(); + await act(async () => refresh().click()); + await act(async () => next.resolve([alpha, { name: 'beta', root_path: '/repos/beta' }])); + expect(status()).toMatch(new RegExp(`^Projects refreshed at ${TIME}$`)); + expect(container.querySelectorAll('.atlas-project-results li')).toHaveLength(2); + + next = deferred(); + await act(async () => refresh().click()); + await act(async () => next.reject(new Error('offline'))); + // The list area says what failed and offers Try again; the status beside the button says when. + expect(status()).toMatch(new RegExp(`^Refresh failed at ${TIME}$`)); + expect(container.querySelector('.atlas-project-results')?.textContent).toContain('Could not load projects.'); + + // Reopening the picker reads the list again by itself: that is no press of Refresh, and the status goes. + next = deferred(); + await act(async () => container.querySelector('summary')!.click()); + await act(async () => container.querySelector('summary')!.click()); + await act(async () => next.resolve([alpha])); + expect(status()).toBe(''); +}); diff --git a/graph-ui/src/projects/ProjectSwitcher.tsx b/graph-ui/src/projects/ProjectSwitcher.tsx index 680866a66..1cd60d6dd 100644 --- a/graph-ui/src/projects/ProjectSwitcher.tsx +++ b/graph-ui/src/projects/ProjectSwitcher.tsx @@ -3,6 +3,7 @@ import type { JSX } from 'react'; import type { ProjectEntry } from '../provider/rpc-schemas'; import { projectSwitcherStrings as text } from './project-switcher-strings'; import { projectPickerPlacement } from './project-picker-placement'; +import { RefreshControl, useRefreshFeedback } from '../ui/refresh/refresh-feedback'; import './project-switcher.css'; interface ProjectSwitcherProps { @@ -18,6 +19,8 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen const [entries, setEntries] = useState([]); const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); const [revision, setRevision] = useState(0); + // Every reading of the list counts, also the one an opening starts; the Refresh status speaks about one of them (review of K42). + const [reads, setReads] = useState(0); const [placement, setPlacement] = useState({ top: 64, left: 12, width: 370, maxHeight: 520 }); const disclosure = useRef(null); const trigger = useRef(null); @@ -49,6 +52,7 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen if (!open) return; let current = true; setStatus('loading'); + setReads(value => value + 1); void Promise.resolve().then(props.listProjects).then( (projects) => { if (!current) return; @@ -69,6 +73,15 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen return () => document.removeEventListener('pointerdown', outside); }, [open]); + /* + * Review of K42: "Refresh projects" showed "Loading projects..." for about + * 150 ms and then the same list. Now it says that it runs, then when and + * whether the list changed; a failure only gets its time, the list area + * says what failed and offers Try again. + */ + const refresh = useRefreshFeedback({ key: String(reads), settled: status !== 'loading', + value: entries.map(entry => [entry.name, entry.root_path]), error: status === 'error' ? '' : undefined }); + const projects = Array.from(new Map(entries.map(entry => [entry.name, entry])).values()) .filter(entry => entry.name.length > 0) .sort((left, right) => Number(right.name === props.currentProject) - Number(left.name === props.currentProject) @@ -120,7 +133,7 @@ export default function ProjectSwitcher(props: ProjectSwitcherProps): JSX.Elemen }
- + { refresh.begin(); setRevision(value => value + 1); }} />
} ; diff --git a/graph-ui/src/projects/project-switcher-strings.ts b/graph-ui/src/projects/project-switcher-strings.ts index 2aab700b5..8de872fdc 100644 --- a/graph-ui/src/projects/project-switcher-strings.ts +++ b/graph-ui/src/projects/project-switcher-strings.ts @@ -10,7 +10,8 @@ export const projectSwitcherStrings = { noMatches: 'No matching projects.', current: 'Current', retry: 'Try again', - refresh: 'Refresh projects', + /** Review of K42: the Refresh button says that it runs, then when it ran (shared words in ui/refresh/strings.ts). */ + refreshFeedback: { idle: 'Refresh projects', busy: 'Refreshing projects…', done: (time: string) => `Projects refreshed at ${time}` }, add: 'Add project index', indexActivity: (status: 'indexing' | 'done' | 'error') => status === 'indexing' ? 'Indexing…' : status === 'done' ? 'Index ready' : 'Index needs attention', }; diff --git a/graph-ui/src/projects/project-switcher.css b/graph-ui/src/projects/project-switcher.css index ad41f0eb9..fca5dd617 100644 --- a/graph-ui/src/projects/project-switcher.css +++ b/graph-ui/src/projects/project-switcher.css @@ -18,8 +18,13 @@ .atlas-project-result-name { display: flex; justify-content: space-between; gap: 8px; overflow-wrap: anywhere; } .atlas-project-result-name small { flex-shrink: 0; align-self: flex-start; color: var(--atlas-phosphor); font-size: 10px; } .atlas-project-result-path { display: block; margin-top: 5px; color: var(--atlas-dim); font: 10px var(--atlas-mono); overflow-wrap: anywhere; } -.atlas-project-picker footer { display: flex; justify-content: space-between; flex-shrink: 0; gap: 8px; padding-top: 10px; border-top: 1px solid var(--atlas-line); } +.atlas-project-picker footer { display: flex; flex-wrap: wrap; justify-content: space-between; flex-shrink: 0; gap: 8px; padding-top: 10px; border-top: 1px solid var(--atlas-line); } .atlas-project-picker footer button { padding: 6px 8px; color: var(--atlas-cyan); background: transparent; border: 1px solid var(--atlas-line); } +/* Review of K42: what Refresh projects found stands on a line of its own under the buttons; beside them it ran past the edge of the picker. */ +.atlas-project-picker footer .atlas-refresh { display: contents; } +.atlas-project-picker footer .atlas-refresh-status { flex-basis: 100%; margin: 0; text-align: right; } +/* Empty, it leaves the row but not the tree: the live region has to be there before it speaks. */ +.atlas-project-picker footer .atlas-refresh-status:empty { position: absolute; } .atlas-project-picker button:disabled { opacity: .5; cursor: default; } .atlas-project-index-activity { display: flex; justify-content: space-between; gap: 10px; padding: 10px; color: var(--atlas-fg); background: var(--atlas-select); border: 1px solid var(--atlas-line); text-align: left; overflow-wrap: anywhere; } .atlas-project-index-activity span:first-child { color: var(--atlas-cyan); } diff --git a/graph-ui/src/architecture/refresh-feedback.test.tsx b/graph-ui/src/ui/refresh/refresh-feedback.test.tsx similarity index 100% rename from graph-ui/src/architecture/refresh-feedback.test.tsx rename to graph-ui/src/ui/refresh/refresh-feedback.test.tsx diff --git a/graph-ui/src/architecture/refresh-feedback.tsx b/graph-ui/src/ui/refresh/refresh-feedback.tsx similarity index 82% rename from graph-ui/src/architecture/refresh-feedback.tsx rename to graph-ui/src/ui/refresh/refresh-feedback.tsx index acee2b579..bdc7ac80a 100644 --- a/graph-ui/src/architecture/refresh-feedback.tsx +++ b/graph-ui/src/ui/refresh/refresh-feedback.tsx @@ -1,13 +1,14 @@ import { useCallback, useEffect, useRef, useState, type JSX } from 'react'; -import { architectureText } from './strings'; - -const text = architectureText.refreshFeedback; +import { refreshText as text } from './strings'; +import './refresh.css'; /** - * What a Refresh button of Architecture says it did (hand test 2026-10-04, A3: - * "Refresh connections" fetched everything again and nothing on screen - * changed). While the reading runs the button reads "Refreshing …"; then a - * status beside it names the time and whether anything changed, or the error. + * What a Refresh button says it did (hand test 2026-10-04, A3: "Refresh + * connections" fetched everything again and nothing on screen changed). While + * the reading runs the button reads "Refreshing …"; then a status beside it + * names the time and whether anything changed, or the error. Made for the + * four Refresh buttons of Architecture and shared since the review of K42 with + * those of ADR, the project picker and the file impact in Explore. */ export type RefreshFeedback = | { phase: 'running' } @@ -74,9 +75,9 @@ export function RefreshControl({ labels, feedback, onRefresh, className }: { feedback?: RefreshFeedback; onRefresh: () => void; className?: string; }): JSX.Element { const running = feedback?.phase === 'running'; - return + return - {refreshStatus(feedback, labels.done)} + {refreshStatus(feedback, labels.done)} ; } diff --git a/graph-ui/src/ui/refresh/refresh.css b/graph-ui/src/ui/refresh/refresh.css new file mode 100644 index 000000000..11797ec79 --- /dev/null +++ b/graph-ui/src/ui/refresh/refresh.css @@ -0,0 +1,6 @@ +/* Hand test 2026-10-04 (A3, K42): a Refresh says that it runs and, beside it, what it found. The live region stays in the tree. */ +.atlas-refresh { display: inline-flex; align-items: center; flex-wrap: wrap; min-width: 0; } +.atlas-refresh > button[aria-disabled="true"] { opacity: 0.6; cursor: progress; } +.atlas-refresh-status { color: var(--atlas-dim); font-size: 11px; line-height: 1.4; } +.atlas-refresh-status:not(:empty) { margin-left: 8px; } +.atlas-refresh-status[data-phase="failed"] { color: var(--atlas-alarm); } diff --git a/graph-ui/src/ui/refresh/strings.ts b/graph-ui/src/ui/refresh/strings.ts new file mode 100644 index 000000000..c92830041 --- /dev/null +++ b/graph-ui/src/ui/refresh/strings.ts @@ -0,0 +1,6 @@ +/** What every Refresh button of the app says after it ran (hand test 2026-10-04, A3, and its review, K42). */ +export const refreshText = { + unchanged: (time: string) => `Up to date at ${time}: no changes since the last load`, + /** Without a reason when the view already shows the error in its own words beside the button. */ + failed: (time: string, error: string) => (error ? `Refresh failed at ${time}: ${error}` : `Refresh failed at ${time}`), +};