From c0158cb44411e84175de40904b3a29afc379dc13 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 17 Feb 2026 20:19:15 -0800 Subject: [PATCH 1/9] 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 --- apps/web/package.json | 1 + apps/web/src/App.tsx | 266 +----------------------- apps/web/src/components/ChatView.tsx | 8 +- apps/web/src/components/Sidebar.tsx | 88 ++++---- apps/web/src/router.tsx | 37 ++++ apps/web/src/routes/_chat.$threadId.tsx | 37 ++++ apps/web/src/routes/_chat.index.tsx | 84 ++++++++ apps/web/src/routes/_chat.tsx | 185 ++++++++++++++++ apps/web/src/threadFactory.ts | 41 ++++ bun.lock | 19 ++ 10 files changed, 457 insertions(+), 309 deletions(-) create mode 100644 apps/web/src/router.tsx create mode 100644 apps/web/src/routes/_chat.$threadId.tsx create mode 100644 apps/web/src/routes/_chat.index.tsx create mode 100644 apps/web/src/routes/_chat.tsx create mode 100644 apps/web/src/threadFactory.ts diff --git a/apps/web/package.json b/apps/web/package.json index f777c3222089..535ee79e1ec9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -16,6 +16,7 @@ "@t3tools/contracts": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", + "@tanstack/react-router": "^1.160.2", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3afec7e3a298..e1bcc719b518 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,265 +1,9 @@ -import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import { Activity, Suspense, lazy, useEffect, useRef } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; -import ChatView from "./components/ChatView"; -import Sidebar from "./components/Sidebar"; -import { isElectron } from "./env"; -import { DEFAULT_MODEL } from "./model-logic"; -import { StoreProvider, useStore } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; -import { onServerWelcome } from "./wsNativeApi"; -import { useNativeApi } from "./hooks/useNativeApi"; -import { useMediaQuery } from "./hooks/useMediaQuery"; import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; -import { Sheet, SheetPopup } from "./components/ui/sheet"; -import { invalidateGitQueries } from "./lib/gitReactQuery"; - -const DiffPanel = lazy(() => import("./components/DiffPanel")); -const DiffWorkerPoolProvider = lazy(() => - import("./components/DiffPanel").then((module) => ({ - default: module.DiffWorkerPoolProvider, - })), -); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; - -const DiffPanelWrapper = (props: { children: React.ReactNode; sheet: boolean }) => { - const { state, dispatch } = useStore(); - if (props.sheet) { - return ( - { - if (!open) { - dispatch({ type: "CLOSE_DIFF" }); - } - }} - > - - {props.children} - - - ); - } - - return ( - - ); -}; - -function EventRouter() { - const api = useNativeApi(); - const { dispatch } = useStore(); - const queryClient = useQueryClient(); - const activeAssistantItemRef = useRef(null); - - useEffect(() => { - if (!api) return; - return api.providers.onEvent((event) => { - if (event.method === "turn/completed") { - void invalidateGitQueries(queryClient); - } - dispatch({ - type: "APPLY_EVENT", - event, - activeAssistantItemRef, - }); - }); - }, [api, dispatch, queryClient]); - - useEffect(() => { - if (!api) return; - return api.terminal.onEvent((event) => { - dispatch({ - type: "APPLY_TERMINAL_EVENT", - event, - }); - }); - }, [api, dispatch]); - - return null; -} - -function AutoProjectBootstrap() { - const { state, dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - // Browser mode bootstraps from server welcome. - // Electron bootstraps from persisted projects via DesktopProjectBootstrap. - if (isElectron) return; - - return onServerWelcome((payload) => { - if (bootstrappedRef.current) return; - - // Don't create duplicate projects for the same cwd - const existing = state.projects.find((p) => p.cwd === payload.cwd); - if (existing) { - bootstrappedRef.current = true; - // Ensure a thread is active - const existingThread = state.threads.find((t) => t.projectId === existing.id); - if (existingThread && !state.activeThreadId) { - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: existingThread.id, - }); - } - return; - } - - bootstrappedRef.current = true; - - // Create project + thread from server cwd - const projectId = crypto.randomUUID(); - dispatch({ - type: "ADD_PROJECT", - project: { - id: projectId, - name: payload.projectName, - cwd: payload.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: [], - }, - }); - dispatch({ - type: "ADD_THREAD", - thread: { - id: crypto.randomUUID(), - codexThreadId: null, - projectId, - title: "New thread", - model: DEFAULT_MODEL, - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: null, - messages: [], - events: [], - turnDiffSummaries: [], - error: null, - createdAt: new Date().toISOString(), - branch: null, - worktreePath: null, - }, - }); - }); - }, [state.projects, state.threads, state.activeThreadId, dispatch]); - - return null; -} - -function DesktopProjectBootstrap() { - const api = useNativeApi(); - const { dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - if (!isElectron || !api || bootstrappedRef.current) return; - - let disposed = false; - let retryDelayMs = 500; - let retryTimer: ReturnType | null = null; - - const attemptBootstrap = async () => { - try { - const projects = await api.projects.list(); - if (disposed) return; - dispatch({ - type: "SYNC_PROJECTS", - projects: projects.map((project) => ({ - id: project.id, - name: project.name, - cwd: project.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: project.scripts, - })), - }); - bootstrappedRef.current = true; - } catch { - if (disposed) return; - retryTimer = setTimeout(() => { - retryTimer = null; - void attemptBootstrap(); - }, retryDelayMs); - retryDelayMs = Math.min(retryDelayMs * 2, 5_000); - } - }; - - void attemptBootstrap(); - - return () => { - disposed = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [api, dispatch]); - - return null; -} - -function Layout() { - const api = useNativeApi(); - const { state } = useStore(); - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); - - const diffLoadingFallback = - !state.diffOpen || shouldUseDiffSheet ? ( -
- Loading diff viewer... -
- ) : ( - - ); - - if (!api) { - return ( -
-
-

Connecting to T3 Code server...

-
-
- ); - } - - return ( -
- - - - - - - - - - - - - - -
- ); -} +import { router } from "./router"; +import { StoreProvider } from "./store"; const queryClient = new QueryClient(); @@ -269,7 +13,7 @@ export default function App() { - + diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 232c07d97658..b51308274ff6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -337,7 +337,11 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ); }); -export default function ChatView() { +interface ChatViewProps { + threadId: string; +} + +export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); const api = useNativeApi(); const { resolvedTheme } = useTheme(); @@ -375,7 +379,7 @@ export default function ChatView() { const terminalOpenByThreadRef = useRef>({}); const checkpointHydrationSessionRequestRef = useRef(new Set()); - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + const activeThread = state.threads.find((t) => t.id === threadId); const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a8ebff1b41bf..35ae22e1af0a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,23 +2,20 @@ import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useParams } from "@tanstack/react-router"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; -import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - type Project, - type Thread, -} from "../types"; +import { type Project, type Thread } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; +import { createThread } from "../threadFactory"; const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -118,6 +115,9 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); + const navigate = useNavigate(); + const params = useParams({ strict: false }); + const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, @@ -147,38 +147,21 @@ export default function Sidebar() { worktreePath?: string | null; }, ) => { + const thread = createThread(projectId, { + model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, + branch: options?.branch ?? null, + worktreePath: options?.worktreePath ?? null, + }); dispatch({ type: "ADD_THREAD", - thread: { - id: crypto.randomUUID(), - codexThreadId: null, - projectId, - title: "New thread", - model: state.projects.find((p) => p.id === projectId)?.model ?? DEFAULT_MODEL, - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: null, - messages: [], - events: [], - turnDiffSummaries: [], - error: null, - createdAt: new Date().toISOString(), - branch: options?.branch ?? null, - worktreePath: options?.worktreePath ?? null, - }, + thread, + }); + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, }); }, - [dispatch, state.projects], + [dispatch, navigate, state.projects], ); const focusMostRecentThreadForProject = useCallback( @@ -192,12 +175,12 @@ export default function Sidebar() { })[0]; if (!latestThread) return; - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: latestThread.id, + void navigate({ + to: "/$threadId", + params: { threadId: latestThread.id }, }); }, - [dispatch, state.threads], + [navigate, state.threads], ); const addProjectFromPath = useCallback( @@ -325,7 +308,20 @@ export default function Sidebar() { // Terminal may already be closed } + const shouldNavigateToFallback = routeThreadId === threadId; + const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; dispatch({ type: "DELETE_THREAD", threadId }); + if (shouldNavigateToFallback) { + if (fallbackThreadId) { + void navigate({ + to: "/$threadId", + params: { threadId: fallbackThreadId }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } + } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { return; @@ -351,7 +347,7 @@ export default function Sidebar() { }); } }, - [api, dispatch, removeWorktreeMutation, state.projects, state.threads], + [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads], ); const handleProjectContextMenu = useCallback( @@ -509,7 +505,7 @@ export default function Sidebar() { {project.expanded && (
{threads.map((thread) => { - const isActive = state.activeThreadId === thread.id; + const isActive = routeThreadId === thread.id; const threadStatus = threadStatusPill( thread, pendingApprovalByThreadId.get(thread.id) === true, @@ -524,12 +520,12 @@ export default function Sidebar() { ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-secondary" }`} - onClick={() => - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: thread.id, - }) - } + onClick={() => { + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }} onContextMenu={(e) => { e.preventDefault(); void handleThreadContextMenu(thread.id, { diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx new file mode 100644 index 000000000000..6653a465a9d4 --- /dev/null +++ b/apps/web/src/router.tsx @@ -0,0 +1,37 @@ +import { Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router"; + +import { ChatThreadRouteView } from "./routes/_chat.$threadId"; +import { ChatIndexRouteView } from "./routes/_chat.index"; +import { ChatRouteLayout } from "./routes/_chat"; + +const rootRoute = createRootRoute({ + component: Outlet, +}); + +const chatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: ChatRouteLayout, +}); + +const chatIndexRoute = createRoute({ + getParentRoute: () => chatRoute, + path: "/", + component: ChatIndexRouteView, +}); + +const chatThreadRoute = createRoute({ + getParentRoute: () => chatRoute, + path: "$threadId", + component: ChatThreadRouteView, +}); + +const routeTree = rootRoute.addChildren([chatRoute.addChildren([chatIndexRoute, chatThreadRoute])]); + +export const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx new file mode 100644 index 000000000000..ed0d9bf1733b --- /dev/null +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -0,0 +1,37 @@ +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import ChatView from "../components/ChatView"; +import { useStore } from "../store"; + +export function ChatThreadRouteView() { + const { state, dispatch } = useStore(); + const navigate = useNavigate(); + const params = useParams({ strict: false }); + const threadId = typeof params.threadId === "string" ? params.threadId : null; + const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; + + useEffect(() => { + if (!threadId) { + void navigate({ to: "/", replace: true }); + return; + } + + if (!threadExists) { + void navigate({ to: "/", replace: true }); + return; + } + + if (state.activeThreadId === threadId) { + return; + } + + dispatch({ type: "SET_ACTIVE_THREAD", threadId }); + }, [dispatch, navigate, state.activeThreadId, threadExists, threadId]); + + if (!threadId || !threadExists) { + return null; + } + + return ; +} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx new file mode 100644 index 000000000000..0a564166f30f --- /dev/null +++ b/apps/web/src/routes/_chat.index.tsx @@ -0,0 +1,84 @@ +import { useNavigate } from "@tanstack/react-router"; +import { type FormEvent, useMemo, useState } from "react"; + +import { Button } from "../components/ui/button"; +import { Textarea } from "../components/ui/textarea"; +import { createThread } from "../threadFactory"; +import { useStore } from "../store"; + +export function ChatIndexRouteView() { + const { state, dispatch } = useStore(); + const navigate = useNavigate(); + const [draft, setDraft] = useState(""); + const defaultProject = state.projects[0] ?? null; + const canCreateThread = defaultProject !== null; + + const placeholder = useMemo(() => { + if (!canCreateThread) { + return "Add a project in the sidebar to start chatting."; + } + return "Start with a goal, bug report, or implementation idea..."; + }, [canCreateThread]); + + const onCreateThread = (event: FormEvent) => { + event.preventDefault(); + if (!defaultProject) return; + + const titleSeed = draft.trim(); + const threadTitle = + titleSeed.length > 0 + ? (titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed) + : undefined; + const thread = createThread( + defaultProject.id, + threadTitle + ? { model: defaultProject.model, title: threadTitle } + : { model: defaultProject.model }, + ); + + dispatch({ + type: "ADD_THREAD", + thread, + }); + + setDraft(""); + + void navigate({ + to: "/$threadId", + params: { threadId: thread.id }, + }); + }; + + return ( +
+
+
+

New chat

+

+ Create a thread and continue in the full chat workspace. +

+
+ +
+