feat(code): introduce TanStack Router file-based routing#2455
feat(code): introduce TanStack Router file-based routing#2455adamleithp wants to merge 1 commit into
Conversation
Replaces the Zustand-driven view switch in MainLayout with TanStack Router (hash history for Electron file:// compatibility). Routes nest under `/code/*` for task surfaces, leaving room for future verticals at the root. - 10 routes generated from `apps/code/src/renderer/routes/`: - `/` → redirect to `/code` - `/code` → TaskInput - `/code/tasks/$taskId` → TaskDetail - `/code/tasks/pending/$key` → TaskPendingView - `/code/inbox`, `/code/archived` - `/settings/$category` (+ `/settings` redirect to `general`) - `/command-center`, `/skills`, `/mcp-servers` - `/folders/$folderId` - `__root.tsx` keeps existing chrome (HeaderRow, MainSidebar, SpaceSwitcher, modals, deep-link hooks) and renders `<Outlet/>` where the view-switch was. - `navigationStore` and `settingsDialogStore` kept as transitional shims that mirror their actions to `router.navigate(...)` via a new `syncToRouter` helper, so the ~35 nav-store and ~20 settings-dialog consumers keep working without per-call migration. - TanStack Router DevTools mounted in dev only. Follow-up PRs noted in the PR description. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
| close: () => { | ||
| if (get().isOpen && window.history.state?.settingsOpen) { | ||
| const wasOpen = get().isOpen; | ||
| if (wasOpen && window.history.state?.settingsOpen) { | ||
| window.history.back(); | ||
| } | ||
| set({ |
There was a problem hiding this comment.
Since the
window.history.pushState({ settingsOpen: true }) call in open() is now dead (see companion comment), the guard here reading window.history.state?.settingsOpen is always false and window.history.back() is never called. This dead branch can be removed, leaving just the router.navigate({ to: "/code" }) path to handle the URL change on close.
| close: () => { | |
| if (get().isOpen && window.history.state?.settingsOpen) { | |
| const wasOpen = get().isOpen; | |
| if (wasOpen && window.history.state?.settingsOpen) { | |
| window.history.back(); | |
| } | |
| set({ | |
| close: () => { | |
| const wasOpen = get().isOpen; | |
| set({ |
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts
Line: 73-78
Comment:
Since the `window.history.pushState({ settingsOpen: true })` call in `open()` is now dead (see companion comment), the guard here reading `window.history.state?.settingsOpen` is always false and `window.history.back()` is never called. This dead branch can be removed, leaving just the `router.navigate({ to: "/code" })` path to handle the URL change on close.
```suggestion
close: () => {
const wasOpen = get().isOpen;
set({
```
How can I resolve this? If you propose a fix, please make it concise.| <HedgehogMode /> | ||
| {import.meta.env.DEV && ( | ||
| <TanStackRouterDevtools position="bottom-right" /> |
There was a problem hiding this comment.
DevTools bundle not tree-shaken in prod — The top-level
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools" is unconditional, so the entire devtools chunk is included in the production bundle regardless of the import.meta.env.DEV guard on the JSX. The fix is to use React.lazy with a conditional dynamic import so the module is only ever loaded in development builds.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/routes/__root.tsx
Line: 155-157
Comment:
**DevTools bundle not tree-shaken in prod** — The top-level `import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"` is unconditional, so the entire devtools chunk is included in the production bundle regardless of the `import.meta.env.DEV` guard on the JSX. The fix is to use `React.lazy` with a conditional dynamic import so the module is only ever loaded in development builds.
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
useNavigationStoreview switch inMainLayoutwith TanStack Router (hash history for Electronfile://compatibility)./code/*, settings at/settings/$category, infra views (command-center,skills,mcp-servers,folders) at the root so future verticals have room.navigationStore+settingsDialogStorekept alive as transitional shims that mirror every action torouter.navigate(...). The ~35 nav-store and ~20 settings-dialog consumers keep working unchanged.import.meta.env.DEV.Route map
//code/codeTaskInput/code/tasks/$taskIdTaskDetail/code/tasks/pending/$keyTaskPendingView/code/inboxInboxView/code/archivedArchivedTasksView/settings/settings/general/settings/$categorySettingsDialogmodal (see follow-up #2)/command-centerCommandCenterView/skillsSkillsView/mcp-serversMcpServersView/folders/$folderIdFolderSettingsViewKnown gaps — follow-up PRs
Captured here so reviewers don't read this as the chosen end-state architecture.
PR 2 — small cleanup (~half day)
routeTree.gen.ts(currently gitignored — fresh-clone typecheck fails without runningpnpm dev/buildfirst).React.lazy+ conditional dynamic import.as stringcast insyncToRouter— guard already prevents the hazard, cast just hides the type narrowing.close()currently always navigates to/code, losing back context. Switch torouter.history.back()(combined with the existingwindow.history.back()in the modal close, pick exactly one).PR 3 — Settings: commit to page or modal (~1 day)
Today
/settings/$categoryrendersnulland toggles a ZustandisOpenflag, which mounts a Radix Dialog from__root. The URL changes but the chrome (sidebar, header) still shows underneath — incoherent. Either:SettingsPanelfrom the dialog, render it from the route as a real page, use a pathless layout route to hideMainSidebar. ORAlso:
registerBillingSubscriptionsauto-opensplan-usagesettings on limit hit — that's now a navigation, not a modal-over-current-view. Verify the UX hasn't regressed for users mid-task.PR 4 — Delete the nav store (the big one)
This is the actual unlock. Today the store and router are dual sources of truth for navigation;
syncToRouterkeeps them aligned. Cost:set, thenrouter.navigate).syncToRouterswitch, route component).Plan:
apps/code/src/renderer/navigationBridge.tsthat importsrouterand exportssyncToRouter. Both stores import the bridge, not the router directly. Breaks the currentstore → router → routeTree → __root → storecircular import (currently works by ES module live binding luck — fragile).useNavigationStoreconsumers touseParams/useNavigate/<Link>.navigationStore.ts,useNavigationStore.ts,navigationStore.test.ts.PR 5 — Polish
TaskDetail,Inbox,FolderSettings.defaultPreload: "intent"is enabled but unused.autoCodeSplitting: true— for an Electron app loading from local disk, splitting 12 tiny route chunks may net negative on parse cost. Measure first.router.history.go(±1)once nav store's own history stack is gone.TaskInputbriefly whilehydrateTaskresolves; restore last URL synchronously at router creation.Test plan
Automated (passing):
pnpm exec tsc -p tsconfig.web.json --noEmit)Manual (please verify before merge — I haven't run the Electron app end-to-end):
/code(new task screen)#/code/tasks/<id>,TaskDetailrenders, sidebar highlight follows/codewithTaskInput#/settings/general, dialog opens/codeposthog-code://...?task=...→ opens correct task and URLTaskInputflicker is a known gap, see PR 5)🤖 Generated with Claude Code