From e2598f498f0ac0d718951504119baf074c8f80ab Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 6 Aug 2026 01:19:10 +0200 Subject: [PATCH 1/4] feat: classify recoverable engine refusals and bind the notice chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault browser gated its listing on `error === null`, so any failure blanked it — a stream-ceiling refusal wiped the rows exactly as a fatal error did. `snapshotStore` classifies on the engine's stable code (fail closed: an unrecognised or absent code is fatal), and the browser renders a recoverable refusal as a retryable notice over the last-known-good rows. Adds the residual notice chrome: an offline banner, a warning-notice store and toast bound to `withheldUpdateEscalation`/`attributableAbuse` as a class distinct from staleness, online/visibility refresh hints, and a manual refresh driven from the status indicator. The snapshot store also takes the focus window back from the folder picker, so a route change during a move cannot strand the engine on the folder the picker opened on. Closes #1069 Closes #808 Closes #1079 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WegkkQ3uhNREerTW4MMeY2 --- apps/web/src/components/NotificationToast.tsx | 39 ++++++ .../file-browser/FileBrowser.test.tsx | 90 ++++++++++++++ .../components/file-browser/FileBrowser.tsx | 30 ++++- apps/web/src/components/layout/AppShell.tsx | 15 ++- .../components/layout/OfflineBanner.test.tsx | 67 ++++++++++ .../src/components/layout/OfflineBanner.tsx | 26 ++++ .../layout/StatusIndicator.test.tsx | 43 +++++++ .../src/components/layout/StatusIndicator.tsx | 11 +- apps/web/src/engine/snapshotStore.test.ts | 91 +++++++++++++- apps/web/src/engine/snapshotStore.ts | 45 ++++++- apps/web/src/engine/testFakes.ts | 6 + apps/web/src/engine/useEngineNotices.test.tsx | 117 ++++++++++++++++++ apps/web/src/engine/useEngineNotices.ts | 40 ++++++ apps/web/src/engine/useRefreshHints.test.tsx | 84 +++++++++++++ apps/web/src/engine/useRefreshHints.ts | 23 ++++ apps/web/src/hooks/useFolderPicker.ts | 13 +- apps/web/src/hooks/useOnlineStatus.ts | 23 ++++ apps/web/src/hooks/useVisibility.ts | 15 +++ .../web/src/stores/notification.store.test.ts | 45 +++++++ apps/web/src/stores/notification.store.ts | 52 ++++++++ apps/web/src/styles/file-browser.css | 34 +++++ apps/web/src/styles/layout.css | 72 +++++++++++ 22 files changed, 961 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/NotificationToast.tsx create mode 100644 apps/web/src/components/file-browser/FileBrowser.test.tsx create mode 100644 apps/web/src/components/layout/OfflineBanner.test.tsx create mode 100644 apps/web/src/components/layout/OfflineBanner.tsx create mode 100644 apps/web/src/components/layout/StatusIndicator.test.tsx create mode 100644 apps/web/src/engine/useEngineNotices.test.tsx create mode 100644 apps/web/src/engine/useEngineNotices.ts create mode 100644 apps/web/src/engine/useRefreshHints.test.tsx create mode 100644 apps/web/src/engine/useRefreshHints.ts create mode 100644 apps/web/src/hooks/useOnlineStatus.ts create mode 100644 apps/web/src/hooks/useVisibility.ts create mode 100644 apps/web/src/stores/notification.store.test.ts create mode 100644 apps/web/src/stores/notification.store.ts diff --git a/apps/web/src/components/NotificationToast.tsx b/apps/web/src/components/NotificationToast.tsx new file mode 100644 index 000000000..26cb1805a --- /dev/null +++ b/apps/web/src/components/NotificationToast.tsx @@ -0,0 +1,39 @@ +import { useSyncExternalStore } from 'react'; +import { notificationStore } from '../stores/notification.store'; + +/** + * Standing warnings, dismissed by hand rather than on a timer: a trust warning + * that expired unread would read as "nothing was wrong". + */ +export function NotificationToast() { + const notices = useSyncExternalStore(notificationStore.subscribe, notificationStore.getState); + + if (notices.length === 0) return null; + + return ( +
+ {notices.map((notice) => ( +
+ + {notice.message} + +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/file-browser/FileBrowser.test.tsx b/apps/web/src/components/file-browser/FileBrowser.test.tsx new file mode 100644 index 000000000..40a7aec21 --- /dev/null +++ b/apps/web/src/components/file-browser/FileBrowser.test.tsx @@ -0,0 +1,90 @@ +import { EngineRequestError } from '@cipherbox/client'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { EngineProvider } from '../../providers/EngineProvider'; +import { ROOT_ID, fakeEngine, view } from '../../engine/testFakes'; +import { FileBrowser } from './FileBrowser'; + +function draw(client: ReturnType['client']) { + return render( + + client}> + + } /> + + + + ); +} + +/** Renders the browser with two rows on screen, then fails its next pull. */ +async function listedThenFailed(failure: Error) { + const engine = fakeEngine(); + draw(engine.client); + + await act(async () => { + engine.emit({ kind: 'snapshotUpdated' }); + }); + await act(async () => { + engine.pulls[0].resolve(view(ROOT_ID, 'fresh', 2)); + }); + await waitFor(() => expect(screen.getAllByTestId('file-list-item')).toHaveLength(2)); + + await act(async () => { + engine.emit({ kind: 'snapshotUpdated' }); + }); + await act(async () => { + engine.pulls[1].reject(failure); + }); + return engine; +} + +describe('the vault browser', () => { + it('keeps the listing on screen when a refusal is recoverable', async () => { + await listedThenFailed( + new EngineRequestError('too many read streams are already open', 'tooManyStreams') + ); + + const notice = await screen.findByTestId('file-browser-notice'); + expect(notice.textContent).toContain('too many read streams are already open'); + // The gate is the rows, not the notice: a blanked listing must not pass. + expect(screen.getAllByTestId('file-list-item')).toHaveLength(2); + expect(screen.queryByTestId('file-browser-error')).toBeNull(); + }); + + it('blanks the listing on a failure that will not clear', async () => { + await listedThenFailed(new EngineRequestError('no such node', 'unknownNode')); + + const error = await screen.findByTestId('file-browser-error'); + expect(error.textContent).toBe('no such node'); + expect(screen.queryAllByTestId('file-list-item')).toHaveLength(0); + expect(screen.queryByTestId('file-browser-notice')).toBeNull(); + }); + + it('treats an engine code it does not recognise as fatal', async () => { + await listedThenFailed(new EngineRequestError('something new', 'someFutureCeiling')); + + expect(await screen.findByTestId('file-browser-error')).toBeTruthy(); + expect(screen.queryAllByTestId('file-list-item')).toHaveLength(0); + }); + + it('re-drives the pull from the recoverable notice', async () => { + const engine = await listedThenFailed( + new EngineRequestError('too many read streams are already open', 'tooManyStreams') + ); + await screen.findByTestId('file-browser-notice'); + expect(engine.refreshes()).toBe(0); + + await act(async () => { + fireEvent.click(screen.getByText('[retry]')); + }); + + expect(engine.refreshes()).toBe(1); + await act(async () => { + engine.pulls[2].resolve(view(ROOT_ID, 'fresh', 2)); + }); + await waitFor(() => expect(screen.queryByTestId('file-browser-notice')).toBeNull()); + expect(screen.getAllByTestId('file-list-item')).toHaveLength(2); + }); +}); diff --git a/apps/web/src/components/file-browser/FileBrowser.tsx b/apps/web/src/components/file-browser/FileBrowser.tsx index 5ebb4d028..a4ef13e31 100644 --- a/apps/web/src/components/file-browser/FileBrowser.tsx +++ b/apps/web/src/components/file-browser/FileBrowser.tsx @@ -1,4 +1,6 @@ +import { isRecoverable } from '../../engine/snapshotStore'; import { useSnapshot } from '../../engine/useSnapshot'; +import { useSnapshotStore } from '../../providers/EngineProvider'; import { useFolderNavigation } from '../../vault/useFolderNavigation'; import { Breadcrumbs } from './Breadcrumbs'; import { DeadLetterNotice } from './DeadLetterNotice'; @@ -11,17 +13,33 @@ export function FileBrowser() { const { rows, folder, breadcrumbs, isLoading, isRoot, error, navigateTo, navigateUp } = useFolderNavigation(); const { view } = useSnapshot(); - const settled = !isLoading && error === null; + const store = useSnapshotStore(); + // A ceiling refusal clears on its own, so it renders over the listing it + // interrupted; anything else is a verdict and blanks it. + const recoverable = error !== null && isRecoverable(error); + const settled = !isLoading && (error === null || (recoverable && folder !== null)); return (
- {error && ( -

- {error.message} -

- )} + {error !== null && + (recoverable ? ( +
+ {error.message} + +
+ ) : ( +

+ {error.message} +

+ ))} {isLoading && (

{'// LOADING VAULT...'} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index bd3a24620..aa51283bf 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,24 +1,37 @@ import type { ReactNode } from 'react'; +import { useEngineNotices } from '../../engine/useEngineNotices'; +import { useRefreshHints } from '../../engine/useRefreshHints'; +import { NotificationToast } from '../NotificationToast'; import { StagingBanner } from '../StagingBanner'; import { AppFooter } from './AppFooter'; import { AppHeader } from './AppHeader'; import { AppSidebar } from './AppSidebar'; +import { OfflineBanner } from './OfflineBanner'; interface AppShellProps { children: ReactNode; } -/** The signed-in frame: header, sidebar, scrollable main, footer. */ +/** + * The signed-in frame: header, sidebar, scrollable main, footer, and the + * cross-cutting chrome that renders event-stream state only + * (blueprint/web-client.md "Composition"). + */ export function AppShell({ children }: AppShellProps) { + useEngineNotices(); + useRefreshHints(); + return (

+
{children}
+
); } diff --git a/apps/web/src/components/layout/OfflineBanner.test.tsx b/apps/web/src/components/layout/OfflineBanner.test.tsx new file mode 100644 index 000000000..77778a97e --- /dev/null +++ b/apps/web/src/components/layout/OfflineBanner.test.tsx @@ -0,0 +1,67 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { fakeEngine, view } from '../../engine/testFakes'; +import { EngineProvider } from '../../providers/EngineProvider'; +import { OfflineBanner } from './OfflineBanner'; + +function setOnline(online: boolean): void { + Object.defineProperty(navigator, 'onLine', { configurable: true, value: online }); + window.dispatchEvent(new Event(online ? 'online' : 'offline')); +} + +afterEach(() => setOnline(true)); + +function draw(client: ReturnType['client']) { + return render( + client}> + + + ); +} + +describe('the offline banner', () => { + it('stays down while the link is up and the engine is reconciling', () => { + draw(fakeEngine().client); + expect(screen.queryByTestId('offline-banner')).toBeNull(); + }); + + it('follows the browser losing its link', async () => { + draw(fakeEngine().client); + + await act(async () => setOnline(false)); + + expect(screen.getByTestId('offline-banner')).toBeTruthy(); + }); + + it('follows the engine reaching the offline rung', async () => { + const engine = fakeEngine(); + draw(engine.client); + + // The link is up; nothing answers over it. + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: 'offline' }); + }); + + await waitFor(() => expect(screen.getByTestId('offline-banner')).toBeTruthy()); + }); + + it('clears once both signals recover', async () => { + const engine = fakeEngine(); + draw(engine.client); + await act(async () => setOnline(false)); + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: 'offline' }); + }); + expect(screen.getByTestId('offline-banner')).toBeTruthy(); + + await act(async () => setOnline(true)); + expect(screen.getByTestId('offline-banner')).toBeTruthy(); + + await act(async () => { + engine.emit({ kind: 'snapshotUpdated' }); + engine.pulls[0].resolve(view()); + }); + + await waitFor(() => expect(screen.queryByTestId('offline-banner')).toBeNull()); + }); +}); diff --git a/apps/web/src/components/layout/OfflineBanner.tsx b/apps/web/src/components/layout/OfflineBanner.tsx new file mode 100644 index 000000000..5a2e3c4b4 --- /dev/null +++ b/apps/web/src/components/layout/OfflineBanner.tsx @@ -0,0 +1,26 @@ +import { useStaleness } from '../../engine/useStaleness'; +import { useOnlineStatus } from '../../hooks/useOnlineStatus'; + +/** + * The staleness ladder's bottom rung, at banner scale (blueprint/web-client.md + * "Staleness ladder rendering"). Either signal alone raises it: the browser + * notices a dropped link first, and the engine's rung outlives a link that is + * up but answers nothing. + */ +export function OfflineBanner() { + const online = useOnlineStatus(); + const staleness = useStaleness(); + + if (online && staleness !== 'offline') return null; + + return ( +
+ + + {'// OFFLINE - changes queue on this device and publish when the network returns'} + +
+ ); +} diff --git a/apps/web/src/components/layout/StatusIndicator.test.tsx b/apps/web/src/components/layout/StatusIndicator.test.tsx new file mode 100644 index 000000000..61dab1400 --- /dev/null +++ b/apps/web/src/components/layout/StatusIndicator.test.tsx @@ -0,0 +1,43 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { fakeEngine } from '../../engine/testFakes'; +import { EngineProvider } from '../../providers/EngineProvider'; +import { StatusIndicator } from './StatusIndicator'; + +function draw(client: ReturnType['client']) { + return render( + client}> + + + ); +} + +describe('the status indicator', () => { + it('names the rung the engine reports', async () => { + const engine = fakeEngine(); + draw(engine.client); + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('reconciling'); + + for (const rung of ['fresh', 'stale', 'offline'] as const) { + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: rung }); + }); + await waitFor(() => + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe(rung) + ); + } + }); + + it('drives a manual refresh from the rung it renders', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(screen.getByTestId('status-indicator')).toBeTruthy()); + + await act(async () => { + fireEvent.click(screen.getByTestId('status-indicator')); + }); + + expect(engine.refreshes()).toBe(1); + expect(engine.pulls).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/layout/StatusIndicator.tsx b/apps/web/src/components/layout/StatusIndicator.tsx index e9bf99733..f2f722e20 100644 --- a/apps/web/src/components/layout/StatusIndicator.tsx +++ b/apps/web/src/components/layout/StatusIndicator.tsx @@ -1,4 +1,5 @@ import { useStaleness } from '../../engine/useStaleness'; +import { useSnapshotStore } from '../../providers/EngineProvider'; /** The staleness ladder's rungs, as the footer renders them (#33 D4). */ const RUNGS = { @@ -8,19 +9,23 @@ const RUNGS = { offline: { label: 'offline', className: 'status-indicator--offline' }, } as const; -/** Where the vault sits on the staleness ladder. */ +/** Where the vault sits on the staleness ladder, and the manual refresh. */ export function StatusIndicator() { const staleness = useStaleness(); + const store = useSnapshotStore(); const rung = RUNGS[staleness]; return ( - store.refresh()} > + ); } diff --git a/apps/web/src/engine/snapshotStore.test.ts b/apps/web/src/engine/snapshotStore.test.ts index 7335bed1d..97fa25ea5 100644 --- a/apps/web/src/engine/snapshotStore.test.ts +++ b/apps/web/src/engine/snapshotStore.test.ts @@ -1,6 +1,6 @@ import { EngineRequestError } from '@cipherbox/client'; import { describe, expect, it } from 'vitest'; -import { createSnapshotStore, idleSnapshotStore } from './snapshotStore'; +import { createSnapshotStore, idleSnapshotStore, isRecoverable } from './snapshotStore'; import { ROOT_ID, fakeEngine, flush, view } from './testFakes'; describe('snapshotStore', () => { @@ -253,3 +253,92 @@ describe('snapshotStore', () => { expect(engine.pulls).toHaveLength(0); }); }); + +describe('the focus window', () => { + const FOLDER = new Uint8Array(16).fill(3); + + it('re-asserts the cached focus after a consumer borrowed the window', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + store.setFocus(FOLDER); + engine.ackFocus(); + await flush(); + + // A folder picker drove `facade.setFocus` itself; the store's cache is + // unchanged, so asking it for the same folder short-circuits. + store.setFocus(FOLDER); + expect(engine.focus).toEqual([FOLDER]); + + store.refocus(); + expect(engine.focus).toEqual([FOLDER, FOLDER]); + expect(engine.reported).toEqual([FOLDER, FOLDER]); + }); + + it('pulls its own folder after taking the window back', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + store.setFocus(FOLDER); + engine.ackFocus(); + await flush(); + engine.pulls[0].resolve(view(FOLDER)); + await flush(); + + store.refocus(); + engine.ackFocus(); + await flush(); + + expect(engine.pulls[1].folder).toBe(FOLDER); + }); +}); + +describe('a manual refresh', () => { + it('resolves nocache, then re-pulls the focused folder', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + + store.refresh(); + await flush(); + + expect(engine.refreshes()).toBe(1); + expect(engine.pulls).toHaveLength(1); + const refreshed = view(); + engine.pulls[0].resolve(refreshed); + await flush(); + expect(store.getSnapshot().view).toBe(refreshed); + }); + + it('clears a failure the retry cleared', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + engine.emit({ kind: 'snapshotUpdated' }); + engine.pulls[0].reject(new EngineRequestError('at the ceiling', 'tooManyStreams')); + await flush(); + expect(store.getSnapshot().error).toEqual({ + message: 'at the ceiling', + code: 'tooManyStreams', + }); + + store.refresh(); + await flush(); + engine.pulls[1].resolve(view()); + await flush(); + + expect(store.getSnapshot().error).toBeNull(); + }); +}); + +describe('failure classification', () => { + it('treats a resource ceiling as recoverable', () => { + expect(isRecoverable({ message: 'ceiling', code: 'tooManyStreams' })).toBe(true); + expect(isRecoverable({ message: 'no room', code: 'overBudget' })).toBe(true); + }); + + it('fails closed on anything it does not name', () => { + // A verdict, an unmapped future engine code, and a codeless transport fault + // all read as fatal rather than as something a retry would clear. + expect(isRecoverable({ message: 'refused', code: 'trustViolation' })).toBe(false); + expect(isRecoverable({ message: 'gone', code: 'unknownNode' })).toBe(false); + expect(isRecoverable({ message: 'new', code: 'someFutureCeiling' })).toBe(false); + expect(isRecoverable({ message: 'worker died' })).toBe(false); + }); +}); diff --git a/apps/web/src/engine/snapshotStore.ts b/apps/web/src/engine/snapshotStore.ts index dcef6e689..d94286872 100644 --- a/apps/web/src/engine/snapshotStore.ts +++ b/apps/web/src/engine/snapshotStore.ts @@ -23,6 +23,19 @@ export interface SnapshotState { error: SnapshotError | null; } +/** + * Engine codes a later pull clears on its own: resource ceilings, not verdicts. + * Everything else — including a codeless transport fault and any code this list + * does not name — is fatal, so a new engine variant can never render as + * recoverable without someone adding it here. + */ +const RECOVERABLE_CODES: ReadonlySet = new Set(['tooManyStreams', 'overBudget']); + +/** Whether a later pull can clear this failure without anything else changing. */ +export function isRecoverable(error: SnapshotError): boolean { + return error.code !== undefined && RECOVERABLE_CODES.has(error.code); +} + export interface SnapshotStore { /** `useSyncExternalStore` subscribe: fires on every committed change. */ subscribe(onStoreChange: () => void): () => void; @@ -32,6 +45,14 @@ export interface SnapshotStore { getStaleness(): Staleness; /** Points the adapter (and the engine's focus window) at a folder. */ setFocus(node: Uint8Array | null): void; + /** + * Re-asserts the cached focus after a consumer drove `facade.setFocus` itself. + * The `setFocus` short-circuit cannot see such a borrow, so without this the + * engine stays on the borrower's folder for the rest of the session. + */ + refocus(): void; + /** Resolves with nocache semantics, then re-pulls the focused folder. */ + refresh(): void; /** Releases the event subscription. */ dispose(): void; } @@ -44,6 +65,8 @@ export const idleSnapshotStore: SnapshotStore = { getSnapshot: () => IDLE, getStaleness: () => 'reconciling', setFocus: () => undefined, + refocus: () => undefined, + refresh: () => undefined, dispose: () => undefined, }; @@ -113,6 +136,19 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore { }); }; + const assertFocus = (): void => { + client.reportFocus(focus); + const id = ++generation; + client.facade.setFocus(focus).then( + () => { + if (id === generation) pull(); + }, + (error: unknown) => { + if (id === generation) commit({ error: describe(error) }); + } + ); + }; + const unsubscribe = client.facade.subscribe((event) => { if (event.kind === 'snapshotUpdated') { pull(); @@ -132,9 +168,12 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore { setFocus(node) { if (sameNode(focus, node)) return; focus = node; - client.reportFocus(node); - const id = ++generation; - client.facade.setFocus(node).then( + assertFocus(); + }, + refocus: assertFocus, + refresh() { + const id = generation; + client.facade.manualRefresh().then( () => { if (id === generation) pull(); }, diff --git a/apps/web/src/engine/testFakes.ts b/apps/web/src/engine/testFakes.ts index ad19c05b6..628e5b060 100644 --- a/apps/web/src/engine/testFakes.ts +++ b/apps/web/src/engine/testFakes.ts @@ -54,6 +54,7 @@ export function fakeEngine() { const reported: (Uint8Array | null)[] = []; let settleFocus: (() => void) | null = null; let failFocus: ((error: Error) => void) | null = null; + let refreshes = 0; const client = { facade: { @@ -73,6 +74,10 @@ export function fakeEngine() { failFocus = reject; }); }, + manualRefresh() { + refreshes += 1; + return Promise.resolve(); + }, }, reportFocus(node: Uint8Array | null) { reported.push(node); @@ -90,6 +95,7 @@ export function fakeEngine() { }, ackFocus: () => settleFocus?.(), rejectFocus: (error: Error) => failFocus?.(error), + refreshes: () => refreshes, subscriberCount: () => listeners.size, }; } diff --git a/apps/web/src/engine/useEngineNotices.test.tsx b/apps/web/src/engine/useEngineNotices.test.tsx new file mode 100644 index 000000000..a8f0ac4b5 --- /dev/null +++ b/apps/web/src/engine/useEngineNotices.test.tsx @@ -0,0 +1,117 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { NotificationToast } from '../components/NotificationToast'; +import { StatusIndicator } from '../components/layout/StatusIndicator'; +import { EngineProvider } from '../providers/EngineProvider'; +import { notificationStore } from '../stores/notification.store'; +import { fakeEngine } from './testFakes'; +import { useEngineNotices } from './useEngineNotices'; + +afterEach(() => notificationStore.clear()); + +/** The two surfaces side by side, so one event cannot land on both. */ +function Chrome() { + useEngineNotices(); + return ( + <> + + + + ); +} + +function draw(client: ReturnType['client']) { + return render( + client}> + + + ); +} + +describe('engine warnings', () => { + it('renders a withheld-update escalation as a warning, never as staleness', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(screen.getByTestId('status-indicator')).toBeTruthy()); + const rung = screen.getByTestId('status-indicator').dataset.staleness; + + await act(async () => { + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: new Uint8Array([0xab, 0xcd]) }); + }); + + const notice = await screen.findByTestId('notification-notice'); + expect(notice.dataset.noticeClass).toBe('warning'); + expect(notice.getAttribute('role')).toBe('alert'); + // The pinned name identifies the scope for de-duplication only. + expect(notice.textContent).not.toContain('abcd'); + // The ladder is untouched: a trust warning is never a rung. + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe(rung); + }); + + it('renders an attributable-abuse report as the same warning class', async () => { + const engine = fakeEngine(); + draw(engine.client); + + await act(async () => { + engine.emit({ kind: 'attributableAbuse', description: 'k51abc: floor regression' }); + }); + + const notice = await screen.findByTestId('notification-notice'); + expect(notice.textContent).toContain('k51abc: floor regression'); + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('reconciling'); + }); + + it('collapses a scope that escalates on every tick', async () => { + const engine = fakeEngine(); + draw(engine.client); + const name = new Uint8Array([0x01, 0x02]); + + await act(async () => { + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name }); + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name }); + engine.emit({ kind: 'withheldUpdateEscalation', ipnsName: name }); + }); + + expect(await screen.findAllByTestId('notification-notice')).toHaveLength(1); + }); + + it('dismisses a warning the reader has read', async () => { + const engine = fakeEngine(); + draw(engine.client); + await act(async () => { + engine.emit({ kind: 'attributableAbuse', description: 'refused' }); + }); + await screen.findByTestId('notification-notice'); + + fireEvent.click(screen.getByLabelText('Dismiss warning')); + + expect(screen.queryByTestId('notification-toast')).toBeNull(); + }); + + it('drops the warnings with the engine that raised them', async () => { + const engine = fakeEngine(); + const { unmount } = draw(engine.client); + await act(async () => { + engine.emit({ kind: 'attributableAbuse', description: 'refused' }); + }); + await screen.findByTestId('notification-notice'); + + unmount(); + + expect(notificationStore.getState()).toHaveLength(0); + }); + + it('leaves the staleness ladder to the events that own it', async () => { + const engine = fakeEngine(); + draw(engine.client); + + await act(async () => { + engine.emit({ kind: 'stalenessChanged', staleness: 'stale' }); + }); + + await waitFor(() => + expect(screen.getByTestId('status-indicator').dataset.staleness).toBe('stale') + ); + expect(screen.queryByTestId('notification-toast')).toBeNull(); + }); +}); diff --git a/apps/web/src/engine/useEngineNotices.ts b/apps/web/src/engine/useEngineNotices.ts new file mode 100644 index 000000000..a1c369a40 --- /dev/null +++ b/apps/web/src/engine/useEngineNotices.ts @@ -0,0 +1,40 @@ +/** + * Binds the engine's trust warnings to the warning-notice surface. A withheld + * update and an attributable-abuse report are verdicts about what the network + * served, so they render as their own class and never move the staleness ladder + * (blueprint/web-client.md "Staleness ladder rendering", AGENTS.md rule 6). + */ + +import { useEffect } from 'react'; +import { toHex } from '@cipherbox/client'; +import { useEngine } from '../providers/EngineProvider'; +import { notificationStore } from '../stores/notification.store'; + +/** + * The pinned name identifies the scope for de-duplication only. It is a routing + * identifier the reader cannot act on, so the notice reads in vault terms. + */ +const WITHHELD = + 'a shared folder stopped serving updates you are entitled to see — what it shows may be behind'; + +export function useEngineNotices(): void { + const client = useEngine(); + + useEffect(() => { + if (client === null) return; + const unsubscribe = client.facade.subscribe((event) => { + if (event.kind === 'withheldUpdateEscalation') { + notificationStore.warn(`withheld:${toHex(event.ipnsName)}`, WITHHELD); + } else if (event.kind === 'attributableAbuse') { + notificationStore.warn( + `abuse:${event.description}`, + `verification refused an update: ${event.description}` + ); + } + }); + return () => { + unsubscribe(); + notificationStore.clear(); + }; + }, [client]); +} diff --git a/apps/web/src/engine/useRefreshHints.test.tsx b/apps/web/src/engine/useRefreshHints.test.tsx new file mode 100644 index 000000000..8961b1536 --- /dev/null +++ b/apps/web/src/engine/useRefreshHints.test.tsx @@ -0,0 +1,84 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { EngineProvider } from '../providers/EngineProvider'; +import { fakeEngine } from './testFakes'; +import { useRefreshHints } from './useRefreshHints'; + +function setOnline(online: boolean): void { + Object.defineProperty(navigator, 'onLine', { configurable: true, value: online }); + window.dispatchEvent(new Event(online ? 'online' : 'offline')); +} + +function setVisible(visible: boolean): void { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => (visible ? 'visible' : 'hidden'), + }); + document.dispatchEvent(new Event('visibilitychange')); +} + +afterEach(() => { + setOnline(true); + setVisible(true); +}); + +function Hints() { + useRefreshHints(); + return null; +} + +function draw(client: ReturnType['client']) { + return render( + client}> + + + ); +} + +describe('refresh hints', () => { + it('does not refresh on mount', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + expect(engine.refreshes()).toBe(0); + }); + + it('refreshes when the network comes back', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + await act(async () => setOnline(false)); + expect(engine.refreshes()).toBe(0); + + await act(async () => setOnline(true)); + expect(engine.refreshes()).toBe(1); + }); + + it('refreshes when a backgrounded tab comes back on screen', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + await act(async () => setVisible(false)); + await act(async () => setVisible(true)); + + expect(engine.refreshes()).toBe(1); + }); + + it('waits for both signals before refreshing', async () => { + const engine = fakeEngine(); + draw(engine.client); + await waitFor(() => expect(engine.subscriberCount()).toBe(1)); + + await act(async () => setVisible(false)); + await act(async () => setOnline(false)); + await act(async () => setOnline(true)); + // Still hidden: nothing on screen is waiting on a fresher answer. + expect(engine.refreshes()).toBe(0); + + await act(async () => setVisible(true)); + expect(engine.refreshes()).toBe(1); + }); +}); diff --git a/apps/web/src/engine/useRefreshHints.ts b/apps/web/src/engine/useRefreshHints.ts new file mode 100644 index 000000000..eb34fef29 --- /dev/null +++ b/apps/web/src/engine/useRefreshHints.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; +import { useOnlineStatus } from '../hooks/useOnlineStatus'; +import { useVisibility } from '../hooks/useVisibility'; +import { useSnapshotStore } from '../providers/EngineProvider'; + +/** + * Regaining the network or coming back to a backgrounded tab are the two + * moments a cached vault is most likely behind, so each edge back into + * on-screen-and-online drives one nocache refresh. Steady state costs nothing: + * only the transition refreshes, never the mount. + */ +export function useRefreshHints(): void { + const store = useSnapshotStore(); + const online = useOnlineStatus(); + const visible = useVisibility(); + const wasReady = useRef(true); + + useEffect(() => { + const ready = online && visible; + if (ready && !wasReady.current) store.refresh(); + wasReady.current = ready; + }, [online, visible, store]); +} diff --git a/apps/web/src/hooks/useFolderPicker.ts b/apps/web/src/hooks/useFolderPicker.ts index a527f9b4a..0642bebad 100644 --- a/apps/web/src/hooks/useFolderPicker.ts +++ b/apps/web/src/hooks/useFolderPicker.ts @@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { SnapshotDescriptor } from '@cipherbox/client'; import { errorMessage } from '../lib/errorMessage'; import { sameNode } from '../lib/nodeId'; -import { useEngine } from '../providers/EngineProvider'; +import { useEngine, useSnapshotStore } from '../providers/EngineProvider'; import { listingRows, type ListingRow } from '../vault/listing'; export interface FolderPicker { @@ -28,14 +28,14 @@ export interface FolderPicker { } /** - * @param openOn the folder to start on and to hand the focus window back to, - * captured on mount + * @param openOn the folder to start the walk on, captured on mount * @param excludedKey a subtree the picker must not offer, by hex node id — * hiding the node hides everything under it, since the walk only descends * through what it lists */ export function useFolderPicker(openOn: Uint8Array | null, excludedKey: string): FolderPicker { const client = useEngine(); + const store = useSnapshotStore(); const home = useRef(openOn).current; const [cursor, setCursor] = useState(home); const [listing, setListing] = useState(null); @@ -65,9 +65,10 @@ export function useFolderPicker(openOn: Uint8Array | null, excludedKey: string): }; }, [client, cursor]); - // Handing the focus window back is best-effort: the picker is gone, and the - // route's own focus effect reasserts it either way. - useEffect(() => () => void client?.facade.setFocus(home).catch(() => undefined), [client, home]); + // The walk borrows the focus window; only the store knows where it belongs by + // the time the picker closes, so the store takes it back rather than the + // picker restoring the folder it opened on. + useEffect(() => () => store.refocus(), [store]); // The read effect retires the listing a render later than the cursor moves, // and a listing outliving its cursor names the folder just left as the diff --git a/apps/web/src/hooks/useOnlineStatus.ts b/apps/web/src/hooks/useOnlineStatus.ts new file mode 100644 index 000000000..0db4240d5 --- /dev/null +++ b/apps/web/src/hooks/useOnlineStatus.ts @@ -0,0 +1,23 @@ +import { useSyncExternalStore } from 'react'; + +function subscribe(onChange: () => void): () => void { + window.addEventListener('online', onChange); + window.addEventListener('offline', onChange); + return () => { + window.removeEventListener('online', onChange); + window.removeEventListener('offline', onChange); + }; +} + +/** + * Whether the browser has a network path. A hint that leads the engine's + * staleness ladder, never a substitute for it: `navigator.onLine` reports the + * link, not whether anything answers over it. + */ +export function useOnlineStatus(): boolean { + return useSyncExternalStore( + subscribe, + () => navigator.onLine, + () => true + ); +} diff --git a/apps/web/src/hooks/useVisibility.ts b/apps/web/src/hooks/useVisibility.ts new file mode 100644 index 000000000..7e45df9d2 --- /dev/null +++ b/apps/web/src/hooks/useVisibility.ts @@ -0,0 +1,15 @@ +import { useSyncExternalStore } from 'react'; + +function subscribe(onChange: () => void): () => void { + document.addEventListener('visibilitychange', onChange); + return () => document.removeEventListener('visibilitychange', onChange); +} + +/** Whether this tab is on screen; a backgrounded tab's cache goes behind. */ +export function useVisibility(): boolean { + return useSyncExternalStore( + subscribe, + () => document.visibilityState === 'visible', + () => true + ); +} diff --git a/apps/web/src/stores/notification.store.test.ts b/apps/web/src/stores/notification.store.test.ts new file mode 100644 index 000000000..baa0513fb --- /dev/null +++ b/apps/web/src/stores/notification.store.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { notificationStore } from './notification.store'; + +afterEach(() => notificationStore.clear()); + +describe('the notification store', () => { + it('collapses a repeat of a warning that already stands', () => { + notificationStore.warn('withheld:aa', 'a shared folder stopped serving updates'); + notificationStore.warn('withheld:aa', 'a shared folder stopped serving updates'); + + expect(notificationStore.getState()).toHaveLength(1); + }); + + it('bounds what an event storm can accumulate', () => { + for (let i = 0; i < 12; i += 1) notificationStore.warn(`abuse:${i}`, `refused ${i}`); + + const keys = notificationStore.getState().map((notice) => notice.key); + expect(keys).toEqual(['abuse:7', 'abuse:8', 'abuse:9', 'abuse:10', 'abuse:11']); + }); + + it('raises the same key again once it was dismissed', () => { + notificationStore.warn('withheld:aa', 'first'); + notificationStore.dismiss('withheld:aa'); + expect(notificationStore.getState()).toHaveLength(0); + + notificationStore.warn('withheld:aa', 'again'); + expect(notificationStore.getState()).toHaveLength(1); + }); + + it('notifies only on a change and publishes a stable snapshot', () => { + let changes = 0; + const drop = notificationStore.subscribe(() => (changes += 1)); + + notificationStore.warn('abuse:x', 'refused'); + notificationStore.warn('abuse:x', 'refused'); + notificationStore.dismiss('abuse:missing'); + expect(changes).toBe(1); + // `useSyncExternalStore` bails out on identity: a repeat read must match. + expect(notificationStore.getState()).toBe(notificationStore.getState()); + + drop(); + notificationStore.warn('abuse:y', 'refused'); + expect(changes).toBe(1); + }); +}); diff --git a/apps/web/src/stores/notification.store.ts b/apps/web/src/stores/notification.store.ts new file mode 100644 index 000000000..a192e9915 --- /dev/null +++ b/apps/web/src/stores/notification.store.ts @@ -0,0 +1,52 @@ +/** + * The warning-notice surface: a trust violation or a withheld-update escalation + * renders here, as its own class, and never on the staleness ladder + * (blueprint/web-client.md "Staleness ladder rendering"). + * + * Memory only, and cleared when the engine that raised the notices goes away — + * a notice names the scope it came from, so it must not outlive that session. + */ + +/** One standing warning. `key` is its identity: a repeat collapses onto it. */ +export interface Notice { + readonly key: string; + readonly message: string; +} + +/** + * The engine emits per resolve attempt, so an unreachable scope raises the same + * warning on every tick; the cap bounds what an event storm can accumulate. + */ +const MAX_NOTICES = 5; + +const EMPTY: readonly Notice[] = Object.freeze([]); + +let notices: readonly Notice[] = EMPTY; +const listeners = new Set<() => void>(); + +function publish(next: readonly Notice[]): void { + // Frozen and identity-compared: `useSyncExternalStore` bails out on identity, + // and a consumer must not be able to mutate what the UI is rendering. + notices = next.length === 0 ? EMPTY : Object.freeze(next); + for (const listener of listeners) listener(); +} + +export const notificationStore = { + subscribe(onStoreChange: () => void): () => void { + listeners.add(onStoreChange); + return () => listeners.delete(onStoreChange); + }, + getState: (): readonly Notice[] => notices, + /** Raises `message` under `key`, or does nothing if that key already stands. */ + warn(key: string, message: string): void { + if (notices.some((notice) => notice.key === key)) return; + publish([...notices, { key, message }].slice(-MAX_NOTICES)); + }, + dismiss(key: string): void { + const next = notices.filter((notice) => notice.key !== key); + if (next.length !== notices.length) publish(next); + }, + clear(): void { + if (notices.length > 0) publish(EMPTY); + }, +}; diff --git a/apps/web/src/styles/file-browser.css b/apps/web/src/styles/file-browser.css index b90fdf9e4..3de990ec9 100644 --- a/apps/web/src/styles/file-browser.css +++ b/apps/web/src/styles/file-browser.css @@ -23,6 +23,40 @@ border: var(--border-thickness) solid rgb(239 68 68 / 20%); } +/* A recoverable refusal: warning-toned and retryable, over the listing it + interrupted, never the settled red of a failure that will not clear. */ +.file-browser-notice { + display: flex; + align-items: center; + gap: var(--spacing-xs); + margin-bottom: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-warning); + background-color: var(--color-warning-bg); + border: var(--border-thickness) solid var(--color-warning-dim); +} + +.file-browser-notice-message { + flex: 1; +} + +.file-browser-notice-retry { + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +.file-browser-notice-retry:hover, +.file-browser-notice-retry:focus-visible { + text-decoration: underline; +} + .file-browser-loading { padding: var(--spacing-sm) 0; font-family: var(--font-family-mono); diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css index 8f75d14de..d89626f12 100644 --- a/apps/web/src/styles/layout.css +++ b/apps/web/src/styles/layout.css @@ -292,9 +292,18 @@ display: flex; align-items: center; gap: 6px; + padding: 0; font-family: var(--font-family-mono); font-size: var(--font-size-xs); color: var(--color-text-secondary); + background: none; + border: none; + cursor: pointer; +} + +.status-indicator:hover, +.status-indicator:focus-visible { + color: var(--color-text-primary); } .status-indicator-dot { @@ -338,3 +347,66 @@ animation: none; } } + +/* ========================================================================== + Offline Banner + ========================================================================== */ + +.offline-banner { + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-md); + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-warning); + background-color: var(--color-warning-bg); + border-bottom: var(--border-thickness) solid var(--color-warning-dim); +} + +/* ========================================================================== + Notification Toast - the warning class, never the staleness ladder + ========================================================================== */ + +.notification-toast { + position: fixed; + right: var(--spacing-md); + bottom: var(--spacing-md); + z-index: 40; + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + max-width: 380px; +} + +.notification-toast-item { + display: flex; + align-items: flex-start; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-warning); + background-color: var(--color-background); + border: var(--border-thickness) solid var(--color-warning); +} + +.notification-toast-message { + flex: 1; +} + +.notification-toast-dismiss { + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + background: none; + border: none; + cursor: pointer; +} + +.notification-toast-dismiss:hover, +.notification-toast-dismiss:focus-visible { + text-decoration: underline; +} From 408229ea1cbeb78c1bab75a1c22dee24ba0fecd7 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 6 Aug 2026 01:26:10 +0200 Subject: [PATCH 2/4] fix: keep the vault listing when the engine refuses a manual refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine does not implement `manualRefresh` yet and answers `unimplemented`, which the store committed as a fatal snapshot error — clicking the status indicator or coming back online blanked the whole listing. Caught driving the built app against a live stack; the unit fake resolved the command, so no test saw it. The nocache hint is best-effort: only the pull it precedes sets `error`. Adds the regression test and the folder-picker's own focus-handback test. Co-Authored-By: Claude Opus 5 --- apps/web/src/engine/snapshotStore.test.ts | 18 ++++++++ apps/web/src/engine/snapshotStore.ts | 17 ++++---- apps/web/src/engine/testFakes.ts | 4 +- apps/web/src/hooks/useFolderPicker.test.tsx | 47 +++++++++++++++++++++ apps/web/src/styles/layout.css | 3 +- 5 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/hooks/useFolderPicker.test.tsx diff --git a/apps/web/src/engine/snapshotStore.test.ts b/apps/web/src/engine/snapshotStore.test.ts index 97fa25ea5..ecf74f36f 100644 --- a/apps/web/src/engine/snapshotStore.test.ts +++ b/apps/web/src/engine/snapshotStore.test.ts @@ -307,6 +307,24 @@ describe('a manual refresh', () => { expect(store.getSnapshot().view).toBe(refreshed); }); + it('still pulls, and keeps the listing, when the engine refuses the refresh', async () => { + const engine = fakeEngine(); + const store = createSnapshotStore(engine.client); + engine.emit({ kind: 'snapshotUpdated' }); + const listed = view(ROOT_ID, 'fresh', 2); + engine.pulls[0].resolve(listed); + await flush(); + + // The engine does not implement `manualRefresh` yet, and answers + // `unimplemented` — a verdict on the hint, never on the listing. + engine.refuseRefresh(new EngineRequestError('not implemented yet', 'unimplemented')); + store.refresh(); + await flush(); + + expect(engine.pulls).toHaveLength(2); + expect(store.getSnapshot()).toEqual({ view: listed, error: null }); + }); + it('clears a failure the retry cleared', async () => { const engine = fakeEngine(); const store = createSnapshotStore(engine.client); diff --git a/apps/web/src/engine/snapshotStore.ts b/apps/web/src/engine/snapshotStore.ts index d94286872..b3a223678 100644 --- a/apps/web/src/engine/snapshotStore.ts +++ b/apps/web/src/engine/snapshotStore.ts @@ -51,7 +51,7 @@ export interface SnapshotStore { * engine stays on the borrower's folder for the rest of the session. */ refocus(): void; - /** Resolves with nocache semantics, then re-pulls the focused folder. */ + /** Re-pulls the focused folder, behind a best-effort nocache resolve. */ refresh(): void; /** Releases the event subscription. */ dispose(): void; @@ -173,14 +173,13 @@ export function createSnapshotStore(client: EngineClient): SnapshotStore { refocus: assertFocus, refresh() { const id = generation; - client.facade.manualRefresh().then( - () => { - if (id === generation) pull(); - }, - (error: unknown) => { - if (id === generation) commit({ error: describe(error) }); - } - ); + const again = (): void => { + if (id === generation) pull(); + }; + // The nocache hint is best-effort; the pull it precedes is the answer. + // A refused refresh command must not replace a listing that is still the + // best the engine has, so only the pull's own outcome sets `error`. + client.facade.manualRefresh().then(again, again); }, dispose() { unsubscribe(); diff --git a/apps/web/src/engine/testFakes.ts b/apps/web/src/engine/testFakes.ts index 628e5b060..4ae93797a 100644 --- a/apps/web/src/engine/testFakes.ts +++ b/apps/web/src/engine/testFakes.ts @@ -55,6 +55,7 @@ export function fakeEngine() { let settleFocus: (() => void) | null = null; let failFocus: ((error: Error) => void) | null = null; let refreshes = 0; + let refuseRefresh: Error | null = null; const client = { facade: { @@ -76,7 +77,7 @@ export function fakeEngine() { }, manualRefresh() { refreshes += 1; - return Promise.resolve(); + return refuseRefresh === null ? Promise.resolve() : Promise.reject(refuseRefresh); }, }, reportFocus(node: Uint8Array | null) { @@ -96,6 +97,7 @@ export function fakeEngine() { ackFocus: () => settleFocus?.(), rejectFocus: (error: Error) => failFocus?.(error), refreshes: () => refreshes, + refuseRefresh: (error: Error) => (refuseRefresh = error), subscriberCount: () => listeners.size, }; } diff --git a/apps/web/src/hooks/useFolderPicker.test.tsx b/apps/web/src/hooks/useFolderPicker.test.tsx new file mode 100644 index 000000000..b501e1587 --- /dev/null +++ b/apps/web/src/hooks/useFolderPicker.test.tsx @@ -0,0 +1,47 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { fakeEngine } from '../engine/testFakes'; +import type { SnapshotStore } from '../engine/snapshotStore'; +import { EngineProvider, useSnapshotStore } from '../providers/EngineProvider'; +import { useFolderPicker } from './useFolderPicker'; + +const HOME = new Uint8Array(16).fill(1); +const NEXT = new Uint8Array(16).fill(2); + +function Picker() { + useFolderPicker(HOME, ''); + return null; +} + +/** Publishes the provider's store so the test can drive the route's writer. */ +function Probe({ onStore }: { onStore: (store: SnapshotStore) => void }) { + onStore(useSnapshotStore()); + return null; +} + +describe('the folder picker', () => { + it('leaves the engine on the folder the store is focused on', async () => { + const engine = fakeEngine(); + let store!: SnapshotStore; + const { rerender } = render( + engine.client}> + (store = next)} /> + + + ); + await waitFor(() => expect(engine.focus.length).toBeGreaterThan(0)); + + // The route moves under the open dialog, then the dialog closes. + await act(async () => { + store.setFocus(NEXT); + }); + rerender( + engine.client}> + (store = next)} /> + + ); + + await waitFor(() => expect(engine.focus.at(-1)).toBe(NEXT)); + expect(engine.reported.at(-1)).toBe(NEXT); + }); +}); diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css index d89626f12..3d0d378a9 100644 --- a/apps/web/src/styles/layout.css +++ b/apps/web/src/styles/layout.css @@ -372,7 +372,8 @@ .notification-toast { position: fixed; right: var(--spacing-md); - bottom: var(--spacing-md); + /* Clears the footer rather than sitting over the staleness rung. */ + bottom: var(--spacing-xl); z-index: 40; display: flex; flex-direction: column; From db4a5304eb2e77a51800e679d8d824e37c60e13b Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 6 Aug 2026 01:42:38 +0200 Subject: [PATCH 3/4] refactor: fold the review-gate findings into the notice chrome /simplify and /security-review, plus a manual reuse/altitude pass. - Narrow `isRecoverable` to `tooManyStreams`, agreeing with the client's own `isRecoverableEngineError`. `overBudget` was a divergent second answer, and its device-full and staging-limit causes do not clear on their own. - Project the trust warnings from the snapshot store's subscription instead of a second one mounted from `AppShell`. A second subscription opens a render after the engine starts, so every cold-start escalation fell in the gap and read as "nothing was wrong". Drops `useEngineNotices` entirely; the notices now clear with the store that raised them. - Render the offline banner from the engine's rung alone. `navigator.onLine` reports the link, not whether anything answers over it, and the blueprint puts it in the refresh-hint seam, not the ladder. - Stable accessible name on the manual-refresh control; flatten the browser's error branch; drop a single-valued data attribute and its tautological assertion; simplify the notice store's publish path; share the jsdom online/visibility stubs; trim the comments the repo's comment law rejects. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/NotificationToast.tsx | 1 - .../components/file-browser/FileBrowser.tsx | 38 ++++++++-------- apps/web/src/components/layout/AppShell.tsx | 2 - .../components/layout/OfflineBanner.test.tsx | 40 +++++++---------- .../src/components/layout/OfflineBanner.tsx | 10 +---- .../src/components/layout/StatusIndicator.tsx | 2 +- ...otices.test.tsx => engineNotices.test.tsx} | 15 +------ apps/web/src/engine/snapshotStore.test.ts | 7 ++- apps/web/src/engine/snapshotStore.ts | 45 +++++++++++-------- apps/web/src/engine/testFakes.ts | 18 ++++++++ apps/web/src/engine/useEngineNotices.ts | 40 ----------------- apps/web/src/engine/useRefreshHints.test.tsx | 15 +------ apps/web/src/engine/useRefreshHints.ts | 8 ++-- apps/web/src/hooks/useFolderPicker.ts | 4 +- apps/web/src/hooks/useOnlineStatus.ts | 6 +-- apps/web/src/stores/notification.store.ts | 16 +++---- 16 files changed, 102 insertions(+), 165 deletions(-) rename apps/web/src/engine/{useEngineNotices.test.tsx => engineNotices.test.tsx} (95%) delete mode 100644 apps/web/src/engine/useEngineNotices.ts diff --git a/apps/web/src/components/NotificationToast.tsx b/apps/web/src/components/NotificationToast.tsx index 26cb1805a..037135689 100644 --- a/apps/web/src/components/NotificationToast.tsx +++ b/apps/web/src/components/NotificationToast.tsx @@ -18,7 +18,6 @@ export function NotificationToast() { className="notification-toast-item" role="alert" data-testid="notification-notice" - data-notice-class="warning" >