authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-23 15:19:06-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 15:19:06-07:00
log91dfe60a99b7bf6cc44138a237e663ba158062b7
treee267b1c2e4dd543a9de1853192ec3888349a9093
parentedb12401e547be4806836077f336295c58097c9b
signature Signed by PGP key B5690EEEBB952194

Fix thread loading flash (#4396)


4 files changed, 119 insertions(+), 8 deletions(-)

apps/web/src/routes/_chat.$environmentId.$threadId.tsx+18-7
......@@ -4,9 +4,14 @@ import { useEffect } from "react";
44import ChatView from "../components/ChatView";
55import { threadHasStarted } from "../components/ChatView.logic";
66import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
7import { resolveThreadRouteRef } from "../threadRoutes";
7import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
88import { SidebarInset } from "~/components/ui/sidebar";
9import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities";
9import {
10 useEnvironmentThreadRefs,
11 useThreadDetail,
12 useThreadShell,
13 useThreadStatus,
14} from "../state/entities";
1015import { useEnvironmentQuery } from "../state/query";
1116import { environmentShell } from "../state/shell";
1217
......@@ -20,9 +25,9 @@ function ChatThreadRouteView() {
2025 );
2126 const serverThreadShell = useThreadShell(threadRef);
2227 const serverThreadDetail = useThreadDetail(threadRef);
28 const serverThreadStatus = useThreadStatus(threadRef);
2329 const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null);
2430 const bootstrapComplete = shell.data?.snapshot._tag === "Some";
25 const threadExists = serverThreadShell !== null || serverThreadDetail !== null;
2631 const environmentHasServerThreads = environmentThreadRefs.length > 0;
2732 const draftThreadExists = useComposerDraftStore((store) =>
2833 threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
......@@ -36,7 +41,13 @@ function ChatThreadRouteView() {
3641 }
3742 return store.hasDraftThreadsInEnvironment(threadRef.environmentId);
3843 });
39 const routeThreadExists = threadExists || draftThreadExists;
44 const renderState = resolveThreadRouteRenderState({
45 bootstrapComplete,
46 serverThreadShellExists: serverThreadShell !== null,
47 serverThreadDetailExists: serverThreadDetail !== null,
48 serverThreadDetailDeleted: serverThreadStatus === "deleted",
49 draftThreadExists,
50 });
4051 const serverThreadStarted = threadHasStarted(serverThreadDetail);
4152 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
4253
......@@ -45,10 +56,10 @@ function ChatThreadRouteView() {
4556 return;
4657 }
4758
48 if (!routeThreadExists && environmentHasAnyThreads) {
59 if (renderState === "missing" && environmentHasAnyThreads) {
4960 void navigate({ to: "/", replace: true });
5061 }
51 }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]);
62 }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]);
5263
5364 useEffect(() => {
5465 if (!threadRef || !serverThreadStarted || !draftThread) {
......@@ -57,7 +68,7 @@ function ChatThreadRouteView() {
5768 finalizePromotedDraftThreadByRef(threadRef);
5869 }, [draftThread, serverThreadStarted, threadRef]);
5970
60 if (!threadRef || !bootstrapComplete || !routeThreadExists) {
71 if (!threadRef || renderState !== "ready") {
6172 return null;
6273 }
6374
apps/web/src/state/entities.ts+13-1
......@@ -4,7 +4,10 @@ import type {
44 EnvironmentThread,
55 EnvironmentThreadShell,
66} from "@t3tools/client-runtime/state/shell";
7import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads";
7import {
8 type EnvironmentThreadStatus,
9 mergeEnvironmentThread,
10} from "@t3tools/client-runtime/state/threads";
811import type {
912 OrchestrationMessage,
1013 OrchestrationProposedPlan,
......@@ -44,6 +47,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
4447const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
4548 Atom.withLabel("web-thread-detail:empty"),
4649);
50const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pipe(
51 Atom.withLabel("web-thread-status:empty"),
52);
4753const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe(
4854 Atom.withLabel("web-thread-messages:empty"),
4955);
......@@ -140,6 +146,12 @@ export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread
140146 );
141147}
142148
149export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus {
150 return useAtomValue(
151 ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref),
152 );
153}
154
143155/** Detail collections composed with shell-authoritative thread/workspace metadata. */
144156export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null {
145157 const shell = useThreadShell(ref);
apps/web/src/threadRoutes.test.ts+67
......@@ -7,6 +7,7 @@ import {
77 buildDraftThreadRouteParams,
88 buildThreadRouteParams,
99 resolveActiveThreadRouteRef,
10 resolveThreadRouteRenderState,
1011 resolveThreadRouteRef,
1112 resolveThreadRouteTarget,
1213} from "./threadRoutes";
......@@ -92,4 +93,70 @@ describe("threadRoutes", () => {
9293 }),
9394 ).toBeNull();
9495 });
96
97 it("keeps shell-only server threads in the loading state", () => {
98 expect(
99 resolveThreadRouteRenderState({
100 bootstrapComplete: true,
101 serverThreadShellExists: true,
102 serverThreadDetailExists: false,
103 serverThreadDetailDeleted: false,
104 draftThreadExists: false,
105 }),
106 ).toBe("loading");
107 });
108
109 it("renders server details and local drafts when they are ready", () => {
110 expect(
111 resolveThreadRouteRenderState({
112 bootstrapComplete: true,
113 serverThreadShellExists: true,
114 serverThreadDetailExists: true,
115 serverThreadDetailDeleted: false,
116 draftThreadExists: false,
117 }),
118 ).toBe("ready");
119 expect(
120 resolveThreadRouteRenderState({
121 bootstrapComplete: true,
122 serverThreadShellExists: false,
123 serverThreadDetailExists: false,
124 serverThreadDetailDeleted: false,
125 draftThreadExists: true,
126 }),
127 ).toBe("ready");
128 });
129
130 it("distinguishes bootstrap loading from a missing thread", () => {
131 expect(
132 resolveThreadRouteRenderState({
133 bootstrapComplete: false,
134 serverThreadShellExists: false,
135 serverThreadDetailExists: false,
136 serverThreadDetailDeleted: false,
137 draftThreadExists: false,
138 }),
139 ).toBe("loading");
140 expect(
141 resolveThreadRouteRenderState({
142 bootstrapComplete: true,
143 serverThreadShellExists: false,
144 serverThreadDetailExists: false,
145 serverThreadDetailDeleted: false,
146 draftThreadExists: false,
147 }),
148 ).toBe("missing");
149 });
150
151 it("redirects deleted shell-only threads", () => {
152 expect(
153 resolveThreadRouteRenderState({
154 bootstrapComplete: true,
155 serverThreadShellExists: true,
156 serverThreadDetailExists: false,
157 serverThreadDetailDeleted: true,
158 draftThreadExists: false,
159 }),
160 ).toBe("missing");
161 });
95162});
apps/web/src/threadRoutes.ts+21
......@@ -18,6 +18,27 @@ type DraftThreadRouteState = {
1818 promotedTo?: ScopedThreadRef | null;
1919};
2020
21export type ThreadRouteRenderState = "loading" | "ready" | "missing";
22
23export function resolveThreadRouteRenderState(input: {
24 bootstrapComplete: boolean;
25 serverThreadShellExists: boolean;
26 serverThreadDetailExists: boolean;
27 serverThreadDetailDeleted: boolean;
28 draftThreadExists: boolean;
29}): ThreadRouteRenderState {
30 if (!input.bootstrapComplete) {
31 return "loading";
32 }
33 if (input.serverThreadDetailExists || input.draftThreadExists) {
34 return "ready";
35 }
36 if (input.serverThreadDetailDeleted) {
37 return "missing";
38 }
39 return input.serverThreadShellExists ? "loading" : "missing";
40}
41
2142export function buildThreadRouteParams(ref: ScopedThreadRef): {
2243 environmentId: EnvironmentId;
2344 threadId: ThreadId;