diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 7dc6702b4ec..ce9de113beb 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -4,9 +4,14 @@ 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 { + useEnvironmentThreadRefs, + useThreadDetail, + useThreadShell, + useThreadStatus, +} from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; @@ -20,9 +25,9 @@ function ChatThreadRouteView() { ); const serverThreadShell = useThreadShell(threadRef); const serverThreadDetail = useThreadDetail(threadRef); + const serverThreadStatus = useThreadStatus(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,7 +41,13 @@ function ChatThreadRouteView() { } return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); - const routeThreadExists = threadExists || draftThreadExists; + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadShellExists: serverThreadShell !== null, + serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailDeleted: serverThreadStatus === "deleted", + draftThreadExists, + }); const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; @@ -45,10 +56,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) { @@ -57,7 +68,7 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || !bootstrapComplete || !routeThreadExists) { + if (!threadRef || renderState !== "ready") { return null; } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 47e63ca2cd3..e85fc1d115e 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,9 @@ 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_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( Atom.withLabel("web-thread-messages:empty"), ); @@ -140,6 +146,12 @@ 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), + ); +} + /** 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 644cdccb5c1..3edb2f38dc8 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,70 @@ describe("threadRoutes", () => { }), ).toBeNull(); }); + + it("keeps shell-only server threads in the loading state", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("loading"); + }); + + it("renders server details and local drafts when they are ready", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: true, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("ready"); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: false, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: true, + }), + ).toBe("ready"); + }); + + it("distinguishes bootstrap loading from a missing thread", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: false, + serverThreadShellExists: false, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("loading"); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: false, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("missing"); + }); + + it("redirects deleted shell-only threads", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: false, + serverThreadDetailDeleted: true, + draftThreadExists: false, + }), + ).toBe("missing"); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index a4d853c0a7f..fd5bc39d836 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -18,6 +18,27 @@ type DraftThreadRouteState = { promotedTo?: ScopedThreadRef | null; }; +export type ThreadRouteRenderState = "loading" | "ready" | "missing"; + +export function resolveThreadRouteRenderState(input: { + bootstrapComplete: boolean; + serverThreadShellExists: boolean; + serverThreadDetailExists: boolean; + serverThreadDetailDeleted: boolean; + draftThreadExists: boolean; +}): ThreadRouteRenderState { + if (!input.bootstrapComplete) { + return "loading"; + } + if (input.serverThreadDetailExists || input.draftThreadExists) { + return "ready"; + } + if (input.serverThreadDetailDeleted) { + return "missing"; + } + return input.serverThreadShellExists ? "loading" : "missing"; +} + export function buildThreadRouteParams(ref: ScopedThreadRef): { environmentId: EnvironmentId; threadId: ThreadId;