diff --git a/src/client/App.tsx b/src/client/App.tsx index 353b631..456047b 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -32,6 +32,13 @@ import type { WorkspaceState, } from '../shared/types'; import { api, ApiError, authHeaders, setToken } from './api'; +import { + applyCaptureResult, + applyRefreshResult, + dismissNotice, + visibleNotice, + type Notices, +} from './poll-notice'; import { Mascot } from './Mascot'; import { Chat } from './Chat'; import { ThreadList } from './ThreadList'; @@ -40,6 +47,14 @@ import { TaskRow } from './TaskPresentation'; import { TaskActions } from './TaskActions'; import { WorkspaceDialog, type Dialog } from './WorkspaceDialog'; +function describeFailure(error: unknown, fallback: string) { + return { + ok: false as const, + status: error instanceof ApiError ? error.status : undefined, + message: error instanceof Error ? error.message : fallback, + }; +} + export function App() { const [state, setState] = useState(); const [workspace, setWorkspace] = useState(); @@ -92,7 +107,15 @@ export function App() { openPageLink(`#/spaces/${space}${page ? `/pages/${page}` : ''}`); }; - const [error, setError] = useState(''); + const [notices, setNotices] = useState({ + connection: '', + action: '', + }); + const error = visibleNotice(notices); + const setError = (action: string) => + setNotices((current) => + current.action === action ? current : { ...current, action }, + ); const [auth, setAuth] = useState(''); const [needsAuth, setNeedsAuth] = useState(false); const [dialog, setDialog] = useState(); @@ -115,12 +138,15 @@ export function App() { setWorkspace(w); setNeedsAuth(false); setSelectedDot((previous) => previous || w.dots[0]?.id || ''); + setNotices((current) => applyRefreshResult(current, { ok: true })); } catch (e) { if (e instanceof ApiError && e.status === 401) setNeedsAuth(true); - else - setError( - e instanceof Error ? e.message : 'Could not connect to the server.', - ); + setNotices((current) => + applyRefreshResult( + current, + describeFailure(e, 'Could not connect to the server.'), + ), + ); } }, []); useEffect(() => { @@ -135,10 +161,18 @@ export function App() { const load = () => void api(`/conversations/${selectedThread}/capture`) .then((result) => { - if (active) setCapture(result ?? undefined); + if (!active) return; + setCapture(result ?? undefined); + setNotices((current) => applyCaptureResult(current, { ok: true })); }) .catch((e) => { - if (active) setError(e.message); + if (!active) return; + setNotices((current) => + applyCaptureResult( + current, + describeFailure(e, 'Could not connect to the server.'), + ), + ); }); load(); const timer = setInterval(load, 3000); @@ -528,7 +562,7 @@ export function App() { diff --git a/src/client/poll-notice.ts b/src/client/poll-notice.ts new file mode 100644 index 0000000..b049c07 --- /dev/null +++ b/src/client/poll-notice.ts @@ -0,0 +1,55 @@ +export interface Notices { + connection: string; + action: string; +} + +export interface PollFailure { + ok: false; + status?: number; + message: string; +} + +const TRANSPORT_MESSAGES = new Set([ + 'Failed to fetch', + 'Server returned an unreadable response.', +]); + +export function visibleNotice(notices: Notices): string { + return notices.action || notices.connection; +} + +export function dismissNotice(notices: Notices): Notices { + if (notices.action) return { ...notices, action: '' }; + if (notices.connection) return { ...notices, connection: '' }; + return notices; +} + +export function applyRefreshResult( + current: Notices, + result: { ok: true } | PollFailure, +): Notices { + if (result.ok) + return current.connection ? { ...current, connection: '' } : current; + if (result.status === 401) return current; + if (current.connection === result.message) return current; + return { ...current, connection: result.message }; +} + +export function applyCaptureResult( + current: Notices, + result: { ok: true } | PollFailure, +): Notices { + if (result.ok) + return current.connection ? { ...current, connection: '' } : current; + if (result.status === 401) return current; + if ( + result.status === 502 || + result.status === 504 || + TRANSPORT_MESSAGES.has(result.message) + ) { + if (current.connection === result.message) return current; + return { ...current, connection: result.message }; + } + if (current.action === result.message) return current; + return { ...current, action: result.message }; +} diff --git a/tests/poll-notice.test.ts b/tests/poll-notice.test.ts new file mode 100644 index 0000000..4989f17 --- /dev/null +++ b/tests/poll-notice.test.ts @@ -0,0 +1,80 @@ +import { expect, it } from 'vitest'; +import { + applyCaptureResult, + applyRefreshResult, + dismissNotice, + visibleNotice, + type Notices, +} from '../src/client/poll-notice'; + +const saved: Notices = { connection: '', action: 'Could not save.' }; + +it('clears a recovered connection error without wiping an action error', () => { + const lost = applyRefreshResult(saved, { + ok: false, + message: 'Server returned an unreadable response.', + status: 502, + }); + expect(lost).toEqual({ + connection: 'Server returned an unreadable response.', + action: 'Could not save.', + }); + expect(visibleNotice(lost)).toBe('Could not save.'); + + const recovered = applyRefreshResult(lost, { ok: true }); + expect(recovered).toEqual(saved); + expect( + visibleNotice( + applyRefreshResult( + { connection: 'Failed to fetch', action: '' }, + { ok: true }, + ), + ), + ).toBe(''); +}); + +it('keeps an unauthorized poll from replacing the connection notice', () => { + const current: Notices = { connection: 'Failed to fetch', action: '' }; + expect( + applyRefreshResult(current, { + ok: false, + status: 401, + message: 'Enter your owner access token to unlock OpenDots.', + }), + ).toBe(current); +}); + +it('treats a capture transport failure as a connection notice and leaves save errors in place', () => { + const lost = applyCaptureResult(saved, { + ok: false, + message: 'Failed to fetch', + }); + expect(lost.connection).toBe('Failed to fetch'); + expect(lost.action).toBe('Could not save.'); + expect(applyCaptureResult(lost, { ok: true })).toEqual(saved); + + const specific = applyCaptureResult( + { connection: 'Failed to fetch', action: 'Could not save.' }, + { ok: false, status: 500, message: 'Thread not found.' }, + ); + expect(specific.connection).toBe('Failed to fetch'); + expect(specific.action).toBe('Thread not found.'); + expect(applyRefreshResult(specific, { ok: true }).action).toBe( + 'Thread not found.', + ); +}); + +it('dismisses the visible notice and leaves the other one', () => { + const both: Notices = { + connection: 'Failed to fetch', + action: 'Could not save.', + }; + expect(dismissNotice(both)).toEqual({ + connection: 'Failed to fetch', + action: '', + }); + expect(dismissNotice({ connection: 'Failed to fetch', action: '' })).toEqual({ + connection: '', + action: '', + }); +});