History

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

Julius Marminge committed 8 months ago

Commit c79ef621 parent b236dcc10 files · +457 −247Browse files

Unified · Split

10 files
  1. apps/web/package.json +1 −0
  2. apps/web/src/App.tsx +5 −200
  3. apps/web/src/components/ChatView.tsx +6 −2
  4. apps/web/src/components/Sidebar.tsx +42 −45
  5. apps/web/src/router.tsx +37 −0
  6. apps/web/src/routes/_chat.$threadId.tsx +37 −0
  7. apps/web/src/routes/_chat.index.tsx +84 −0
  8. apps/web/src/routes/_chat.tsx +185 −0
  9. apps/web/src/threadFactory.ts +41 −0
  10. bun.lock +19 −0
apps/web/package.json+1−0
@@ -15,6 +15,7 @@ · 14 unmodified lines
1515 "@t3tools/contracts": "workspace:*",
1616 "@tanstack/react-pacer": "^0.19.4",
1717 "@tanstack/react-query": "^5.90.0",
18+ "@tanstack/react-router": "^1.160.2",
1819 "@xterm/addon-fit": "^0.11.0",
1920 "@xterm/xterm": "^6.0.0",
2021 "class-variance-authority": "^0.7.1",
apps/web/src/App.tsx+5−200
@@ -1,204 +1,9 @@
1−import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
2−import { useEffect, useRef } from "react";
1+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+import { RouterProvider } from "@tanstack/react-router";
33
4−import ChatView from "./components/ChatView";
5−import DiffPanel from "./components/DiffPanel";
6−import Sidebar from "./components/Sidebar";
7−import { isElectron } from "./env";
8−import { DEFAULT_MODEL } from "./model-logic";
9−import { StoreProvider, useStore } from "./store";
10−import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types";
11−import { onServerWelcome } from "./wsNativeApi";
12−import { useNativeApi } from "./hooks/useNativeApi";
134 import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
14−import { invalidateGitQueries } from "./lib/gitReactQuery";
15−
16−function EventRouter() {
17− const api = useNativeApi();
18− const { dispatch } = useStore();
19− const queryClient = useQueryClient();
20− const activeAssistantItemRef = useRef<string | null>(null);
21−
22− useEffect(() => {
23− if (!api) return;
24− return api.providers.onEvent((event) => {
25− if (event.method === "turn/completed") {
26− void invalidateGitQueries(queryClient);
27− }
28− dispatch({
29− type: "APPLY_EVENT",
30− event,
31− activeAssistantItemRef,
32− });
33− });
34− }, [api, dispatch, queryClient]);
35−
36− useEffect(() => {
37− if (!api) return;
38− return api.terminal.onEvent((event) => {
39− dispatch({
40− type: "APPLY_TERMINAL_EVENT",
41− event,
42− });
43− });
44− }, [api, dispatch]);
45−
46− return null;
47−}
48−
49−function AutoProjectBootstrap() {
50− const { state, dispatch } = useStore();
51− const bootstrappedRef = useRef(false);
52−
53− useEffect(() => {
54− // Browser mode bootstraps from server welcome.
55− // Electron bootstraps from persisted projects via DesktopProjectBootstrap.
56− if (isElectron) return;
57−
58− return onServerWelcome((payload) => {
59− if (bootstrappedRef.current) return;
60−
61− // Don't create duplicate projects for the same cwd
62− const existing = state.projects.find((p) => p.cwd === payload.cwd);
63− if (existing) {
64− bootstrappedRef.current = true;
65− // Ensure a thread is active
66− const existingThread = state.threads.find((t) => t.projectId === existing.id);
67− if (existingThread && !state.activeThreadId) {
68− dispatch({
69− type: "SET_ACTIVE_THREAD",
70− threadId: existingThread.id,
71− });
72− }
73− return;
74− }
75−
76− bootstrappedRef.current = true;
77−
78− // Create project + thread from server cwd
79− const projectId = crypto.randomUUID();
80− dispatch({
81− type: "ADD_PROJECT",
82− project: {
83− id: projectId,
84− name: payload.projectName,
85− cwd: payload.cwd,
86− model: DEFAULT_MODEL,
87− expanded: true,
88− scripts: [],
89− },
90− });
91− dispatch({
92− type: "ADD_THREAD",
93− thread: {
94− id: crypto.randomUUID(),
95− codexThreadId: null,
96− projectId,
97− title: "New thread",
98− model: DEFAULT_MODEL,
99− terminalOpen: false,
100− terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT,
101− terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
102− runningTerminalIds: [],
103− activeTerminalId: DEFAULT_THREAD_TERMINAL_ID,
104− terminalGroups: [
105− {
106− id: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
107− terminalIds: [DEFAULT_THREAD_TERMINAL_ID],
108− },
109− ],
110− activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`,
111− session: null,
112− messages: [],
113− events: [],
114− error: null,
115− createdAt: new Date().toISOString(),
116− branch: null,
117− worktreePath: null,
118− },
119− });
120− });
121− }, [state.projects, state.threads, state.activeThreadId, dispatch]);
122−
123− return null;
124−}
125−
126−function DesktopProjectBootstrap() {
127− const api = useNativeApi();
128− const { dispatch } = useStore();
129− const bootstrappedRef = useRef(false);
130−
131− useEffect(() => {
132− if (!isElectron || !api || bootstrappedRef.current) return;
133−
134− let disposed = false;
135− let retryDelayMs = 500;
136− let retryTimer: ReturnType<typeof setTimeout> | null = null;
137−
138− const attemptBootstrap = async () => {
139− try {
140− const projects = await api.projects.list();
141− if (disposed) return;
142− dispatch({
143− type: "SYNC_PROJECTS",
144− projects: projects.map((project) => ({
145− id: project.id,
146− name: project.name,
147− cwd: project.cwd,
148− model: DEFAULT_MODEL,
149− expanded: true,
150− scripts: project.scripts,
151− })),
152− });
153− bootstrappedRef.current = true;
154− } catch {
155− if (disposed) return;
156− retryTimer = setTimeout(() => {
157− retryTimer = null;
158− void attemptBootstrap();
159− }, retryDelayMs);
160− retryDelayMs = Math.min(retryDelayMs * 2, 5_000);
161− }
162− };
163−
164− void attemptBootstrap();
165−
166− return () => {
167− disposed = true;
168− if (retryTimer) {
169− clearTimeout(retryTimer);
170− }
171− };
172− }, [api, dispatch]);
173−
174− return null;
175−}
176−
177−function Layout() {
178− const api = useNativeApi();
179− const { state } = useStore();
180−
181− if (!api) {
182− return (
183− <div className="flex h-screen flex-col bg-background text-foreground">
184− <div className="flex flex-1 items-center justify-center">
185− <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p>
186− </div>
187− </div>
188− );
189− }
190−
191− return (
192− <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
193− <EventRouter />
194− <AutoProjectBootstrap />
195− <DesktopProjectBootstrap />
196− <Sidebar />
197− <ChatView />
198− {state.diffOpen && <DiffPanel />}
199− </div>
200− );
201−}
5+import { router } from "./router";
6+import { StoreProvider } from "./store";
2027
2038 const queryClient = new QueryClient();
2049
@@ -208,7 +13,7 @@ export default function App() { · 3 unmodified lines
20813 <StoreProvider>
20914 <ToastProvider>
21015 <AnchoredToastProvider>
211− <Layout />
16+ <RouterProvider router={router} />
21217 </AnchoredToastProvider>
21318 </ToastProvider>
21419 </StoreProvider>
apps/web/src/components/ChatView.tsx+6−2
@@ -330,7 +330,11 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { · 329 unmodified lines
330330 );
331331 });
332332
333−export default function ChatView() {
333+interface ChatViewProps {
334+ threadId: string;
335+}
336+
337+export default function ChatView({ threadId }: ChatViewProps) {
334338 const { state, dispatch } = useStore();
335339 const api = useNativeApi();
336340 const { resolvedTheme } = useTheme();
@@ -366,7 +370,7 @@ export default function ChatView() { · 29 unmodified lines
366370 const dragDepthRef = useRef(0);
367371 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
368372
369− const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
373+ const activeThread = state.threads.find((t) => t.id === threadId);
370374 const activeThreadId = activeThread?.id ?? null;
371375 const activeSessionId = activeThread?.session?.sessionId;
372376 const activeProject = state.projects.find((p) => p.id === activeThread?.projectId);
apps/web/src/components/Sidebar.tsx+42−45
@@ -2,23 +2,20 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; · 1 unmodified line
22 import { useCallback, useEffect, useMemo, useState } from "react";
33 import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
44 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5+import { useNavigate, useParams } from "@tanstack/react-router";
56 import { isElectron } from "../env";
67 import { useTheme } from "../hooks/useTheme";
78 import { DEFAULT_MODEL } from "../model-logic";
89 import { derivePendingApprovals } from "../session-logic";
910 import { useStore } from "../store";
1011 import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
11−import {
12− DEFAULT_THREAD_TERMINAL_HEIGHT,
13− DEFAULT_THREAD_TERMINAL_ID,
14− type Project,
15− type Thread,
16−} from "../types";
12+import { type Project, type Thread } from "../types";
1713 import { useNativeApi } from "../hooks/useNativeApi";
1814 import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";
1915 import { serverConfigQueryOptions } from "../lib/serverReactQuery";
2016 import { toastManager } from "./ui/toast";
2117 import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
18+import { createThread } from "../threadFactory";
2219
2320 const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
2421 const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
@@ -118,6 +115,9 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null · 93 unmodified lines
118115 export 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,37 +147,21 @@ export default function Sidebar() { · 23 unmodified lines
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− error: null,
174− createdAt: new Date().toISOString(),
175− branch: options?.branch ?? null,
176− worktreePath: options?.worktreePath ?? null,
177− },
157+ thread,
158+ });
159+ void navigate({
160+ to: "/$threadId",
161+ params: { threadId: thread.id },
178162 });
179163 },
180− [dispatch, state.projects],
164+ [dispatch, navigate, state.projects],
181165 );
182166
183167 const focusMostRecentThreadForProject = useCallback(
@@ -191,12 +175,12 @@ export default function Sidebar() { · 7 unmodified lines
191175 })[0];
192176 if (!latestThread) return;
193177
194− dispatch({
195− type: "SET_ACTIVE_THREAD",
196− threadId: latestThread.id,
178+ void navigate({
179+ to: "/$threadId",
180+ params: { threadId: latestThread.id },
197181 });
198182 },
199− [dispatch, state.threads],
183+ [navigate, state.threads],
200184 );
201185
202186 const addProjectFromPath = useCallback(
@@ -324,7 +308,20 @@ export default function Sidebar() { · 121 unmodified lines
324308 // Terminal may already be closed
325309 }
326310
311+ const shouldNavigateToFallback = routeThreadId === threadId;
312+ const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null;
327313 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+ }
328325
329326 if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) {
330327 return;
@@ -339,7 +336,7 @@ export default function Sidebar() { · 8 unmodified lines
339336 // Worktree deletion is best-effort and should not block thread deletion.
340337 }
341338 },
342− [api, dispatch, removeWorktreeMutation, state.projects, state.threads],
339+ [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads],
343340 );
344341
345342 const handleProjectContextMenu = useCallback(
@@ -497,7 +494,7 @@ export default function Sidebar() { · 151 unmodified lines
497494 {project.expanded && (
498495 <div className="ml-2 border-l border-border/80 pl-2">
499496 {threads.map((thread) => {
500− const isActive = state.activeThreadId === thread.id;
497+ const isActive = routeThreadId === thread.id;
501498 const threadStatus = threadStatusPill(
502499 thread,
503500 pendingApprovalByThreadId.get(thread.id) === true,
@@ -512,12 +509,12 @@ export default function Sidebar() { · 8 unmodified lines
512509 ? "bg-accent text-foreground"
513510 : "text-muted-foreground hover:bg-secondary"
514511 }`}
515− onClick={() =>
516− dispatch({
517− type: "SET_ACTIVE_THREAD",
518− threadId: thread.id,
519− })
520− }
512+ onClick={() => {
513+ void navigate({
514+ to: "/$threadId",
515+ params: { threadId: thread.id },
516+ });
517+ }}
521518 onContextMenu={(e) => {
522519 e.preventDefault();
523520 void handleThreadContextMenu(thread.id, {
apps/web/src/router.tsx+37−0
@@ -0,0 +1,37 @@
1+import { Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
2+
3+import { ChatThreadRouteView } from "./routes/_chat.$threadId";
4+import { ChatIndexRouteView } from "./routes/_chat.index";
5+import { ChatRouteLayout } from "./routes/_chat";
6+
7+const rootRoute = createRootRoute({
8+ component: Outlet,
9+});
10+
11+const chatRoute = createRoute({
12+ getParentRoute: () => rootRoute,
13+ path: "/",
14+ component: ChatRouteLayout,
15+});
16+
17+const chatIndexRoute = createRoute({
18+ getParentRoute: () => chatRoute,
19+ path: "/",
20+ component: ChatIndexRouteView,
21+});
22+
23+const chatThreadRoute = createRoute({
24+ getParentRoute: () => chatRoute,
25+ path: "$threadId",
26+ component: ChatThreadRouteView,
27+});
28+
29+const routeTree = rootRoute.addChildren([chatRoute.addChildren([chatIndexRoute, chatThreadRoute])]);
30+
31+export const router = createRouter({ routeTree });
32+
33+declare module "@tanstack/react-router" {
34+ interface Register {
35+ router: typeof router;
36+ }
37+}
apps/web/src/routes/_chat.$threadId.tsx+37−0
@@ -0,0 +1,37 @@
1+import { useNavigate, useParams } from "@tanstack/react-router";
2+import { useEffect } from "react";
3+
4+import ChatView from "../components/ChatView";
5+import { useStore } from "../store";
6+
7+export 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+84−0
@@ -0,0 +1,84 @@
1+import { useNavigate } from "@tanstack/react-router";
2+import { type FormEvent, useMemo, useState } from "react";
3+
4+import { Button } from "../components/ui/button";
5+import { Textarea } from "../components/ui/textarea";
6+import { createThread } from "../threadFactory";
7+import { useStore } from "../store";
8+
9+export 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+185−0
@@ -0,0 +1,185 @@
1+import { Outlet, useNavigate, useParams } from "@tanstack/react-router";
2+import { useEffect, useRef } from "react";
3+import { useQueryClient } from "@tanstack/react-query";
4+
5+import DiffPanel from "../components/DiffPanel";
6+import Sidebar from "../components/Sidebar";
7+import { isElectron } from "../env";
8+import { useNativeApi } from "../hooks/useNativeApi";
9+import { invalidateGitQueries } from "../lib/gitReactQuery";
10+import { DEFAULT_MODEL } from "../model-logic";
11+import { createThread } from "../threadFactory";
12+import { useStore } from "../store";
13+import { onServerWelcome } from "../wsNativeApi";
14+
15+function 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+
48+function 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+
99+function 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+
149+export 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+41−0
@@ -0,0 +1,41 @@
1+import { DEFAULT_MODEL } from "./model-logic";
2+import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, type Thread } from "./types";
3+
4+interface CreateThreadOptions {
5+ branch?: string | null;
6+ createdAt?: string;
7+ model?: string;
8+ title?: string;
9+ worktreePath?: string | null;
10+}
11+
12+export 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−0
@@ -55,6 +55,7 @@ · 54 unmodified lines
5555 "@t3tools/contracts": "workspace:*",
5656 "@tanstack/react-pacer": "^0.19.4",
5757 "@tanstack/react-query": "^5.90.0",
58+ "@tanstack/react-router": "^1.160.2",
5859 "@xterm/addon-fit": "^0.11.0",
5960 "@xterm/xterm": "^6.0.0",
6061 "class-variance-authority": "^0.7.1",
@@ -428,6 +429,8 @@ · 367 unmodified lines
428429
429430 "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.0", "", {}, "sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw=="],
430431
432+ "@tanstack/history": ["@tanstack/history@1.154.14", "", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
433+
431434 "@tanstack/pacer": ["@tanstack/pacer@0.18.0", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.0", "@tanstack/store": "^0.8.0" } }, "sha512-qhCRSFei0hokQr3xYcQXqxsRD/LKlgHCxHXtKHrQoImp4x2Zu6tUOpUGVH4y2qexIrzSu3aibQBNNfC3Eay6Mg=="],
432435
433436 "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
@@ -436,8 +439,12 @@ · 2 unmodified lines
436439
437440 "@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=="],
438441
442+ "@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=="],
443+
439444 "@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=="],
440445
446+ "@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=="],
447+
441448 "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="],
442449
443450 "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -590,6 +597,8 @@ · 146 unmodified lines
590597
591598 "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
592599
600+ "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],
601+
593602 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
594603
595604 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -768,6 +777,8 @@ · 172 unmodified lines
768777
769778 "is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
770779
780+ "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="],
781+
771782 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
772783
773784 "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=="],
@@ -1046,6 +1057,10 @@ · 272 unmodified lines
10461057
10471058 "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
10481059
1060+ "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
1061+
1062+ "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
1063+
10491064 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
10501065
10511066 "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
@@ -1090,6 +1105,10 @@ · 38 unmodified lines
10901105
10911106 "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
10921107
1108+ "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
1109+
1110+ "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="],
1111+
10931112 "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
10941113
10951114 "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],