From 8cc48de7502fab73fabe149079636f6155cd80d1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 12:15:59 -0800 Subject: [PATCH 1/4] fix(web): keep the open project on screen when a refresh fails --- apps/web/src/App.tsx | 38 ++++++++------------------ apps/web/src/first-run.test.ts | 49 ++++++++++++++++++++++++++++++++++ apps/web/src/first-run.ts | 43 +++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/first-run.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ef1824de..58320f37 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -81,7 +81,11 @@ import { } from "@corbits/react-ui/ui/dialog"; import { Auth } from "./pages/auth.jsx"; import { StageWorkspace } from "./pages/workspace/index.tsx"; -import { firstRunScreen, type HubAuthState } from "./first-run.ts"; +import { + firstRunScreen, + hubReadState, + type HubAuthState, +} from "./first-run.ts"; import { ModelSetup, clearModelSetupDeferral, @@ -571,27 +575,10 @@ export function App() { const [chosen, setSelected] = useState(projectInUrl); const selected = urlProject ?? chosen; const [error, setError] = useState(null); - // A fold read that failed. A 401/403 means the session cookie no longer - // holds: that is "not signed in", not "not answering". Only a dropped - // connection or a real server error is offline: the hub going away is a - // visible state, not a blank screen. - const readFailure = providersRead.error ?? projectsRead.error ?? null; - const readStatus = - readFailure instanceof ApiFailure - ? readFailure.httpStatus - : readFailure instanceof Error && - "response" in readFailure && - readFailure.response instanceof Response - ? readFailure.response.status - : undefined; - const signedOutByHost = readStatus === 401 || readStatus === 403; - // Auth and "no coordinator yet" are not a dead hub. Only a dropped - // connection or a 5xx is offline. - const offline = - sessionReadFailed || - (readFailure !== null && - !signedOutByHost && - (readStatus === undefined || readStatus >= 500)); + // The hub going away is a visible state, not a blank screen. + const reads = hubReadState([providersRead, projectsRead]); + const signedOutByHost = reads.signedOut; + const offline = sessionReadFailed || reads.offline; useEffect(() => { if (signedOutByHost) setAuth("signed-out"); }, [signedOutByHost]); @@ -774,11 +761,8 @@ export function App() { // No model credential yet: ask on its own screen, before the project list. // A refused read stays on the shell, which already says the hub is down. const modelCatalogUnsettled = - !setupLater && - view !== "invite" && - !providersRead.isSuccess && - (providersRead.isPending || providersRead.isFetching); - if (modelCatalogUnsettled || offline) { + !setupLater && view !== "invite" && providersRead.isPending; + if (modelCatalogUnsettled || sessionReadFailed || reads.blocksShell) { return ; } if ( diff --git a/apps/web/src/first-run.test.ts b/apps/web/src/first-run.test.ts new file mode 100644 index 00000000..48761fc8 --- /dev/null +++ b/apps/web/src/first-run.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test"; +import { ApiFailure } from "./api/types.ts"; +import { hubReadState } from "./first-run.ts"; + +const dropped = new TypeError("Failed to fetch"); +const refused = (httpStatus: number) => + new ApiFailure( + { + code: "refused", + message: "refused", + correlationId: "c", + retryable: false, + }, + httpStatus, + ); + +test("a refresh failing after a good read keeps the shell under the offline banner", () => { + const state = hubReadState([ + { data: { providers: [] }, error: dropped }, + { data: { projects: [] }, error: refused(502) }, + ]); + expect(state).toEqual({ + signedOut: false, + offline: true, + blocksShell: false, + }); +}); + +test("a first read failing shows the boot screen", () => { + const state = hubReadState([ + { data: { providers: [] }, error: null }, + { data: undefined, error: dropped }, + ]); + expect(state).toEqual({ signedOut: false, offline: true, blocksShell: true }); +}); + +test("a 401 or 403 on a refresh signs out rather than reading as offline", () => { + for (const status of [401, 403]) { + const state = hubReadState([ + { data: { providers: [] }, error: dropped }, + { data: { projects: [] }, error: refused(status) }, + ]); + expect(state).toEqual({ + signedOut: true, + offline: false, + blocksShell: false, + }); + } +}); diff --git a/apps/web/src/first-run.ts b/apps/web/src/first-run.ts index 1a845016..a89e9c81 100644 --- a/apps/web/src/first-run.ts +++ b/apps/web/src/first-run.ts @@ -1,3 +1,5 @@ +import { ApiFailure } from "./api/types.ts"; + /** Which full screen first launch shows: the better-auth session is the only gate. */ export type HubAuthState = "unknown" | "signed-out" | "signed-in"; @@ -11,3 +13,44 @@ export function firstRunScreen(input: { auth: HubAuthState }): FirstRunScreen { if (input.auth === "unknown") return "boot"; return "ready"; } + +export interface HubRead { + data: unknown; + error: Error | null; +} + +function failureStatus(failure: Error): number | undefined { + if (failure instanceof ApiFailure) return failure.httpStatus; + if ("response" in failure && failure.response instanceof Response) { + return failure.response.status; + } + return undefined; +} + +/** + * What the fold reads' failures mean. A 401/403 means the session cookie no + * longer holds: that is "not signed in", not "not answering". Only a dropped + * connection or a real server error is offline. + */ +export function hubReadState(reads: readonly HubRead[]): { + signedOut: boolean; + offline: boolean; + blocksShell: boolean; +} { + const failed = reads.flatMap(({ data, error }) => + error ? [{ status: failureStatus(error), unread: data === undefined }] : [], + ); + const signedOut = failed.some( + ({ status }) => status === 401 || status === 403, + ); + const down = signedOut + ? [] + : failed.filter(({ status }) => status === undefined || status >= 500); + return { + signedOut, + offline: down.length > 0, + // A failed refresh keeps its last data, so whatever is open stays on + // screen under the offline banner; only a hub never read blocks it. + blocksShell: down.some(({ unread }) => unread), + }; +} From a183fe788d5c7968cce2abac8e184addc5f051ed Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 13:52:15 -0800 Subject: [PATCH 2/4] refactor(web): inline the shell gate for failed refreshes --- apps/web/src/App.tsx | 38 +++++++++++++++++++------- apps/web/src/first-run.test.ts | 49 ---------------------------------- apps/web/src/first-run.ts | 43 ----------------------------- 3 files changed, 28 insertions(+), 102 deletions(-) delete mode 100644 apps/web/src/first-run.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 58320f37..74c88320 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -81,11 +81,7 @@ import { } from "@corbits/react-ui/ui/dialog"; import { Auth } from "./pages/auth.jsx"; import { StageWorkspace } from "./pages/workspace/index.tsx"; -import { - firstRunScreen, - hubReadState, - type HubAuthState, -} from "./first-run.ts"; +import { firstRunScreen, type HubAuthState } from "./first-run.ts"; import { ModelSetup, clearModelSetupDeferral, @@ -575,10 +571,32 @@ export function App() { const [chosen, setSelected] = useState(projectInUrl); const selected = urlProject ?? chosen; const [error, setError] = useState(null); - // The hub going away is a visible state, not a blank screen. - const reads = hubReadState([providersRead, projectsRead]); - const signedOutByHost = reads.signedOut; - const offline = sessionReadFailed || reads.offline; + // A fold read that failed. A 401/403 means the session cookie no longer + // holds: that is "not signed in", not "not answering". Only a dropped + // connection or a real server error is offline: the hub going away is a + // visible state, not a blank screen. + const readFailure = providersRead.error ?? projectsRead.error ?? null; + const readStatus = + readFailure instanceof ApiFailure + ? readFailure.httpStatus + : readFailure instanceof Error && + "response" in readFailure && + readFailure.response instanceof Response + ? readFailure.response.status + : undefined; + const signedOutByHost = readStatus === 401 || readStatus === 403; + // Auth and "no coordinator yet" are not a dead hub. Only a dropped + // connection or a 5xx is offline. + const offline = + sessionReadFailed || + (readFailure !== null && + !signedOutByHost && + (readStatus === undefined || readStatus >= 500)); + // A failed refresh keeps its last data, so only a read that never answered + // hides the shell. + const nothingToShow = + (providersRead.isError && providersRead.data === undefined) || + (projectsRead.isError && projectsRead.data === undefined); useEffect(() => { if (signedOutByHost) setAuth("signed-out"); }, [signedOutByHost]); @@ -762,7 +780,7 @@ export function App() { // A refused read stays on the shell, which already says the hub is down. const modelCatalogUnsettled = !setupLater && view !== "invite" && providersRead.isPending; - if (modelCatalogUnsettled || sessionReadFailed || reads.blocksShell) { + if (modelCatalogUnsettled || (offline && nothingToShow)) { return ; } if ( diff --git a/apps/web/src/first-run.test.ts b/apps/web/src/first-run.test.ts deleted file mode 100644 index 48761fc8..00000000 --- a/apps/web/src/first-run.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { expect, test } from "bun:test"; -import { ApiFailure } from "./api/types.ts"; -import { hubReadState } from "./first-run.ts"; - -const dropped = new TypeError("Failed to fetch"); -const refused = (httpStatus: number) => - new ApiFailure( - { - code: "refused", - message: "refused", - correlationId: "c", - retryable: false, - }, - httpStatus, - ); - -test("a refresh failing after a good read keeps the shell under the offline banner", () => { - const state = hubReadState([ - { data: { providers: [] }, error: dropped }, - { data: { projects: [] }, error: refused(502) }, - ]); - expect(state).toEqual({ - signedOut: false, - offline: true, - blocksShell: false, - }); -}); - -test("a first read failing shows the boot screen", () => { - const state = hubReadState([ - { data: { providers: [] }, error: null }, - { data: undefined, error: dropped }, - ]); - expect(state).toEqual({ signedOut: false, offline: true, blocksShell: true }); -}); - -test("a 401 or 403 on a refresh signs out rather than reading as offline", () => { - for (const status of [401, 403]) { - const state = hubReadState([ - { data: { providers: [] }, error: dropped }, - { data: { projects: [] }, error: refused(status) }, - ]); - expect(state).toEqual({ - signedOut: true, - offline: false, - blocksShell: false, - }); - } -}); diff --git a/apps/web/src/first-run.ts b/apps/web/src/first-run.ts index a89e9c81..1a845016 100644 --- a/apps/web/src/first-run.ts +++ b/apps/web/src/first-run.ts @@ -1,5 +1,3 @@ -import { ApiFailure } from "./api/types.ts"; - /** Which full screen first launch shows: the better-auth session is the only gate. */ export type HubAuthState = "unknown" | "signed-out" | "signed-in"; @@ -13,44 +11,3 @@ export function firstRunScreen(input: { auth: HubAuthState }): FirstRunScreen { if (input.auth === "unknown") return "boot"; return "ready"; } - -export interface HubRead { - data: unknown; - error: Error | null; -} - -function failureStatus(failure: Error): number | undefined { - if (failure instanceof ApiFailure) return failure.httpStatus; - if ("response" in failure && failure.response instanceof Response) { - return failure.response.status; - } - return undefined; -} - -/** - * What the fold reads' failures mean. A 401/403 means the session cookie no - * longer holds: that is "not signed in", not "not answering". Only a dropped - * connection or a real server error is offline. - */ -export function hubReadState(reads: readonly HubRead[]): { - signedOut: boolean; - offline: boolean; - blocksShell: boolean; -} { - const failed = reads.flatMap(({ data, error }) => - error ? [{ status: failureStatus(error), unread: data === undefined }] : [], - ); - const signedOut = failed.some( - ({ status }) => status === 401 || status === 403, - ); - const down = signedOut - ? [] - : failed.filter(({ status }) => status === undefined || status >= 500); - return { - signedOut, - offline: down.length > 0, - // A failed refresh keeps its last data, so whatever is open stays on - // screen under the offline banner; only a hub never read blocks it. - blocksShell: down.some(({ unread }) => unread), - }; -} From 6fc16a732c582eab4dd1669566853332c0a46e71 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 14:13:14 -0800 Subject: [PATCH 3/4] fix(web): keep model setup on screen when a refresh fails --- apps/web/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 74c88320..ca845725 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -785,7 +785,7 @@ export function App() { } if ( view !== "invite" && - providersRead.isSuccess && + providersRead.data !== undefined && needsModelSetup(providers) && !setupLater ) { @@ -890,7 +890,7 @@ export function App() { onOpen={openProject} onChanged={refresh} needsProvider={ - providersRead.isSuccess && needsModelSetup(providers) + providersRead.data !== undefined && needsModelSetup(providers) } /> ) : null} From fa5dea7e8272124100950cb1a1b57d01a8eb5376 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 6 Nov 2026 14:35:10 -0800 Subject: [PATCH 4/4] fix(web): keep the project list on screen when a refresh fails --- apps/web/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ca845725..515f866d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -898,7 +898,7 @@ export function App() { {view === "library" ? ( openProject(projectId)} onNewProject={() => navigate("projects")}