authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 20:19:15-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-18 23:54:48-08:00
loged9f561dc3c75f65acb9bd60174afbbbfc4c3652
tree41279b43505b5fdd51fed169a4f2bc9c23c964ff
parent7de054fa4964e5cc6c406f39551dc62a2c214b38

Adopt TanStack Router for thread-based chat navigation

- add route-based chat layout with index and `/$threadId` views - navigate sidebar thread actions through router instead of store-only selection - extract shared thread creation into `threadFactory` and wire new router deps

10 files changed, 457 insertions(+), 309 deletions(-)

apps/web/package.json+1
......@@ -16,6 +16,7 @@
1616 "@t3tools/contracts": "workspace:*",
1717 "@tanstack/react-pacer": "^0.19.4",
1818 "@tanstack/react-query": "^5.90.0",
19 "@tanstack/react-router": "^1.160.2",
1920 "@xterm/addon-fit": "^0.11.0",
2021 "@xterm/xterm": "^6.0.0",
2122 "class-variance-authority": "^0.7.1",
apps/web/src/App.tsx+5-261
......@@ -1,265 +1,9 @@
1import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
2import { Activity, Suspense, lazy, useEffect, useRef } from "react";
1import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2import { RouterProvider } from "@tanstack/react-router";
33
4import ChatView from "./components/ChatView";
5import Sidebar from "./components/Sidebar";
6import { isElectron } from "./env";
7import { DEFAULT_MODEL } from "./model-logic";
8import { StoreProvider, useStore } from "./store";
9import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
10import { onServerWelcome } from "./wsNativeApi";
11import { useNativeApi } from "./hooks/useNativeApi";
12import { useMediaQuery } from "./hooks/useMediaQuery";
134import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14import { Sheet, SheetPopup } from "./components/ui/sheet";
15import { invalidateGitQueries } from "./lib/gitReactQuery";
16
17const DiffPanel = lazy(() => import("./components/DiffPanel"));
18const DiffWorkerPoolProvider = lazy(() =>
19 import("./components/DiffPanel").then((module) => ({
20 default: module.DiffWorkerPoolProvider,
21 })),
22);
23const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
24
25const DiffPanelWrapper = (props: { children: React.ReactNode; sheet: boolean }) => {
26 const { state, dispatch } = useStore();
27 if (props.sheet) {
28 return (
29 <Sheet
30 open={state.diffOpen}
31 onOpenChange={(open) => {
32 if (!open) {
33 dispatch({ type: "CLOSE_DIFF" });
34 }
35 }}
36 >
37 <SheetPopup
38 side="right"
39 showCloseButton={false}
40 keepMounted
41 className="w-[min(88vw,820px)] max-w-[820px] p-0"
42 >
43 {props.children}
44 </SheetPopup>
45 </Sheet>
46 );
47 }
48
49 return (
50 <aside className={state.diffOpen ? undefined : "hidden"} aria-hidden={!state.diffOpen}>
51 {props.children}
52 </aside>
53 );
54};
55
56function EventRouter() {
57 const api = useNativeApi();
58 const { dispatch } = useStore();
59 const queryClient = useQueryClient();
60 const activeAssistantItemRef = useRef<string | null>(null);
61
62 useEffect(() => {
63 if (!api) return;
64 return api.providers.onEvent((event) => {
65 if (event.method === "turn/completed") {
66 void invalidateGitQueries(queryClient);
67 }
68 dispatch({
69 type: "APPLY_EVENT",
70 event,
71 activeAssistantItemRef,
72 });
73 });
74 }, [api, dispatch, queryClient]);
75
76 useEffect(() => {
77 if (!api) return;
78 return api.terminal.onEvent((event) => {
79 dispatch({
80 type: "APPLY_TERMINAL_EVENT",
81 event,
82 });
83 });
84 }, [api, dispatch]);
85
86 return null;
87}
88
89function AutoProjectBootstrap() {
90 const { state, dispatch } = useStore();
91 const bootstrappedRef = useRef(false);
92
93 useEffect(() => {
94 // Browser mode bootstraps from server welcome.
95 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
96 if (isElectron) return;
97
98 return onServerWelcome((payload) => {
99 if (bootstrappedRef.current) return;
100
101 // Don't create duplicate projects for the same cwd
102 const existing = state.projects.find((p) => p.cwd === payload.cwd);
103 if (existing) {
104 bootstrappedRef.current = true;
105 // Ensure a thread is active
106 const existingThread = state.threads.find((t) => t.projectId === existing.id);
107 if (existingThread && !state.activeThreadId) {
108 dispatch({
109 type: "SET_ACTIVE_THREAD",
110 threadId: existingThread.id,
111 });
112 }
113 return;
114 }
115
116 bootstrappedRef.current = true;
117
118 // Create project + thread from server cwd
119 const projectId = crypto.randomUUID();
120 dispatch({
121 type: "ADD_PROJECT",
122 project: {
123 id: projectId,
124 name: payload.projectName,
125 cwd: payload.cwd,
126 model: DEFAULT_MODEL,
127 expanded: true,
128 scripts: [],
129 },
130 });
131 dispatch({
132 type: "ADD_THREAD",
133 thread: {
134 id: crypto.randomUUID(),
135 codexThreadId: null,
136 projectId,
137 title: "New thread",
138 model: DEFAULT_MODEL,
139 terminalOpen: false,
140 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
141 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
142 runningTerminalIds: [],
143 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
144 terminalGroups: [
145 {
146 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
147 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
148 },
149 ],
150 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
151 session: null,
152 messages: [],
153 events: [],
154 turnDiffSummaries: [],
155 error: null,
156 createdAt: new Date().toISOString(),
157 branch: null,
158 worktreePath: null,
159 },
160 });
161 });
162 }, [state.projects, state.threads, state.activeThreadId, dispatch]);
163
164 return null;
165}
166
167function DesktopProjectBootstrap() {
168 const api = useNativeApi();
169 const { dispatch } = useStore();
170 const bootstrappedRef = useRef(false);
171
172 useEffect(() => {
173 if (!isElectron || !api || bootstrappedRef.current) return;
174
175 let disposed = false;
176 let retryDelayMs = 500;
177 let retryTimer: ReturnType<typeof setTimeout> | null = null;
178
179 const attemptBootstrap = async () => {
180 try {
181 const projects = await api.projects.list();
182 if (disposed) return;
183 dispatch({
184 type: "SYNC_PROJECTS",
185 projects: projects.map((project) => ({
186 id: project.id,
187 name: project.name,
188 cwd: project.cwd,
189 model: DEFAULT_MODEL,
190 expanded: true,
191 scripts: project.scripts,
192 })),
193 });
194 bootstrappedRef.current = true;
195 } catch {
196 if (disposed) return;
197 retryTimer = setTimeout(() => {
198 retryTimer = null;
199 void attemptBootstrap();
200 }, retryDelayMs);
201 retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
202 }
203 };
204
205 void attemptBootstrap();
206
207 return () => {
208 disposed = true;
209 if (retryTimer) {
210 clearTimeout(retryTimer);
211 }
212 };
213 }, [api, dispatch]);
214
215 return null;
216}
217
218function Layout() {
219 const api = useNativeApi();
220 const { state } = useStore();
221 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
222
223 const diffLoadingFallback =
224 !state.diffOpen || shouldUseDiffSheet ? (
225 <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70">
226 Loading diff viewer...
227 </div>
228 ) : (
229 <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70">
230 Loading diff viewer...
231 </aside>
232 );
233
234 if (!api) {
235 return (
236 <div className="flex h-screen flex-col bg-background text-foreground">
237 <div className="flex flex-1 items-center justify-center">
238 <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p>
239 </div>
240 </div>
241 );
242 }
243
244 return (
245 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
246 <EventRouter />
247 <AutoProjectBootstrap />
248 <DesktopProjectBootstrap />
249 <Sidebar />
250 <ChatView />
251 <Activity mode={state.diffOpen ? "visible" : "hidden"}>
252 <DiffPanelWrapper sheet={shouldUseDiffSheet}>
253 <Suspense fallback={diffLoadingFallback}>
254 <DiffWorkerPoolProvider>
255 <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} />
256 </DiffWorkerPoolProvider>
257 </Suspense>
258 </DiffPanelWrapper>
259 </Activity>
260 </div>
261 );
262}
5import { router } from "./router";
6import { StoreProvider } from "./store";
2637
2648const queryClient = new QueryClient();
2659
......@@ -269,7 +13,7 @@ export default function App() {
26913 <StoreProvider>
27014 <ToastProvider>
27115 <AnchoredToastProvider>
272 <Layout />
16 <RouterProvider router={router} />
27317 </AnchoredToastProvider>
27418 </ToastProvider>
27519 </StoreProvider>
apps/web/src/components/ChatView.tsx+6-2
......@@ -337,7 +337,11 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
337337 );
338338});
339339
340export default function ChatView() {
340interface ChatViewProps {
341 threadId: string;
342}
343
344export default function ChatView({ threadId }: ChatViewProps) {
341345 const { state, dispatch } = useStore();
342346 const api = useNativeApi();
343347 const { resolvedTheme } = useTheme();
......@@ -375,7 +379,7 @@ export default function ChatView() {
375379 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
376380 const checkpointHydrationSessionRequestRef = useRef(new Set<string>());
377381
378 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
382 const activeThread = state.threads.find((t) => t.id === threadId);
379383 const activeThreadId = activeThread?.id ?? null;
380384 const activeSessionId = activeThread?.session?.sessionId ?? null;
381385 const activeThreadRuntimeId =
apps/web/src/components/Sidebar.tsx+42-46
......@@ -2,23 +2,20 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
44import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5import { useNavigate, useParams } from "@tanstack/react-router";
56import { isElectron } from "../env";
67import { useTheme } from "../hooks/useTheme";
78import { DEFAULT_MODEL } from "../model-logic";
89import { derivePendingApprovals } from "../session-logic";
910import { useStore } from "../store";
1011import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
11import {
12 DEFAULT_THREAD_TERMINAL_HEIGHT,
13 DEFAULT_THREAD_TERMINAL_ID,
14 type Project,
15 type Thread,
16} from "../types";
12import { type Project, type Thread } from "../types";
1713import { useNativeApi } from "../hooks/useNativeApi";
1814import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
1915import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2016import { toastManager } from "./ui/toast";
2117import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
18import { createThread } from "../threadFactory";
2219
2320const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
2421const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
......@@ -118,6 +115,9 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
118115export default function Sidebar() {
119116 const { state, dispatch } = useStore();
120117 const api = useNativeApi();
118 const navigate = useNavigate();
119 const params = useParams({ strict: false });
120 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
121121 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
122122 ...serverConfigQueryOptions(api),
123123 select: (config) => config.keybindings,
......@@ -147,38 +147,21 @@ export default function Sidebar() {
147147 worktreePath?: string | null;
148148 },
149149 ) => {
150 const thread = createThread(projectId, {
151 model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL,
152 branch: options?.branch ?? null,
153 worktreePath: options?.worktreePath ?? null,
154 });
150155 dispatch({
151156 type: "ADD_THREAD",
152 thread: {
153 id: crypto.randomUUID(),
154 codexThreadId: null,
155 projectId,
156 title: "New thread",
157 model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL,
158 terminalOpen: false,
159 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
160 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
161 runningTerminalIds: [],
162 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
163 terminalGroups: [
164 {
165 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
166 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
167 },
168 ],
169 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
170 session: null,
171 messages: [],
172 events: [],
173 turnDiffSummaries: [],
174 error: null,
175 createdAt: new Date().toISOString(),
176 branch: options?.branch ?? null,
177 worktreePath: options?.worktreePath ?? null,
178 },
157 thread,
158 });
159 void navigate({
160 to: "/$threadId",
161 params: { threadId: thread.id },
179162 });
180163 },
181 [dispatch, state.projects],
164 [dispatch, navigate, state.projects],
182165 );
183166
184167 const focusMostRecentThreadForProject = useCallback(
......@@ -192,12 +175,12 @@ export default function Sidebar() {
192175 })[0];
193176 if (!latestThread) return;
194177
195 dispatch({
196 type: "SET_ACTIVE_THREAD",
197 threadId: latestThread.id,
178 void navigate({
179 to: "/$threadId",
180 params: { threadId: latestThread.id },
198181 });
199182 },
200 [dispatch, state.threads],
183 [navigate, state.threads],
201184 );
202185
203186 const addProjectFromPath = useCallback(
......@@ -325,7 +308,20 @@ export default function Sidebar() {
325308 // Terminal may already be closed
326309 }
327310
311 const shouldNavigateToFallback = routeThreadId === threadId;
312 const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null;
328313 dispatch({ type: "DELETE_THREAD", threadId });
314 if (shouldNavigateToFallback) {
315 if (fallbackThreadId) {
316 void navigate({
317 to: "/$threadId",
318 params: { threadId: fallbackThreadId },
319 replace: true,
320 });
321 } else {
322 void navigate({ to: "/", replace: true });
323 }
324 }
329325
330326 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
331327 return;
......@@ -351,7 +347,7 @@ export default function Sidebar() {
351347 });
352348 }
353349 },
354 [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
350 [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads],
355351 );
356352
357353 const handleProjectContextMenu = useCallback(
......@@ -509,7 +505,7 @@ export default function Sidebar() {
509505 {project.expanded && (
510506 <div className="ml-2 border-l border-border/80 pl-2">
511507 {threads.map((thread) => {
512 const isActive = state.activeThreadId === thread.id;
508 const isActive = routeThreadId === thread.id;
513509 const threadStatus = threadStatusPill(
514510 thread,
515511 pendingApprovalByThreadId.get(thread.id) === true,
......@@ -524,12 +520,12 @@ export default function Sidebar() {
524520 ? "bg-accent text-foreground"
525521 : "text-muted-foreground hover:bg-secondary"
526522 }`}
527 onClick={() =>
528 dispatch({
529 type: "SET_ACTIVE_THREAD",
530 threadId: thread.id,
531 })
532 }
523 onClick={() => {
524 void navigate({
525 to: "/$threadId",
526 params: { threadId: thread.id },
527 });
528 }}
533529 onContextMenu={(e) => {
534530 e.preventDefault();
535531 void handleThreadContextMenu(thread.id, {
apps/web/src/router.tsx created+37
......@@ -0,0 +1,37 @@
1import { Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
2
3import { ChatThreadRouteView } from "./routes/_chat.$threadId";
4import { ChatIndexRouteView } from "./routes/_chat.index";
5import { ChatRouteLayout } from "./routes/_chat";
6
7const rootRoute = createRootRoute({
8 component: Outlet,
9});
10
11const chatRoute = createRoute({
12 getParentRoute: () => rootRoute,
13 path: "/",
14 component: ChatRouteLayout,
15});
16
17const chatIndexRoute = createRoute({
18 getParentRoute: () => chatRoute,
19 path: "/",
20 component: ChatIndexRouteView,
21});
22
23const chatThreadRoute = createRoute({
24 getParentRoute: () => chatRoute,
25 path: "$threadId",
26 component: ChatThreadRouteView,
27});
28
29const routeTree = rootRoute.addChildren([chatRoute.addChildren([chatIndexRoute, chatThreadRoute])]);
30
31export const router = createRouter({ routeTree });
32
33declare module "@tanstack/react-router" {
34 interface Register {
35 router: typeof router;
36 }
37}
apps/web/src/routes/_chat.$threadId.tsx created+37
......@@ -0,0 +1,37 @@
1import { useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect } from "react";
3
4import ChatView from "../components/ChatView";
5import { useStore } from "../store";
6
7export function ChatThreadRouteView() {
8 const { state, dispatch } = useStore();
9 const navigate = useNavigate();
10 const params = useParams({ strict: false });
11 const threadId = typeof params.threadId === "string" ? params.threadId : null;
12 const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false;
13
14 useEffect(() => {
15 if (!threadId) {
16 void navigate({ to: "/", replace: true });
17 return;
18 }
19
20 if (!threadExists) {
21 void navigate({ to: "/", replace: true });
22 return;
23 }
24
25 if (state.activeThreadId === threadId) {
26 return;
27 }
28
29 dispatch({ type: "SET_ACTIVE_THREAD", threadId });
30 }, [dispatch, navigate, state.activeThreadId, threadExists, threadId]);
31
32 if (!threadId || !threadExists) {
33 return null;
34 }
35
36 return <ChatView threadId={threadId} />;
37}
apps/web/src/routes/_chat.index.tsx created+84
......@@ -0,0 +1,84 @@
1import { useNavigate } from "@tanstack/react-router";
2import { type FormEvent, useMemo, useState } from "react";
3
4import { Button } from "../components/ui/button";
5import { Textarea } from "../components/ui/textarea";
6import { createThread } from "../threadFactory";
7import { useStore } from "../store";
8
9export function ChatIndexRouteView() {
10 const { state, dispatch } = useStore();
11 const navigate = useNavigate();
12 const [draft, setDraft] = useState("");
13 const defaultProject = state.projects[0] ?? null;
14 const canCreateThread = defaultProject !== null;
15
16 const placeholder = useMemo(() => {
17 if (!canCreateThread) {
18 return "Add a project in the sidebar to start chatting.";
19 }
20 return "Start with a goal, bug report, or implementation idea...";
21 }, [canCreateThread]);
22
23 const onCreateThread = (event: FormEvent<HTMLFormElement>) => {
24 event.preventDefault();
25 if (!defaultProject) return;
26
27 const titleSeed = draft.trim();
28 const threadTitle =
29 titleSeed.length > 0
30 ? (titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed)
31 : undefined;
32 const thread = createThread(
33 defaultProject.id,
34 threadTitle
35 ? { model: defaultProject.model, title: threadTitle }
36 : { model: defaultProject.model },
37 );
38
39 dispatch({
40 type: "ADD_THREAD",
41 thread,
42 });
43
44 setDraft("");
45
46 void navigate({
47 to: "/$threadId",
48 params: { threadId: thread.id },
49 });
50 };
51
52 return (
53 <section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
54 <div className="mx-auto flex h-full w-full max-w-3xl flex-col justify-center px-5 py-6">
55 <div className="mb-5">
56 <h1 className="text-xl font-semibold text-foreground">New chat</h1>
57 <p className="mt-1 text-sm text-muted-foreground/70">
58 Create a thread and continue in the full chat workspace.
59 </p>
60 </div>
61
62 <form onSubmit={onCreateThread} className="space-y-3">
63 <Textarea
64 value={draft}
65 onChange={(event) => setDraft(event.target.value)}
66 placeholder={placeholder}
67 className="min-h-36 resize-y"
68 disabled={!canCreateThread}
69 />
70 <div className="flex items-center justify-between">
71 <p className="text-xs text-muted-foreground/60">
72 {canCreateThread
73 ? "Create a thread first, then send your first prompt."
74 : "Projects are required before creating threads."}
75 </p>
76 <Button type="submit" disabled={!canCreateThread}>
77 Create thread
78 </Button>
79 </div>
80 </form>
81 </div>
82 </section>
83 );
84}
apps/web/src/routes/_chat.tsx created+185
......@@ -0,0 +1,185 @@
1import { Outlet, useNavigate, useParams } from "@tanstack/react-router";
2import { useEffect, useRef } from "react";
3import { useQueryClient } from "@tanstack/react-query";
4
5import DiffPanel from "../components/DiffPanel";
6import Sidebar from "../components/Sidebar";
7import { isElectron } from "../env";
8import { useNativeApi } from "../hooks/useNativeApi";
9import { invalidateGitQueries } from "../lib/gitReactQuery";
10import { DEFAULT_MODEL } from "../model-logic";
11import { createThread } from "../threadFactory";
12import { useStore } from "../store";
13import { onServerWelcome } from "../wsNativeApi";
14
15function EventRouter() {
16 const api = useNativeApi();
17 const { dispatch } = useStore();
18 const queryClient = useQueryClient();
19 const activeAssistantItemRef = useRef<string | null>(null);
20
21 useEffect(() => {
22 if (!api) return;
23 return api.providers.onEvent((event) => {
24 if (event.method === "turn/completed") {
25 void invalidateGitQueries(queryClient);
26 }
27 dispatch({
28 type: "APPLY_EVENT",
29 event,
30 activeAssistantItemRef,
31 });
32 });
33 }, [api, dispatch, queryClient]);
34
35 useEffect(() => {
36 if (!api) return;
37 return api.terminal.onEvent((event) => {
38 dispatch({
39 type: "APPLY_TERMINAL_EVENT",
40 event,
41 });
42 });
43 }, [api, dispatch]);
44
45 return null;
46}
47
48function AutoProjectBootstrap() {
49 const { state, dispatch } = useStore();
50 const bootstrappedRef = useRef(false);
51
52 useEffect(() => {
53 // Browser mode bootstraps from server welcome.
54 // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
55 if (isElectron) return;
56
57 return onServerWelcome((payload) => {
58 if (bootstrappedRef.current) return;
59
60 // Don't create duplicate projects for the same cwd
61 const existing = state.projects.find((project) => project.cwd === payload.cwd);
62 if (existing) {
63 bootstrappedRef.current = true;
64 // Ensure a thread is active
65 const existingThread = state.threads.find((thread) => thread.projectId === existing.id);
66 if (existingThread && !state.activeThreadId) {
67 dispatch({
68 type: "SET_ACTIVE_THREAD",
69 threadId: existingThread.id,
70 });
71 }
72 return;
73 }
74
75 bootstrappedRef.current = true;
76
77 // Create project + thread from server cwd
78 const projectId = crypto.randomUUID();
79 dispatch({
80 type: "ADD_PROJECT",
81 project: {
82 id: projectId,
83 name: payload.projectName,
84 cwd: payload.cwd,
85 model: DEFAULT_MODEL,
86 expanded: true,
87 },
88 });
89 dispatch({
90 type: "ADD_THREAD",
91 thread: createThread(projectId),
92 });
93 });
94 }, [state.projects, state.threads, state.activeThreadId, dispatch]);
95
96 return null;
97}
98
99function DesktopProjectBootstrap() {
100 const api = useNativeApi();
101 const { dispatch } = useStore();
102 const bootstrappedRef = useRef(false);
103
104 useEffect(() => {
105 if (!isElectron || !api || bootstrappedRef.current) return;
106
107 let disposed = false;
108 let retryDelayMs = 500;
109 let retryTimer: ReturnType<typeof setTimeout> | null = null;
110
111 const attemptBootstrap = async () => {
112 try {
113 const projects = await api.projects.list();
114 if (disposed) return;
115 dispatch({
116 type: "SYNC_PROJECTS",
117 projects: projects.map((project) => ({
118 id: project.id,
119 name: project.name,
120 cwd: project.cwd,
121 model: DEFAULT_MODEL,
122 expanded: true,
123 })),
124 });
125 bootstrappedRef.current = true;
126 } catch {
127 if (disposed) return;
128 retryTimer = setTimeout(() => {
129 retryTimer = null;
130 void attemptBootstrap();
131 }, retryDelayMs);
132 retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
133 }
134 };
135
136 void attemptBootstrap();
137
138 return () => {
139 disposed = true;
140 if (retryTimer) {
141 clearTimeout(retryTimer);
142 }
143 };
144 }, [api, dispatch]);
145
146 return null;
147}
148
149export function ChatRouteLayout() {
150 const api = useNativeApi();
151 const { state } = useStore();
152 const navigate = useNavigate();
153 const params = useParams({ strict: false });
154 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
155
156 useEffect(() => {
157 if (routeThreadId || !state.activeThreadId) return;
158 void navigate({
159 to: "/$threadId",
160 params: { threadId: state.activeThreadId },
161 replace: true,
162 });
163 }, [navigate, routeThreadId, state.activeThreadId]);
164
165 if (!api) {
166 return (
167 <div className="flex h-screen flex-col bg-background text-foreground">
168 <div className="flex flex-1 items-center justify-center">
169 <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p>
170 </div>
171 </div>
172 );
173 }
174
175 return (
176 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
177 <EventRouter />
178 <AutoProjectBootstrap />
179 <DesktopProjectBootstrap />
180 <Sidebar />
181 <Outlet />
182 {state.diffOpen && <DiffPanel />}
183 </div>
184 );
185}
apps/web/src/threadFactory.ts created+41
......@@ -0,0 +1,41 @@
1import { DEFAULT_MODEL } from "./model-logic";
2import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
3
4interface CreateThreadOptions {
5 branch?: string | null;
6 createdAt?: string;
7 model?: string;
8 title?: string;
9 worktreePath?: string | null;
10}
11
12export function createThread(projectId: string, options: CreateThreadOptions = {}): Thread {
13 const createdAt = options.createdAt ?? new Date().toISOString();
14
15 return {
16 id: crypto.randomUUID(),
17 codexThreadId: null,
18 projectId,
19 title: options.title ?? "New thread",
20 model: options.model ?? DEFAULT_MODEL,
21 terminalOpen: false,
22 terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
23 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
24 runningTerminalIds: [],
25 activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
26 terminalGroups: [
27 {
28 id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
29 terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
30 },
31 ],
32 activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
33 session: null,
34 messages: [],
35 events: [],
36 error: null,
37 createdAt,
38 branch: options.branch ?? null,
39 worktreePath: options.worktreePath ?? null,
40 };
41}
bun.lock+19
......@@ -56,6 +56,7 @@
5656 "@t3tools/contracts": "workspace:*",
5757 "@tanstack/react-pacer": "^0.19.4",
5858 "@tanstack/react-query": "^5.90.0",
59 "@tanstack/react-router": "^1.160.2",
5960 "@xterm/addon-fit": "^0.11.0",
6061 "@xterm/xterm": "^6.0.0",
6162 "class-variance-authority": "^0.7.1",
......@@ -447,6 +448,8 @@
447448
448449 "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
449450
451 "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
452
450453 "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],
451454
452455 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
......@@ -455,8 +458,12 @@
455458
456459 "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
457460
461 "@tanstack/react-router": ["@tanstack/react-router@1.160.2", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.160.0", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-EJWAMS4qCfWKNCzzYGy6ZuWTdBATYEEWieaQdmM7zUesyOQ01j7o6aKXdmCp9rWuSKjPHXagWubEnEo+Puhi3w=="],
462
458463 "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="],
459464
465 "@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="],
466
460467 "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
461468
462469 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
......@@ -609,6 +616,8 @@
609616
610617 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
611618
619 "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
620
612621 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
613622
614623 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
......@@ -793,6 +802,8 @@
793802
794803 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
795804
805 "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
806
796807 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
797808
798809 "joi": ["joi@18.0.2", "", { "dependencies": { "@hapi/address": "^5.1.1", "@hapi/formula": "^3.0.2", "@hapi/hoek": "^11.0.7", "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", "@standard-schema/spec": "^1.0.0" } }, "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA=="],
......@@ -1083,6 +1094,10 @@
10831094
10841095 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
10851096
1097 "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
1098
1099 "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
1100
10861101 "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="],
10871102
10881103 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
......@@ -1129,6 +1144,10 @@
11291144
11301145 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
11311146
1147 "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
1148
1149 "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
1150
11321151 "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
11331152
11341153 "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],