From f3cce6b5020e5c8d6859821530e2ab466c44aeaf Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 2 Jun 2026 10:29:50 +0100 Subject: [PATCH] feat(code): TanStack Router architectural cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2455. Lands the high-impact cleanups from that PR's review; defers the navigationStore consumer migration (~91 call sites) and full store deletion to follow-up PRs that can be reviewed independently. PR 2 cleanups: - Check in routeTree.gen.ts; .gitattributes marks it as linguist-generated so reviewers don't get noise from regen diffs. Fresh-clone typecheck now works without a prior dev/build run. - TanStack Router DevTools moved to lazy() + Suspense behind import.meta.env.DEV so the ~50KB devtools chunk is excluded from the prod bundle (was getting bundled despite the conditional render). - Drop `as string` cast in syncToRouter; cleaner narrowing via early return. - settings close() now uses router.history.back() instead of hard router.navigate("/code"), so dismissing settings returns to the prior page (e.g. /code/inbox) rather than always landing at /code. - settings open() no longer issues window.history.pushState — it was fighting hashHistory's own state management. Circular import fix: - New apps/code/src/renderer/navigationBridge.ts holds all router.navigate calls used by Zustand stores. Stores import the bridge, not the router. Breaks the store → router → routeTree → __root → store cycle that was working only because ES module bindings are live. - SettingsCategory moved to features/settings/types.ts to keep type imports cycle-free. Settings as a real route (PR 3): - New SettingsPanel.tsx holds the visual content (sidebar + sections). - /settings/$category route renders SettingsPanel as a full-screen page. - __root.tsx detects /settings/* matches and skips the app chrome (HeaderRow, MainSidebar, SpaceSwitcher, TourOverlay, HedgehogMode) for those routes — settings takes the full window. - SettingsDialog.tsx becomes a thin overlay wrapper around SettingsPanel, used only in pre-router shells (AiApprovalScreen) where RouterProvider isn't mounted yet. Cold-boot URL restore (partial PR 5): - router.ts writes window.location.hash to localStorage on every router resolution and restores it synchronously before createTanStackRouter reads location. Eliminates the TaskInput flash users saw on cold start when their last route was a task or inbox view. New taskInputPrefillStore scaffold (unused yet, prep for PR 6) that will hold transient TaskInput prefill (initialPrompt, reportAssociation, etc.) when navigationStore is deleted. Deferred to follow-up PRs (documented in the PR body): - PR 6: full navigationStore deletion. 91 call sites, ~25 read view.data (the Task object), need queryClient module ref to populate view.data from cache when URL is the navigation source. - PR 7: settingsDialogStore consumer migration to useNavigate/. - Route loaders for TaskDetail/Inbox, menu Cmd+[/] hotkeys. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 - apps/code/.gitattributes | 1 + .../settings/components/SettingsDialog.tsx | 323 ++---------------- .../settings/components/SettingsPanel.tsx | 291 ++++++++++++++++ .../stores/settingsDialogStore.test.ts | 3 +- .../settings/stores/settingsDialogStore.ts | 55 +-- .../src/renderer/features/settings/types.ts | 38 +++ .../stores/taskInputPrefillStore.ts | 41 +++ apps/code/src/renderer/navigationBridge.ts | 71 ++++ apps/code/src/renderer/routeTree.gen.ts | 294 ++++++++++++++++ apps/code/src/renderer/router.ts | 31 ++ apps/code/src/renderer/routes/__root.tsx | 56 ++- .../renderer/routes/settings/$category.tsx | 54 ++- .../src/renderer/stores/navigationStore.ts | 40 +-- 14 files changed, 894 insertions(+), 406 deletions(-) create mode 100644 apps/code/.gitattributes create mode 100644 apps/code/src/renderer/features/settings/components/SettingsPanel.tsx create mode 100644 apps/code/src/renderer/features/settings/types.ts create mode 100644 apps/code/src/renderer/features/task-detail/stores/taskInputPrefillStore.ts create mode 100644 apps/code/src/renderer/navigationBridge.ts create mode 100644 apps/code/src/renderer/routeTree.gen.ts diff --git a/.gitignore b/.gitignore index 85b203236b..0d718003e2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,8 +11,6 @@ out/ storybook-static bin/ -# TanStack Router generated route tree -apps/code/src/renderer/routeTree.gen.ts # Environment .env diff --git a/apps/code/.gitattributes b/apps/code/.gitattributes new file mode 100644 index 0000000000..1d461a3d14 --- /dev/null +++ b/apps/code/.gitattributes @@ -0,0 +1 @@ +src/renderer/routeTree.gen.ts linguist-generated=true diff --git a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx index 606229da76..8756d5f127 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsDialog.tsx @@ -1,318 +1,47 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useLogoutMutation } from "@features/auth/hooks/authMutations"; -import { - useAuthStateValue, - useCurrentUser, -} from "@features/auth/hooks/authQueries"; -import { getUserInitials } from "@features/auth/utils/userInitials"; -import { - type SettingsCategory, - useSettingsDialogStore, -} from "@features/settings/stores/settingsDialogStore"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useSeat } from "@hooks/useSeat"; -import { - ArrowLeft, - ArrowsClockwise, - CaretRight, - Code, - CreditCard, - Cube, - Folder, - GearSix, - GithubLogo, - Keyboard, - Palette, - SignOut, - SlackLogo, - Terminal, - TrafficSignal, - TreeStructure, - Wrench, -} from "@phosphor-icons/react"; -import { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; -import { BILLING_FLAG } from "@shared/constants"; -import { type ReactNode, useEffect, useMemo } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { AdvancedSettings } from "./sections/AdvancedSettings"; -import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings"; -import { EnvironmentsSettings } from "./sections/environments/EnvironmentsSettings"; -import { GeneralSettings } from "./sections/GeneralSettings"; -import { GitHubSettings } from "./sections/GitHubSettings"; -import { PersonalizationSettings } from "./sections/PersonalizationSettings"; -import { PlanUsageSettings } from "./sections/PlanUsageSettings"; -import { ShortcutsSettings } from "./sections/ShortcutsSettings"; -import { SignalSourcesSettings } from "./sections/SignalSourcesSettings"; -import { SlackSettings } from "./sections/SlackSettings"; -import { TerminalSettings } from "./sections/TerminalSettings"; -import { UpdatesSettings } from "./sections/UpdatesSettings"; -import { WorkspacesSettings } from "./sections/WorkspacesSettings"; -import { WorktreesSettings } from "./sections/worktrees/WorktreesSettings"; - -interface SidebarItem { - id: SettingsCategory; - label: string; - icon: ReactNode; - hasChevron?: boolean; -} - -const SIDEBAR_ITEMS: SidebarItem[] = [ - { id: "general", label: "General", icon: }, - { id: "plan-usage", label: "Plan & usage", icon: }, - { id: "workspaces", label: "Workspaces", icon: }, - { id: "worktrees", label: "Worktrees", icon: }, - { - id: "environments", - label: "Environments", - icon: , - }, - { - id: "personalization", - label: "Personalization", - icon: , - }, - { id: "terminal", label: "Terminal", icon: }, - { id: "claude-code", label: "Claude Code", icon: }, - { id: "shortcuts", label: "Shortcuts", icon: }, - { id: "github", label: "GitHub", icon: }, - { id: "slack", label: "Slack", icon: }, - - { - id: "signals", - label: "Signals", - icon: , - }, - { id: "updates", label: "Updates", icon: }, - { id: "advanced", label: "Advanced", icon: }, -]; - -const CATEGORY_TITLES: Record = { - general: "General", - "plan-usage": "Plan & usage", - workspaces: "Workspaces", - worktrees: "Worktrees", - environments: "Environments", - "cloud-environments": "Environments", - personalization: "Personalization", - terminal: "Terminal", - "claude-code": "Claude Code", - shortcuts: "Shortcuts", - github: "GitHub", - slack: "Slack integration", - - signals: "Signals", - updates: "Updates", - advanced: "Advanced", -}; - -const CATEGORY_COMPONENTS: Record = { - general: GeneralSettings, - "plan-usage": PlanUsageSettings, - workspaces: WorkspacesSettings, - worktrees: WorktreesSettings, - environments: EnvironmentsSettings, - "cloud-environments": EnvironmentsSettings, - personalization: PersonalizationSettings, - terminal: TerminalSettings, - "claude-code": ClaudeCodeSettings, - shortcuts: ShortcutsSettings, - github: GitHubSettings, - slack: SlackSettings, - - signals: SignalSourcesSettings, - updates: UpdatesSettings, - advanced: AdvancedSettings, -}; - +import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useEffect } from "react"; +import { SettingsPanel } from "./SettingsPanel"; + +// Modal/overlay form of the settings UI. Used in pre-router shells (e.g. +// `AiApprovalScreen`) where the routed `/settings/$category` page isn't +// available because RouterProvider hasn't mounted yet. Inside the main app, +// settings is a real route — `routes/settings/$category.tsx` renders +// `` directly. export function SettingsDialog() { - const { isOpen, activeCategory, close, setCategory, formMode } = - useSettingsDialogStore(); - const isAuthenticated = useAuthStateValue( - (state) => state.status === "authenticated", - ); - const client = useOptionalAuthenticatedClient(); - const { data: user } = useCurrentUser({ client }); - const { seat, planLabel } = useSeat(); - const billingEnabled = useFeatureFlag(BILLING_FLAG); - const logoutMutation = useLogoutMutation(); - - const sidebarItems = useMemo( - () => - billingEnabled - ? SIDEBAR_ITEMS - : SIDEBAR_ITEMS.filter((item) => item.id !== "plan-usage"), - [billingEnabled], - ); - - useHotkeys("escape", close, { - enabled: isOpen, - enableOnContentEditable: true, - enableOnFormTags: true, - preventDefault: true, - }); + const isOpen = useSettingsDialogStore((s) => s.isOpen); + const close = useSettingsDialogStore((s) => s.close); useEffect(() => { + if (!isOpen) return; const handlePopState = () => { - if (isOpen && !window.history.state?.settingsOpen) { + if (!window.history.state?.settingsOpen) { useSettingsDialogStore.setState({ isOpen: false }); } }; - window.addEventListener("popstate", handlePopState); return () => window.removeEventListener("popstate", handlePopState); }, [isOpen]); - if (!isOpen) { - return null; - } - - const ActiveComponent = CATEGORY_COMPONENTS[activeCategory]; + useEffect(() => { + if (!isOpen) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + } + }; + window.addEventListener("keydown", handleEscape); + return () => window.removeEventListener("keydown", handleEscape); + }, [isOpen, close]); - const initials = getUserInitials(user); + if (!isOpen) return null; return (
-
-
- - {isAuthenticated && user && ( - - - - - {user.email} - - {seat && ( - - {planLabel} Plan - - )} - - - )} - - - - -
- {sidebarItems.map((item) => { - const isActive = - activeCategory === item.id || - (item.id === "environments" && - activeCategory === "cloud-environments"); - return ( - setCategory(item.id)} - /> - ); - })} -
-
- - {isAuthenticated && ( - - )} -
- -
-
-
- - - - - {!formMode && ( - - {CATEGORY_TITLES[activeCategory]} - - )} - - - - -
-
+
); } - -interface SidebarNavItemProps { - item: SidebarItem; - isActive: boolean; - onClick: () => void; -} - -function SidebarNavItem({ item, isActive, onClick }: SidebarNavItemProps) { - return ( - - ); -} diff --git a/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx b/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx new file mode 100644 index 0000000000..edbf809dda --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx @@ -0,0 +1,291 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { useLogoutMutation } from "@features/auth/hooks/authMutations"; +import { + useAuthStateValue, + useCurrentUser, +} from "@features/auth/hooks/authQueries"; +import { getUserInitials } from "@features/auth/utils/userInitials"; +import { + type SettingsCategory, + useSettingsDialogStore, +} from "@features/settings/stores/settingsDialogStore"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; +import { useSeat } from "@hooks/useSeat"; +import { + ArrowLeft, + ArrowsClockwise, + CaretRight, + Code, + CreditCard, + Cube, + Folder, + GearSix, + GithubLogo, + Keyboard, + Palette, + SignOut, + SlackLogo, + Terminal, + TrafficSignal, + TreeStructure, + Wrench, +} from "@phosphor-icons/react"; +import { Avatar, Box, Flex, ScrollArea, Text } from "@radix-ui/themes"; +import { BILLING_FLAG } from "@shared/constants"; +import { type ReactNode, useMemo } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { AdvancedSettings } from "./sections/AdvancedSettings"; +import { ClaudeCodeSettings } from "./sections/ClaudeCodeSettings"; +import { EnvironmentsSettings } from "./sections/environments/EnvironmentsSettings"; +import { GeneralSettings } from "./sections/GeneralSettings"; +import { GitHubSettings } from "./sections/GitHubSettings"; +import { PersonalizationSettings } from "./sections/PersonalizationSettings"; +import { PlanUsageSettings } from "./sections/PlanUsageSettings"; +import { ShortcutsSettings } from "./sections/ShortcutsSettings"; +import { SignalSourcesSettings } from "./sections/SignalSourcesSettings"; +import { SlackSettings } from "./sections/SlackSettings"; +import { TerminalSettings } from "./sections/TerminalSettings"; +import { UpdatesSettings } from "./sections/UpdatesSettings"; +import { WorkspacesSettings } from "./sections/WorkspacesSettings"; +import { WorktreesSettings } from "./sections/worktrees/WorktreesSettings"; + +interface SidebarItem { + id: SettingsCategory; + label: string; + icon: ReactNode; + hasChevron?: boolean; +} + +const SIDEBAR_ITEMS: SidebarItem[] = [ + { id: "general", label: "General", icon: }, + { id: "plan-usage", label: "Plan & usage", icon: }, + { id: "workspaces", label: "Workspaces", icon: }, + { id: "worktrees", label: "Worktrees", icon: }, + { id: "environments", label: "Environments", icon: }, + { + id: "personalization", + label: "Personalization", + icon: , + }, + { id: "terminal", label: "Terminal", icon: }, + { id: "claude-code", label: "Claude Code", icon: }, + { id: "shortcuts", label: "Shortcuts", icon: }, + { id: "github", label: "GitHub", icon: }, + { id: "slack", label: "Slack", icon: }, + { id: "signals", label: "Signals", icon: }, + { id: "updates", label: "Updates", icon: }, + { id: "advanced", label: "Advanced", icon: }, +]; + +const CATEGORY_TITLES: Record = { + general: "General", + "plan-usage": "Plan & usage", + workspaces: "Workspaces", + worktrees: "Worktrees", + environments: "Environments", + "cloud-environments": "Environments", + personalization: "Personalization", + terminal: "Terminal", + "claude-code": "Claude Code", + shortcuts: "Shortcuts", + github: "GitHub", + slack: "Slack integration", + signals: "Signals", + updates: "Updates", + advanced: "Advanced", +}; + +const CATEGORY_COMPONENTS: Record = { + general: GeneralSettings, + "plan-usage": PlanUsageSettings, + workspaces: WorkspacesSettings, + worktrees: WorktreesSettings, + environments: EnvironmentsSettings, + "cloud-environments": EnvironmentsSettings, + personalization: PersonalizationSettings, + terminal: TerminalSettings, + "claude-code": ClaudeCodeSettings, + shortcuts: ShortcutsSettings, + github: GitHubSettings, + slack: SlackSettings, + signals: SignalSourcesSettings, + updates: UpdatesSettings, + advanced: AdvancedSettings, +}; + +export function SettingsPanel() { + const { activeCategory, close, setCategory, formMode } = + useSettingsDialogStore(); + const isAuthenticated = useAuthStateValue( + (state) => state.status === "authenticated", + ); + const client = useOptionalAuthenticatedClient(); + const { data: user } = useCurrentUser({ client }); + const { seat, planLabel } = useSeat(); + const billingEnabled = useFeatureFlag(BILLING_FLAG); + const logoutMutation = useLogoutMutation(); + + const sidebarItems = useMemo( + () => + billingEnabled + ? SIDEBAR_ITEMS + : SIDEBAR_ITEMS.filter((item) => item.id !== "plan-usage"), + [billingEnabled], + ); + + useHotkeys("escape", close, { + enabled: true, + enableOnContentEditable: true, + enableOnFormTags: true, + preventDefault: true, + }); + + const ActiveComponent = CATEGORY_COMPONENTS[activeCategory]; + const initials = getUserInitials(user); + + return ( +
+
+
+ + {isAuthenticated && user && ( + + + + + {user.email} + + {seat && ( + + {planLabel} Plan + + )} + + + )} + + + + +
+ {sidebarItems.map((item) => { + const isActive = + activeCategory === item.id || + (item.id === "environments" && + activeCategory === "cloud-environments"); + return ( + setCategory(item.id)} + /> + ); + })} +
+
+ + {isAuthenticated && ( + + )} +
+ +
+
+
+ + + + + {!formMode && ( + + {CATEGORY_TITLES[activeCategory]} + + )} + + + + +
+
+
+ ); +} + +interface SidebarNavItemProps { + item: SidebarItem; + isActive: boolean; + onClick: () => void; +} + +function SidebarNavItem({ item, isActive, onClick }: SidebarNavItemProps) { + return ( + + ); +} diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts index d5166cfc92..6d936ffd46 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts +++ b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts @@ -4,6 +4,7 @@ vi.mock("@renderer/router", () => ({ router: { navigate: vi.fn(), state: { matches: [] }, + history: { back: vi.fn() }, }, })); @@ -11,8 +12,6 @@ import { useSettingsDialogStore } from "./settingsDialogStore"; describe("settingsDialogStore", () => { beforeEach(() => { - vi.spyOn(window.history, "pushState").mockImplementation(() => {}); - vi.spyOn(window.history, "back").mockImplementation(() => {}); useSettingsDialogStore.setState({ isOpen: false, activeCategory: "general", diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts index 7d673ff6f0..21c4c43e7e 100644 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts +++ b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts @@ -1,22 +1,12 @@ -import { router } from "@renderer/router"; +import type { SettingsCategory } from "@features/settings/types"; +import { + goBackInHistory, + isOnSettingsRoute, + navigateToSettings, +} from "@renderer/navigationBridge"; import { create } from "zustand"; -export type SettingsCategory = - | "general" - | "plan-usage" - | "workspaces" - | "worktrees" - | "environments" - | "cloud-environments" - | "personalization" - | "terminal" - | "claude-code" - | "shortcuts" - | "github" - | "slack" - | "signals" - | "updates" - | "advanced"; +export type { SettingsCategory }; interface SettingsDialogContext { repoPath?: string; @@ -53,9 +43,6 @@ export const useSettingsDialogStore = create()( formMode: false, open: (category, contextOrAction) => { - if (!get().isOpen) { - window.history.pushState({ settingsOpen: true }, ""); - } const isAction = typeof contextOrAction === "string"; const nextCategory = category ?? get().activeCategory; set({ @@ -65,38 +52,28 @@ export const useSettingsDialogStore = create()( initialAction: isAction ? contextOrAction : null, formMode: false, }); - void router.navigate({ - to: "/settings/$category", - params: { category: nextCategory }, - }); + // Router push handles browser-history integration; we no longer need a + // manual window.history.pushState (which was colliding with hashHistory). + navigateToSettings(nextCategory); }, close: () => { const wasOpen = get().isOpen; - if (wasOpen && window.history.state?.settingsOpen) { - window.history.back(); - } set({ isOpen: false, context: {}, initialAction: null, formMode: false, }); - if (wasOpen) { - const matches = router.state.matches; - const onSettings = matches.some((m) => - m.routeId.startsWith("/settings"), - ); - if (onSettings) { - void router.navigate({ to: "/code" }); - } + if (!wasOpen) return; + if (isOnSettingsRoute()) { + // Prefer history.back() so the user returns to their prior context + // (e.g. /code/inbox), not a hard reset to /code. + goBackInHistory(); } }, setCategory: (category) => { set({ activeCategory: category, initialAction: null, formMode: false }); - void router.navigate({ - to: "/settings/$category", - params: { category }, - }); + navigateToSettings(category); }, clearContext: () => set({ context: {} }), consumeInitialAction: () => { diff --git a/apps/code/src/renderer/features/settings/types.ts b/apps/code/src/renderer/features/settings/types.ts new file mode 100644 index 0000000000..4382a64df9 --- /dev/null +++ b/apps/code/src/renderer/features/settings/types.ts @@ -0,0 +1,38 @@ +export type SettingsCategory = + | "general" + | "plan-usage" + | "workspaces" + | "worktrees" + | "environments" + | "cloud-environments" + | "personalization" + | "terminal" + | "claude-code" + | "shortcuts" + | "github" + | "slack" + | "signals" + | "updates" + | "advanced"; + +export const SETTINGS_CATEGORIES: readonly SettingsCategory[] = [ + "general", + "plan-usage", + "workspaces", + "worktrees", + "environments", + "cloud-environments", + "personalization", + "terminal", + "claude-code", + "shortcuts", + "github", + "slack", + "signals", + "updates", + "advanced", +]; + +export function isSettingsCategory(value: string): value is SettingsCategory { + return (SETTINGS_CATEGORIES as readonly string[]).includes(value); +} diff --git a/apps/code/src/renderer/features/task-detail/stores/taskInputPrefillStore.ts b/apps/code/src/renderer/features/task-detail/stores/taskInputPrefillStore.ts new file mode 100644 index 0000000000..e9e98c1897 --- /dev/null +++ b/apps/code/src/renderer/features/task-detail/stores/taskInputPrefillStore.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +export interface TaskInputReportAssociation { + reportId: string; + title: string; +} + +export interface TaskInputPrefill { + requestId?: string; + folderId?: string; + initialPrompt?: string; + initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; + reportAssociation?: TaskInputReportAssociation; +} + +interface PrefillStoreState { + prefill: TaskInputPrefill; + setPrefill: (prefill: TaskInputPrefill) => void; + clearReportAssociation: () => void; + clear: () => void; +} + +// Holds transient state used to prefill the TaskInput screen when navigation +// is triggered with options (e.g. deep links, "discuss in new task" flows). +// Lives outside the URL because the values are large/structured and don't +// belong in a hash fragment. +export const useTaskInputPrefillStore = create((set) => ({ + prefill: {}, + setPrefill: (prefill) => set({ prefill }), + clearReportAssociation: () => + set((s) => ({ + prefill: { + ...s.prefill, + reportAssociation: undefined, + initialCloudRepository: undefined, + }, + })), + clear: () => set({ prefill: {} }), +})); diff --git a/apps/code/src/renderer/navigationBridge.ts b/apps/code/src/renderer/navigationBridge.ts new file mode 100644 index 0000000000..213ee1610a --- /dev/null +++ b/apps/code/src/renderer/navigationBridge.ts @@ -0,0 +1,71 @@ +import type { SettingsCategory } from "@features/settings/types"; +import { router } from "@renderer/router"; + +// This bridge isolates router calls used by Zustand stores so the stores +// don't import the router directly. Importing `@renderer/router` from a store +// would create a cycle through routeTree.gen.ts → __root.tsx → store, which +// works today only because ES module bindings are live. +// +// Once the navigationStore is deleted, the only consumer here is the settings +// store helpers — and ideally those go too as settings consumers move to +// useNavigate/. + +export function navigateToCode(): void { + void router.navigate({ to: "/code" }); +} + +export function navigateToTaskDetail(taskId: string): void { + void router.navigate({ + to: "/code/tasks/$taskId", + params: { taskId }, + }); +} + +export function navigateToTaskPending(key: string): void { + void router.navigate({ + to: "/code/tasks/pending/$key", + params: { key }, + }); +} + +export function navigateToFolderSettings(folderId: string): void { + void router.navigate({ + to: "/folders/$folderId", + params: { folderId }, + }); +} + +export function navigateToInbox(): void { + void router.navigate({ to: "/code/inbox" }); +} + +export function navigateToArchived(): void { + void router.navigate({ to: "/code/archived" }); +} + +export function navigateToCommandCenter(): void { + void router.navigate({ to: "/command-center" }); +} + +export function navigateToSkills(): void { + void router.navigate({ to: "/skills" }); +} + +export function navigateToMcpServers(): void { + void router.navigate({ to: "/mcp-servers" }); +} + +export function navigateToSettings(category: SettingsCategory): void { + void router.navigate({ + to: "/settings/$category", + params: { category }, + }); +} + +export function isOnSettingsRoute(): boolean { + return router.state.matches.some((m) => m.routeId.startsWith("/settings")); +} + +export function goBackInHistory(): void { + router.history.back(); +} diff --git a/apps/code/src/renderer/routeTree.gen.ts b/apps/code/src/renderer/routeTree.gen.ts new file mode 100644 index 0000000000..e6d4d9846e --- /dev/null +++ b/apps/code/src/renderer/routeTree.gen.ts @@ -0,0 +1,294 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as SkillsRouteImport } from './routes/skills' +import { Route as McpServersRouteImport } from './routes/mcp-servers' +import { Route as CommandCenterRouteImport } from './routes/command-center' +import { Route as IndexRouteImport } from './routes/index' +import { Route as SettingsIndexRouteImport } from './routes/settings/index' +import { Route as CodeIndexRouteImport } from './routes/code/index' +import { Route as SettingsCategoryRouteImport } from './routes/settings/$category' +import { Route as FoldersFolderIdRouteImport } from './routes/folders/$folderId' +import { Route as CodeInboxRouteImport } from './routes/code/inbox' +import { Route as CodeArchivedRouteImport } from './routes/code/archived' +import { Route as CodeTasksTaskIdRouteImport } from './routes/code/tasks/$taskId' +import { Route as CodeTasksPendingKeyRouteImport } from './routes/code/tasks/pending.$key' + +const SkillsRoute = SkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => rootRouteImport, +} as any) +const McpServersRoute = McpServersRouteImport.update({ + id: '/mcp-servers', + path: '/mcp-servers', + getParentRoute: () => rootRouteImport, +} as any) +const CommandCenterRoute = CommandCenterRouteImport.update({ + id: '/command-center', + path: '/command-center', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsIndexRoute = SettingsIndexRouteImport.update({ + id: '/settings/', + path: '/settings/', + getParentRoute: () => rootRouteImport, +} as any) +const CodeIndexRoute = CodeIndexRouteImport.update({ + id: '/code/', + path: '/code/', + getParentRoute: () => rootRouteImport, +} as any) +const SettingsCategoryRoute = SettingsCategoryRouteImport.update({ + id: '/settings/$category', + path: '/settings/$category', + getParentRoute: () => rootRouteImport, +} as any) +const FoldersFolderIdRoute = FoldersFolderIdRouteImport.update({ + id: '/folders/$folderId', + path: '/folders/$folderId', + getParentRoute: () => rootRouteImport, +} as any) +const CodeInboxRoute = CodeInboxRouteImport.update({ + id: '/code/inbox', + path: '/code/inbox', + getParentRoute: () => rootRouteImport, +} as any) +const CodeArchivedRoute = CodeArchivedRouteImport.update({ + id: '/code/archived', + path: '/code/archived', + getParentRoute: () => rootRouteImport, +} as any) +const CodeTasksTaskIdRoute = CodeTasksTaskIdRouteImport.update({ + id: '/code/tasks/$taskId', + path: '/code/tasks/$taskId', + getParentRoute: () => rootRouteImport, +} as any) +const CodeTasksPendingKeyRoute = CodeTasksPendingKeyRouteImport.update({ + id: '/code/tasks/pending/$key', + path: '/code/tasks/pending/$key', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/command-center': typeof CommandCenterRoute + '/mcp-servers': typeof McpServersRoute + '/skills': typeof SkillsRoute + '/code/archived': typeof CodeArchivedRoute + '/code/inbox': typeof CodeInboxRoute + '/folders/$folderId': typeof FoldersFolderIdRoute + '/settings/$category': typeof SettingsCategoryRoute + '/code/': typeof CodeIndexRoute + '/settings/': typeof SettingsIndexRoute + '/code/tasks/$taskId': typeof CodeTasksTaskIdRoute + '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/command-center': typeof CommandCenterRoute + '/mcp-servers': typeof McpServersRoute + '/skills': typeof SkillsRoute + '/code/archived': typeof CodeArchivedRoute + '/code/inbox': typeof CodeInboxRoute + '/folders/$folderId': typeof FoldersFolderIdRoute + '/settings/$category': typeof SettingsCategoryRoute + '/code': typeof CodeIndexRoute + '/settings': typeof SettingsIndexRoute + '/code/tasks/$taskId': typeof CodeTasksTaskIdRoute + '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/command-center': typeof CommandCenterRoute + '/mcp-servers': typeof McpServersRoute + '/skills': typeof SkillsRoute + '/code/archived': typeof CodeArchivedRoute + '/code/inbox': typeof CodeInboxRoute + '/folders/$folderId': typeof FoldersFolderIdRoute + '/settings/$category': typeof SettingsCategoryRoute + '/code/': typeof CodeIndexRoute + '/settings/': typeof SettingsIndexRoute + '/code/tasks/$taskId': typeof CodeTasksTaskIdRoute + '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/command-center' + | '/mcp-servers' + | '/skills' + | '/code/archived' + | '/code/inbox' + | '/folders/$folderId' + | '/settings/$category' + | '/code/' + | '/settings/' + | '/code/tasks/$taskId' + | '/code/tasks/pending/$key' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/command-center' + | '/mcp-servers' + | '/skills' + | '/code/archived' + | '/code/inbox' + | '/folders/$folderId' + | '/settings/$category' + | '/code' + | '/settings' + | '/code/tasks/$taskId' + | '/code/tasks/pending/$key' + id: + | '__root__' + | '/' + | '/command-center' + | '/mcp-servers' + | '/skills' + | '/code/archived' + | '/code/inbox' + | '/folders/$folderId' + | '/settings/$category' + | '/code/' + | '/settings/' + | '/code/tasks/$taskId' + | '/code/tasks/pending/$key' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + CommandCenterRoute: typeof CommandCenterRoute + McpServersRoute: typeof McpServersRoute + SkillsRoute: typeof SkillsRoute + CodeArchivedRoute: typeof CodeArchivedRoute + CodeInboxRoute: typeof CodeInboxRoute + FoldersFolderIdRoute: typeof FoldersFolderIdRoute + SettingsCategoryRoute: typeof SettingsCategoryRoute + CodeIndexRoute: typeof CodeIndexRoute + SettingsIndexRoute: typeof SettingsIndexRoute + CodeTasksTaskIdRoute: typeof CodeTasksTaskIdRoute + CodeTasksPendingKeyRoute: typeof CodeTasksPendingKeyRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/skills': { + id: '/skills' + path: '/skills' + fullPath: '/skills' + preLoaderRoute: typeof SkillsRouteImport + parentRoute: typeof rootRouteImport + } + '/mcp-servers': { + id: '/mcp-servers' + path: '/mcp-servers' + fullPath: '/mcp-servers' + preLoaderRoute: typeof McpServersRouteImport + parentRoute: typeof rootRouteImport + } + '/command-center': { + id: '/command-center' + path: '/command-center' + fullPath: '/command-center' + preLoaderRoute: typeof CommandCenterRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/': { + id: '/settings/' + path: '/settings' + fullPath: '/settings/' + preLoaderRoute: typeof SettingsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/code/': { + id: '/code/' + path: '/code' + fullPath: '/code/' + preLoaderRoute: typeof CodeIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/settings/$category': { + id: '/settings/$category' + path: '/settings/$category' + fullPath: '/settings/$category' + preLoaderRoute: typeof SettingsCategoryRouteImport + parentRoute: typeof rootRouteImport + } + '/folders/$folderId': { + id: '/folders/$folderId' + path: '/folders/$folderId' + fullPath: '/folders/$folderId' + preLoaderRoute: typeof FoldersFolderIdRouteImport + parentRoute: typeof rootRouteImport + } + '/code/inbox': { + id: '/code/inbox' + path: '/code/inbox' + fullPath: '/code/inbox' + preLoaderRoute: typeof CodeInboxRouteImport + parentRoute: typeof rootRouteImport + } + '/code/archived': { + id: '/code/archived' + path: '/code/archived' + fullPath: '/code/archived' + preLoaderRoute: typeof CodeArchivedRouteImport + parentRoute: typeof rootRouteImport + } + '/code/tasks/$taskId': { + id: '/code/tasks/$taskId' + path: '/code/tasks/$taskId' + fullPath: '/code/tasks/$taskId' + preLoaderRoute: typeof CodeTasksTaskIdRouteImport + parentRoute: typeof rootRouteImport + } + '/code/tasks/pending/$key': { + id: '/code/tasks/pending/$key' + path: '/code/tasks/pending/$key' + fullPath: '/code/tasks/pending/$key' + preLoaderRoute: typeof CodeTasksPendingKeyRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + CommandCenterRoute: CommandCenterRoute, + McpServersRoute: McpServersRoute, + SkillsRoute: SkillsRoute, + CodeArchivedRoute: CodeArchivedRoute, + CodeInboxRoute: CodeInboxRoute, + FoldersFolderIdRoute: FoldersFolderIdRoute, + SettingsCategoryRoute: SettingsCategoryRoute, + CodeIndexRoute: CodeIndexRoute, + SettingsIndexRoute: SettingsIndexRoute, + CodeTasksTaskIdRoute: CodeTasksTaskIdRoute, + CodeTasksPendingKeyRoute: CodeTasksPendingKeyRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/code/src/renderer/router.ts b/apps/code/src/renderer/router.ts index 2584c73894..2bc8b933bd 100644 --- a/apps/code/src/renderer/router.ts +++ b/apps/code/src/renderer/router.ts @@ -4,6 +4,23 @@ import { } from "@tanstack/react-router"; import { routeTree } from "./routeTree.gen"; +const LAST_ROUTE_KEY = "code:last-route-hash"; + +// Cold-boot URL restore: Electron's BrowserWindow.loadFile resets the URL +// hash, so a quit + relaunch loses the user's last route. localStorage is +// sync, so we can read the persisted hash before the router parses location. +// Without this the user sees a TaskInput flash before hydrateTask catches up. +if (typeof window !== "undefined" && !window.location.hash) { + try { + const last = window.localStorage.getItem(LAST_ROUTE_KEY); + if (last && last !== "#" && last !== "#/") { + window.location.hash = last; + } + } catch { + // localStorage may throw in restricted contexts; safe to ignore. + } +} + export const router = createTanStackRouter({ routeTree, history: createHashHistory(), @@ -11,6 +28,20 @@ export const router = createTanStackRouter({ scrollRestoration: false, }); +// Persist current hash on every navigation so we can restore it next boot. +if (typeof window !== "undefined") { + router.subscribe("onResolved", () => { + try { + const hash = window.location.hash; + if (hash && hash !== "#" && hash !== "#/") { + window.localStorage.setItem(LAST_ROUTE_KEY, hash); + } + } catch { + // Ignore localStorage failures. + } + }); +} + declare module "@tanstack/react-router" { interface Register { router: typeof router; diff --git a/apps/code/src/renderer/routes/__root.tsx b/apps/code/src/renderer/routes/__root.tsx index fb6b12e41a..ef95f70055 100644 --- a/apps/code/src/renderer/routes/__root.tsx +++ b/apps/code/src/renderer/routes/__root.tsx @@ -5,7 +5,6 @@ import { SpaceSwitcher } from "@components/SpaceSwitcher"; import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; import { CommandMenu } from "@features/command/components/CommandMenu"; import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; -import { SettingsDialog } from "@features/settings/components/SettingsDialog"; import { useSetupDiscovery } from "@features/setup/hooks/useSetupDiscovery"; import { MainSidebar } from "@features/sidebar/components/MainSidebar"; import { useSidebarData } from "@features/sidebar/hooks/useSidebarData"; @@ -25,10 +24,25 @@ import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore"; import { useQueryClient } from "@tanstack/react-query"; -import { createRootRoute, Outlet } from "@tanstack/react-router"; -import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; +import { + createRootRoute, + Outlet, + useRouterState, +} from "@tanstack/react-router"; import { logger } from "@utils/logger"; -import { useCallback, useEffect, useRef } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef } from "react"; + +// Dynamic import keeps the devtools chunk out of the prod bundle. Without the +// gate at the import level, conditional render alone still ships ~50KB of +// devtools code to users. +const TanStackRouterDevtools = import.meta.env.DEV + ? lazy(() => + import("@tanstack/react-router-devtools").then((m) => ({ + default: m.TanStackRouterDevtools, + })), + ) + : () => null; + import { GlobalEventHandlers } from "../components/GlobalEventHandlers"; import { useNewTaskDeepLink } from "../hooks/useNewTaskDeepLink"; import { useTaskDeepLink } from "../hooks/useTaskDeepLink"; @@ -122,6 +136,35 @@ function RootLayout() { toggleCommandMenu(); }, [toggleCommandMenu]); + // Settings is a full-page route — drop the app chrome (header/sidebar/ + // space-switcher) so the panel occupies the full window. + const isSettingsRoute = useRouterState({ + select: (s) => s.matches.some((m) => m.routeId.startsWith("/settings")), + }); + + if (isSettingsRoute) { + return ( + + + + (open ? null : closeShortcutsSheet())} + /> + + {billingEnabled && } + {import.meta.env.DEV && ( + + + + )} + + ); + } + return ( @@ -149,12 +192,13 @@ function RootLayout() { onToggleCommandMenu={handleToggleCommandMenu} onToggleShortcutsSheet={toggleShortcutsSheet} /> - {billingEnabled && } {import.meta.env.DEV && ( - + + + )} ); diff --git a/apps/code/src/renderer/routes/settings/$category.tsx b/apps/code/src/renderer/routes/settings/$category.tsx index 8ed11cc9c5..cc21a78d5e 100644 --- a/apps/code/src/renderer/routes/settings/$category.tsx +++ b/apps/code/src/renderer/routes/settings/$category.tsx @@ -1,54 +1,40 @@ +import { SettingsPanel } from "@features/settings/components/SettingsPanel"; import { type SettingsCategory, useSettingsDialogStore, } from "@features/settings/stores/settingsDialogStore"; +import { isSettingsCategory } from "@features/settings/types"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; -const VALID_CATEGORIES: SettingsCategory[] = [ - "general", - "plan-usage", - "workspaces", - "worktrees", - "environments", - "cloud-environments", - "personalization", - "terminal", - "claude-code", - "shortcuts", - "github", - "slack", - "signals", - "updates", - "advanced", -]; - export const Route = createFileRoute("/settings/$category")({ component: SettingsRoute, }); function SettingsRoute() { const { category } = Route.useParams(); + const cat: SettingsCategory = isSettingsCategory(category) + ? category + : "general"; + // Sync the settings store's category to the URL param. Components nested in + // SettingsPanel still read activeCategory from the store; this keeps both in + // sync when navigation lands here from a deep link or back/forward. useEffect(() => { - const cat = VALID_CATEGORIES.includes(category as SettingsCategory) - ? (category as SettingsCategory) - : "general"; - const store = useSettingsDialogStore.getState(); - if (!store.isOpen || store.activeCategory !== cat) { - store.open(cat); + const state = useSettingsDialogStore.getState(); + if (state.activeCategory !== cat || !state.isOpen) { + useSettingsDialogStore.setState({ + isOpen: true, + activeCategory: cat, + formMode: false, + }); } return () => { - // Closing here would trigger close()'s navigate-to-/code, which is the - // desired behavior when the user navigates away from the settings URL. - // The dialog component closes itself on Escape; this cleanup only fires - // when the route is unmounted by router navigation. - const current = useSettingsDialogStore.getState(); - if (current.isOpen && current.activeCategory === cat) { - useSettingsDialogStore.setState({ isOpen: false }); - } + // Clear the open flag when leaving the settings route so legacy + // consumers reading `isOpen` don't see a stale value. + useSettingsDialogStore.setState({ isOpen: false, formMode: false }); }; - }, [category]); + }, [cat]); - return null; + return ; } diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 615fb41c02..acd9fa6500 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -1,7 +1,7 @@ import { foldersApi } from "@features/folders/hooks/useFolders"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; -import { router } from "@renderer/router"; +import * as nav from "@renderer/navigationBridge"; import type { Task } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { setActiveTaskAnalyticsContext, track } from "@utils/analytics"; @@ -19,46 +19,34 @@ const log = logger.scope("navigation-store"); const syncToRouter = (view: ViewState) => { switch (view.type) { case "task-input": - void router.navigate({ to: "/code" }); + nav.navigateToCode(); return; - case "task-detail": - if (view.taskId || view.data?.id) { - void router.navigate({ - to: "/code/tasks/$taskId", - params: { taskId: view.taskId ?? (view.data?.id as string) }, - }); - } + case "task-detail": { + const taskId = view.taskId ?? view.data?.id; + if (!taskId) return; + nav.navigateToTaskDetail(taskId); return; + } case "task-pending": - if (view.pendingTaskKey) { - void router.navigate({ - to: "/code/tasks/pending/$key", - params: { key: view.pendingTaskKey }, - }); - } + if (view.pendingTaskKey) nav.navigateToTaskPending(view.pendingTaskKey); return; case "folder-settings": - if (view.folderId) { - void router.navigate({ - to: "/folders/$folderId", - params: { folderId: view.folderId }, - }); - } + if (view.folderId) nav.navigateToFolderSettings(view.folderId); return; case "inbox": - void router.navigate({ to: "/code/inbox" }); + nav.navigateToInbox(); return; case "archived": - void router.navigate({ to: "/code/archived" }); + nav.navigateToArchived(); return; case "command-center": - void router.navigate({ to: "/command-center" }); + nav.navigateToCommandCenter(); return; case "skills": - void router.navigate({ to: "/skills" }); + nav.navigateToSkills(); return; case "mcp-servers": - void router.navigate({ to: "/mcp-servers" }); + nav.navigateToMcpServers(); return; } };