From c7eee02bd099561ddf7793e39c61bfa252ae471c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 13:53:06 -0700 Subject: [PATCH 1/3] Fix thread loading flash --- .../routes/_chat.$environmentId.$threadId.tsx | 10 +++- apps/web/src/threadRoutes.test.ts | 50 +++++++++++++++++++ apps/web/src/threadRoutes.ts | 17 +++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 7dc6702b4ec..2f5234226f1 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -4,7 +4,7 @@ import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef } from "../threadRoutes"; +import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -37,6 +37,12 @@ function ChatThreadRouteView() { return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); const routeThreadExists = threadExists || draftThreadExists; + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadShellExists: serverThreadShell !== null, + serverThreadDetailExists: serverThreadDetail !== null, + draftThreadExists, + }); const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; @@ -57,7 +63,7 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || !bootstrapComplete || !routeThreadExists) { + if (!threadRef || renderState !== "ready") { return null; } diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 644cdccb5c1..ba2e8c1e157 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -7,6 +7,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams, resolveActiveThreadRouteRef, + resolveThreadRouteRenderState, resolveThreadRouteRef, resolveThreadRouteTarget, } from "./threadRoutes"; @@ -92,4 +93,53 @@ describe("threadRoutes", () => { }), ).toBeNull(); }); + + it("keeps shell-only server threads in the loading state", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: false, + draftThreadExists: false, + }), + ).toBe("loading"); + }); + + it("renders server details and local drafts when they are ready", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: true, + draftThreadExists: false, + }), + ).toBe("ready"); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: false, + serverThreadDetailExists: false, + draftThreadExists: true, + }), + ).toBe("ready"); + }); + + it("distinguishes bootstrap loading from a missing thread", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: false, + serverThreadShellExists: false, + serverThreadDetailExists: false, + draftThreadExists: false, + }), + ).toBe("loading"); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: false, + serverThreadDetailExists: false, + draftThreadExists: false, + }), + ).toBe("missing"); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index a4d853c0a7f..55a6ca83957 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -18,6 +18,23 @@ type DraftThreadRouteState = { promotedTo?: ScopedThreadRef | null; }; +export type ThreadRouteRenderState = "loading" | "ready" | "missing"; + +export function resolveThreadRouteRenderState(input: { + bootstrapComplete: boolean; + serverThreadShellExists: boolean; + serverThreadDetailExists: boolean; + draftThreadExists: boolean; +}): ThreadRouteRenderState { + if (!input.bootstrapComplete) { + return "loading"; + } + if (input.serverThreadDetailExists || input.draftThreadExists) { + return "ready"; + } + return input.serverThreadShellExists ? "loading" : "missing"; +} + export function buildThreadRouteParams(ref: ScopedThreadRef): { environmentId: EnvironmentId; threadId: ThreadId; From 7b64b9cce7eec74c2aa8c4cea3ea58ee4d88aced Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 14:36:33 -0700 Subject: [PATCH 2/3] Handle thread load failures --- .../routes/_chat.$environmentId.$threadId.tsx | 37 ++++++++++++++++--- apps/web/src/state/entities.ts | 23 +++++++++++- apps/web/src/threadRoutes.test.ts | 33 +++++++++++++++++ apps/web/src/threadRoutes.ts | 10 ++++- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 2f5234226f1..9f97ed34249 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -5,8 +5,15 @@ import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "~/components/ui/empty"; import { SidebarInset } from "~/components/ui/sidebar"; -import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; +import { + useEnvironmentThreadRefs, + useThreadDetail, + useThreadError, + useThreadShell, + useThreadStatus, +} from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; @@ -20,9 +27,10 @@ function ChatThreadRouteView() { ); const serverThreadShell = useThreadShell(threadRef); const serverThreadDetail = useThreadDetail(threadRef); + const serverThreadStatus = useThreadStatus(threadRef); + const serverThreadError = useThreadError(threadRef); const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); const bootstrapComplete = shell.data?.snapshot._tag === "Some"; - const threadExists = serverThreadShell !== null || serverThreadDetail !== null; const environmentHasServerThreads = environmentThreadRefs.length > 0; const draftThreadExists = useComposerDraftStore((store) => threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, @@ -36,11 +44,12 @@ function ChatThreadRouteView() { } return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); - const routeThreadExists = threadExists || draftThreadExists; const renderState = resolveThreadRouteRenderState({ bootstrapComplete, serverThreadShellExists: serverThreadShell !== null, serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailDeleted: serverThreadStatus === "deleted", + serverThreadDetailFailed: serverThreadError !== null, draftThreadExists, }); const serverThreadStarted = threadHasStarted(serverThreadDetail); @@ -51,10 +60,10 @@ function ChatThreadRouteView() { return; } - if (!routeThreadExists && environmentHasAnyThreads) { + if (renderState === "missing" && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } - }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); + }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); useEffect(() => { if (!threadRef || !serverThreadStarted || !draftThread) { @@ -64,6 +73,9 @@ function ChatThreadRouteView() { }, [draftThread, serverThreadStarted, threadRef]); if (!threadRef || renderState !== "ready") { + if (renderState === "error") { + return ; + } return null; } @@ -78,6 +90,21 @@ function ChatThreadRouteView() { ); } +function ThreadLoadErrorState({ error }: { error: string | null }) { + return ( + + + + Couldn’t load thread + + {error ?? "T3 Code will keep trying to reconnect automatically."} + + + + + ); +} + export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ component: ChatThreadRouteView, }); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 47e63ca2cd3..75849d45207 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -4,7 +4,10 @@ import type { EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; +import { + type EnvironmentThreadStatus, + mergeEnvironmentThread, +} from "@t3tools/client-runtime/state/threads"; import type { OrchestrationMessage, OrchestrationProposedPlan, @@ -44,6 +47,12 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-detail:empty"), ); +const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pipe( + Atom.withLabel("web-thread-status:empty"), +); +const EMPTY_THREAD_ERROR_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-error:empty"), +); const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( Atom.withLabel("web-thread-messages:empty"), ); @@ -140,6 +149,18 @@ export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread ); } +export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus { + return useAtomValue( + ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref), + ); +} + +export function useThreadError(ref: ScopedThreadRef | null): string | null { + return useAtomValue( + ref === null ? EMPTY_THREAD_ERROR_ATOM : environmentThreadDetails.errorAtom(ref), + ); +} + /** Detail collections composed with shell-authoritative thread/workspace metadata. */ export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { const shell = useThreadShell(ref); diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index ba2e8c1e157..71b7f8bb4d7 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -100,6 +100,8 @@ describe("threadRoutes", () => { bootstrapComplete: true, serverThreadShellExists: true, serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("loading"); @@ -111,6 +113,8 @@ describe("threadRoutes", () => { bootstrapComplete: true, serverThreadShellExists: true, serverThreadDetailExists: true, + serverThreadDetailDeleted: false, + serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("ready"); @@ -119,6 +123,8 @@ describe("threadRoutes", () => { bootstrapComplete: true, serverThreadShellExists: false, serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + serverThreadDetailFailed: false, draftThreadExists: true, }), ).toBe("ready"); @@ -130,6 +136,8 @@ describe("threadRoutes", () => { bootstrapComplete: false, serverThreadShellExists: false, serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("loading"); @@ -138,6 +146,31 @@ describe("threadRoutes", () => { bootstrapComplete: true, serverThreadShellExists: false, serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + serverThreadDetailFailed: false, + draftThreadExists: false, + }), + ).toBe("missing"); + }); + + it("does not leave failed or deleted shell-only threads loading", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + serverThreadDetailFailed: true, + draftThreadExists: false, + }), + ).toBe("error"); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: false, + serverThreadDetailDeleted: true, + serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("missing"); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 55a6ca83957..6e63b7db65c 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -18,12 +18,14 @@ type DraftThreadRouteState = { promotedTo?: ScopedThreadRef | null; }; -export type ThreadRouteRenderState = "loading" | "ready" | "missing"; +export type ThreadRouteRenderState = "loading" | "ready" | "missing" | "error"; export function resolveThreadRouteRenderState(input: { bootstrapComplete: boolean; serverThreadShellExists: boolean; serverThreadDetailExists: boolean; + serverThreadDetailDeleted: boolean; + serverThreadDetailFailed: boolean; draftThreadExists: boolean; }): ThreadRouteRenderState { if (!input.bootstrapComplete) { @@ -32,6 +34,12 @@ export function resolveThreadRouteRenderState(input: { if (input.serverThreadDetailExists || input.draftThreadExists) { return "ready"; } + if (input.serverThreadDetailDeleted) { + return "missing"; + } + if (input.serverThreadDetailFailed) { + return "error"; + } return input.serverThreadShellExists ? "loading" : "missing"; } From 4eb3ad3009d33a67a315c29c96292c7cb8407d8f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 23 Jul 2026 14:45:01 -0700 Subject: [PATCH 3/3] Avoid transient thread error flash --- .../routes/_chat.$environmentId.$threadId.tsx | 22 ------------------- apps/web/src/state/entities.ts | 9 -------- apps/web/src/threadRoutes.test.ts | 18 +-------------- apps/web/src/threadRoutes.ts | 6 +---- 4 files changed, 2 insertions(+), 53 deletions(-) diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 9f97ed34249..ce9de113beb 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -5,12 +5,10 @@ import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; -import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "~/components/ui/empty"; import { SidebarInset } from "~/components/ui/sidebar"; import { useEnvironmentThreadRefs, useThreadDetail, - useThreadError, useThreadShell, useThreadStatus, } from "../state/entities"; @@ -28,7 +26,6 @@ function ChatThreadRouteView() { const serverThreadShell = useThreadShell(threadRef); const serverThreadDetail = useThreadDetail(threadRef); const serverThreadStatus = useThreadStatus(threadRef); - const serverThreadError = useThreadError(threadRef); const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); const bootstrapComplete = shell.data?.snapshot._tag === "Some"; const environmentHasServerThreads = environmentThreadRefs.length > 0; @@ -49,7 +46,6 @@ function ChatThreadRouteView() { serverThreadShellExists: serverThreadShell !== null, serverThreadDetailExists: serverThreadDetail !== null, serverThreadDetailDeleted: serverThreadStatus === "deleted", - serverThreadDetailFailed: serverThreadError !== null, draftThreadExists, }); const serverThreadStarted = threadHasStarted(serverThreadDetail); @@ -73,9 +69,6 @@ function ChatThreadRouteView() { }, [draftThread, serverThreadStarted, threadRef]); if (!threadRef || renderState !== "ready") { - if (renderState === "error") { - return ; - } return null; } @@ -90,21 +83,6 @@ function ChatThreadRouteView() { ); } -function ThreadLoadErrorState({ error }: { error: string | null }) { - return ( - - - - Couldn’t load thread - - {error ?? "T3 Code will keep trying to reconnect automatically."} - - - - - ); -} - export const Route = createFileRoute("/_chat/$environmentId/$threadId")({ component: ChatThreadRouteView, }); diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 75849d45207..e85fc1d115e 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -50,9 +50,6 @@ const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pipe( Atom.withLabel("web-thread-status:empty"), ); -const EMPTY_THREAD_ERROR_ATOM = Atom.make(null).pipe( - Atom.withLabel("web-thread-error:empty"), -); const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( Atom.withLabel("web-thread-messages:empty"), ); @@ -155,12 +152,6 @@ export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadS ); } -export function useThreadError(ref: ScopedThreadRef | null): string | null { - return useAtomValue( - ref === null ? EMPTY_THREAD_ERROR_ATOM : environmentThreadDetails.errorAtom(ref), - ); -} - /** Detail collections composed with shell-authoritative thread/workspace metadata. */ export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { const shell = useThreadShell(ref); diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 71b7f8bb4d7..3edb2f38dc8 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -101,7 +101,6 @@ describe("threadRoutes", () => { serverThreadShellExists: true, serverThreadDetailExists: false, serverThreadDetailDeleted: false, - serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("loading"); @@ -114,7 +113,6 @@ describe("threadRoutes", () => { serverThreadShellExists: true, serverThreadDetailExists: true, serverThreadDetailDeleted: false, - serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("ready"); @@ -124,7 +122,6 @@ describe("threadRoutes", () => { serverThreadShellExists: false, serverThreadDetailExists: false, serverThreadDetailDeleted: false, - serverThreadDetailFailed: false, draftThreadExists: true, }), ).toBe("ready"); @@ -137,7 +134,6 @@ describe("threadRoutes", () => { serverThreadShellExists: false, serverThreadDetailExists: false, serverThreadDetailDeleted: false, - serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("loading"); @@ -147,30 +143,18 @@ describe("threadRoutes", () => { serverThreadShellExists: false, serverThreadDetailExists: false, serverThreadDetailDeleted: false, - serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("missing"); }); - it("does not leave failed or deleted shell-only threads loading", () => { - expect( - resolveThreadRouteRenderState({ - bootstrapComplete: true, - serverThreadShellExists: true, - serverThreadDetailExists: false, - serverThreadDetailDeleted: false, - serverThreadDetailFailed: true, - draftThreadExists: false, - }), - ).toBe("error"); + it("redirects deleted shell-only threads", () => { expect( resolveThreadRouteRenderState({ bootstrapComplete: true, serverThreadShellExists: true, serverThreadDetailExists: false, serverThreadDetailDeleted: true, - serverThreadDetailFailed: false, draftThreadExists: false, }), ).toBe("missing"); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 6e63b7db65c..fd5bc39d836 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -18,14 +18,13 @@ type DraftThreadRouteState = { promotedTo?: ScopedThreadRef | null; }; -export type ThreadRouteRenderState = "loading" | "ready" | "missing" | "error"; +export type ThreadRouteRenderState = "loading" | "ready" | "missing"; export function resolveThreadRouteRenderState(input: { bootstrapComplete: boolean; serverThreadShellExists: boolean; serverThreadDetailExists: boolean; serverThreadDetailDeleted: boolean; - serverThreadDetailFailed: boolean; draftThreadExists: boolean; }): ThreadRouteRenderState { if (!input.bootstrapComplete) { @@ -37,9 +36,6 @@ export function resolveThreadRouteRenderState(input: { if (input.serverThreadDetailDeleted) { return "missing"; } - if (input.serverThreadDetailFailed) { - return "error"; - } return input.serverThreadShellExists ? "loading" : "missing"; }