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";...@@ -4,9 +4,14 @@ import { useEffect } from "react";
4import ChatView from "../components/ChatView";4import ChatView from "../components/ChatView";
5import { threadHasStarted } from "../components/ChatView.logic";5import { threadHasStarted } from "../components/ChatView.logic";
6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";6import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore";
7import { resolveThreadRouteRef } from "../threadRoutes";7import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes";
8import { SidebarInset } from "~/components/ui/sidebar";8import { 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";
10import { useEnvironmentQuery } from "../state/query";15import { useEnvironmentQuery } from "../state/query";
11import { environmentShell } from "../state/shell";16import { environmentShell } from "../state/shell";
1217
...@@ -20,9 +25,9 @@ function ChatThreadRouteView() {...@@ -20,9 +25,9 @@ function ChatThreadRouteView() {
20 );25 );
21 const serverThreadShell = useThreadShell(threadRef);26 const serverThreadShell = useThreadShell(threadRef);
22 const serverThreadDetail = useThreadDetail(threadRef);27 const serverThreadDetail = useThreadDetail(threadRef);
28 const serverThreadStatus = useThreadStatus(threadRef);
23 const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null);29 const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null);
24 const bootstrapComplete = shell.data?.snapshot._tag === "Some";30 const bootstrapComplete = shell.data?.snapshot._tag === "Some";
25 const threadExists = serverThreadShell !== null || serverThreadDetail !== null;
26 const environmentHasServerThreads = environmentThreadRefs.length > 0;31 const environmentHasServerThreads = environmentThreadRefs.length > 0;
27 const draftThreadExists = useComposerDraftStore((store) =>32 const draftThreadExists = useComposerDraftStore((store) =>
28 threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,33 threadRef ? store.getDraftThreadByRef(threadRef) !== null : false,
...@@ -36,7 +41,13 @@ function ChatThreadRouteView() {...@@ -36,7 +41,13 @@ function ChatThreadRouteView() {
36 }41 }
37 return store.hasDraftThreadsInEnvironment(threadRef.environmentId);42 return store.hasDraftThreadsInEnvironment(threadRef.environmentId);
38 });43 });
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 });
40 const serverThreadStarted = threadHasStarted(serverThreadDetail);51 const serverThreadStarted = threadHasStarted(serverThreadDetail);
41 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;52 const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads;
4253
...@@ -45,10 +56,10 @@ function ChatThreadRouteView() {...@@ -45,10 +56,10 @@ function ChatThreadRouteView() {
45 return;56 return;
46 }57 }
4758
48 if (!routeThreadExists && environmentHasAnyThreads) {59 if (renderState === "missing" && environmentHasAnyThreads) {
49 void navigate({ to: "/", replace: true });60 void navigate({ to: "/", replace: true });
50 }61 }
51 }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]);62 }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]);
5263
53 useEffect(() => {64 useEffect(() => {
54 if (!threadRef || !serverThreadStarted || !draftThread) {65 if (!threadRef || !serverThreadStarted || !draftThread) {
...@@ -57,7 +68,7 @@ function ChatThreadRouteView() {...@@ -57,7 +68,7 @@ function ChatThreadRouteView() {
57 finalizePromotedDraftThreadByRef(threadRef);68 finalizePromotedDraftThreadByRef(threadRef);
58 }, [draftThread, serverThreadStarted, threadRef]);69 }, [draftThread, serverThreadStarted, threadRef]);
5970
60 if (!threadRef || !bootstrapComplete || !routeThreadExists) {71 if (!threadRef || renderState !== "ready") {
61 return null;72 return null;
62 }73 }
6374
apps/web/src/state/entities.ts+13-1
...@@ -4,7 +4,10 @@ import type {...@@ -4,7 +4,10 @@ import type {
4 EnvironmentThread,4 EnvironmentThread,
5 EnvironmentThreadShell,5 EnvironmentThreadShell,
6} from "@t3tools/client-runtime/state/shell";6} 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";
8import type {11import type {
9 OrchestrationMessage,12 OrchestrationMessage,
10 OrchestrationProposedPlan,13 OrchestrationProposedPlan,
...@@ -44,6 +47,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p...@@ -44,6 +47,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make<EnvironmentThreadShell | null>(null).p
44const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(47const EMPTY_THREAD_DETAIL_ATOM = Atom.make<EnvironmentThread | null>(null).pipe(
45 Atom.withLabel("web-thread-detail:empty"),48 Atom.withLabel("web-thread-detail:empty"),
46);49);
50const EMPTY_THREAD_STATUS_ATOM = Atom.make<EnvironmentThreadStatus>("empty").pipe(
51 Atom.withLabel("web-thread-status:empty"),
52);
47const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe(53const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe(
48 Atom.withLabel("web-thread-messages:empty"),54 Atom.withLabel("web-thread-messages:empty"),
49);55);
...@@ -140,6 +146,12 @@ export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread...@@ -140,6 +146,12 @@ export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread
140 );146 );
141}147}
142148
149export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus {
150 return useAtomValue(
151 ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref),
152 );
153}
154
143/** Detail collections composed with shell-authoritative thread/workspace metadata. */155/** Detail collections composed with shell-authoritative thread/workspace metadata. */
144export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null {156export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null {
145 const shell = useThreadShell(ref);157 const shell = useThreadShell(ref);
apps/web/src/threadRoutes.test.ts+67
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 buildDraftThreadRouteParams,7 buildDraftThreadRouteParams,
8 buildThreadRouteParams,8 buildThreadRouteParams,
9 resolveActiveThreadRouteRef,9 resolveActiveThreadRouteRef,
10 resolveThreadRouteRenderState,
10 resolveThreadRouteRef,11 resolveThreadRouteRef,
11 resolveThreadRouteTarget,12 resolveThreadRouteTarget,
12} from "./threadRoutes";13} from "./threadRoutes";
...@@ -92,4 +93,70 @@ describe("threadRoutes", () => {...@@ -92,4 +93,70 @@ describe("threadRoutes", () => {
92 }),93 }),
93 ).toBeNull();94 ).toBeNull();
94 });95 });
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 });
95});162});
apps/web/src/threadRoutes.ts+21
...@@ -18,6 +18,27 @@ type DraftThreadRouteState = {...@@ -18,6 +18,27 @@ type DraftThreadRouteState = {
18 promotedTo?: ScopedThreadRef | null;18 promotedTo?: ScopedThreadRef | null;
19};19};
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
21export function buildThreadRouteParams(ref: ScopedThreadRef): {42export function buildThreadRouteParams(ref: ScopedThreadRef): {
22 environmentId: EnvironmentId;43 environmentId: EnvironmentId;
23 threadId: ThreadId;44 threadId: ThreadId;