1import { scopedThreadKey } from "@t3tools/client-runtime/environment";
2import type { ScopedThreadRef } from "@t3tools/contracts";
3import { useNavigate } from "@tanstack/react-router";
4import { useEffect, useState } from "react";
5
6import ChatView from "./ChatView";
7import { resolveDraftPromotionNavigationTarget, threadHasStarted } from "./ChatView.logic";
8import { waitForDraftHeroTransition } from "./chat/draftHeroTransition";
9import { SidebarInset } from "./ui/sidebar";
10import {
11 finalizePromotedDraftThreadByRef,
12 markPromotedDraftThreadByRef,
13 useBackgroundDraftSubmissionPending,
14 useComposerDraftStore,
15} from "../composerDraftStore";
16import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore";
17import { useEnvironmentThreadRefs, useThreadRefs, useThreadShell } from "../state/entities";
18import { useEnvironmentQuery } from "../state/query";
19import { environmentShell } from "../state/shell";
20import {
21 buildThreadRouteParams,
22 isThreadRouteSnapshotAuthoritative,
23 resolveThreadRouteRenderState,
24 type ThreadRouteTarget,
25} from "../threadRoutes";
26
27/**
28 * The single chat surface behind both `/draft/$draftId` and
29 * `/$environmentId/$threadId`. Each draft gets its own ChatView instance (so
30 * a background send's state stays with the draft it came from), and that
31 * instance carries the draft through its promotion to a server thread: the
32 * thread route keeps keying by the draft id while the draft record exists,
33 * so the route swap only changes props and the timeline never paints an
34 * empty frame. Plain server threads are unkeyed, so navigating between them
35 * reuses one instance as ChatView expects.
36 *
37 * Rendered by the `_chat` layout rather than by the two leaf routes, since
38 * an element only survives a route swap when the same parent renders it.
39 */
40export function ThreadRouteView({ target }: { target: ThreadRouteTarget }) {
41 const navigate = useNavigate();
42 const draftId = target.kind === "draft" ? target.draftId : null;
43 const draftSession = useComposerDraftStore((store) =>
44 draftId === null ? null : store.getDraftSession(draftId),
45 );
46 const threadRefs = useThreadRefs();
47 // The server thread this view is about: the route's own ref, or the draft's
48 // reserved ref once the server knows it.
49 const inferredThreadRef = draftSession
50 ? (threadRefs.find(
51 (ref) =>
52 ref.environmentId === draftSession.environmentId &&
53 ref.threadId === draftSession.threadId,
54 ) ?? null)
55 : null;
56 const serverThreadRef: ScopedThreadRef | null =
57 target.kind === "server" ? target.threadRef : (draftSession?.promotedTo ?? inferredThreadRef);
58 const serverThread = useThreadShell(serverThreadRef);
59 const backgroundSubmissionPending = useBackgroundDraftSubmissionPending(
60 target.kind === "draft" ? serverThreadRef : null,
61 );
62 const canonicalThreadRef =
63 target.kind === "draft"
64 ? resolveDraftPromotionNavigationTarget({
65 serverThreadRef,
66 serverThread,
67 backgroundSubmissionPending,
68 })
69 : null;
70
71 const shell = useEnvironmentQuery(
72 serverThreadRef === null ? null : environmentShell.stateAtom(serverThreadRef.environmentId),
73 );
74 const serverThreadShell = serverThread;
75 const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null);
76 const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shell.data?.status);
77 const draftThread = useComposerDraftStore((store) =>
78 serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null,
79 );
80 const promotedDraftId = useComposerDraftStore((store) =>
81 target.kind === "server" ? store.getDraftIdByRef(target.threadRef) : null,
82 );
83 // The draft record is removed once the promoted thread has started, which
84 // is after the route swap. Latch the key so the element that carried the
85 // draft keeps its identity for as long as this thread stays on screen.
86 const [chatViewKey, setChatViewKey] = useState<{ threadKey: string; key: string } | null>(null);
87 const serverThreadKey = target.kind === "server" ? scopedThreadKey(target.threadRef) : null;
88 const nextChatViewKey =
89 serverThreadKey === null
90 ? null
91 : chatViewKey?.threadKey === serverThreadKey
92 ? chatViewKey
93 : promotedDraftId
94 ? { threadKey: serverThreadKey, key: promotedDraftId }
95 : null;
96 if (nextChatViewKey !== chatViewKey) {
97 setChatViewKey(nextChatViewKey);
98 }
99 const environmentHasDraftThreads = useComposerDraftStore((store) =>
100 serverThreadRef ? store.hasDraftThreadsInEnvironment(serverThreadRef.environmentId) : false,
101 );
102 const renderState = resolveThreadRouteRenderState({
103 bootstrapComplete,
104 serverThreadExists: serverThreadShell !== null,
105 serverThreadDeleted: serverThreadShell?.deletedAt != null,
106 draftThreadExists: draftThread !== null,
107 });
108 const serverThreadStarted = threadHasStarted(serverThreadShell);
109 const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads;
110
111 useEffect(() => {
112 if (!inferredThreadRef || draftSession?.promotedTo) {
113 return;
114 }
115 markPromotedDraftThreadByRef(inferredThreadRef);
116 }, [draftSession?.promotedTo, inferredThreadRef]);
117
118 useEffect(() => {
119 if (!canonicalThreadRef) {
120 return;
121 }
122 let cancelled = false;
123 void waitForDraftHeroTransition().then(() => {
124 if (cancelled) {
125 return;
126 }
127 void navigate({
128 to: "/$environmentId/$threadId",
129 params: buildThreadRouteParams(canonicalThreadRef),
130 replace: true,
131 });
132 });
133 return () => {
134 cancelled = true;
135 };
136 }, [canonicalThreadRef, navigate]);
137
138 useEffect(() => {
139 if (target.kind !== "draft" || draftSession || canonicalThreadRef) {
140 return;
141 }
142 void navigate({ to: "/", replace: true });
143 }, [canonicalThreadRef, draftSession, navigate, target.kind]);
144
145 useEffect(() => {
146 if (target.kind !== "server" || !bootstrapComplete) {
147 return;
148 }
149 // Navigation already resolved onto this path, so a drop aimed here
150 // passed its landing check; once the thread reads as missing it can
151 // never be attached, release it even when there is nowhere to redirect.
152 if (renderState === "missing") {
153 const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState();
154 clearPendingFileDropsForThread(target.threadRef);
155 if (environmentHasAnyThreads) {
156 void navigate({ to: "/", replace: true });
157 }
158 }
159 }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]);
160
161 useEffect(() => {
162 if (target.kind !== "server" || !serverThreadStarted || !draftThread) {
163 return;
164 }
165 finalizePromotedDraftThreadByRef(target.threadRef);
166 }, [draftThread, serverThreadStarted, target]);
167
168 let view: React.ReactNode = null;
169 if (target.kind === "draft") {
170 if (draftSession) {
171 view = (
172 <ChatView
173 key={target.draftId}
174 draftId={target.draftId}
175 environmentId={draftSession.environmentId}
176 threadId={draftSession.threadId}
177 routeKind="draft"
178 forceExpandedMobileComposer
179 />
180 );
181 }
182 } else if (renderState === "ready" || (renderState === "loading" && serverThreadShell !== null)) {
183 view = (
184 <ChatView
185 key={nextChatViewKey?.key}
186 environmentId={target.threadRef.environmentId}
187 threadId={target.threadRef.threadId}
188 routeKind="server"
189 />
190 );
191 }
192
193 return (
194 <SidebarInset className="h-svh min-h-0 overflow-hidden overscroll-y-none md:h-dvh">
195 {view}
196 </SidebarInset>
197 );
198}