Skip to content

Adopt TanStack Router for thread-based chat navigation - #68

Merged
juliusmarminge merged 9 commits into
mainfrom
codething/eb6f6614
Feb 19, 2026
Merged

Adopt TanStack Router for thread-based chat navigation#68
juliusmarminge merged 9 commits into
mainfrom
codething/eb6f6614

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Feb 18, 2026

Copy link
Copy Markdown
Member

Summary

  • Introduce @tanstack/react-router and replace the monolithic App layout rendering with a router-driven app shell.
  • Add route structure for chat index and thread detail (/ and /$threadId) with a shared chat layout.
  • Update sidebar thread interactions to use router navigation instead of directly setting active thread state.
  • Refactor ChatView to accept threadId as a prop so route state drives the active thread.
  • Extract thread initialization into a reusable createThread factory and reuse it across bootstrap and thread creation flows.

Testing

  • Not run (tests not provided in patch context).
  • Not run (lint not executed in patch context).

Open with Devin

Note

Medium Risk
This is a broad navigation/state refactor that changes how the active thread is determined and how events/toasts are scoped, so regressions could affect thread routing and diff targeting despite being largely UI/state-layer changes.

Overview
Migrates the web app from a monolithic App shell to TanStack Router with / and /$threadId routes, making the URL the source of truth for the active chat thread (including Electron hash history support).

Refactors thread selection/creation/deletion flows to navigate via the router instead of storing activeThreadId in the app state; adds threadsHydrated gating for route validity, updates toasts and git controls to be thread-aware via route params, and updates DiffPanel to resolve its target thread from either stored diff state or the route.

Improves chat rendering performance by virtualizing the message timeline with @tanstack/react-virtual, and adds small utilities/tests (createThread, truncateTitle) plus build/lint updates (router Vite plugin, ignore generated routeTree.gen.ts).

Written by Cursor Bugbot for commit cdde1ff. This will update automatically on new commits. Configure here.

Summary by CodeRabbit

  • New Features

    • URL-based routing for individual chat threads and a chat index view for direct/shareable access.
    • Route-driven thread creation and navigation so new and deleted threads update the URL automatically.
  • Refactor

    • Routing-first architecture: chat UI, sidebar, and toasts now respect per-thread route context for consistent per-thread behavior.
  • Tests

    • Added/updated tests around title truncation and thread hydration behavior.

@coderabbitai

coderabbitai Bot commented Feb 18, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Integrates TanStack Router, introduces route-driven chat layout and thread routes, moves active-thread selection from global state into URL params, adds route tree generation and thread factory, and updates components and store to propagate per-thread context and a threadsHydrated flag.

Changes

Cohort / File(s) Summary
Router & Build
apps/web/package.json, apps/web/vite.config.ts, apps/web/vite.config.js, .oxlintrc.json
Added @tanstack/react-router and router plugin; wired tanstackRouter() in Vite config(s); added VITE_WS_URL define and HMR settings; excluded generated routeTree.gen.ts from linting.
Route Layout & Tree
apps/web/src/routes/__root.tsx, apps/web/src/routes/_chat.tsx, apps/web/src/routes/_chat.index.tsx, apps/web/src/routes/_chat.$threadId.tsx, apps/web/src/routeTree.gen.ts, apps/web/src/main.tsx
Added root route with context and event/bootstrap components; created chat layout, index, and thread routes; generated typed routeTree and switched app bootstrap to TanStack Router with environment-aware history and QueryClient in router context.
Thread model & factory
apps/web/src/threadFactory.ts
New createThread factory to centralize Thread object creation with defaults and options.
Component prop plumbing
apps/web/src/components/ChatView.tsx, apps/web/src/components/BranchToolbar.tsx, apps/web/src/components/GitActionsControl.tsx, apps/web/src/components/BranchToolbar.tsx, apps/web/src/components/ui/toast.tsx, apps/web/src/components/BranchToolbar.tsx
Refactored components to accept threadId/activeThreadId props (propagate thread context via props instead of global activeThreadId); updated usages (ChatHeader, OpenInPicker, BranchToolbar, GitActionsControl, toasts) to resolve active thread from route-provided id.
Sidebar navigation
apps/web/src/components/Sidebar.tsx
Replaced store-based SET_ACTIVE_THREAD flows with route navigation (navigate to /$threadId) for create, select, delete and focus; uses createThread factory for new threads.
State and persistence
apps/web/src/store.ts, apps/web/src/store.test.ts, apps/web/src/persistenceSchema.ts, apps/web/src/persistenceSchema.test.ts
Removed activeThreadId from AppState; added threadsHydrated: boolean and SET_THREADS_HYDRATED action; updated reducer, persistence schema, and tests to stop reading/writing activeThreadId and to track hydration state.
Utilities & tests
apps/web/src/truncateTitle.ts, apps/web/src/truncateTitle.test.ts, apps/web/src/components/GitActionsControl.logic.ts, apps/web/src/components/ui/toast.tsx
Added truncateTitle utility and tests; centralized description helper in GitActionsControl.logic; replaced toast active-thread lookup with router-based hook.
Minor
apps/web/src/routeTree.gen.ts (generated), .oxlintrc.json
Added generated route tree (typed routes, module augmentation) and lint ignore entry.

Sequence Diagram

sequenceDiagram
    participant User
    participant Browser
    participant Router as TanStack Router
    participant Root as RootRoute
    participant ChatLayout as Chat Layout
    participant ThreadRoute as ChatThreadRoute
    participant Store

    User->>Browser: Open /_chat/$threadId
    Browser->>Router: route match
    Router->>Root: mount RootRoute with context
    Root->>Store: access store (useStore)
    Router->>ChatLayout: mount chat layout (Sidebar + Outlet)
    Router->>ThreadRoute: mount ThreadRoute (has threadId param)
    ThreadRoute->>Store: validate thread exists & check threadsHydrated
    alt thread missing or not hydrated
        ThreadRoute->>Router: navigate("/") 
    else
        ThreadRoute->>ChatLayout: render ChatView with threadId prop
        ChatLayout->>Store: read threads by threadId for rendering
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main architectural change: adopting TanStack Router for navigation based on thread IDs, which is the primary focus across multiple file changes.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codething/eb6f6614

Comment @coderabbitai help to get the list of available commands and usage tips.

@macroscopeapp

macroscopeapp Bot commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

Adopt TanStack Router to drive thread navigation via URL and render chat under '/_chat/$threadId' with route-scoped events and virtualization

Replace store-based activeThreadId with route params, add /_chat routes and /$threadId handling, bootstrap routing in main.tsx, wire event handling to the current threadId, virtualize message lists, and remove App.tsx. Update persistence to drop activeThreadId and adjust components to accept threadId props. Configure Vite with the TanStack Router plugin.

📍Where to Start

Start with router setup and entry in main.tsx, then review route definitions in routes/__root.tsx and routes/_chat.tsx, followed by the parameterized thread view in routes/_chat.$threadId.tsx.


Macroscope summarized cdde1ff.

@greptile-apps

greptile-apps Bot commented Feb 18, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces @tanstack/react-router to replace the monolithic App layout with a route-driven structure. Thread navigation (/ for index, /$threadId for active thread) now uses URL state instead of relying solely on state.activeThreadId. The Sidebar and ChatView are updated to navigate via router instead of dispatching store actions directly. A threadFactory.ts consolidates thread object creation that was previously duplicated across App.tsx and Sidebar.tsx.

  • Electron routing broken: The router uses browser history by default, which does not work when Electron loads the app via file:// protocol in production mode (window.loadFile). This needs hash or memory history for the Electron path.
  • Thread factory extraction: createThread is a clean and correct consolidation of duplicated thread initialization logic.
  • Dual state sync: The activeThreadId in the store is now synced bidirectionally with the route param. ChatRouteLayout pushes store → URL, and ChatThreadRouteView pushes URL → store. This works but adds complexity — the store's activeThreadId may eventually be redundant if all consumers switch to reading from route params.
  • Bootstrap flash: AutoProjectBootstrap creates threads without navigating, causing a brief flash of the index route before the layout effect redirects.

Confidence Score: 2/5

  • This PR will break Electron production builds due to browser history routing under file:// protocol.
  • The browser-mode web app should work correctly, but Electron production mode uses window.loadFile() which loads via file:// protocol — incompatible with TanStack Router's default browser history. This is a blocking issue for the desktop app. The rest of the refactor is sound.
  • apps/web/src/router.tsx needs hash or memory history for Electron; apps/web/src/routes/_chat.tsx has a minor flash-of-wrong-content on bootstrap.

Important Files Changed

Filename Overview
apps/web/src/App.tsx Simplified to just provide RouterProvider, delegating layout and bootstrap logic to route components. Clean refactor.
apps/web/src/components/ChatView.tsx Changed to accept threadId as a prop instead of reading from state.activeThreadId. Clean change.
apps/web/src/components/Sidebar.tsx Replaced store-based thread selection with router navigation. Uses useNavigate and useParams to determine active thread. Well-structured migration.
apps/web/src/router.tsx Defines route tree with browser history (default). Missing hash/memory history for Electron file:// protocol — browser history routing won't work when loaded via loadFile.
apps/web/src/routes/_chat.$threadId.tsx Route component that syncs URL threadId to store and renders ChatView. Includes redirect for invalid/missing threads.
apps/web/src/routes/_chat.index.tsx New "New chat" landing page. Creates thread from draft text and navigates. Draft text is used for title only — not sent as first message.
apps/web/src/routes/_chat.tsx Chat layout with bootstrap components relocated from App.tsx. AutoProjectBootstrap creates threads without navigating — relies on a useEffect redirect that may flash the wrong view.
apps/web/src/threadFactory.ts Clean extraction of thread creation logic into a reusable factory function. Matches the original inline logic exactly.

Sequence Diagram

sequenceDiagram
    participant User
    participant Router as TanStack Router
    participant Layout as ChatRouteLayout
    participant Index as ChatIndexRouteView
    participant Thread as ChatThreadRouteView
    participant Store as AppState Store
    participant Sidebar

    User->>Router: Load app at /
    Router->>Layout: Render ChatRouteLayout
    Layout->>Index: Render ChatIndexRouteView (Outlet)
    Note over Layout: AutoProjectBootstrap runs
    Layout->>Store: ADD_THREAD (sets activeThreadId)
    Store-->>Layout: state.activeThreadId updated
    Layout->>Router: navigate(/$threadId, replace)
    Router->>Thread: Render ChatThreadRouteView
    Thread->>Store: SET_ACTIVE_THREAD (sync URL → store)
    Thread->>Thread: Render ChatView

    User->>Sidebar: Click thread in sidebar
    Sidebar->>Router: navigate(/$threadId)
    Router->>Thread: Render ChatThreadRouteView
    Thread->>Store: SET_ACTIVE_THREAD
    Thread->>Thread: Render ChatView

    User->>Sidebar: Click "New thread"
    Sidebar->>Store: ADD_THREAD (via createThread)
    Sidebar->>Router: navigate(/$newThreadId)
    Router->>Thread: Render ChatThreadRouteView
Loading

Last reviewed commit: 6d885eb

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment thread apps/web/src/router.tsx Outdated
Comment thread apps/web/src/routes/_chat.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/components/Sidebar.tsx (1)

495-515: ⚠️ Potential issue | 🟠 Major

Use routeThreadId directly in keyboard shortcut handler instead of state.activeThreadId.

The keyboard handler (line 387) extracts the active thread from state.activeThreadId, but this depends on SET_ACTIVE_THREAD being dispatched synchronously when the route changes. If a user triggers a shortcut during the brief window between route navigation and store update—or if a thread is deleted while navigated to it—the handler could target the wrong thread or a deleted one. Since routeThreadId is already in scope and represents the definitive navigation state, the handler should derive from it instead.

Suggested fix
-      const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
+      const activeThread = state.threads.find((t) => t.id === routeThreadId);
-  }, [handleNewThread, keybindings, state.activeThreadId, state.projects, state.threads]);
+  }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/Sidebar.tsx` around lines 495 - 515, The keyboard
shortcut handler currently reads the active thread from state.activeThreadId
which can lag behind route changes; update the handler to use the routeThreadId
variable already in scope (instead of state.activeThreadId) so shortcuts always
operate against the canonical route-derived thread, and handle the case where
routeThreadId may be undefined or reference a deleted thread by early-returning
or validating existence (references: routeThreadId, state.activeThreadId,
SET_ACTIVE_THREAD, the keyboard handler function).
🧹 Nitpick comments (2)
apps/web/src/components/ChatView.tsx (1)

339-340: Consider passing threadId to nested components that need it.

The OpenInPicker component (line 2083) still reads state.activeThreadId directly rather than using the threadId prop passed to ChatView. While the route component keeps these in sync via SET_ACTIVE_THREAD, there's a brief window where they could differ. This is low-risk given the synchronization, but for consistency, consider threading the threadId prop through to child components that need it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` around lines 339 - 340, The
OpenInPicker component reads state.activeThreadId instead of using the threadId
prop passed into ChatView, which can briefly diverge; update ChatView to pass
its computed activeThreadId (from activeThread?.id ?? null) down to OpenInPicker
(and any other children that currently read state.activeThreadId) via a prop,
and update OpenInPicker to accept and use that prop (rename its internal
reference from state.activeThreadId to the new prop) so components rely on the
threadId coming from ChatView rather than reading global state directly
(references: ChatView, activeThread, activeThreadId, threadId, OpenInPicker,
SET_ACTIVE_THREAD).
apps/web/src/routes/_chat.index.tsx (1)

27-31: Consider extracting title truncation logic.

The title truncation pattern (length > 50 ? slice(0, 50) + "..." : title) is duplicated here and in ChatView.tsx (around line 1071). Consider extracting to a shared utility like truncateTitle(text: string, maxLength = 50): string for consistency.

💡 Example shared utility
// In a shared utils file
export function truncateTitle(text: string, maxLength = 50): string {
  const trimmed = text.trim();
  return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength)}...` : trimmed;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/_chat.index.tsx` around lines 27 - 31, Extract the
duplicated truncation logic into a shared utility function named
truncateTitle(text: string, maxLength = 50) and replace the inline logic that
builds titleSeed/threadTitle in _chat.index.tsx (the titleSeed and threadTitle
expressions) and the corresponding truncation in ChatView (around where titles
are trimmed/sliced) to call truncateTitle(draft) or truncateTitle(text); ensure
the utility trims input and returns the truncated string with "..." when length
exceeds maxLength so both places use the same implementation for consistency.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/web/src/components/Sidebar.tsx`:
- Around line 495-515: The keyboard shortcut handler currently reads the active
thread from state.activeThreadId which can lag behind route changes; update the
handler to use the routeThreadId variable already in scope (instead of
state.activeThreadId) so shortcuts always operate against the canonical
route-derived thread, and handle the case where routeThreadId may be undefined
or reference a deleted thread by early-returning or validating existence
(references: routeThreadId, state.activeThreadId, SET_ACTIVE_THREAD, the
keyboard handler function).

---

Nitpick comments:
In `@apps/web/src/components/ChatView.tsx`:
- Around line 339-340: The OpenInPicker component reads state.activeThreadId
instead of using the threadId prop passed into ChatView, which can briefly
diverge; update ChatView to pass its computed activeThreadId (from
activeThread?.id ?? null) down to OpenInPicker (and any other children that
currently read state.activeThreadId) via a prop, and update OpenInPicker to
accept and use that prop (rename its internal reference from
state.activeThreadId to the new prop) so components rely on the threadId coming
from ChatView rather than reading global state directly (references: ChatView,
activeThread, activeThreadId, threadId, OpenInPicker, SET_ACTIVE_THREAD).

In `@apps/web/src/routes/_chat.index.tsx`:
- Around line 27-31: Extract the duplicated truncation logic into a shared
utility function named truncateTitle(text: string, maxLength = 50) and replace
the inline logic that builds titleSeed/threadTitle in _chat.index.tsx (the
titleSeed and threadTitle expressions) and the corresponding truncation in
ChatView (around where titles are trimmed/sliced) to call truncateTitle(draft)
or truncateTitle(text); ensure the utility trims input and returns the truncated
string with "..." when length exceeds maxLength so both places use the same
implementation for consistency.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/routes/_chat`.$threadId.tsx:
- Around line 13-29: The effect in useEffect that redirects when threadExists is
false breaks deep links during bootstrap because state.threads may be empty
until onServerWelcome populates them; add a threadsHydrated boolean to your
store (set true after onServerWelcome completes) and change the effect in the
component to skip the redirect until threadsHydrated is true (i.e., only check
threadExists and dispatch SET_ACTIVE_THREAD when threadsHydrated && threadId are
present), or alternatively move the thread existence check into a route
beforeLoad/loader that awaits bootstrap data before deciding to redirect. Ensure
you update the store initializer and the code paths that set threads
(onServerWelcome) to flip threadsHydrated so the component can rely on it.

Comment thread apps/web/src/routes/_chat.$threadId.tsx Outdated
Comment thread apps/web/src/routes/_chat.$threadId.tsx Outdated
Comment thread apps/web/src/components/ChatView.tsx
@cursor

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/web/src/components/ChatView.tsx (1)

2397-2429: Minor redundancy: OpenInPicker re-looks up thread from state.

OpenInPicker receives activeThreadId as a prop but then performs its own state.threads.find lookup (Line 2429). Since ChatView already resolved the thread, consider passing the relevant data (e.g., worktreePath) directly to avoid the duplicate lookup.

This is a minor efficiency concern and doesn't affect correctness.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/components/ChatView.tsx` around lines 2397 - 2429, OpenInPicker
currently receives activeThreadId but re-queries state.threads.find to get the
active thread; instead, change OpenInPicker's props to accept the
already-resolved data it needs (e.g., worktreePath or the full activeThread
object) from ChatView and remove the internal lookup (the call to
state.threads.find inside OpenInPicker). Update the prop type for OpenInPicker
and the call site in ChatView to pass the thread.worktreePath (or thread) so
OpenInPicker uses that prop directly and no longer imports or reads state to
find the thread.
apps/web/src/routes/_chat.index.tsx (1)

17-22: Consider removing unnecessary useMemo.

The placeholder computation is a simple conditional on a boolean. The memoization overhead likely exceeds the cost of the inline ternary.

♻️ Suggested simplification
-  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 placeholder = canCreateThread
+    ? "Start with a goal, bug report, or implementation idea..."
+    : "Add a project in the sidebar to start chatting.";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/_chat.index.tsx` around lines 17 - 22, The placeholder
value is over-memoized: remove the useMemo wrapper and replace "const
placeholder = useMemo(() => { ... }, [canCreateThread]);" with a plain
conditional assignment like "const placeholder = !canCreateThread ? 'Add a
project in the sidebar to start chatting.' : 'Start with a goal, bug report, or
implementation idea...';" and then remove the now-unused useMemo import (and any
unused React import) so canCreateThread and placeholder remain as simple,
readable locals.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/routes/__root.tsx`:
- Around line 50-95: The onServerWelcome callback closes over state.projects and
state.threads causing stale checks when the welcome payload is replayed; update
the callback to read latest values via refs (e.g., create projectsRef and
threadsRef that you keep in sync in the reducer effect) or perform the duplicate
check inside a reducer by dispatching an action that includes payload and doing
the existence check in the reducer so you use the latest state; specifically
modify the onServerWelcome handler in the useEffect (and keep bootstrappedRef
logic) to either consult projectsRef.current and threadsRef.current instead of
state.projects/state.threads or to dispatch a single action like
"HANDLE_SERVER_WELCOME" that the reducer uses to add project/thread (using
createThread(projectId) invoked from reducer code or a subsequent effect)
ensuring no duplicates are created.

In `@apps/web/src/store.ts`:
- Around line 124-130: readPersistedState currently sets threadsHydrated to
threads.length > 0 which conflicts with the explicit SET_THREADS_HYDRATED: true
dispatched in __root.tsx; change readPersistedState (the return object in
apps/web/src/store.ts) to set threadsHydrated = true whenever persisted state
has been loaded (regardless of threads.length), keep threads and activeThreadId
as-is, and leave the bootstrap dispatch of SET_THREADS_HYDRATED untouched so
both paths mean "persisted state loaded"; update any logic that needs to know
whether threads exist (e.g., guards in _chat.$threadId.tsx) to check
threads.length > 0 or a new explicit flag (like hasThreads) instead of relying
on threadsHydrated.

---

Nitpick comments:
In `@apps/web/src/components/ChatView.tsx`:
- Around line 2397-2429: OpenInPicker currently receives activeThreadId but
re-queries state.threads.find to get the active thread; instead, change
OpenInPicker's props to accept the already-resolved data it needs (e.g.,
worktreePath or the full activeThread object) from ChatView and remove the
internal lookup (the call to state.threads.find inside OpenInPicker). Update the
prop type for OpenInPicker and the call site in ChatView to pass the
thread.worktreePath (or thread) so OpenInPicker uses that prop directly and no
longer imports or reads state to find the thread.

In `@apps/web/src/routes/_chat.index.tsx`:
- Around line 17-22: The placeholder value is over-memoized: remove the useMemo
wrapper and replace "const placeholder = useMemo(() => { ... },
[canCreateThread]);" with a plain conditional assignment like "const placeholder
= !canCreateThread ? 'Add a project in the sidebar to start chatting.' : 'Start
with a goal, bug report, or implementation idea...';" and then remove the
now-unused useMemo import (and any unused React import) so canCreateThread and
placeholder remain as simple, readable locals.

Comment thread apps/web/src/routes/__root.tsx Outdated
Comment on lines +50 to +95
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((project) => project.cwd === payload.cwd);
if (existing) {
bootstrappedRef.current = true;
// Ensure a thread is active
const existingThread = state.threads.find((thread) => thread.projectId === existing.id);
if (existingThread && !state.activeThreadId) {
dispatch({
type: "SET_ACTIVE_THREAD",
threadId: existingThread.id,
});
}
dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
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: createThread(projectId),
});
dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });
});
}, [state.projects, state.threads, state.activeThreadId, dispatch]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential stale closure when server welcome replays.

The onServerWelcome callback captures state.projects and state.threads at effect registration time. If the welcome payload is replayed to a late subscriber (as documented in wsNativeApi.ts), the callback may check against stale project/thread lists, potentially creating duplicates or missing existing matches.

Consider using refs or moving the duplicate check inside the dispatch logic.

🛠️ Possible fix using refs
 function AutoProjectBootstrap() {
   const { state, dispatch } = useStore();
   const bootstrappedRef = useRef(false);
+  const projectsRef = useRef(state.projects);
+  const threadsRef = useRef(state.threads);
+  const activeThreadIdRef = useRef(state.activeThreadId);
+
+  useEffect(() => {
+    projectsRef.current = state.projects;
+    threadsRef.current = state.threads;
+    activeThreadIdRef.current = state.activeThreadId;
+  }, [state.projects, state.threads, state.activeThreadId]);

   useEffect(() => {
     if (isElectron) return;

     return onServerWelcome((payload) => {
       if (bootstrappedRef.current) return;

-      const existing = state.projects.find((project) => project.cwd === payload.cwd);
+      const existing = projectsRef.current.find((project) => project.cwd === payload.cwd);
       if (existing) {
         bootstrappedRef.current = true;
-        const existingThread = state.threads.find((thread) => thread.projectId === existing.id);
-        if (existingThread && !state.activeThreadId) {
+        const existingThread = threadsRef.current.find((thread) => thread.projectId === existing.id);
+        if (existingThread && !activeThreadIdRef.current) {
           dispatch({
             type: "SET_ACTIVE_THREAD",
             threadId: existingThread.id,
           });
         }
         // ...
       }
       // ...
     });
-  }, [state.projects, state.threads, state.activeThreadId, dispatch]);
+  }, [dispatch]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/routes/__root.tsx` around lines 50 - 95, The onServerWelcome
callback closes over state.projects and state.threads causing stale checks when
the welcome payload is replayed; update the callback to read latest values via
refs (e.g., create projectsRef and threadsRef that you keep in sync in the
reducer effect) or perform the duplicate check inside a reducer by dispatching
an action that includes payload and doing the existence check in the reducer so
you use the latest state; specifically modify the onServerWelcome handler in the
useEffect (and keep bootstrappedRef logic) to either consult projectsRef.current
and threadsRef.current instead of state.projects/state.threads or to dispatch a
single action like "HANDLE_SERVER_WELCOME" that the reducer uses to add
project/thread (using createThread(projectId) invoked from reducer code or a
subsequent effect) ensuring no duplicates are created.

Comment thread apps/web/src/store.ts
Comment on lines +124 to +130
return {
...hydrated,
threads,
activeThreadId,
threadsHydrated: threads.length > 0,
diffOpen: false,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "threadsHydrated|SET_THREADS_HYDRATED" --type ts --type tsx

Repository: pingdotgg/codething-mvp

Length of output: 94


🏁 Script executed:

cat -n apps/web/src/store.ts | head -150 | tail -50

Repository: pingdotgg/codething-mvp

Length of output: 2031


🏁 Script executed:

rg -n "threadsHydrated" --type ts

Repository: pingdotgg/codething-mvp

Length of output: 1024


🏁 Script executed:

rg -n "SET_THREADS_HYDRATED" --type ts

Repository: pingdotgg/codething-mvp

Length of output: 578


🏁 Script executed:

cat -n apps/web/src/store.ts | head -110

Repository: pingdotgg/codething-mvp

Length of output: 4496


🏁 Script executed:

cat -n apps/web/src/routes/__root.tsx | sed -n '60,100p'

Repository: pingdotgg/codething-mvp

Length of output: 1509


🏁 Script executed:

cat -n apps/web/src/routes/__root.tsx | sed -n '80,130p'

Repository: pingdotgg/codething-mvp

Length of output: 1852


🏁 Script executed:

cat -n apps/web/src/store.test.ts | sed -n '630,650p'

Repository: pingdotgg/codething-mvp

Length of output: 673


🏁 Script executed:

cat -n apps/web/src/store.test.ts | sed -n '534,550p'

Repository: pingdotgg/codething-mvp

Length of output: 550


🏁 Script executed:

cat -n apps/web/src/routes/__root.tsx | sed -n '1,50p'

Repository: pingdotgg/codething-mvp

Length of output: 1806


🏁 Script executed:

cat -n apps/web/src/store.ts | sed -n '450,470p'

Repository: pingdotgg/codething-mvp

Length of output: 848


🏁 Script executed:

cat -n apps/web/src/routes/_chat.$threadId.tsx | sed -n '15,40p'

Repository: pingdotgg/codething-mvp

Length of output: 129


🏁 Script executed:

find apps/web/src/routes -name "*chat*" -type f

Repository: pingdotgg/codething-mvp

Length of output: 172


🏁 Script executed:

cat -n "apps/web/src/routes/_chat.\$threadId.tsx" | sed -n '15,40p'

Repository: pingdotgg/codething-mvp

Length of output: 816


🏁 Script executed:

cat -n apps/web/src/store.ts | sed -n '175,210p'

Repository: pingdotgg/codething-mvp

Length of output: 1521


🏁 Script executed:

rg -n "readPersistedState" --type ts

Repository: pingdotgg/codething-mvp

Length of output: 240


🏁 Script executed:

cat -n apps/web/src/store.ts | sed -n '895,910p'

Repository: pingdotgg/codething-mvp

Length of output: 629


🏁 Script executed:

rg -n "threadsHydrated" -B 2 -A 2 --type ts

Repository: pingdotgg/codething-mvp

Length of output: 3539


Semantic inconsistency: threadsHydrated has conflicting meanings across initialization paths.

readPersistedState() ties threadsHydrated to thread existence (threads.length > 0), but bootstrap effects in __root.tsx explicitly dispatch SET_THREADS_HYDRATED: true regardless of thread count. This creates an edge case where the flag's meaning is ambiguous.

However, the guard in _chat.$threadId.tsx includes threadsHydrated in its dependency array, so it re-runs whenever the state changes. When bootstrap dispatches the explicit SET_THREADS_HYDRATED: true, the guard is not permanently stuck—it re-evaluates.

The suggested fix improves semantics by decoupling "hydration complete" from "threads exist":

-threadsHydrated: threads.length > 0,
+threadsHydrated: true,

This makes threadsHydrated consistently mean "persisted state loaded successfully" across both paths (localStorage and bootstrap), and allows checking thread existence separately when needed.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
...hydrated,
threads,
activeThreadId,
threadsHydrated: threads.length > 0,
diffOpen: false,
};
return {
...hydrated,
threads,
activeThreadId,
threadsHydrated: true,
diffOpen: false,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/store.ts` around lines 124 - 130, readPersistedState currently
sets threadsHydrated to threads.length > 0 which conflicts with the explicit
SET_THREADS_HYDRATED: true dispatched in __root.tsx; change readPersistedState
(the return object in apps/web/src/store.ts) to set threadsHydrated = true
whenever persisted state has been loaded (regardless of threads.length), keep
threads and activeThreadId as-is, and leave the bootstrap dispatch of
SET_THREADS_HYDRATED untouched so both paths mean "persisted state loaded";
update any logic that needs to know whether threads exist (e.g., guards in
_chat.$threadId.tsx) to check threads.length > 0 or a new explicit flag (like
hasThreads) instead of relying on threadsHydrated.

Comment thread apps/web/src/store.ts
@cursor

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/web/src/routes/__root.tsx`:
- Around line 84-124: The onServerWelcome callback closes over stale state
(state.projects, state.threads, state.activeThreadId) which can cause
duplicates/missed matches; fix by reading live values via refs or delegating the
existence logic into the reducer: keep bootstrappedRef for one-time bootstrap,
replace direct checks of state.projects/state.threads/state.activeThreadId
inside the onServerWelcome handler with current refs (e.g., projectsRef.current,
threadsRef.current, activeThreadIdRef.current) or dispatch a single action like
"ENSURE_PROJECT_FROM_SERVER" with the payload and perform the existence check
and thread activation within the reducer (or reducer-handled helper) so the
latest state is used atomically.

Comment thread apps/web/src/routes/_chat.tsx Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/web/src/routes/__root.tsx`:
- Around line 92-120: The onServerWelcome callback closes over state.projects
causing a stale-closure bug; change to read the latest projects from a ref
(e.g., projectsRef.current) inside the callback instead of using state.projects,
update that ref whenever state.projects changes, and keep bootstrappedRef and
dispatch usage intact; ensure the effect registers onServerWelcome uses the
ref-based check for existing project (compare payload.cwd against
projectsRef.current) before dispatching ADD_PROJECT and SET_THREADS_HYDRATED so
late subscriber replays see current project state.

In `@apps/web/src/store.ts`:
- Around line 119-124: In readPersistedState (apps/web/src/store.ts) change the
semantic of threadsHydrated so it reflects "persisted state loaded" rather than
"threads exist": replace the current threadsHydrated: threads.length > 0 with
threadsHydrated: true so that successful hydration always marks threadsHydrated
as true (bootstrap can still dispatch SET_THREADS_HYDRATED to indicate
completion); update any related return object that includes threads and diffOpen
accordingly.

if (event.method === "turn/completed") {
void invalidateGitQueries(queryClient);
}
if (!activeThreadId) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Provider events silently dropped when no thread active

High Severity

The if (!activeThreadId) return; guard in EventRouter causes ALL APPLY_EVENT dispatches to be skipped when the user is on the index route (no threadId in the URL). The reducer's findThreadBySessionId already correctly routes events to the right thread by sessionId, so activeThreadId is only needed for the lastVisitedAt optimization. By short-circuiting entirely, background threads lose all messages, session state updates, and completion events whenever the user navigates away. The dispatch needs to fire unconditionally, with activeThreadId passed as optional context.

Additional Locations (1)

Fix in Cursor Fix in Web

worktreePath: null,
},
});
dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Browser bootstrap no longer creates initial thread

Medium Severity

AutoProjectBootstrap previously created both a project and an initial thread on first browser-mode load. The refactor removed the ADD_THREAD dispatch while the comment on line 105 still says "Create project + thread from server cwd." New browser users get a project but no thread, landing on a blank "Select a thread" placeholder with no automatic way to start chatting.

Fix in Cursor Fix in Web

Comment thread apps/web/src/store.ts
};
}

case "SET_ACTIVE_THREAD": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread visit no longer updates lastVisitedAt timestamp

Medium Severity

The removed SET_ACTIVE_THREAD action was the only place lastVisitedAt was updated when a user navigated to a thread. Now lastVisitedAt is only set on thread creation and turn/completed events. The sidebar's hasUnseenCompletion compares latestTurnCompletedAt to lastVisitedAt to show a "Completed" pill — without updating lastVisitedAt on navigation, this indicator can never be dismissed by visiting the thread.

Additional Locations (1)

Fix in Cursor Fix in Web

Comment thread apps/web/vite.config.js
outDir: "dist",
emptyOutDir: true,
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate vite.config.js committed alongside TypeScript version

Low Severity

vite.config.js is a new file with the same configuration as the existing vite.config.ts. It appears to be a transpiled or auto-generated copy. Having both files is confusing and Vite may pick one over the other unexpectedly depending on resolution order.

Fix in Cursor Fix in Web

Comment thread apps/web/src/components/Sidebar.tsx
@cursor

This comment has been minimized.

juliusmarminge and others added 5 commits February 18, 2026 22:11
- 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
- Switch web router setup to TanStack file routes and generated `routeTree.gen.ts`
- Move app bootstrap/event wiring into new root route component
- Enable TanStack router Vite plugin and ignore generated route tree in oxlint
Co-authored-by: codex <codex@users.noreply.github.com>
- remove `activeThreadId` from web store state and persistence schema
- drive thread-scoped event handling and toast visibility from router params
- simplify chat index/route flow and add explicit `apps/web/vite.config.js`

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is ON. A Cloud Agent has been kicked off to fix the reported issue.

Comment thread apps/web/src/routes/_chat.tsx Outdated
@cursor

cursor Bot commented Feb 19, 2026

Copy link
Copy Markdown
Contributor

Bugbot Autofix prepared fixes for 1 of the 1 bugs found in the latest run.

  • ✅ Fixed: DiffPanel rendered without required DiffWorkerPoolProvider ancestor
    • Wrapped DiffPanel in DiffWorkerPoolProvider in _chat.tsx to provide the required WorkerPoolContextProvider ancestor that FileDiff and Virtualizer depend on.

Create PR

Or push these changes by commenting:

@cursor push 470cabc110
Preview (470cabc110)
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -1,6 +1,6 @@
 import { Outlet, createFileRoute } from "@tanstack/react-router";
 
-import DiffPanel from "../components/DiffPanel";
+import DiffPanel, { DiffWorkerPoolProvider } from "../components/DiffPanel";
 import Sidebar from "../components/Sidebar";
 import { useStore } from "../store";
 
@@ -11,7 +11,11 @@
     <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
       <Sidebar />
       <Outlet />
-      {state.diffOpen && <DiffPanel />}
+      {state.diffOpen && (
+        <DiffWorkerPoolProvider>
+          <DiffPanel />
+        </DiffWorkerPoolProvider>
+      )}
     </div>
   );
 }

juliusmarminge and others added 3 commits February 18, 2026 22:40
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
);
const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";

const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium

routes/_chat.tsx:17 Conditionally returning <Sheet> vs <aside> as root elements causes React to unmount/remount children when crossing the 1180px breakpoint, losing scroll position and re-initializing workers. Consider always rendering both elements and toggling visibility, or lifting the shared children into a portal that persists across layout changes.

🚀 Want me to fix this? Reply ex: "fix it for me".

🤖 Prompt for AI
In file apps/web/src/routes/_chat.tsx around line 17:

Conditionally returning `<Sheet>` vs `<aside>` as root elements causes React to unmount/remount children when crossing the 1180px breakpoint, losing scroll position and re-initializing workers. Consider always rendering both elements and toggling visibility, or lifting the shared children into a portal that persists across layout changes.

Evidence trail:
Viewed `apps/web/src/routes/_chat.tsx` lines 12-53 at commit `36234e70c6` (conditional return of `<Sheet>` vs `<aside>` in `DiffPanelWrapper`).

- Add `@tanstack/react-virtual` to web dependencies
- Render chat timeline with virtualized rows for work logs, messages, and working indicator
- Replace end-of-list sentinel approach with scroll-container-based virtualization
@juliusmarminge
juliusmarminge merged commit 5cf140b into main Feb 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant