diff --git a/.gitignore b/.gitignore index 90dbc33161..0d718003e2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ out/ storybook-static bin/ + # Environment .env .env.local 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/package.json b/apps/code/package.json index dee944027f..cf39116137 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -23,6 +23,7 @@ "build-icons": "bash scripts/generate-icns.sh", "typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.web.json --noEmit", "generate-client": "tsx scripts/update-openapi-client.ts", + "generate-routes": "node scripts/generate-routes.mjs", "test": "vitest run", "test:e2e": "playwright test --config=tests/e2e/playwright.config.ts", "test:e2e:headed": "playwright test --config=tests/e2e/playwright.config.ts --headed", @@ -143,7 +144,10 @@ "@radix-ui/themes": "^3.2.1", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.90.2", + "@tanstack/react-router": "^1.95.0", + "@tanstack/react-router-devtools": "^1.95.0", "@tanstack/react-virtual": "^3.13.26", + "@tanstack/router-plugin": "^1.95.0", "@tiptap/core": "^3.13.0", "@tiptap/extension-mention": "^3.13.0", "@tiptap/extension-placeholder": "^3.13.0", diff --git a/apps/code/scripts/generate-routes.mjs b/apps/code/scripts/generate-routes.mjs new file mode 100644 index 0000000000..1f3e122615 --- /dev/null +++ b/apps/code/scripts/generate-routes.mjs @@ -0,0 +1,20 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Generator, getConfig } from "@tanstack/router-generator"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); + +const config = getConfig( + { + target: "react", + autoCodeSplitting: true, + routesDirectory: path.resolve(root, "src/renderer/routes"), + generatedRouteTree: path.resolve(root, "src/renderer/routeTree.gen.ts"), + }, + root, +); + +const generator = new Generator({ config, root }); +await generator.run(); +console.log("Generated routeTree.gen.ts"); diff --git a/apps/code/src/renderer/App.tsx b/apps/code/src/renderer/App.tsx index a5748db25b..adda957201 100644 --- a/apps/code/src/renderer/App.tsx +++ b/apps/code/src/renderer/App.tsx @@ -1,6 +1,5 @@ import { ErrorBoundary } from "@components/ErrorBoundary"; import { LoginTransition } from "@components/LoginTransition"; -import { MainLayout } from "@components/MainLayout"; import { ScopeReauthPrompt } from "@components/ScopeReauthPrompt"; import { AiApprovalScreen } from "@features/ai-approval/components/AiApprovalScreen"; import { AuthScreen } from "@features/auth/components/AuthScreen"; @@ -18,6 +17,7 @@ import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { Flex, Spinner, Text } from "@radix-ui/themes"; import { initializeConnectivityToast } from "@renderer/features/connectivity/connectivityToast"; +import { router } from "@renderer/router"; import { initializeConnectivityStore } from "@renderer/stores/connectivityStore"; import { useFocusStore } from "@renderer/stores/focusStore"; import { useThemeStore } from "@renderer/stores/themeStore"; @@ -26,6 +26,7 @@ import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { isNotAuthenticatedError } from "@shared/errors"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useQueryClient } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; import { useSubscription } from "@trpc/tanstack-react-query"; import { initializePostHog, registerAppVersion, track } from "@utils/analytics"; import { logger } from "@utils/logger"; @@ -292,7 +293,7 @@ function App() { animate={{ opacity: 1 }} transition={{ duration: 0.5, delay: showTransition ? 0.5 : 0 }} > - + ); }; diff --git a/apps/code/src/renderer/components/GlobalEventHandlers.tsx b/apps/code/src/renderer/components/GlobalEventHandlers.tsx index 2e7fe4c763..87ec8b4a5c 100644 --- a/apps/code/src/renderer/components/GlobalEventHandlers.tsx +++ b/apps/code/src/renderer/components/GlobalEventHandlers.tsx @@ -2,18 +2,25 @@ import { useReviewNavigationStore } from "@features/code-review/stores/reviewNav import { useFolders } from "@features/folders/hooks/useFolders"; import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore"; import { getSessionService } from "@features/sessions/service/service"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { openSettings } from "@features/settings/hooks/useOpenSettings"; import { useSidebarData } from "@features/sidebar/hooks/useSidebarData"; import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { useFocusWorkspace } from "@features/workspace/hooks/useFocusWorkspace"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import { useAppView } from "@hooks/useAppView"; +import { openTask, openTaskInput } from "@hooks/useOpenTask"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; +import { + goBackInHistory, + goForwardInHistory, + navigateToFolderSettings, + navigateToInbox, +} from "@renderer/navigationBridge"; import { useTRPC } from "@renderer/trpc"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { useSubscription } from "@trpc/tanstack-react-query"; import { clearApplicationStorage } from "@utils/clearStorage"; import { shipIt } from "@utils/confetti"; @@ -32,18 +39,10 @@ export function GlobalEventHandlers({ }: GlobalEventHandlersProps) { const trpcReact = useTRPC(); const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); - const openSettingsDialog = useSettingsDialogStore((state) => state.open); - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); - const navigateToTask = useNavigationStore((state) => state.navigateToTask); - const navigateToInbox = useNavigationStore((state) => state.navigateToInbox); - const navigateToFolderSettings = useNavigationStore( - (state) => state.navigateToFolderSettings, - ); - const view = useNavigationStore((state) => state.view); - const goBack = useNavigationStore((state) => state.goBack); - const goForward = useNavigationStore((state) => state.goForward); + const openSettingsDialog = openSettings; + const view = useAppView(); + const goBack = goBackInHistory; + const goForward = goForwardInHistory; const { folders, loadFolders } = useFolders(); const { data: workspaces = {} } = useWorkspaces(); const clearAllLayouts = usePanelLayoutStore((state) => state.clearAllLayouts); @@ -55,7 +54,7 @@ export function GlobalEventHandlers({ (state) => state.getReviewMode, ); - const currentTaskId = view.type === "task-detail" ? view.data?.id : undefined; + const currentTaskId = view.type === "task-detail" ? view.taskId : undefined; const { workspace: currentWorkspace, handleToggleFocus } = useFocusWorkspace( currentTaskId ?? "", ); @@ -77,60 +76,51 @@ export function GlobalEventHandlers({ (index: number) => { const taskData = visualTaskOrder[index - 1]; const task = taskData ? taskById.get(taskData.id) : undefined; - if (task) { - navigateToTask(task); - } + if (task) void openTask(task); }, - [visualTaskOrder, taskById, navigateToTask], + [visualTaskOrder, taskById], ); const handlePrevTask = useCallback(() => { if (visualTaskOrder.length === 0) return; - if (view.type !== "task-detail" || !view.data) { + if (view.type !== "task-detail" || !view.taskId) { const lastTaskData = visualTaskOrder[visualTaskOrder.length - 1]; const task = lastTaskData ? taskById.get(lastTaskData.id) : undefined; - if (task) navigateToTask(task); + if (task) void openTask(task); return; } - const currentIndex = visualTaskOrder.findIndex( - (t) => t.id === view.data?.id, - ); + const currentIndex = visualTaskOrder.findIndex((t) => t.id === view.taskId); const prevIndex = currentIndex <= 0 ? visualTaskOrder.length - 1 : currentIndex - 1; const prevTaskData = visualTaskOrder[prevIndex]; const task = prevTaskData ? taskById.get(prevTaskData.id) : undefined; - if (task) navigateToTask(task); - }, [visualTaskOrder, taskById, navigateToTask, view]); + if (task) void openTask(task); + }, [visualTaskOrder, taskById, view]); const handleNextTask = useCallback(() => { if (visualTaskOrder.length === 0) return; - if (view.type !== "task-detail" || !view.data) { + if (view.type !== "task-detail" || !view.taskId) { const firstTaskData = visualTaskOrder[0]; const task = firstTaskData ? taskById.get(firstTaskData.id) : undefined; - if (task) navigateToTask(task); + if (task) void openTask(task); return; } - const currentIndex = visualTaskOrder.findIndex( - (t) => t.id === view.data?.id, - ); + const currentIndex = visualTaskOrder.findIndex((t) => t.id === view.taskId); const nextIndex = currentIndex >= visualTaskOrder.length - 1 ? 0 : currentIndex + 1; const nextTaskData = visualTaskOrder[nextIndex]; const task = nextTaskData ? taskById.get(nextTaskData.id) : undefined; - if (task) navigateToTask(task); - }, [visualTaskOrder, taskById, navigateToTask, view]); + if (task) void openTask(task); + }, [visualTaskOrder, taskById, view]); const handleOpenSettings = useCallback(() => { openSettingsDialog(); }, [openSettingsDialog]); - const handleFocusTaskMode = useCallback( - (data?: unknown) => { - if (!data) return; - navigateToTaskInput(); - }, - [navigateToTaskInput], - ); + const handleFocusTaskMode = useCallback((data?: unknown) => { + if (!data) return; + openTaskInput(); + }, []); const handleResetLayout = useCallback( (data?: unknown) => { @@ -271,16 +261,16 @@ export function GlobalEventHandlers({ // Check if current task's folder became invalid (e.g., moved while app was open) useEffect(() => { - if (view.type !== "task-detail" || !view.data) return; + if (view.type !== "task-detail" || !view.taskId) return; - const workspace = workspaces[view.data.id]; + const workspace = workspaces[view.taskId]; if (!workspace?.folderId) return; const folder = folders.find((f) => f.id === workspace.folderId); if (folder && folder.exists === false) { navigateToFolderSettings(folder.id); } - }, [view, folders, workspaces, navigateToFolderSettings]); + }, [view, folders, workspaces]); useSubscription( trpcReact.ui.onOpenSettings.subscriptionOptions(undefined, { diff --git a/apps/code/src/renderer/components/HeaderRow.tsx b/apps/code/src/renderer/components/HeaderRow.tsx index bad22ee33a..38ccea28b5 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -10,14 +10,15 @@ import { useHandoffDialogStore } from "@features/sessions/stores/handoffDialogSt import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; import { SkillButtonsMenu } from "@features/skill-buttons/components/SkillButtonsMenu"; +import { useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspace } from "@features/workspace/hooks/useWorkspace"; +import { useAppView } from "@hooks/useAppView"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Cloud, Spinner } from "@phosphor-icons/react"; import { Button as QuillButton } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import { useHeaderStore } from "@stores/headerStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { isWindows } from "@utils/platform"; import { useState } from "react"; @@ -108,14 +109,21 @@ const WINDOWS_TITLEBAR_INSET = 140; export function HeaderRow() { const content = useHeaderStore((state) => state.content); - const view = useNavigationStore((state) => state.view); + const view = useAppView(); const sidebarOpen = useSidebarStore((state) => state.open); const sidebarWidth = useSidebarStore((state) => state.width); const isResizing = useSidebarStore((state) => state.isResizing); const setIsResizing = useSidebarStore((state) => state.setIsResizing); - const activeTaskId = view.type === "task-detail" ? view.data?.id : undefined; + const activeTaskId = view.type === "task-detail" ? view.taskId : undefined; + // Read the live task from the list cache instead of a stale snapshot off the + // memoized view, so header content (diff stats, status) stays current while + // the user remains on the task. + const { data: tasks } = useTasks(); + const activeTask = activeTaskId + ? tasks?.find((t) => t.id === activeTaskId) + : undefined; const activeWorkspace = useWorkspace(activeTaskId); const isCloudTask = activeWorkspace?.mode === "cloud"; const showTaskSection = view.type === "task-detail"; @@ -172,7 +180,7 @@ export function HeaderRow() { )} - {showTaskSection && view.type === "task-detail" && view.data && ( + {showTaskSection && view.type === "task-detail" && activeTask && (
- +
{activeWorkspace && (activeWorkspace.branchName || activeWorkspace.baseBranch) && ( @@ -198,18 +206,21 @@ export function HeaderRow() { activeWorkspace.baseBranch ?? null } - taskId={view.data.id} + taskId={activeTask.id} /> )} - + {isCloudTask ? ( - + ) : ( - + )} - +
)} diff --git a/apps/code/src/renderer/components/RoutePending.tsx b/apps/code/src/renderer/components/RoutePending.tsx new file mode 100644 index 0000000000..e917a272d0 --- /dev/null +++ b/apps/code/src/renderer/components/RoutePending.tsx @@ -0,0 +1,18 @@ +import { Flex, Spinner } from "@radix-ui/themes"; + +// Default per-route pending UI. TanStack Router renders a route's +// `pendingComponent` (falling back to this) the moment its loader is pending, +// so navigation commits instantly and each route shows a loading state while +// its data resolves. Routes can override `pendingComponent` with a tailored +// skeleton later — this centered spinner is the baseline. +// +// It fills its slot in normal flow (height: 100%) rather than `absolute +// inset-0`: the Outlet's container isn't positioned, so an absolute overlay +// would escape to the viewport and flash over the sidebar/header. +export function RoutePending() { + return ( + + + + ); +} diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx index 9075faa289..cf114aec9c 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.test.tsx @@ -4,9 +4,15 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const approveAiDataProcessing = vi.fn(); -const logoutMutate = vi.fn(); -const openSettings = vi.fn(); +// Hoisted so the spies are initialized before the hoisted vi.mock factories +// that reference them run. +const { approveAiDataProcessing, logoutMutate, openSettings } = vi.hoisted( + () => ({ + approveAiDataProcessing: vi.fn(), + logoutMutate: vi.fn(), + openSettings: vi.fn(), + }), +); vi.mock("@features/auth/hooks/authClient", () => ({ useAuthenticatedClient: () => ({ approveAiDataProcessing }), @@ -22,12 +28,7 @@ vi.mock("@features/auth/hooks/authQueries", () => ({ vi.mock("@features/settings/components/SettingsDialog", () => ({ SettingsDialog: () => null, -})); - -vi.mock("@features/settings/stores/settingsDialogStore", () => ({ - useSettingsDialogStore: ( - selector: (state: { open: typeof openSettings }) => unknown, - ) => selector({ open: openSettings }), + openSettingsDialog: openSettings, })); vi.mock("@utils/analytics", () => ({ track: vi.fn() })); diff --git a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx index 88df47dc64..e33f987f45 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -2,8 +2,10 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useLogoutMutation } from "@features/auth/hooks/authMutations"; import { authKeys } from "@features/auth/hooks/authQueries"; -import { SettingsDialog } from "@features/settings/components/SettingsDialog"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { + openSettingsDialog, + SettingsDialog, +} from "@features/settings/components/SettingsDialog"; import { GearSix, Robot, SignOut, WarningCircle } from "@phosphor-icons/react"; import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; import { SHORTCUTS } from "@renderer/constants/keyboard-shortcuts"; @@ -21,7 +23,6 @@ interface AiApprovalScreenProps { export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const logoutMutation = useLogoutMutation(); - const openSettings = useSettingsDialogStore((s) => s.open); const client = useAuthenticatedClient(); const queryClient = useQueryClient(); @@ -42,7 +43,7 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { track(ANALYTICS_EVENTS.AI_CONSENT_GATE_SHOWN, { is_org_admin: isAdmin }); }, []); - useHotkeys(SHORTCUTS.SETTINGS, () => openSettings(), { + useHotkeys(SHORTCUTS.SETTINGS, () => openSettingsDialog(), { preventDefault: true, enableOnFormTags: true, }); @@ -52,7 +53,7 @@ export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { size="1" variant="ghost" color="gray" - onClick={() => openSettings()} + onClick={() => openSettingsDialog()} className="opacity-70" > diff --git a/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx b/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx index 1dc7f35316..e2312a9101 100644 --- a/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx +++ b/apps/code/src/renderer/features/archive/components/ArchivedTasksView.tsx @@ -1,6 +1,7 @@ import { DotsCircleSpinner } from "@components/DotsCircleSpinner"; import { Tooltip } from "@components/ui/Tooltip"; import { useTasks } from "@features/tasks/hooks/useTasks"; +import { openTask } from "@hooks/useOpenTask"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { @@ -26,7 +27,6 @@ import { import { trpcClient, useTRPC } from "@renderer/trpc"; import type { Task } from "@shared/types"; import type { ArchivedTask } from "@shared/types/archive"; -import { useNavigationStore } from "@stores/navigationStore"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { formatRelativeTimeLong } from "@utils/time"; import { toast } from "@utils/toast"; @@ -524,7 +524,7 @@ export function ArchivedTasksView() { action: task ? { label: "View task", - onClick: () => useNavigationStore.getState().navigateToTask(task), + onClick: () => void openTask(task), } : undefined, }); @@ -600,7 +600,7 @@ export function ArchivedTasksView() { action: task ? { label: "View task", - onClick: () => useNavigationStore.getState().navigateToTask(task), + onClick: () => void openTask(task), } : undefined, }); diff --git a/apps/code/src/renderer/features/auth/hooks/authMutations.ts b/apps/code/src/renderer/features/auth/hooks/authMutations.ts index 81501a0751..0d664fd9ae 100644 --- a/apps/code/src/renderer/features/auth/hooks/authMutations.ts +++ b/apps/code/src/renderer/features/auth/hooks/authMutations.ts @@ -6,10 +6,10 @@ import { import { useAuthUiStateStore } from "@features/auth/stores/authUiStateStore"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { resetSessionService } from "@features/sessions/service/service"; +import { openTaskInput } from "@hooks/useOpenTask"; import { trpcClient } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { CloudRegion } from "@shared/types/regions"; -import { useNavigationStore } from "@stores/navigationStore"; import { useMutation } from "@tanstack/react-query"; import { track } from "@utils/analytics"; @@ -54,7 +54,7 @@ export function useSelectProjectMutation() { onSuccess: async () => { clearAuthScopedQueries(); await refreshAuthStateQuery(); - useNavigationStore.getState().navigateToTaskInput(); + openTaskInput(); }, }); } @@ -95,7 +95,7 @@ export function useLogoutMutation() { onSuccess: async ({ previousState }) => { clearAuthScopedQueries(); useAuthUiStateStore.getState().setStaleRegion(previousState.cloudRegion); - useNavigationStore.getState().navigateToTaskInput(); + openTaskInput(); useOnboardingStore.getState().resetSelections(); await trpcClient.auth.logout.mutate(); diff --git a/apps/code/src/renderer/features/auth/stores/authStore.test.ts b/apps/code/src/renderer/features/auth/stores/authStore.test.ts index b014b6d0fe..00877ed771 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -80,10 +80,8 @@ vi.mock("@utils/queryClient", () => ({ }, })); -vi.mock("@stores/navigationStore", () => ({ - useNavigationStore: { - getState: () => ({ navigateToTaskInput: vi.fn() }), - }, +vi.mock("@hooks/useOpenTask", () => ({ + openTaskInput: vi.fn(), })); import { resetUser, setUserGroups } from "@utils/analytics"; diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 41cb715ca3..5aa1c2df13 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,6 +1,7 @@ import { authKeys, getAuthIdentity } from "@features/auth/hooks/authQueries"; import { useSeatStore } from "@features/billing/stores/seatStore"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { closeSettings } from "@features/settings/hooks/useOpenSettings"; +import { openTaskInput } from "@hooks/useOpenTask"; import { flattenProjectIds, type OrgProjectsMap, @@ -10,7 +11,6 @@ import { trpcClient } from "@renderer/trpc/client"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import type { CloudRegion } from "@shared/types/regions"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useNavigationStore } from "@stores/navigationStore"; import { identifyUser, resetUser, @@ -247,21 +247,21 @@ export const useAuthStore = create((set) => ({ sessionResetCallback?.(); await trpcClient.auth.selectProject.mutate({ projectId }); await syncAuthState(); - useNavigationStore.getState().navigateToTaskInput(); + openTaskInput(); }, switchOrg: async (orgId: string) => { sessionResetCallback?.(); await trpcClient.auth.switchOrg.mutate({ orgId }); await syncAuthState(); - useNavigationStore.getState().navigateToTaskInput(); + openTaskInput(); }, logout: async () => { track(ANALYTICS_EVENTS.USER_LOGGED_OUT); sessionResetCallback?.(); useSeatStore.getState().reset(); - useSettingsDialogStore.getState().close(); + closeSettings(); set((state) => ({ ...state, @@ -281,7 +281,7 @@ export const useAuthStore = create((set) => ({ lastCompletedAuthSyncKey = null; clearAuthenticatedRendererState({ clearAllQueries: true }); - useNavigationStore.getState().navigateToTaskInput(); + openTaskInput(); await trpcClient.auth.logout.mutate(); }, })); diff --git a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx index ec1f27bfb9..3fb6f76cc8 100644 --- a/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx +++ b/apps/code/src/renderer/features/billing/components/SidebarUsageBar.tsx @@ -1,6 +1,6 @@ import { useFreeUsage } from "@features/billing/hooks/useFreeUsage"; import { formatResetTime, isUsageExceeded } from "@features/billing/utils"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { openSettings } from "@features/settings/hooks/useOpenSettings"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { Circle } from "@phosphor-icons/react"; import { BILLING_FLAG } from "@shared/constants"; @@ -15,7 +15,7 @@ export function SidebarUsageBar() { const handleUpgrade = () => { track(ANALYTICS_EVENTS.UPGRADE_PROMPT_CLICKED, { surface: "sidebar" }); - useSettingsDialogStore.getState().open("plan-usage"); + openSettings("plan-usage"); }; if (!usage) { diff --git a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx index 66c5c5e082..c546eaa678 100644 --- a/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx +++ b/apps/code/src/renderer/features/billing/components/TokenSpendAnalysisBanner.tsx @@ -11,7 +11,8 @@ import { formatWindow, } from "@features/billing/utils/spendAnalysisFormat"; import { buildAnalysisPrompt } from "@features/billing/utils/spendAnalysisPrompt"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { closeSettings } from "@features/settings/hooks/useOpenSettings"; +import { openTaskInput } from "@hooks/useOpenTask"; import { ArrowSquareOut, ChartLine, @@ -21,7 +22,6 @@ import { } from "@phosphor-icons/react"; import { Button, Callout, Flex, Spinner, Table, Text } from "@radix-ui/themes"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; const DOCS_URL = "https://posthog.com/docs/llm-analytics"; @@ -219,11 +219,6 @@ function SectionTable({ } function FooterLinks({ data }: { data: SpendAnalysisResponse }) { - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); - const closeSettings = useSettingsDialogStore((state) => state.close); - const handleAnalyseClick = (): void => { track(ANALYTICS_EVENTS.SPEND_ANALYSIS_TASK_OPENED, { total_cost_usd: data.summary.total_cost_usd, @@ -244,7 +239,7 @@ function FooterLinks({ data }: { data: SpendAnalysisResponse }) { // changes the underlying view but the dialog stays mounted on top, so the user // doesn't see the prefilled task input. Close the dialog first. closeSettings(); - navigateToTaskInput({ + openTaskInput({ initialPrompt: buildAnalysisPrompt(data), }); }; diff --git a/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx b/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx index 81e9ab8c33..bf5fe83256 100644 --- a/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx +++ b/apps/code/src/renderer/features/billing/components/UsageLimitModal.tsx @@ -1,6 +1,6 @@ import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; import { formatResetTime } from "@features/billing/utils"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { openSettings } from "@features/settings/hooks/useOpenSettings"; import { useSeat } from "@hooks/useSeat"; import { WarningCircle } from "@phosphor-icons/react"; import { Button, Dialog, Flex, Text } from "@radix-ui/themes"; @@ -34,7 +34,7 @@ export function UsageLimitModal() { surface: "usage_limit_modal", }); hide(); - useSettingsDialogStore.getState().open("plan-usage"); + openSettings("plan-usage"); }; const handleSupport = () => { diff --git a/apps/code/src/renderer/features/billing/subscriptions.ts b/apps/code/src/renderer/features/billing/subscriptions.ts index 94efa1bb59..24c336fccc 100644 --- a/apps/code/src/renderer/features/billing/subscriptions.ts +++ b/apps/code/src/renderer/features/billing/subscriptions.ts @@ -1,6 +1,6 @@ import { useUsageLimitStore } from "@features/billing/stores/usageLimitStore"; import { formatResetTime } from "@features/billing/utils"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { openSettings } from "@features/settings/hooks/useOpenSettings"; import { trpcClient } from "@renderer/trpc/client"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; @@ -8,7 +8,7 @@ import { toast } from "@utils/toast"; const log = logger.scope("billing-subscriptions"); const openPlanUsage = () => { - useSettingsDialogStore.getState().open("plan-usage"); + openSettings("plan-usage"); }; export function registerBillingSubscriptions() { diff --git a/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx b/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx index 6a5fa61aa8..61c41d5a05 100644 --- a/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx +++ b/apps/code/src/renderer/features/command-center/components/CommandCenterPanel.tsx @@ -3,6 +3,7 @@ import { useDraftStore } from "@features/message-editor/stores/draftStore"; import { TaskIcon } from "@features/sidebar/components/items/TaskIcon"; import { useTaskPrStatus } from "@features/sidebar/hooks/useTaskPrStatus"; import { TaskInput } from "@features/task-detail/components/TaskInput"; +import { openTask } from "@hooks/useOpenTask"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { ArrowsOut, @@ -15,7 +16,6 @@ import { } from "@phosphor-icons/react"; import { Flex, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; -import { useNavigationStore } from "@stores/navigationStore"; import { useCallback, useEffect, useRef, useState } from "react"; import type { CellStatus, @@ -195,12 +195,11 @@ function PopulatedCell({ cell: CommandCenterCellData & { task: Task }; isActiveSession: boolean; }) { - const navigateToTask = useNavigationStore((s) => s.navigateToTask); const removeTask = useCommandCenterStore((s) => s.removeTask); const handleExpand = useCallback(() => { - navigateToTask(cell.task); - }, [navigateToTask, cell.task]); + void openTask(cell.task); + }, [cell.task]); const handleRemove = useCallback(() => { removeTask(cell.cellIndex); diff --git a/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx b/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx index 5cd09d1fe4..3f51e2c12b 100644 --- a/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx +++ b/apps/code/src/renderer/features/command-center/components/TaskSelector.tsx @@ -1,7 +1,7 @@ import { Combobox } from "@components/ui/combobox/Combobox"; +import { openTaskInput } from "@hooks/useOpenTask"; import { Plus } from "@phosphor-icons/react"; import { Popover } from "@radix-ui/themes"; -import { useNavigationStore } from "@stores/navigationStore"; import { type ReactNode, useCallback } from "react"; import { useAvailableTasks } from "../hooks/useAvailableTasks"; import { useCommandCenterStore } from "../stores/commandCenterStore"; @@ -23,7 +23,6 @@ export function TaskSelector({ }: TaskSelectorProps) { const availableTasks = useAvailableTasks(); const assignTask = useCommandCenterStore((s) => s.assignTask); - const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); const handleSelect = useCallback( (taskId: string) => { @@ -38,9 +37,9 @@ export function TaskSelector({ if (onNewTask) { onNewTask(); } else { - navigateToTaskInput(); + openTaskInput(); } - }, [onOpenChange, onNewTask, navigateToTaskInput]); + }, [onOpenChange, onNewTask]); return ( state.open); - const closeSettingsDialog = useSettingsDialogStore((state) => state.close); + const openSettingsDialog = openSettings; + const closeSettingsDialog = closeSettings; const { folders } = useFolders(); const { theme, setTheme } = useThemeStore(); const toggleLeftSidebar = useSidebarStore((state) => state.toggle); - const view = useNavigationStore((state) => state.view); + const view = useAppView(); const setReviewMode = useReviewNavigationStore( (state) => state.setReviewMode, ); @@ -110,7 +113,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }, []); const openReviewPanel = useCallback(() => { - const taskId = view.type === "task-detail" ? view.data?.id : undefined; + const taskId = view.type === "task-detail" ? view.taskId : undefined; if (!taskId) return; const mode = getReviewMode(taskId); if (mode === "closed") { @@ -173,7 +176,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { action: "home", onRun: () => { closeSettingsDialog(); - navigateToTaskInput(); + openTaskInput(); }, }, { @@ -209,7 +212,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { action: "new-task", onRun: () => { closeSettingsDialog(); - navigateToTaskInput(); + openTaskInput(); }, }, ]; @@ -230,7 +233,7 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { action: "new-task", onRun: () => { closeSettingsDialog(); - navigateToTaskInput(folder.id); + openTaskInput(folder.id); }, })), }); @@ -240,7 +243,6 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { }, [ folders, themeOptions, - navigateToTaskInput, openSettingsDialog, closeSettingsDialog, toggleLeftSidebar, @@ -259,12 +261,12 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { action: "open-task" as CommandMenuAction, onRun: () => { closeSettingsDialog(); - navigateToTask(task); + void openTask(task); }, })), }, ]; - }, [tasks, navigateToTask, closeSettingsDialog]); + }, [tasks, closeSettingsDialog]); // Commands and tasks share a single filterable list. const sections = useMemo( diff --git a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx b/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx index 389f450cde..d5a6ac55f8 100644 --- a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx +++ b/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx @@ -1,4 +1,4 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { openSettings } from "@features/settings/hooks/useOpenSettings"; import { CaretDown, HardDrives, Plus } from "@phosphor-icons/react"; import { Button, @@ -59,9 +59,7 @@ export function EnvironmentSelector({ const handleOpenSettings = () => { setOpen(false); - useSettingsDialogStore - .getState() - .open("environments", { repoPath: repoPath ?? undefined }); + openSettings("environments", { repoPath: repoPath ?? undefined }); }; const isDisabled = disabled || !repoPath; diff --git a/apps/code/src/renderer/features/git-interaction/hooks/useFixWithAgent.ts b/apps/code/src/renderer/features/git-interaction/hooks/useFixWithAgent.ts index a9e1939214..f5825e215c 100644 --- a/apps/code/src/renderer/features/git-interaction/hooks/useFixWithAgent.ts +++ b/apps/code/src/renderer/features/git-interaction/hooks/useFixWithAgent.ts @@ -1,6 +1,6 @@ import { useSessionForTask } from "@features/sessions/stores/sessionStore"; import { sendPromptToAgent } from "@features/sessions/utils/sendPromptToAgent"; -import { useNavigationStore } from "@stores/navigationStore"; +import { useAppView } from "@hooks/useAppView"; import { useCallback } from "react"; import type { FixWithAgentPrompt } from "../utils/errorPrompts"; @@ -16,9 +16,8 @@ export function useFixWithAgent( canFixWithAgent: boolean; fixWithAgent: (error: string) => Promise; } { - const taskId = useNavigationStore((s) => - s.view.type === "task-detail" ? s.view.data?.id : undefined, - ); + const view = useAppView(); + const taskId = view.type === "task-detail" ? view.taskId : undefined; const session = useSessionForTask(taskId); const isSessionReady = session?.status === "connected"; diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 955db61c9d..bab01018cc 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -34,6 +34,7 @@ import { } from "@features/inbox/utils/filterReports"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; import { setPendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; +import { useAppView } from "@hooks/useAppView"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { useIntegrations, @@ -43,7 +44,6 @@ import { Box, Flex, ScrollArea } from "@radix-ui/themes"; import { isDismissalReasonSnooze } from "@shared/dismissalReasons"; import type { SignalReport, SignalReportsQueryParams } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { track } from "@utils/analytics"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -120,7 +120,7 @@ export function InboxSignalsTab() { // ── Polling control ───────────────────────────────────────────────────── const windowFocused = useRendererWindowFocusStore((s) => s.focused); - const isInboxView = useNavigationStore((s) => s.view.type === "inbox"); + const isInboxView = useAppView().type === "inbox"; const inboxPollingActive = windowFocused && isInboxView; const inboxSourcesPrerequisitesLoaded = diff --git a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts index 77203e8bfe..bba623899d 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts @@ -2,12 +2,12 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useCreateTask } from "@features/tasks/hooks/useTasks"; import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; +import { openTask } from "@hooks/useOpenTask"; import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { toast } from "@renderer/utils/toast"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { useCallback, useState } from "react"; @@ -48,7 +48,6 @@ export function useCreatePrReport({ cloudRepository, }: UseCreatePrReportOptions): UseCreatePrReportReturn { const [isCreatingPr, setIsCreatingPr] = useState(false); - const { navigateToTask } = useNavigationStore(); const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); const { invalidateTasks } = useCreateTask(); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); @@ -119,7 +118,7 @@ export function useCreatePrReport({ const taskService = get(RENDERER_TOKENS.TaskService); const result = await taskService.createTask(input, (output) => { invalidateTasks(output.task); - navigateToTask(output.task); + void openTask(output.task); }); if (result.success) { @@ -167,7 +166,6 @@ export function useCreatePrReport({ reportTitle, getUserIntegrationIdForRepo, invalidateTasks, - navigateToTask, ]); return { createPrReport, isCreatingPr }; diff --git a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts index 2b660a1681..2da49cd517 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts @@ -2,12 +2,12 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useCreateTask } from "@features/tasks/hooks/useTasks"; import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; +import { openTask } from "@hooks/useOpenTask"; import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { toast } from "@renderer/utils/toast"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { useCallback, useState } from "react"; @@ -47,7 +47,6 @@ export function useDiscussReport({ cloudRepository, }: UseDiscussReportOptions): UseDiscussReportReturn { const [isDiscussing, setIsDiscussing] = useState(false); - const { navigateToTask } = useNavigationStore(); const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); const { invalidateTasks } = useCreateTask(); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); @@ -121,7 +120,7 @@ export function useDiscussReport({ const taskService = get(RENDERER_TOKENS.TaskService); const result = await taskService.createTask(input, (output) => { invalidateTasks(output.task); - navigateToTask(output.task); + void openTask(output.task); }); if (result.success) { @@ -170,7 +169,6 @@ export function useDiscussReport({ reportTitle, getUserIntegrationIdForRepo, invalidateTasks, - navigateToTask, ], ); diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts index aa619e4373..e7f1e86e07 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts @@ -7,8 +7,8 @@ import { reportKeys } from "@features/inbox/hooks/useInboxReports"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { setPendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; +import { navigateToInbox } from "@renderer/navigationBridge"; import { trpcClient, useTRPC } from "@renderer/trpc"; -import { useNavigationStore } from "@stores/navigationStore"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { logger } from "@utils/logger"; @@ -40,7 +40,6 @@ export function useInboxDeepLink() { ); const pendingDrainedRef = useRef(false); - const navigateToInbox = useNavigationStore((s) => s.navigateToInbox); const setSelectedReportIds = useInboxReportSelectionStore( (s) => s.setSelectedReportIds, ); @@ -78,7 +77,7 @@ export function useInboxDeepLink() { toast.error("Failed to open report"); } }, - [client, navigateToInbox, queryClient, resetFilters, setSelectedReportIds], + [client, queryClient, resetFilters, setSelectedReportIds], ); useEffect(() => { diff --git a/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts b/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts index c70fa57e7c..5cf4ece09e 100644 --- a/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts +++ b/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts @@ -1,5 +1,6 @@ import { useDraftStore } from "@features/message-editor/stores/draftStore"; import type { ToolCall } from "@features/sessions/types"; +import { getAppViewSnapshot } from "@hooks/useAppView"; import { AppBridge, type McpUiDisplayMode, @@ -14,7 +15,6 @@ import type { Tool, } from "@modelcontextprotocol/sdk/types.js"; import type { McpUiResource } from "@shared/types/mcp-apps"; -import { useNavigationStore } from "@stores/navigationStore"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef } from "react"; import { @@ -213,10 +213,10 @@ export function useAppBridge(args: UseAppBridgeArgs): UseAppBridgeReturn { const message = textParts.join("\n"); if (message) { // Route to the current task's session, or "default" if not on a task - const view = useNavigationStore.getState().view; + const view = getAppViewSnapshot(); const sessionId = view.type === "task-detail" - ? (view.data?.id ?? "default") + ? (view.taskId ?? "default") : "default"; const { setPendingContent, requestFocus } = useDraftStore.getState().actions; diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 3ee898f3a9..89c07b7c9a 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -3,6 +3,7 @@ import { useLogoutMutation } from "@features/auth/hooks/authMutations"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useUserGithubIntegrations } from "@hooks/useIntegrations"; +import { openTaskInput } from "@hooks/useOpenTask"; import { ArrowRight, SignOut } from "@phosphor-icons/react"; import { Button, Flex } from "@radix-ui/themes"; import { IS_DEV } from "@shared/constants/environment"; @@ -10,7 +11,6 @@ import { ANALYTICS_EVENTS, type OnboardingStepCompletedProperties, } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; import { shipIt } from "@utils/confetti"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; @@ -49,9 +49,6 @@ export function OnboardingFlow() { (state) => state.completeOnboarding, ); const resetOnboarding = useOnboardingStore((state) => state.resetOnboarding); - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); const logoutMutation = useLogoutMutation(); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", @@ -147,7 +144,7 @@ export function OnboardingFlow() { }); shipIt(); completeOnboarding(); - navigateToTaskInput(); + openTaskInput(); }; const handleSkip = () => { @@ -157,7 +154,7 @@ export function OnboardingFlow() { reason: "dev_skip", }); completeOnboarding(); - navigateToTaskInput(); + openTaskInput(); }; const handleLogout = () => { diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts index ae236342fb..05f4975563 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts @@ -1,9 +1,9 @@ import { tryExecuteCodeCommand } from "@features/message-editor/commands"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed"; +import { getAppViewSnapshot } from "@hooks/useAppView"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; -import { useNavigationStore } from "@stores/navigationStore"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { useCallback, useRef } from "react"; @@ -59,9 +59,9 @@ export function useSessionCallbacks({ markActivity(taskId); await getSessionService().sendPrompt(taskId, text); - const view = useNavigationStore.getState().view; + const view = getAppViewSnapshot(); const isViewingTask = - view?.type === "task-detail" && view?.data?.id === taskId; + view?.type === "task-detail" && view?.taskId === taskId; if (isViewingTask) { markAsViewed(taskId); } diff --git a/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx b/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx index 61ea0c4b75..f4fe4c5a21 100644 --- a/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx +++ b/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx @@ -1,4 +1,6 @@ import { useFolders } from "@features/folders/hooks/useFolders"; +import { useAppView } from "@hooks/useAppView"; +import { openTaskInput } from "@hooks/useOpenTask"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { ArrowLeft, Warning } from "@phosphor-icons/react"; import { @@ -11,7 +13,6 @@ import { Heading, Text, } from "@radix-ui/themes"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; import { logger } from "@utils/logger"; import { useState } from "react"; @@ -20,7 +21,7 @@ const log = logger.scope("folder-settings"); export function FolderSettingsView() { useSetHeaderContent(null); - const { view, navigateToTaskInput } = useNavigationStore(); + const view = useAppView(); const { folders, removeFolder } = useFolders(); const folderId = view.type === "folder-settings" ? view.folderId : undefined; @@ -32,7 +33,7 @@ export function FolderSettingsView() { if (!folderId) return; try { await removeFolder(folderId); - navigateToTaskInput(); + openTaskInput(); } catch (err) { log.error("Failed to remove folder:", err); setError(err instanceof Error ? err.message : "Failed to remove folder"); @@ -53,7 +54,7 @@ export function FolderSettingsView() { - - -
- {sidebarItems.map((item) => { - const isActive = - activeCategory === item.id || - (item.id === "environments" && - activeCategory === "cloud-environments"); - return ( - setCategory(item.id)} - /> - ); - })} -
-
- - {isAuthenticated && ( - - )} - - -
-
-
- - - - - {!formMode && ( - - {CATEGORY_TITLES[activeCategory]} - - )} - - - - -
-
+ publish({ isOpen: true, category: cat })} + />
); } - -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..3a801d7b5a --- /dev/null +++ b/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx @@ -0,0 +1,312 @@ +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 { closeSettings } from "@features/settings/hooks/useOpenSettings"; +import { useSettingsPageStore } from "@features/settings/stores/settingsPageStore"; +import type { SettingsCategory } from "@features/settings/types"; +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 * as nav from "@renderer/navigationBridge"; +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 interface SettingsPanelProps { + /** + * Override the active category. Defaults to the `$category` URL param + * (which is what every in-app entry point uses). Provided for the + * pre-router `AiApprovalScreen` shell where RouterProvider isn't mounted. + */ + activeCategory?: SettingsCategory; + /** Override the close handler. Defaults to router history back. */ + onClose?: () => void; + /** Override the category-change handler. Defaults to router navigation. */ + onCategoryChange?: (category: SettingsCategory) => void; +} + +export function SettingsPanel({ + activeCategory: activeCategoryProp, + onClose, + onCategoryChange, +}: SettingsPanelProps = {}) { + const formMode = useSettingsPageStore((s) => s.formMode); + const activeCategory = activeCategoryProp ?? "general"; + const close = onClose ?? closeSettings; + const setCategory = + onCategoryChange ?? + ((cat: SettingsCategory) => nav.navigateToSettings(cat, { replace: true })); + 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/components/sections/AdvancedSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx index 72dd67866e..fd586b18f1 100644 --- a/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/AdvancedSettings.tsx @@ -1,6 +1,6 @@ import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { SettingRow } from "@features/settings/components/SettingRow"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { closeSettings } from "@features/settings/hooks/useOpenSettings"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useSetupStore } from "@features/setup/stores/setupStore"; import { useTourStore } from "@features/tour/stores/tourStore"; @@ -26,7 +26,7 @@ export function AdvancedSettings() { variant="soft" size="1" onClick={() => { - useSettingsDialogStore.getState().close(); + closeSettings(); useOnboardingStore.getState().resetOnboarding(); useSetupStore.getState().resetSetup(); useTourStore.getState().resetTours(); diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/CloudEnvironmentsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/environments/CloudEnvironmentsSettings.tsx index 399190ab24..8cc1f13fd5 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/CloudEnvironmentsSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/environments/CloudEnvironmentsSettings.tsx @@ -1,5 +1,5 @@ import { useSandboxEnvironments } from "@features/settings/hooks/useSandboxEnvironments"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useSettingsPageStore } from "@features/settings/stores/settingsPageStore"; import { ArrowLeft, PencilSimple, Plus, Trash } from "@phosphor-icons/react"; import { ChevronDownIcon } from "@radix-ui/react-icons"; import { @@ -192,10 +192,10 @@ export function CloudEnvironmentsSettings() { updateMutation, deleteMutation, } = useSandboxEnvironments(); - const consumeInitialAction = useSettingsDialogStore( + const consumeInitialAction = useSettingsPageStore( (s) => s.consumeInitialAction, ); - const setFormMode = useSettingsDialogStore((s) => s.setFormMode); + const setFormMode = useSettingsPageStore((s) => s.setFormMode); const [editingEnv, setEditingEnv] = useState(null); const [isCreating, setIsCreating] = useState(false); const [form, setForm] = useState(emptyForm()); diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentsSettings.tsx index 74d084aaeb..6ee610542a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentsSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/environments/EnvironmentsSettings.tsx @@ -1,21 +1,32 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useSettingsPageStore } from "@features/settings/stores/settingsPageStore"; import { Cloud, HardDrives } from "@phosphor-icons/react"; import { Flex, SegmentedControl, Text } from "@radix-ui/themes"; +import { navigateToSettings } from "@renderer/navigationBridge"; +import { useRouterState } from "@tanstack/react-router"; import { CloudEnvironmentsSettings } from "./CloudEnvironmentsSettings"; import { LocalEnvironmentsSettings } from "./LocalEnvironmentsSettings"; type Segment = "local" | "cloud"; export function EnvironmentsSettings() { - const activeCategory = useSettingsDialogStore((s) => s.activeCategory); - const setCategory = useSettingsDialogStore((s) => s.setCategory); - const formMode = useSettingsDialogStore((s) => s.formMode); + const formMode = useSettingsPageStore((s) => s.formMode); + // Read category from the URL — falls back to "environments" when the + // component is rendered outside a router shell (e.g. AiApprovalScreen). + const activeCategory = useRouterState({ + select: (s) => { + const match = s.matches.find((m) => m.routeId === "/settings/$category"); + const params = match?.params as { category?: string } | undefined; + return params?.category ?? "environments"; + }, + }); const segment: Segment = activeCategory === "cloud-environments" ? "cloud" : "local"; const handleSegmentChange = (value: string) => { - setCategory(value === "cloud" ? "cloud-environments" : "environments"); + navigateToSettings( + value === "cloud" ? "cloud-environments" : "environments", + ); }; return ( diff --git a/apps/code/src/renderer/features/settings/components/sections/environments/LocalEnvironmentsSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/environments/LocalEnvironmentsSettings.tsx index b272dc22c7..bd5ac7f472 100644 --- a/apps/code/src/renderer/features/settings/components/sections/environments/LocalEnvironmentsSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/environments/LocalEnvironmentsSettings.tsx @@ -1,5 +1,5 @@ import { useFolders } from "@features/folders/hooks/useFolders"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useSettingsPageStore } from "@features/settings/stores/settingsPageStore"; import type { Environment } from "@main/services/environment/schemas"; import type { RegisteredFolder } from "@main/services/folders/schemas"; import { Flex, Text } from "@radix-ui/themes"; @@ -51,9 +51,9 @@ export function LocalEnvironmentsSettings() { return result.sort((a, b) => a.folder.name.localeCompare(b.folder.name)); }, [folders, environmentQueries]); - const context = useSettingsDialogStore((s) => s.context); - const clearContext = useSettingsDialogStore((s) => s.clearContext); - const setFormMode = useSettingsDialogStore((s) => s.setFormMode); + const context = useSettingsPageStore((s) => s.context); + const clearContext = useSettingsPageStore((s) => s.clearContext); + const setFormMode = useSettingsPageStore((s) => s.setFormMode); useEffect(() => { if (!context.repoPath) return; diff --git a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeRow.tsx b/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeRow.tsx index d252998f4d..7848093575 100644 --- a/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeRow.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/worktrees/WorktreeRow.tsx @@ -1,8 +1,8 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { closeSettings } from "@features/settings/hooks/useOpenSettings"; +import { openTask } from "@hooks/useOpenTask"; import { Trash } from "@phosphor-icons/react"; import { Button, Flex, Text } from "@radix-ui/themes"; import { DotsCircleSpinner } from "@renderer/components/DotsCircleSpinner"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; import type { Task } from "@shared/types"; import { WorktreeSize } from "./WorktreeSize"; @@ -39,16 +39,13 @@ export function WorktreeRow({ isLast, onDelete, }: WorktreeRowProps) { - const { close } = useSettingsDialogStore(); - const { navigateToTask } = useNavigationStore(); - const linkedTasks = worktree.taskIds .map((id) => taskMap.get(id)) .filter((task): task is Task => task !== undefined); const handleTaskClick = (task: Task) => { - close(); - navigateToTask(task); + closeSettings(); + void openTask(task); }; return ( diff --git a/apps/code/src/renderer/features/settings/hooks/useOpenSettings.ts b/apps/code/src/renderer/features/settings/hooks/useOpenSettings.ts new file mode 100644 index 0000000000..2d0e5efcef --- /dev/null +++ b/apps/code/src/renderer/features/settings/hooks/useOpenSettings.ts @@ -0,0 +1,55 @@ +import { useSettingsPageStore } from "@features/settings/stores/settingsPageStore"; +import type { SettingsCategory } from "@features/settings/types"; +import * as nav from "@renderer/navigationBridge"; +import { useRouterState } from "@tanstack/react-router"; +import { useCallback } from "react"; + +interface SettingsContext { + repoPath?: string; +} + +/** + * Open the settings page. Optionally pin context (e.g. repoPath for the + * worktrees page) or fire a one-shot initial action (e.g. "create-new" to + * open the create-environment form on entry). The store holds these; the + * URL holds the category. + */ +export function openSettings( + category: SettingsCategory = "general", + contextOrAction?: SettingsContext | string, +): void { + const store = useSettingsPageStore.getState(); + if (typeof contextOrAction === "string") { + store.setContext({}); + store.setInitialAction(contextOrAction); + } else { + store.setContext(contextOrAction ?? {}); + store.setInitialAction(null); + } + store.setFormMode(false); + nav.navigateToSettings(category); +} + +/** + * Close the settings page — returns the user to their prior route via + * router history. If they came in via a deep link, falls back to /code. + */ +export function closeSettings(): void { + useSettingsPageStore.getState().reset(); + if (nav.isOnSettingsRoute()) { + nav.goBackInHistory(); + } +} + +export function useCloseSettings(): typeof closeSettings { + return useCallback(closeSettings, []); +} + +/** + * True when the current route is anywhere under `/settings/*`. + */ +export function useIsSettingsOpen(): boolean { + return useRouterState({ + select: (s) => s.matches.some((m) => m.routeId.startsWith("/settings")), + }); +} diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts deleted file mode 100644 index 1751746cc0..0000000000 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -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", - context: {}, - initialAction: null, - formMode: false, - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("defaults the first open to general when no category is given", () => { - useSettingsDialogStore.getState().open(); - expect(useSettingsDialogStore.getState().activeCategory).toBe("general"); - }); - - it("remembers the last active category when reopened without a category", () => { - const { open, close, setCategory } = useSettingsDialogStore.getState(); - - open(); - setCategory("terminal"); - close(); - open(); - - expect(useSettingsDialogStore.getState().activeCategory).toBe("terminal"); - }); - - it("respects an explicit category over the remembered one", () => { - const { open, close, setCategory } = useSettingsDialogStore.getState(); - - open(); - setCategory("terminal"); - close(); - open("plan-usage"); - - expect(useSettingsDialogStore.getState().activeCategory).toBe("plan-usage"); - }); - - it("treats a string second argument as an initial action, not context", () => { - useSettingsDialogStore.getState().open("environments", "create-new"); - const state = useSettingsDialogStore.getState(); - expect(state.activeCategory).toBe("environments"); - expect(state.initialAction).toBe("create-new"); - expect(state.context).toEqual({}); - }); - - it("consumeInitialAction returns and clears the pending action", () => { - useSettingsDialogStore.getState().open("environments", "create-new"); - expect(useSettingsDialogStore.getState().consumeInitialAction()).toBe( - "create-new", - ); - expect(useSettingsDialogStore.getState().initialAction).toBeNull(); - }); -}); diff --git a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts b/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts deleted file mode 100644 index b3c1557cf0..0000000000 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts +++ /dev/null @@ -1,88 +0,0 @@ -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"; - -interface SettingsDialogContext { - repoPath?: string; -} - -interface SettingsDialogState { - isOpen: boolean; - activeCategory: SettingsCategory; - context: SettingsDialogContext; - initialAction: string | null; - formMode: boolean; -} - -interface SettingsDialogActions { - open: ( - category?: SettingsCategory, - contextOrAction?: SettingsDialogContext | string, - ) => void; - close: () => void; - setCategory: (category: SettingsCategory) => void; - clearContext: () => void; - consumeInitialAction: () => string | null; - setFormMode: (formMode: boolean) => void; -} - -type SettingsDialogStore = SettingsDialogState & SettingsDialogActions; - -export const useSettingsDialogStore = create()( - (set, get) => ({ - isOpen: false, - activeCategory: "general", - context: {}, - initialAction: null, - formMode: false, - - open: (category, contextOrAction) => { - if (!get().isOpen) { - window.history.pushState({ settingsOpen: true }, ""); - } - const isAction = typeof contextOrAction === "string"; - set({ - isOpen: true, - activeCategory: category ?? get().activeCategory, - context: isAction ? {} : (contextOrAction ?? {}), - initialAction: isAction ? contextOrAction : null, - formMode: false, - }); - }, - close: () => { - if (get().isOpen && window.history.state?.settingsOpen) { - window.history.back(); - } - set({ - isOpen: false, - context: {}, - initialAction: null, - formMode: false, - }); - }, - setCategory: (category) => - set({ activeCategory: category, initialAction: null, formMode: false }), - clearContext: () => set({ context: {} }), - consumeInitialAction: () => { - const action = get().initialAction; - if (action) set({ initialAction: null }); - return action; - }, - setFormMode: (formMode) => set({ formMode }), - }), -); diff --git a/apps/code/src/renderer/features/settings/stores/settingsPageStore.test.ts b/apps/code/src/renderer/features/settings/stores/settingsPageStore.test.ts new file mode 100644 index 0000000000..37903c5810 --- /dev/null +++ b/apps/code/src/renderer/features/settings/stores/settingsPageStore.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useSettingsPageStore } from "./settingsPageStore"; + +describe("settingsPageStore", () => { + beforeEach(() => { + useSettingsPageStore.getState().reset(); + }); + + it("starts with empty context and no initial action or form mode", () => { + const s = useSettingsPageStore.getState(); + expect(s.context).toEqual({}); + expect(s.initialAction).toBeNull(); + expect(s.formMode).toBe(false); + }); + + it("setContext / clearContext write and clear", () => { + useSettingsPageStore.getState().setContext({ repoPath: "/r" }); + expect(useSettingsPageStore.getState().context.repoPath).toBe("/r"); + useSettingsPageStore.getState().clearContext(); + expect(useSettingsPageStore.getState().context).toEqual({}); + }); + + it("consumeInitialAction returns and clears the pending action", () => { + useSettingsPageStore.getState().setInitialAction("create-new"); + expect(useSettingsPageStore.getState().consumeInitialAction()).toBe( + "create-new", + ); + expect(useSettingsPageStore.getState().initialAction).toBeNull(); + }); + + it("setFormMode toggles formMode", () => { + useSettingsPageStore.getState().setFormMode(true); + expect(useSettingsPageStore.getState().formMode).toBe(true); + }); + + it("reset clears everything", () => { + useSettingsPageStore.getState().setContext({ repoPath: "/r" }); + useSettingsPageStore.getState().setInitialAction("a"); + useSettingsPageStore.getState().setFormMode(true); + useSettingsPageStore.getState().reset(); + const s = useSettingsPageStore.getState(); + expect(s.context).toEqual({}); + expect(s.initialAction).toBeNull(); + expect(s.formMode).toBe(false); + }); +}); diff --git a/apps/code/src/renderer/features/settings/stores/settingsPageStore.ts b/apps/code/src/renderer/features/settings/stores/settingsPageStore.ts new file mode 100644 index 0000000000..d5a185d08e --- /dev/null +++ b/apps/code/src/renderer/features/settings/stores/settingsPageStore.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; + +interface SettingsPageContext { + repoPath?: string; +} + +interface SettingsPageState { + context: SettingsPageContext; + initialAction: string | null; + formMode: boolean; +} + +interface SettingsPageActions { + setContext: (context: SettingsPageContext) => void; + clearContext: () => void; + setInitialAction: (action: string | null) => void; + consumeInitialAction: () => string | null; + setFormMode: (formMode: boolean) => void; + reset: () => void; +} + +type SettingsPageStore = SettingsPageState & SettingsPageActions; + +/** + * UI-only state for the Settings page. Holds per-open context (e.g. which + * repo to focus on the worktrees page), a one-shot action (e.g. "open the + * create-environment form on entry"), and the section's form/list mode. + * + * The active category is NOT here — it lives in the URL + * (`/settings/$category`) and is read via `Route.useParams()`. + */ +export const useSettingsPageStore = create()((set, get) => ({ + context: {}, + initialAction: null, + formMode: false, + setContext: (context) => set({ context }), + clearContext: () => set({ context: {} }), + setInitialAction: (action) => set({ initialAction: action }), + consumeInitialAction: () => { + const action = get().initialAction; + if (action) set({ initialAction: null }); + return action; + }, + setFormMode: (formMode) => set({ formMode }), + reset: () => set({ context: {}, initialAction: null, formMode: false }), +})); 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/setup/components/DiscoveredTaskDetailDialog.tsx b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx index 75ce6731e4..92604dec34 100644 --- a/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx +++ b/apps/code/src/renderer/features/setup/components/DiscoveredTaskDetailDialog.tsx @@ -13,6 +13,7 @@ import { FALLBACK_CATEGORY_CONFIG, } from "@features/setup/utils/categoryConfig"; import { useDetectedCloudRepository } from "@hooks/useDetectedCloudRepository"; +import { openTaskInput } from "@hooks/useOpenTask"; import { PlusIcon, SparkleIcon } from "@phosphor-icons/react"; import { Box, @@ -24,7 +25,6 @@ import { } from "@radix-ui/themes"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { useActiveRepoStore } from "@stores/activeRepoStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; interface DiscoveredTaskDetailDialogProps { @@ -67,7 +67,6 @@ function DialogBody({ s.discoveredTasks.filter((t) => isTaskForRepo(t, task.repoPath ?? null)), ); const selectedDirectory = useActiveRepoStore((s) => s.path); - const navigateToTaskInput = useNavigationStore((s) => s.navigateToTaskInput); const { folders } = useFolders(); const detectedCloudRepository = useDetectedCloudRepository(selectedDirectory); @@ -86,7 +85,7 @@ function DialogBody({ .getState() .removeDiscoveredTask(task.id, task.repoPath ?? null); onClose(); - navigateToTaskInput({ + openTaskInput({ initialPrompt, folderId, initialCloudRepository: detectedCloudRepository ?? undefined, diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index 08d182b228..59b38a7495 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -9,7 +9,7 @@ import { } from "@features/auth/hooks/authQueries"; import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; import { useProjects } from "@features/projects/hooks/useProjects"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { openSettings } from "@features/settings/hooks/useOpenSettings"; import { ArrowSquareOut, Check, @@ -94,8 +94,6 @@ export function ProjectSwitcher() { setDialogOpen(true); }; - const openSettings = useSettingsDialogStore((s) => s.open); - const handleSettings = () => { setPopoverOpen(false); openSettings(); diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx index 81dc03740c..42177b90c6 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarContent.tsx @@ -2,7 +2,7 @@ import { useArchivedTaskIds } from "@features/archive/hooks/useArchivedTaskIds"; import { SidebarUsageBar } from "@features/billing/components/SidebarUsageBar"; import { ArchiveIcon } from "@phosphor-icons/react"; import { Box, Flex } from "@radix-ui/themes"; -import { useNavigationStore } from "@stores/navigationStore"; +import { navigateToArchived } from "@renderer/navigationBridge"; import type React from "react"; import { ProjectSwitcher } from "./ProjectSwitcher"; import { SidebarMenu } from "./SidebarMenu"; @@ -10,9 +10,6 @@ import { UpdateBanner } from "./UpdateBanner"; export const SidebarContent: React.FC = () => { const archivedTaskIds = useArchivedTaskIds(); - const navigateToArchived = useNavigationStore( - (state) => state.navigateToArchived, - ); return ( diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 89ff09e1c4..3bd7dba6b1 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -12,13 +12,21 @@ import { } from "@features/tasks/hooks/useArchiveTask"; import { useRenameTask, useTasks } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; +import { useAppView } from "@hooks/useAppView"; +import { openTask, openTaskInput } from "@hooks/useOpenTask"; import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; +import { + navigateToCommandCenter, + navigateToInbox, + navigateToMcpServers, + navigateToSkills, + navigateToTaskDetail, +} from "@renderer/navigationBridge"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; @@ -40,15 +48,7 @@ import { TaskListView } from "./TaskListView"; const log = logger.scope("sidebar-menu"); function SidebarMenuComponent() { - const { - view, - navigateToTask, - navigateToTaskInput, - navigateToInbox, - navigateToCommandCenter, - navigateToSkills, - navigateToMcpServers, - } = useNavigationStore(); + const view = useAppView(); // Must mirror useSidebarData's filters so taskMap covers every rendered // task — otherwise handleTaskClick silently bails for tasks not in the map. @@ -95,7 +95,7 @@ function SidebarMenuComponent() { useEffect(() => { const currentTaskId = - view.type === "task-detail" && view.data ? view.data.id : null; + view.type === "task-detail" && view.taskId ? view.taskId : null; if ( previousTaskIdRef.current && @@ -112,7 +112,7 @@ function SidebarMenuComponent() { }, [view, markAsViewed]); const handleNewTaskClick = () => { - navigateToTaskInput(); + openTaskInput(); }; const handleInboxClick = () => { @@ -211,7 +211,12 @@ function SidebarMenuComponent() { clearSelection(); const task = taskMap.get(taskId); if (task) { - navigateToTask(task); + void openTask(task); + } else { + // Sidebar rows come from the summaries path, which can include tasks the + // full-list query (taskMap) doesn't carry. Don't silently bail — navigate + // by id; the task-detail route resolves the task from its own query. + navigateToTaskDetail(taskId); } }; diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 9a3b17f17a..9882e8bbf2 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -2,7 +2,9 @@ import { PointerSensor } from "@dnd-kit/dom"; import type { DragDropEvents } from "@dnd-kit/react"; import { DragDropProvider } from "@dnd-kit/react"; import { useFolders } from "@features/folders/hooks/useFolders"; +import { useAppView } from "@hooks/useAppView"; import { useMeQuery } from "@hooks/useMeQuery"; +import { openTaskInput } from "@hooks/useOpenTask"; import { FunnelSimple as FunnelSimpleIcon, GitBranch, @@ -23,7 +25,6 @@ import builderHog from "@renderer/assets/images/hedgehogs/builder-hog-03.png"; import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace"; import { normalizeRepoKey } from "@shared/utils/repo"; import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { getRelativeDateGroup } from "@utils/time"; import { motion } from "framer-motion"; import { Fragment, useCallback, useEffect, useMemo } from "react"; @@ -286,13 +287,9 @@ export function TaskListView({ (state) => state.resetHistoryVisibleCount, ); const { folders } = useFolders(); - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); - const isOnTaskInput = useNavigationStore( - (state) => - state.view.type === "task-input" || state.view.type === "task-pending", - ); + const view = useAppView(); + const isOnTaskInput = + view.type === "task-input" || view.type === "task-pending"; // biome-ignore lint/correctness/useExhaustiveDependencies: reset pagination when filters change useEffect(() => { @@ -398,7 +395,7 @@ export function TaskListView({ navigateToTaskInput()} + onClick={() => openTaskInput()} whileHover={{ scale: 1.05, backgroundColor: "var(--gray-4)" }} whileTap={{ scale: 0.97 }} > @@ -443,9 +440,9 @@ export function TaskListView({ tooltipContent={folder?.path ?? group.id} onNewTask={() => { if (groupFolderId) { - navigateToTaskInput(groupFolderId); + openTaskInput(groupFolderId); } else { - navigateToTaskInput(); + openTaskInput(); } }} newTaskTooltip={`Start new task in ${folder?.name ?? group.name}`} diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index c7a49b150a..f4f872b8c4 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -9,7 +9,7 @@ import { } from "@features/tasks/hooks/useTasks"; import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import type { Schemas } from "@renderer/api/generated"; -import type { Task, TaskRunStatus } from "@shared/types"; +import type { TaskRunStatus } from "@shared/types"; import { useEffect, useMemo, useRef } from "react"; import { useSidebarStore } from "../stores/sidebarStore"; import type { SortMode } from "../types"; @@ -75,7 +75,7 @@ interface ViewState { | "skills" | "mcp-servers" | "setup"; - data?: Task; + taskId?: string; } interface UseSidebarDataProps { @@ -226,9 +226,7 @@ export function useSidebarData({ const isMcpServersActive = activeView.type === "mcp-servers"; const activeTaskId = - activeView.type === "task-detail" && activeView.data - ? activeView.data.id - : null; + activeView.type === "task-detail" ? (activeView.taskId ?? null) : null; const sessionByTaskId = useMemo(() => { const map = new Map(); diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index f49729abd4..592c3d8e3f 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -23,6 +23,9 @@ import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModel import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import type { TaskInputReportAssociation } from "@features/task-detail/stores/taskInputPrefillStore"; +import { useTaskInputPrefillStore } from "@features/task-detail/stores/taskInputPrefillStore"; +import { useAppView } from "@hooks/useAppView"; import { useAutoFocusOnTyping } from "@hooks/useAutoFocusOnTyping"; import { useConnectivity } from "@hooks/useConnectivity"; import { @@ -35,13 +38,10 @@ import { ButtonGroup } from "@posthog/quill"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; +import { navigateToInbox } from "@renderer/navigationBridge"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import { useActiveRepoStore } from "@stores/activeRepoStore"; -import { - type TaskInputReportAssociation, - useNavigationStore, -} from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { FOCUSABLE_SELECTOR } from "@utils/overlay"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -76,8 +76,10 @@ export function TaskInput({ }: TaskInputProps = {}) { const { cloudRegion } = useAuthStore(); const trpcReact = useTRPC(); - const { view, clearTaskInputReportAssociation, navigateToInbox } = - useNavigationStore(); + const view = useAppView(); + const clearTaskInputReportAssociation = useTaskInputPrefillStore( + (s) => s.clearReportAssociation, + ); const setSelectedReportIds = useInboxReportSelectionStore( (s) => s.setSelectedReportIds, ); @@ -168,7 +170,7 @@ export function TaskInput({ if (!activeReportAssociation) return; navigateToInbox(); setSelectedReportIds([activeReportAssociation.reportId]); - }, [activeReportAssociation, navigateToInbox, setSelectedReportIds]); + }, [activeReportAssociation, setSelectedReportIds]); useEffect(() => { if (!selectedDirectory && mostRecentRepo?.path) { diff --git a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx b/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx index 21d8c9380d..3dfb10d851 100644 --- a/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx +++ b/apps/code/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx @@ -1,5 +1,5 @@ +import { openSettings } from "@features/settings/hooks/useOpenSettings"; import { useSandboxEnvironments } from "@features/settings/hooks/useSandboxEnvironments"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { @@ -72,13 +72,12 @@ export function WorkspaceModeSelect({ useFeatureFlag("twig-cloud-mode-toggle") || import.meta.env.DEV; const { environments } = useSandboxEnvironments(); - const openSettings = useSettingsDialogStore((s) => s.open); const [menuOpen, setMenuOpen] = useState(false); const handleAddEnvironment = useCallback(() => { setMenuOpen(false); openSettings("cloud-environments", "create"); - }, [openSettings]); + }, []); const showCloud = overrideModes ? overrideModes.includes("cloud") diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index 8b553ab95c..c8b9d531f0 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -9,18 +9,20 @@ import { extractFilePaths, } from "@features/message-editor/utils/content"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { useTaskInputPrefillStore } from "@features/task-detail/stores/taskInputPrefillStore"; import { useCreateTask } from "@features/tasks/hooks/useTasks"; import { useTourStore } from "@features/tour/stores/tourStore"; import { createFirstTaskTour } from "@features/tour/tours/createFirstTaskTour"; import { useConnectivity } from "@hooks/useConnectivity"; +import { openTask, openTaskInput } from "@hooks/useOpenTask"; import type { WorkspaceMode } from "@main/services/workspace/schemas"; import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; +import { navigateToTaskPending } from "@renderer/navigationBridge"; import { trpcClient } from "@renderer/trpc/client"; import { toast } from "@renderer/utils/toast"; import type { ExecutionMode, Task } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; import { pendingTaskPromptStoreApi } from "@stores/pendingTaskPromptStore"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; @@ -190,12 +192,9 @@ export function useTaskCreation({ onTaskCreated, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); - const { - clearTaskInputReportAssociation, - navigateToTask, - navigateToPendingTask, - navigateToTaskInput, - } = useNavigationStore(); + const clearTaskInputReportAssociation = useTaskInputPrefillStore( + (s) => s.clearReportAssociation, + ); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); @@ -232,7 +231,7 @@ export function useTaskCreation({ label: a.label, })), }); - navigateToPendingTask(pendingTaskKey); + navigateToTaskPending(pendingTaskKey); if (!contentOverride) { editor.clear(); } @@ -278,7 +277,7 @@ export function useTaskCreation({ if (onTaskCreated) { onTaskCreated(output.task); } else { - navigateToTask(output.task); + void openTask(output.task); } useTourStore.getState().completeTour(createFirstTaskTour.id); if (!pendingTaskKey && !contentOverride) { @@ -299,7 +298,7 @@ export function useTaskCreation({ }); if (pendingTaskKey) { pendingTaskPromptStoreApi.clear(pendingTaskKey); - navigateToTaskInput({ initialPrompt: plainPromptText }); + openTaskInput({ initialPrompt: plainPromptText }); } } return result.success; @@ -310,7 +309,7 @@ export function useTaskCreation({ log.error("Unexpected error during task creation", { error }); if (pendingTaskKey) { pendingTaskPromptStoreApi.clear(pendingTaskKey); - navigateToTaskInput({ initialPrompt: plainPromptText }); + openTaskInput({ initialPrompt: plainPromptText }); } return false; } finally { @@ -336,9 +335,6 @@ export function useTaskCreation({ signalReportId, clearTaskInputReportAssociation, invalidateTasks, - navigateToTask, - navigateToPendingTask, - navigateToTaskInput, onTaskCreated, ], ); 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/features/tasks/hooks/useArchiveTask.ts b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts index b36240e773..576773670a 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts @@ -3,10 +3,11 @@ import { getSessionService } from "@features/sessions/service/service"; import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; import { useTerminalStore } from "@features/terminal/stores/terminalStore"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; +import { getAppViewSnapshot } from "@hooks/useAppView"; +import { openTaskInput } from "@hooks/useOpenTask"; import { trpc, trpcClient } from "@renderer/trpc"; import type { ArchivedTask } from "@shared/types/archive"; import { useFocusStore } from "@stores/focusStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { type QueryClient, useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; @@ -28,9 +29,9 @@ export async function archiveTaskImperative( const wasPinned = pinnedTaskIds.includes(taskId); if (!options?.skipNavigate) { - const nav = useNavigationStore.getState(); - if (nav.view.type === "task-detail" && nav.view.data?.id === taskId) { - nav.navigateToTaskInput(); + const view = getAppViewSnapshot(); + if (view.type === "task-detail" && view.taskId === taskId) { + openTaskInput(); } } @@ -123,14 +124,10 @@ export async function archiveTasksImperative( ): Promise<{ archived: number; failed: number }> { if (taskIds.length === 0) return { archived: 0, failed: 0 }; - const nav = useNavigationStore.getState(); + const view = getAppViewSnapshot(); const idSet = new Set(taskIds); - if ( - nav.view.type === "task-detail" && - nav.view.data && - idSet.has(nav.view.data.id) - ) { - nav.navigateToTaskInput(); + if (view.type === "task-detail" && view.taskId && idSet.has(view.taskId)) { + openTaskInput(); } let archived = 0; diff --git a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts index d598060dfa..39f550c384 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useTasks.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useTasks.ts @@ -2,12 +2,13 @@ import { getSessionService } from "@features/sessions/service/service"; import { pinnedTasksApi } from "@features/sidebar/hooks/usePinnedTasks"; import { taskKeys } from "@features/tasks/hooks/taskKeys"; import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; +import { useAppView } from "@hooks/useAppView"; import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { useMeQuery } from "@hooks/useMeQuery"; +import { openTaskInput } from "@hooks/useOpenTask"; import type { Schemas } from "@renderer/api/generated"; import { useFocusStore } from "@renderer/stores/focusStore"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { keepPreviousData, useQueryClient } from "@tanstack/react-query"; @@ -292,7 +293,7 @@ interface DeleteTaskOptions { export function useDeleteTask() { const queryClient = useQueryClient(); - const { view, navigateToTaskInput } = useNavigationStore(); + const view = useAppView(); const mutation = useAuthenticatedMutation( async (client, taskId: string) => { @@ -376,8 +377,8 @@ export function useDeleteTask() { } // Navigate away if viewing the deleted task - if (view.type === "task-detail" && view.data?.id === taskId) { - navigateToTaskInput(); + if (view.type === "task-detail" && view.taskId === taskId) { + openTaskInput(); } pinnedTasksApi.unpin(taskId); @@ -386,7 +387,7 @@ export function useDeleteTask() { return true; }, - [mutation, view, navigateToTaskInput], + [mutation, view], ); return { ...mutation, deleteWithConfirm }; diff --git a/apps/code/src/renderer/features/tasks/queries.ts b/apps/code/src/renderer/features/tasks/queries.ts new file mode 100644 index 0000000000..4d363bc9fe --- /dev/null +++ b/apps/code/src/renderer/features/tasks/queries.ts @@ -0,0 +1,22 @@ +import { getAuthenticatedClient } from "@features/auth/hooks/authClient"; +import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; +import { taskKeys } from "@features/tasks/hooks/taskKeys"; +import { NotAuthenticatedError } from "@shared/errors"; +import type { Task } from "@shared/types"; +import { queryOptions } from "@tanstack/react-query"; + +// Shared query definition so a route `loader` (ensureQueryData) and the +// component (useQuery) hit the same cache entry. The queryFn resolves the +// authenticated client imperatively, so it works outside React (in loaders) as +// well as inside components. +export function taskDetailQuery(taskId: string) { + return queryOptions({ + queryKey: taskKeys.detail(taskId), + queryFn: async (): Promise => { + const client = await getAuthenticatedClient(); + if (!client) throw new NotAuthenticatedError(); + return (await client.getTask(taskId)) as unknown as Task; + }, + meta: AUTH_SCOPED_QUERY_META, + }); +} diff --git a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx index 3de387ed35..7d28080e6c 100644 --- a/apps/code/src/renderer/features/tour/components/TourOverlay.tsx +++ b/apps/code/src/renderer/features/tour/components/TourOverlay.tsx @@ -1,4 +1,4 @@ -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +import { useIsSettingsOpen } from "@features/settings/hooks/useOpenSettings"; import { useCommandMenuStore } from "@stores/commandMenuStore"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useRef } from "react"; @@ -133,7 +133,7 @@ export function TourOverlay() { }; }, [step, selector, advance, activeTourId]); - const settingsOpen = useSettingsDialogStore((s) => s.isOpen); + const settingsOpen = useIsSettingsOpen(); const commandMenuOpen = useCommandMenuStore((s) => s.isOpen); const overlayBlocked = settingsOpen || commandMenuOpen; const isActive = !!(tour && step && targetRect && !overlayBlocked); diff --git a/apps/code/src/renderer/hooks/useAppView.ts b/apps/code/src/renderer/hooks/useAppView.ts new file mode 100644 index 0000000000..ab7a113f3b --- /dev/null +++ b/apps/code/src/renderer/hooks/useAppView.ts @@ -0,0 +1,132 @@ +import { + type TaskInputReportAssociation, + useTaskInputPrefillStore, +} from "@features/task-detail/stores/taskInputPrefillStore"; +import { getCurrentMatches } from "@renderer/navigationBridge"; +import { useRouterState } from "@tanstack/react-router"; +import { useMemo } from "react"; + +export type AppViewType = + | "task-detail" + | "task-pending" + | "task-input" + | "folder-settings" + | "inbox" + | "archived" + | "command-center" + | "skills" + | "mcp-servers" + | "settings"; + +export interface AppView { + type: AppViewType; + taskId?: string; + folderId?: string; + pendingTaskKey?: string; + taskInputRequestId?: string; + initialPrompt?: string; + initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; + reportAssociation?: TaskInputReportAssociation; +} + +type Match = { routeId: string; params: Record }; + +function deriveFromMatches(matches: Match[]): AppView { + const last = matches[matches.length - 1]; + if (!last) return { type: "task-input" }; + + switch (last.routeId) { + case "/code/tasks/$taskId": { + const taskId = last.params.taskId; + if (!taskId) return { type: "task-input" }; + // Intentionally no `data` snapshot: consumers read live task state via + // their own query hooks (e.g. useTasks) keyed on `taskId`. + return { type: "task-detail", taskId }; + } + case "/code/tasks/pending/$key": + return { type: "task-pending", pendingTaskKey: last.params.key }; + case "/folders/$folderId": + return { type: "folder-settings", folderId: last.params.folderId }; + case "/code/inbox": + return { type: "inbox" }; + case "/code/archived": + return { type: "archived" }; + case "/command-center": + return { type: "command-center" }; + case "/skills": + return { type: "skills" }; + case "/mcp-servers": + return { type: "mcp-servers" }; + case "/settings/$category": + case "/settings/": + return { type: "settings" }; + default: + return { type: "task-input" }; + } +} + +/** + * Single source of truth for the current view. Replaces the + * pre-router `useNavigationStore((s) => s.view)` pattern. + * + * The returned object is memoized on the route's primitive values so its + * identity is stable across unrelated re-renders. This matters: the old + * navigationStore handed out a stable `view` reference, and consumers depend on + * `[view]` in effects/memos. Returning a fresh object every render turns any + * such effect into an infinite loop (e.g. SidebarMenu → markViewed → cache + * write → re-render → repeat), which starves the UI thread. + */ +export function useAppView(): AppView { + const last = useRouterState({ + select: (s) => { + const m = s.matches[s.matches.length - 1]; + return m + ? { + routeId: m.routeId, + params: m.params as Record, + } + : null; + }, + }); + const prefill = useTaskInputPrefillStore((s) => s.prefill); + + const routeId = last?.routeId ?? ""; + const taskId = last?.params.taskId; + const pendingKey = last?.params.key; + const folderId = last?.params.folderId; + + return useMemo(() => { + // Rebuild the match from primitives so the memo depends only on stable + // values — the `last` selector returns a fresh object every render. + const match = routeId + ? { routeId, params: { taskId, key: pendingKey, folderId } } + : null; + const view = deriveFromMatches(match ? [match] : []); + + // /code/ → merge prefill so the TaskInput screen surfaces transient fields. + if (view.type === "task-input") { + return { + ...view, + folderId: prefill.folderId, + initialPrompt: prefill.initialPrompt, + initialCloudRepository: prefill.initialCloudRepository, + initialModel: prefill.initialModel, + initialMode: prefill.initialMode, + reportAssociation: prefill.reportAssociation, + taskInputRequestId: prefill.requestId, + }; + } + return view; + }, [routeId, taskId, pendingKey, folderId, prefill]); +} + +/** + * Read the current view outside React (event handlers, imperative code). + * Components should prefer `useAppView()` for proper subscription. + */ +export function getAppViewSnapshot(): AppView { + const matches = getCurrentMatches() as unknown as Match[]; + return deriveFromMatches(matches); +} diff --git a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts index bfc614286a..9fc43f2f75 100644 --- a/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useNewTaskDeepLink.ts @@ -1,11 +1,9 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { useTaskInputPrefillStore } from "@features/task-detail/stores/taskInputPrefillStore"; +import { openTaskInput } from "@hooks/useOpenTask"; import { trpcClient, useTRPC } from "@renderer/trpc"; import type { NewTaskLinkPayload } from "@shared/types"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { - type TaskInputNavigationOptions, - useNavigationStore, -} from "@stores/navigationStore"; import { useSubscription } from "@trpc/tanstack-react-query"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; @@ -14,37 +12,26 @@ import { toast } from "sonner"; const log = logger.scope("new-task-deep-link"); -type NavigateToTaskInput = (options?: TaskInputNavigationOptions) => void; - export function useNewTaskDeepLink() { const trpcReact = useTRPC(); - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); - const clearTaskInputReportAssociation = useNavigationStore( - (state) => state.clearTaskInputReportAssociation, - ); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); const hasFetchedPending = useRef(false); - const handleAction = useCallback( - async (payload: NewTaskLinkPayload) => { - log.info(`Handling deep link action: ${payload.action}`); - clearTaskInputReportAssociation(); - - switch (payload.action) { - case "new": - return handleNew(payload, navigateToTaskInput); - case "plan": - return handlePlan(payload, navigateToTaskInput); - case "issue": - return handleIssue(payload, navigateToTaskInput); - } - }, - [navigateToTaskInput, clearTaskInputReportAssociation], - ); + const handleAction = useCallback(async (payload: NewTaskLinkPayload) => { + log.info(`Handling deep link action: ${payload.action}`); + useTaskInputPrefillStore.getState().clearReportAssociation(); + + switch (payload.action) { + case "new": + return handleNew(payload); + case "plan": + return handlePlan(payload); + case "issue": + return handleIssue(payload); + } + }, []); useEffect(() => { if (!isAuthenticated) { @@ -84,11 +71,8 @@ export function useNewTaskDeepLink() { ); } -function handleNew( - payload: Extract, - navigateToTaskInput: NavigateToTaskInput, -) { - navigateToTaskInput({ +function handleNew(payload: Extract) { + openTaskInput({ initialPrompt: payload.prompt, initialCloudRepository: payload.repo, initialModel: payload.model, @@ -105,11 +89,8 @@ function handleNew( log.info("Navigated to task input from new deep link"); } -function handlePlan( - payload: Extract, - navigateToTaskInput: NavigateToTaskInput, -) { - navigateToTaskInput({ +function handlePlan(payload: Extract) { + openTaskInput({ initialPrompt: payload.plan, initialCloudRepository: payload.repo, initialModel: payload.model, @@ -128,7 +109,6 @@ function handlePlan( async function handleIssue( payload: Extract, - navigateToTaskInput: NavigateToTaskInput, ) { try { const issue = await trpcClient.git.getGithubIssue.query({ @@ -161,7 +141,7 @@ async function handleIssue( const cloudRepo = payload.repo ?? `${payload.owner}/${payload.issueRepo}`; - navigateToTaskInput({ + openTaskInput({ initialPrompt: prompt, initialCloudRepository: cloudRepo, initialModel: payload.model, diff --git a/apps/code/src/renderer/hooks/useOpenTask.ts b/apps/code/src/renderer/hooks/useOpenTask.ts new file mode 100644 index 0000000000..fb76777b9a --- /dev/null +++ b/apps/code/src/renderer/hooks/useOpenTask.ts @@ -0,0 +1,130 @@ +import { foldersApi } from "@features/folders/hooks/useFolders"; +import { useTaskInputPrefillStore } from "@features/task-detail/stores/taskInputPrefillStore"; +import { taskDetailQuery } from "@features/tasks/queries"; +import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; +import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; +import * as nav from "@renderer/navigationBridge"; +import type { Task } from "@shared/types"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { track } from "@utils/analytics"; +import { logger } from "@utils/logger"; +import { queryClient } from "@utils/queryClient"; +import { getTaskRepository } from "@utils/repository"; +import { useCallback } from "react"; + +const log = logger.scope("open-task"); + +/** + * Opens a task: navigates to /code/tasks/$taskId and ensures a workspace + * exists (auto-registering the folder if a local repo is available, or + * creating a cloud workspace stub if the task is cloud-mode). + * + * Replaces the old `navigationStore.navigateToTask` action. + */ +export async function openTask(task: Task): Promise { + // Seed the detail cache so the route loader resolves from cache and never + // fetches — critical for optimistic/local/cloud-pending tasks that the API + // can't yet return, which would otherwise hang the route in its pending state. + queryClient.setQueryData(taskDetailQuery(task.id).queryKey, task); + nav.navigateToTaskDetail(task.id); + track(ANALYTICS_EVENTS.TASK_VIEWED, { task_id: task.id }); + + const repoKey = getTaskRepository(task) ?? undefined; + const existingWorkspace = await workspaceApi.get(task.id); + + if (existingWorkspace?.folderId) { + const folders = await foldersApi.getFolders(); + const folder = folders.find((f) => f.id === existingWorkspace.folderId); + + if (folder && folder.exists === false) { + log.info("Folder path is stale, redirecting to folder settings", { + folderId: folder.id, + path: folder.path, + }); + nav.navigateToFolderSettings(folder.id); + return; + } + if (folder) return; + } + + const directory = await getTaskDirectory(task.id, repoKey ?? undefined); + + if (directory) { + try { + await foldersApi.addFolder(directory); + const workspaceMode = + task.latest_run?.environment === "cloud" ? "cloud" : "local"; + await workspaceApi.create({ + taskId: task.id, + mainRepoPath: directory, + folderId: "", + folderPath: directory, + mode: workspaceMode, + }); + } catch (error) { + log.error("Failed to auto-register folder on task open:", error); + } + } else if (task.latest_run?.environment === "cloud") { + await workspaceApi.create({ + taskId: task.id, + mainRepoPath: "", + folderId: "", + folderPath: "", + mode: "cloud", + }); + } +} + +/** React hook wrapper returning a stable `openTask` callback. */ +export function useOpenTask(): (task: Task) => Promise { + return useCallback(openTask, []); +} + +export interface TaskInputNavigationOptions { + folderId?: string; + initialPrompt?: string; + initialCloudRepository?: string; + initialModel?: string; + initialMode?: string; + reportAssociation?: { reportId: string; title: string }; +} + +/** + * Navigate to the new-task screen, optionally with prefill (initial prompt, + * report association, cloud repository, etc.). Replaces the old + * `navigationStore.navigateToTaskInput` action. + */ +export function openTaskInput( + folderIdOrOptions?: string | TaskInputNavigationOptions, +): void { + const options = + typeof folderIdOrOptions === "string" + ? { folderId: folderIdOrOptions } + : (folderIdOrOptions ?? {}); + + const hasTransientState = + !!options.initialPrompt || + !!options.initialCloudRepository || + !!options.initialModel || + !!options.initialMode || + !!options.reportAssociation; + + useTaskInputPrefillStore.setState({ + prefill: { + folderId: options.folderId, + initialPrompt: options.initialPrompt, + initialCloudRepository: options.initialCloudRepository, + initialModel: options.initialModel, + initialMode: options.initialMode, + reportAssociation: options.reportAssociation, + requestId: hasTransientState + ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) + : undefined, + }, + }); + nav.navigateToCode(); +} + +export function useOpenTaskInput(): typeof openTaskInput { + return useCallback(openTaskInput, []); +} diff --git a/apps/code/src/renderer/hooks/useTaskDeepLink.ts b/apps/code/src/renderer/hooks/useTaskDeepLink.ts index 73c0b101d7..acacb203e0 100644 --- a/apps/code/src/renderer/hooks/useTaskDeepLink.ts +++ b/apps/code/src/renderer/hooks/useTaskDeepLink.ts @@ -1,11 +1,11 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed"; import type { TaskService } from "@features/task-detail/service/service"; +import { openTask as openTaskHelper } from "@hooks/useOpenTask"; import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { trpcClient, useTRPC } from "@renderer/trpc"; import type { Task } from "@shared/types"; -import { useNavigationStore } from "@stores/navigationStore"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { logger } from "@utils/logger"; @@ -27,7 +27,6 @@ const taskKeys = { */ export function useTaskDeepLink() { const trpcReact = useTRPC(); - const navigateToTask = useNavigationStore((state) => state.navigateToTask); const { markAsViewed } = useTaskViewed(); const queryClient = useQueryClient(); const isAuthenticated = useAuthStateValue( @@ -74,7 +73,7 @@ export function useTaskDeepLink() { queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); markAsViewed(taskId); - navigateToTask(task); + void openTaskHelper(task); log.info( `Successfully opened task from deep link: ${taskId}${taskRunId ? `, run: ${taskRunId}` : ""}`, @@ -84,7 +83,7 @@ export function useTaskDeepLink() { toast.error("Failed to open task"); } }, - [navigateToTask, markAsViewed, queryClient], + [markAsViewed, queryClient], ); // Check for pending deep link on mount (for cold start via deep link) diff --git a/apps/code/src/renderer/navigationBridge.ts b/apps/code/src/renderer/navigationBridge.ts new file mode 100644 index 0000000000..224219851f --- /dev/null +++ b/apps/code/src/renderer/navigationBridge.ts @@ -0,0 +1,110 @@ +import type { SettingsCategory } from "@features/settings/types"; +import { getRouterOrNull } from "@renderer/routerRef"; +import { ANALYTICS_EVENTS } from "@shared/types/analytics"; +import { track } from "@utils/analytics"; + +// This bridge isolates imperative router calls behind a stable API and, by +// reaching the router through `routerRef` (a leaf module) rather than importing +// `@renderer/router` directly, keeps itself out of the route-tree import cycle: +// router.ts → routeTree.gen.ts → __root.tsx → hooks → navigationBridge +// A static `import { router }` here would close that loop and break code-split +// route chunks (TDZ on `rootRouteImport`). See routerRef.ts. +// +// Every call degrades to a no-op / empty read when the router isn't mounted yet +// (early boot, unit tests). These are renderer conveniences — they must never +// throw just because the router singleton hasn't been created. + +export function navigateToCode(): void { + void getRouterOrNull()?.navigate({ to: "/code" }); +} + +export function navigateToTaskDetail(taskId: string): void { + void getRouterOrNull()?.navigate({ + to: "/code/tasks/$taskId", + params: { taskId }, + }); +} + +export function navigateToTaskPending(key: string): void { + void getRouterOrNull()?.navigate({ + to: "/code/tasks/pending/$key", + params: { key }, + }); +} + +export function navigateToFolderSettings(folderId: string): void { + void getRouterOrNull()?.navigate({ + to: "/folders/$folderId", + params: { folderId }, + }); +} + +export function navigateToInbox(): void { + void getRouterOrNull()?.navigate({ to: "/code/inbox" }); +} + +export function navigateToArchived(): void { + void getRouterOrNull()?.navigate({ to: "/code/archived" }); +} + +export function navigateToCommandCenter(): void { + void getRouterOrNull()?.navigate({ to: "/command-center" }); + // Parity with the pre-router navigationStore.navigateToCommandCenter action, + // which emitted this event; the route component does not track it. + track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); +} + +export function navigateToSkills(): void { + void getRouterOrNull()?.navigate({ to: "/skills" }); +} + +export function navigateToMcpServers(): void { + void getRouterOrNull()?.navigate({ to: "/mcp-servers" }); +} + +export function navigateToSettings( + category: SettingsCategory, + options?: { replace?: boolean }, +): void { + void getRouterOrNull()?.navigate({ + to: "/settings/$category", + params: { category }, + // Switching categories within settings should replace, not stack, so a + // single history.back() (closeSettings) exits to the app rather than + // walking back through every category that was visited. + replace: options?.replace, + }); +} + +export function isOnSettingsRoute(): boolean { + return ( + getRouterOrNull()?.state.matches.some((m) => + m.routeId.startsWith("/settings"), + ) ?? false + ); +} + +export function goBackInHistory(): void { + getRouterOrNull()?.history.back(); +} + +export function goForwardInHistory(): void { + getRouterOrNull()?.history.forward(); +} + +// Accessors for code that needs to read router state outside of React (e.g. +// Zustand actions, imperative event handlers). Components should prefer the +// `useRouterState` hook from `@tanstack/react-router`. +export function getCurrentMatches() { + return getRouterOrNull()?.state.matches ?? []; +} + +export function getCurrentLocation() { + return getRouterOrNull()?.state.location ?? null; +} + +export function subscribeToRouterResolved(handler: () => void): () => void { + const router = getRouterOrNull(); + if (!router) return () => {}; + return router.subscribe("onResolved", handler); +} 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 new file mode 100644 index 0000000000..afda0453a7 --- /dev/null +++ b/apps/code/src/renderer/router.ts @@ -0,0 +1,63 @@ +import { + createHashHistory, + createRouter as createTanStackRouter, +} from "@tanstack/react-router"; +import { queryClient } from "@utils/queryClient"; +import { RoutePending } from "./components/RoutePending"; +import { setRouter } from "./routerRef"; +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(), + context: { queryClient }, + defaultPreload: "intent", + // Show the route's pending UI the instant its loader is still resolving, so + // navigation commits immediately instead of stalling on the previous screen. + // defaultPendingMinMs (500ms default) keeps it on screen long enough to avoid + // a flicker once shown; cache hits resolve before this fires and skip it. + defaultPendingMs: 0, + defaultPendingComponent: RoutePending, + scrollRestoration: false, +}); + +// Publish the instance to the leaf ref so imperative callers reach it without a +// static import of this module (which would re-create the route-tree cycle). +setRouter(router); + +// 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/routerRef.ts b/apps/code/src/renderer/routerRef.ts new file mode 100644 index 0000000000..a3b5d3b1de --- /dev/null +++ b/apps/code/src/renderer/routerRef.ts @@ -0,0 +1,34 @@ +// Leaf module holding the live router singleton so imperative callers +// (navigationBridge, deep-link handlers, store actions) can reach the router +// WITHOUT a static `import { router } from "@renderer/router"`. +// +// That static import creates a cycle: +// router.ts → routeTree.gen.ts → __root.tsx → hooks → navigationBridge → router.ts +// Under `autoCodeSplitting` each route's component becomes its own module that +// re-enters the cycle, and the TDZ ("Cannot access 'rootRouteImport' before +// initialization") leaves code-split route chunks stuck loading. +// +// The `import type` below is erased at build time, so this module has no runtime +// imports and cannot participate in the cycle. +import type { router as RouterInstance } from "./router"; + +let routerInstance: typeof RouterInstance | null = null; + +export function setRouter(instance: typeof RouterInstance): void { + routerInstance = instance; +} + +export function getRouter(): typeof RouterInstance { + if (!routerInstance) { + throw new Error("Router accessed before initialization"); + } + return routerInstance; +} + +// Nullable accessor for imperative navigation helpers that must not throw when +// the router isn't mounted yet (early boot, unit tests). In the running app the +// instance is always set before these fire; callers treat null as "no router, +// nothing to navigate". +export function getRouterOrNull(): typeof RouterInstance | null { + return routerInstance; +} diff --git a/apps/code/src/renderer/components/MainLayout.tsx b/apps/code/src/renderer/routes/__root.tsx similarity index 59% rename from apps/code/src/renderer/components/MainLayout.tsx rename to apps/code/src/renderer/routes/__root.tsx index ff1d04eecf..bf87501665 100644 --- a/apps/code/src/renderer/components/MainLayout.tsx +++ b/apps/code/src/renderer/routes/__root.tsx @@ -2,56 +2,64 @@ import { HeaderRow } from "@components/HeaderRow"; import { HedgehogMode } from "@components/HedgehogMode"; import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet"; import { SpaceSwitcher } from "@components/SpaceSwitcher"; - -import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView"; import { UsageLimitModal } from "@features/billing/components/UsageLimitModal"; import { CommandMenu } from "@features/command/components/CommandMenu"; -import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; -import { InboxView } from "@features/inbox/components/InboxView"; import { useInboxDeepLink } from "@features/inbox/hooks/useInboxDeepLink"; -import { McpServersView } from "@features/mcp-servers/components/McpServersView"; -import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; -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"; import { useVisualTaskOrder } from "@features/sidebar/hooks/useVisualTaskOrder"; -import { SkillsView } from "@features/skills/components/SkillsView"; -import { TaskDetail } from "@features/task-detail/components/TaskDetail"; -import { TaskInput } from "@features/task-detail/components/TaskInput"; -import { TaskPendingView } from "@features/task-detail/components/TaskPendingView"; import { useTasks } from "@features/tasks/hooks/useTasks"; import { TourOverlay } from "@features/tour/components/TourOverlay"; import { useWorkspaces, workspaceApi, } from "@features/workspace/hooks/useWorkspace"; +import { useAppView } from "@hooks/useAppView"; import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useIntegrations } from "@hooks/useIntegrations"; +import { openTask, openTaskInput } from "@hooks/useOpenTask"; import { Box, Flex } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; import { BILLING_FLAG, SYNC_CLOUD_TASKS_FLAG } from "@shared/constants"; import { useCommandMenuStore } from "@stores/commandMenuStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { useShortcutsSheetStore } from "@stores/shortcutsSheetStore"; -import { useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { + createRootRouteWithContext, + 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"; -import { GlobalEventHandlers } from "./GlobalEventHandlers"; -const log = logger.scope("main-layout"); +const log = logger.scope("root-route"); -export function MainLayout() { - const { - view, - hydrateTask, - navigateToTaskInput, - navigateToTask, - taskInputReportAssociation, - taskInputCloudRepository, - } = useNavigationStore(); +export interface RouterContext { + queryClient: QueryClient; +} + +export const Route = createRootRouteWithContext()({ + component: RootLayout, +}); + +function RootLayout() { + const view = useAppView(); const { isOpen: commandMenuOpen, setOpen: setCommandMenuOpen, @@ -70,11 +78,10 @@ export function MainLayout() { const billingEnabled = useFeatureFlag(BILLING_FLAG); const syncCloudTasksEnabled = useFeatureFlag(SYNC_CLOUD_TASKS_FLAG); - // Space switcher data const sidebarData = useSidebarData({ activeView: view }); const visualTaskOrder = useVisualTaskOrder(sidebarData); const activeTaskId = - view.type === "task-detail" && view.data ? view.data.id : null; + view.type === "task-detail" && view.taskId ? view.taskId : null; useIntegrations(); useTaskDeepLink(); @@ -82,11 +89,8 @@ export function MainLayout() { useSetupDiscovery(); useNewTaskDeepLink(); - useEffect(() => { - if (tasks) { - hydrateTask(tasks); - } - }, [tasks, hydrateTask]); + // hydrateTask is no longer needed — the URL is the source of truth and the + // task cache populates view.data automatically. useEffect(() => { if (!syncCloudTasksEnabled) return; @@ -100,8 +104,6 @@ export function MainLayout() { if (missing.length === 0) return; const missingIds = missing.map((t) => t.id); for (const id of missingIds) reconcilingTaskIds.current.add(id); - // Single batched IPC instead of one mutation per task — with many cloud - // tasks the per-task pattern saturates the main thread at boot. workspaceApi .reconcileCloudWorkspaces(missingIds) .then((result) => { @@ -125,57 +127,49 @@ export function MainLayout() { trpcReact, ]); - useEffect(() => { - if (view.type === "task-detail" && !view.data && !view.taskId) { - navigateToTaskInput(); - } - }, [view, navigateToTaskInput]); + // Note: a malformed /code/tasks/$taskId without a valid id is impossible — + // TanStack Router only mounts the task-detail route when taskId is in the URL. const handleToggleCommandMenu = useCallback(() => { 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 ( - - {view.type === "task-input" && ( - - )} - - {view.type === "task-detail" && view.data && ( - - )} - - {view.type === "task-pending" && view.pendingTaskKey && ( - - )} - - {view.type === "folder-settings" && } - - {view.type === "inbox" && } - - {view.type === "archived" && } - - {view.type === "command-center" && } - - {view.type === "skills" && } - - {view.type === "mcp-servers" && } + @@ -184,8 +178,8 @@ export function MainLayout() { activeTaskId={activeTaskId} allTasks={tasks ?? []} isOnNewTask={view.type === "task-input" || view.type === "task-pending"} - onNavigateToTask={navigateToTask} - onNewTask={navigateToTaskInput} + onNavigateToTask={openTask} + onNewTask={openTaskInput} /> - {billingEnabled && } + {import.meta.env.DEV && ( + + + + )} ); } diff --git a/apps/code/src/renderer/routes/code/archived.tsx b/apps/code/src/renderer/routes/code/archived.tsx new file mode 100644 index 0000000000..6b5978376b --- /dev/null +++ b/apps/code/src/renderer/routes/code/archived.tsx @@ -0,0 +1,6 @@ +import { ArchivedTasksView } from "@features/archive/components/ArchivedTasksView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/archived")({ + component: ArchivedTasksView, +}); diff --git a/apps/code/src/renderer/routes/code/inbox.tsx b/apps/code/src/renderer/routes/code/inbox.tsx new file mode 100644 index 0000000000..5294f31d60 --- /dev/null +++ b/apps/code/src/renderer/routes/code/inbox.tsx @@ -0,0 +1,6 @@ +import { InboxView } from "@features/inbox/components/InboxView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/inbox")({ + component: InboxView, +}); diff --git a/apps/code/src/renderer/routes/code/index.tsx b/apps/code/src/renderer/routes/code/index.tsx new file mode 100644 index 0000000000..abb68d3323 --- /dev/null +++ b/apps/code/src/renderer/routes/code/index.tsx @@ -0,0 +1,22 @@ +import { TaskInput } from "@features/task-detail/components/TaskInput"; +import { useAppView } from "@hooks/useAppView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/")({ + component: CodeIndexRoute, +}); + +function CodeIndexRoute() { + const view = useAppView(); + + return ( + + ); +} diff --git a/apps/code/src/renderer/routes/code/tasks/$taskId.tsx b/apps/code/src/renderer/routes/code/tasks/$taskId.tsx new file mode 100644 index 0000000000..929b05ede1 --- /dev/null +++ b/apps/code/src/renderer/routes/code/tasks/$taskId.tsx @@ -0,0 +1,49 @@ +import { RoutePending } from "@components/RoutePending"; +import { TaskDetail } from "@features/task-detail/components/TaskDetail"; +import { useTasks } from "@features/tasks/hooks/useTasks"; +import { taskDetailQuery } from "@features/tasks/queries"; +import type { Task } from "@shared/types"; +import { useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { getCachedTask } from "@utils/queryClient"; + +export const Route = createFileRoute("/code/tasks/$taskId")({ + component: TaskDetailRoute, + // Synchronous + cache-only: return whatever is already cached (the detail + // entry seeded by openTask, or the sidebar list) and never await the network. + // A blocking loader would leave the route pending — and thus un-navigable — + // whenever the fetch is slow or never resolves (optimistic/cloud-pending + // tasks the API can't return). The cold-miss fetch + spinner live in the + // component instead, so navigation always commits instantly. + loader: ({ context, params }): Task | null => { + const key = taskDetailQuery(params.taskId).queryKey; + return ( + context.queryClient.getQueryData(key) ?? + getCachedTask(params.taskId) ?? + null + ); + }, +}); + +function TaskDetailRoute() { + const { taskId } = Route.useParams(); + const loaderTask = Route.useLoaderData(); + const { data: tasks } = useTasks(); + const fromList = tasks?.find((t) => t.id === taskId); + + // Cold deep-link / URL restore: nothing cached. Fetch the single task here so + // a hang or 404 only affects this view's spinner, never the router. + const { data: fetched } = useQuery({ + ...taskDetailQuery(taskId), + enabled: !fromList && !loaderTask, + }); + + // Prefer the live list task (kept fresh by polling + subscriptions). + const task = fromList ?? loaderTask ?? fetched; + + if (!task) { + return ; + } + + return ; +} diff --git a/apps/code/src/renderer/routes/code/tasks/pending.$key.tsx b/apps/code/src/renderer/routes/code/tasks/pending.$key.tsx new file mode 100644 index 0000000000..d976aeb87e --- /dev/null +++ b/apps/code/src/renderer/routes/code/tasks/pending.$key.tsx @@ -0,0 +1,11 @@ +import { TaskPendingView } from "@features/task-detail/components/TaskPendingView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/tasks/pending/$key")({ + component: TaskPendingRoute, +}); + +function TaskPendingRoute() { + const { key } = Route.useParams(); + return ; +} diff --git a/apps/code/src/renderer/routes/command-center.tsx b/apps/code/src/renderer/routes/command-center.tsx new file mode 100644 index 0000000000..2f9393958b --- /dev/null +++ b/apps/code/src/renderer/routes/command-center.tsx @@ -0,0 +1,6 @@ +import { CommandCenterView } from "@features/command-center/components/CommandCenterView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/command-center")({ + component: CommandCenterView, +}); diff --git a/apps/code/src/renderer/routes/folders/$folderId.tsx b/apps/code/src/renderer/routes/folders/$folderId.tsx new file mode 100644 index 0000000000..2a5b9e0884 --- /dev/null +++ b/apps/code/src/renderer/routes/folders/$folderId.tsx @@ -0,0 +1,6 @@ +import { FolderSettingsView } from "@features/settings/components/FolderSettingsView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/folders/$folderId")({ + component: FolderSettingsView, +}); diff --git a/apps/code/src/renderer/routes/index.tsx b/apps/code/src/renderer/routes/index.tsx new file mode 100644 index 0000000000..fef5274c32 --- /dev/null +++ b/apps/code/src/renderer/routes/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/")({ + beforeLoad: () => { + throw redirect({ to: "/code" }); + }, +}); diff --git a/apps/code/src/renderer/routes/mcp-servers.tsx b/apps/code/src/renderer/routes/mcp-servers.tsx new file mode 100644 index 0000000000..38eedecbca --- /dev/null +++ b/apps/code/src/renderer/routes/mcp-servers.tsx @@ -0,0 +1,6 @@ +import { McpServersView } from "@features/mcp-servers/components/McpServersView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/mcp-servers")({ + component: McpServersView, +}); diff --git a/apps/code/src/renderer/routes/settings/$category.tsx b/apps/code/src/renderer/routes/settings/$category.tsx new file mode 100644 index 0000000000..c059a33626 --- /dev/null +++ b/apps/code/src/renderer/routes/settings/$category.tsx @@ -0,0 +1,23 @@ +import { SettingsPanel } from "@features/settings/components/SettingsPanel"; +import { useSettingsPageStore } from "@features/settings/stores/settingsPageStore"; +import { isSettingsCategory } from "@features/settings/types"; +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect } from "react"; + +export const Route = createFileRoute("/settings/$category")({ + component: SettingsRoute, +}); + +function SettingsRoute() { + const { category } = Route.useParams(); + const cat = isSettingsCategory(category) ? category : "general"; + + // Reset transient state when leaving the route entirely. Switching between + // categories (e.g. general → environments) does not unmount this component, + // only the cleanup on full unmount needs to fire. + useEffect(() => { + return () => useSettingsPageStore.getState().reset(); + }, []); + + return ; +} diff --git a/apps/code/src/renderer/routes/settings/index.tsx b/apps/code/src/renderer/routes/settings/index.tsx new file mode 100644 index 0000000000..e842724ca5 --- /dev/null +++ b/apps/code/src/renderer/routes/settings/index.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/settings/")({ + beforeLoad: () => { + throw redirect({ + to: "/settings/$category", + params: { category: "general" }, + }); + }, +}); diff --git a/apps/code/src/renderer/routes/skills.tsx b/apps/code/src/renderer/routes/skills.tsx new file mode 100644 index 0000000000..1a10d4b74e --- /dev/null +++ b/apps/code/src/renderer/routes/skills.tsx @@ -0,0 +1,6 @@ +import { SkillsView } from "@features/skills/components/SkillsView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/skills")({ + component: SkillsView, +}); diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts deleted file mode 100644 index f1773a568b..0000000000 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import type { Task } from "@shared/types"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const { getItem, setItem } = vi.hoisted(() => ({ - getItem: vi.fn(), - setItem: vi.fn(), -})); - -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - secureStore: { - getItem: { query: getItem }, - setItem: { query: setItem }, - removeItem: { query: vi.fn() }, - }, - }, -})); - -vi.mock("@utils/analytics", () => ({ - track: vi.fn(), - setActiveTaskAnalyticsContext: vi.fn(), -})); -vi.mock("@utils/logger", () => ({ - logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), debug: vi.fn() }) }, -})); -vi.mock("@features/workspace/hooks/useWorkspace", () => ({ - workspaceApi: { - get: vi.fn().mockResolvedValue(null), - getAll: vi.fn().mockResolvedValue({}), - create: vi.fn().mockResolvedValue(null), - }, -})); -vi.mock("@features/folders/hooks/useFolders", () => ({ - foldersApi: { - getFolders: vi.fn().mockResolvedValue([]), - addFolder: vi.fn().mockResolvedValue(null), - }, -})); -vi.mock("@hooks/useRepositoryDirectory", () => ({ - getTaskDirectory: vi.fn().mockResolvedValue(null), -})); - -import { useNavigationStore } from "./navigationStore"; - -const mockTask: Task = { - id: "task-123", - task_number: 1, - slug: "test-task", - title: "Test task", - description: "Test task description", - origin_product: "twig", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", -}; - -const getStore = () => useNavigationStore.getState(); -const getView = () => getStore().view; - -describe("navigationStore", () => { - beforeEach(() => { - getItem.mockReset(); - setItem.mockReset(); - getItem.mockResolvedValue(null); - setItem.mockResolvedValue(undefined); - useNavigationStore.setState({ - view: { type: "task-input" }, - history: [{ type: "task-input" }], - historyIndex: 0, - }); - }); - - it("starts with task-input view", () => { - expect(getView().type).toBe("task-input"); - }); - - describe("navigation", () => { - it("navigates to task detail with taskId", async () => { - await getStore().navigateToTask(mockTask); - expect(getView()).toMatchObject({ - type: "task-detail", - data: mockTask, - taskId: "task-123", - }); - }); - - it("navigates to folder settings", () => { - getStore().navigateToFolderSettings("folder-123"); - expect(getView()).toMatchObject({ - type: "folder-settings", - folderId: "folder-123", - }); - }); - - it("navigates to task input with folderId", () => { - getStore().navigateToTaskInput("folder-123"); - expect(getView()).toMatchObject({ - type: "task-input", - folderId: "folder-123", - }); - }); - - it("navigates to task input with report association", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - - expect(getView()).toMatchObject({ - type: "task-input", - initialPrompt: "Fix this report", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - expect(getView().taskInputRequestId).toBeTruthy(); - }); - - it("mints a fresh taskInputRequestId on each navigation with transient state", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Discuss this", - reportAssociation: { reportId: "report-456", title: "Slow checkout" }, - }); - const firstRequestId = getView().taskInputRequestId; - expect(firstRequestId).toBeTruthy(); - - getStore().navigateToInbox(); - getStore().navigateToTaskInput({ - initialPrompt: "Discuss this", - reportAssociation: { reportId: "report-456", title: "Slow checkout" }, - }); - expect(getView().taskInputRequestId).not.toBe(firstRequestId); - }); - - it("clears task input report association", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - initialCloudRepository: "posthog/code", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - - getStore().clearTaskInputReportAssociation(); - - expect(getView().reportAssociation).toBeUndefined(); - expect(getView().initialCloudRepository).toBeUndefined(); - expect( - getStore().history[getStore().historyIndex].reportAssociation, - ).toBeUndefined(); - expect( - getStore().history[getStore().historyIndex].initialCloudRepository, - ).toBeUndefined(); - expect(getStore().taskInputReportAssociation).toBeUndefined(); - }); - - it("clears cloud-only task input state without report association", () => { - getStore().navigateToTaskInput({ - initialCloudRepository: "posthog/code", - }); - - getStore().clearTaskInputReportAssociation(); - - expect(getView().initialCloudRepository).toBeUndefined(); - expect(getStore().taskInputCloudRepository).toBeUndefined(); - expect( - getStore().history[getStore().historyIndex].initialCloudRepository, - ).toBeUndefined(); - }); - - it("clears persisted task input report association after returning to task input", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - initialCloudRepository: "posthog/code", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - getStore().navigateToInbox(); - getStore().navigateToTaskInput(); - - getStore().clearTaskInputReportAssociation(); - - expect(getStore().taskInputReportAssociation).toBeUndefined(); - expect(getStore().taskInputCloudRepository).toBeUndefined(); - expect(getView().initialCloudRepository).toBeUndefined(); - }); - - it("keeps task input report association after leaving task input", () => { - getStore().navigateToTaskInput({ - initialPrompt: "Fix this report", - initialCloudRepository: "posthog/code", - reportAssociation: { reportId: "report-123", title: "Broken signup" }, - }); - - getStore().navigateToInbox(); - getStore().navigateToTaskInput(); - - expect(getStore().taskInputReportAssociation).toEqual({ - reportId: "report-123", - title: "Broken signup", - }); - expect(getStore().taskInputCloudRepository).toBe("posthog/code"); - }); - - it("navigates to inbox", () => { - getStore().navigateToInbox(); - expect(getView()).toMatchObject({ - type: "inbox", - }); - }); - - it("navigates to pending task with key", () => { - getStore().navigateToPendingTask("pending-key-123"); - expect(getView()).toMatchObject({ - type: "task-pending", - pendingTaskKey: "pending-key-123", - }); - }); - - it("replaces task-pending in history when navigating to real task", async () => { - getStore().navigateToTaskInput(); - getStore().navigateToPendingTask("pending-key-123"); - const indexBeforeReal = getStore().history.length - 1; - expect(getStore().history[indexBeforeReal].type).toBe("task-pending"); - - await getStore().navigateToTask(mockTask); - - const finalHistory = getStore().history; - expect(finalHistory[finalHistory.length - 1].type).toBe("task-detail"); - expect(finalHistory.some((v) => v.type === "task-pending")).toBe(false); - }); - }); - - describe("history", () => { - it("tracks history and supports back/forward", async () => { - await getStore().navigateToTask(mockTask); - getStore().navigateToFolderSettings("folder-123"); - - expect(getStore().history).toHaveLength(3); - expect(getStore().canGoBack()).toBe(true); - - getStore().goBack(); - expect(getView().type).toBe("task-detail"); - - expect(getStore().canGoForward()).toBe(true); - getStore().goForward(); - expect(getView().type).toBe("folder-settings"); - }); - }); - - describe("persistence", () => { - it("persists view type and taskId but not full task data", async () => { - await getStore().navigateToTask(mockTask); - - await vi.waitFor(() => { - expect(setItem).toHaveBeenCalled(); - }); - - const lastCall = setItem.mock.calls[setItem.mock.calls.length - 1]; - const persisted = JSON.parse(lastCall[0].value); - expect(persisted.state.view).toEqual({ - type: "task-detail", - taskId: "task-123", - folderId: undefined, - }); - }); - - it("restores view from electronStorage without task data", async () => { - const storedState = JSON.stringify({ - state: { - view: { - type: "task-detail", - taskId: "task-123", - folderId: undefined, - }, - }, - version: 0, - }); - - getItem.mockResolvedValue(storedState); - - useNavigationStore.setState({ - view: { type: "task-input" }, - history: [{ type: "task-input" }], - historyIndex: 0, - }); - - await useNavigationStore.persist.rehydrate(); - - expect(getView()).toMatchObject({ - type: "task-detail", - taskId: "task-123", - }); - expect(getView().data).toBeUndefined(); - }); - }); -}); diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts deleted file mode 100644 index 3bfb98fb3b..0000000000 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { foldersApi } from "@features/folders/hooks/useFolders"; -import { workspaceApi } from "@features/workspace/hooks/useWorkspace"; -import { getTaskDirectory } from "@hooks/useRepositoryDirectory"; -import type { Task } from "@shared/types"; -import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { setActiveTaskAnalyticsContext, track } from "@utils/analytics"; -import { electronStorage } from "@utils/electronStorage"; -import { logger } from "@utils/logger"; -import { getTaskRepository } from "@utils/repository"; -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -const log = logger.scope("navigation-store"); - -type ViewType = - | "task-detail" - | "task-pending" - | "task-input" - | "folder-settings" - | "inbox" - | "archived" - | "command-center" - | "skills" - | "mcp-servers"; - -export interface TaskInputReportAssociation { - reportId: string; - title: string; -} - -export interface TaskInputNavigationOptions { - folderId?: string; - initialPrompt?: string; - initialCloudRepository?: string; - initialModel?: string; - initialMode?: string; - reportAssociation?: TaskInputReportAssociation; -} - -interface ViewState { - type: ViewType; - data?: Task; - taskId?: string; - folderId?: string; - taskInputRequestId?: string; - initialPrompt?: string; - initialCloudRepository?: string; - initialModel?: string; - initialMode?: string; - reportAssociation?: TaskInputReportAssociation; - pendingTaskKey?: string; -} - -interface NavigationStore { - view: ViewState; - history: ViewState[]; - historyIndex: number; - taskInputReportAssociation?: TaskInputReportAssociation; - taskInputCloudRepository?: string; - navigateToTask: (task: Task) => void; - navigateToPendingTask: (pendingTaskKey: string) => void; - navigateToTaskInput: ( - folderIdOrOptions?: string | TaskInputNavigationOptions, - ) => void; - clearTaskInputReportAssociation: () => void; - navigateToFolderSettings: (folderId: string) => void; - navigateToInbox: () => void; - navigateToArchived: () => void; - navigateToCommandCenter: () => void; - navigateToSkills: () => void; - navigateToMcpServers: () => void; - goBack: () => void; - goForward: () => void; - canGoBack: () => boolean; - canGoForward: () => boolean; - hydrateTask: (tasks: Task[]) => void; -} - -const isSameView = (view1: ViewState, view2: ViewState): boolean => { - if (view1.type !== view2.type) return false; - if (view1.type === "task-detail" && view2.type === "task-detail") { - return view1.data?.id === view2.data?.id; - } - if (view1.type === "task-pending" && view2.type === "task-pending") { - return view1.pendingTaskKey === view2.pendingTaskKey; - } - if (view1.type === "task-input" && view2.type === "task-input") { - return ( - view1.folderId === view2.folderId && - view1.taskInputRequestId === view2.taskInputRequestId - ); - } - if (view1.type === "folder-settings" && view2.type === "folder-settings") { - return view1.folderId === view2.folderId; - } - if (view1.type === "inbox" && view2.type === "inbox") { - return true; - } - if (view1.type === "archived" && view2.type === "archived") { - return true; - } - if (view1.type === "command-center" && view2.type === "command-center") { - return true; - } - if (view1.type === "skills" && view2.type === "skills") { - return true; - } - if (view1.type === "mcp-servers" && view2.type === "mcp-servers") { - return true; - } - return false; -}; - -export const useNavigationStore = create()( - persist( - (set, get) => { - const navigate = (newView: ViewState) => { - const { view, history, historyIndex } = get(); - if (isSameView(view, newView)) { - return; - } - // Replace transient task-pending entries instead of stacking them in - // history — going back to a pending view after the real task lands - // would render an empty placeholder. - const baseHistory = - view.type === "task-pending" - ? history.slice(0, historyIndex) - : history.slice(0, historyIndex + 1); - const newHistory = [...baseHistory, newView]; - set({ - view: newView, - history: newHistory, - historyIndex: newHistory.length - 1, - }); - setActiveTaskAnalyticsContext( - newView.type === "task-detail" ? (newView.data ?? null) : null, - ); - }; - - return { - view: { type: "task-input" }, - history: [{ type: "task-input" }], - historyIndex: 0, - taskInputReportAssociation: undefined, - taskInputCloudRepository: undefined, - - navigateToTask: async (task: Task) => { - navigate({ type: "task-detail", data: task, taskId: task.id }); - track(ANALYTICS_EVENTS.TASK_VIEWED, { - task_id: task.id, - }); - - const repoKey = getTaskRepository(task) ?? undefined; - - const existingWorkspace = await workspaceApi.get(task.id); - if (existingWorkspace?.folderId) { - const folders = await foldersApi.getFolders(); - const folder = folders.find( - (f) => f.id === existingWorkspace.folderId, - ); - - if (folder && folder.exists === false) { - log.info("Folder path is stale, redirecting to folder settings", { - folderId: folder.id, - path: folder.path, - }); - navigate({ type: "folder-settings", folderId: folder.id }); - return; - } - - if (folder) { - return; - } - } - - const directory = await getTaskDirectory( - task.id, - repoKey ?? undefined, - ); - - if (directory) { - try { - await foldersApi.addFolder(directory); - - const workspaceMode = - task.latest_run?.environment === "cloud" ? "cloud" : "local"; - - await workspaceApi.create({ - taskId: task.id, - mainRepoPath: directory, - folderId: "", - folderPath: directory, - mode: workspaceMode, - }); - } catch (error) { - log.error("Failed to auto-register folder on task open:", error); - } - } else if (task.latest_run?.environment === "cloud") { - await workspaceApi.create({ - taskId: task.id, - mainRepoPath: "", - folderId: "", - folderPath: "", - mode: "cloud", - }); - } - }, - - navigateToPendingTask: (pendingTaskKey: string) => { - navigate({ type: "task-pending", pendingTaskKey }); - }, - - navigateToTaskInput: (folderIdOrOptions) => { - const options = - typeof folderIdOrOptions === "string" - ? { folderId: folderIdOrOptions } - : (folderIdOrOptions ?? {}); - const hasTransientState = - !!options.initialPrompt || - !!options.initialCloudRepository || - !!options.initialModel || - !!options.initialMode || - !!options.reportAssociation; - if (options.reportAssociation || options.initialCloudRepository) { - set({ - taskInputReportAssociation: options.reportAssociation, - taskInputCloudRepository: options.initialCloudRepository, - }); - } - navigate({ - type: "task-input", - folderId: options.folderId, - initialPrompt: options.initialPrompt, - initialCloudRepository: options.initialCloudRepository, - initialModel: options.initialModel, - initialMode: options.initialMode, - reportAssociation: options.reportAssociation, - taskInputRequestId: hasTransientState - ? (globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`) - : undefined, - }); - }, - - clearTaskInputReportAssociation: () => { - const { - view, - history, - historyIndex, - taskInputReportAssociation, - taskInputCloudRepository, - } = get(); - if ( - !taskInputReportAssociation && - !view.reportAssociation && - !taskInputCloudRepository && - !view.initialCloudRepository - ) { - return; - } - - const updatedView = { - ...view, - reportAssociation: undefined, - initialCloudRepository: undefined, - }; - const updatedHistory = [...history]; - if (updatedHistory[historyIndex]?.type === "task-input") { - updatedHistory[historyIndex] = { - ...updatedHistory[historyIndex], - reportAssociation: undefined, - initialCloudRepository: undefined, - }; - } - - set({ - view: updatedView, - history: updatedHistory, - taskInputReportAssociation: undefined, - taskInputCloudRepository: undefined, - }); - }, - - navigateToFolderSettings: (folderId: string) => { - navigate({ type: "folder-settings", folderId }); - }, - - navigateToInbox: () => { - navigate({ type: "inbox" }); - }, - - navigateToArchived: () => { - navigate({ type: "archived" }); - }, - - navigateToCommandCenter: () => { - navigate({ type: "command-center" }); - track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); - }, - - navigateToSkills: () => { - navigate({ type: "skills" }); - }, - - navigateToMcpServers: () => { - navigate({ type: "mcp-servers" }); - }, - - goBack: () => { - const { history, historyIndex } = get(); - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - const newView = history[newIndex]; - set({ - view: newView, - historyIndex: newIndex, - }); - setActiveTaskAnalyticsContext( - newView.type === "task-detail" ? (newView.data ?? null) : null, - ); - } - }, - - goForward: () => { - const { history, historyIndex } = get(); - if (historyIndex < history.length - 1) { - const newIndex = historyIndex + 1; - const newView = history[newIndex]; - set({ - view: newView, - historyIndex: newIndex, - }); - setActiveTaskAnalyticsContext( - newView.type === "task-detail" ? (newView.data ?? null) : null, - ); - } - }, - - canGoBack: () => { - const { historyIndex } = get(); - return historyIndex > 0; - }, - - canGoForward: () => { - const { history, historyIndex } = get(); - return historyIndex < history.length - 1; - }, - - hydrateTask: (tasks: Task[]) => { - const { view, navigateToTask, navigateToTaskInput } = get(); - if (view.type !== "task-detail" || !view.taskId || view.data) return; - - const task = tasks.find((t) => t.id === view.taskId); - if (task) { - navigateToTask(task); - } else { - navigateToTaskInput(); - } - }, - }; - }, - { - name: "navigation-storage", - storage: electronStorage, - partialize: (state) => ({ - view: - state.view.type === "task-pending" - ? { type: "task-input" as const } - : { - type: state.view.type, - taskId: state.view.taskId, - folderId: state.view.folderId, - }, - }), - }, - ), -); diff --git a/apps/code/src/renderer/utils/notifications.test.ts b/apps/code/src/renderer/utils/notifications.test.ts index 98546573fa..e922dac788 100644 --- a/apps/code/src/renderer/utils/notifications.test.ts +++ b/apps/code/src/renderer/utils/notifications.test.ts @@ -1,14 +1,29 @@ import { useSettingsStore } from "@features/settings/stores/settingsStore"; -import { useNavigationStore } from "@stores/navigationStore"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { sendMutate, showDockBadgeMutate, bounceDockMutate, playSound } = - vi.hoisted(() => ({ - sendMutate: vi.fn().mockResolvedValue(undefined), - showDockBadgeMutate: vi.fn().mockResolvedValue(undefined), - bounceDockMutate: vi.fn().mockResolvedValue(undefined), - playSound: vi.fn(), - })); +const { + sendMutate, + showDockBadgeMutate, + bounceDockMutate, + playSound, + getViewSnapshot, +} = vi.hoisted(() => ({ + sendMutate: vi.fn().mockResolvedValue(undefined), + showDockBadgeMutate: vi.fn().mockResolvedValue(undefined), + bounceDockMutate: vi.fn().mockResolvedValue(undefined), + playSound: vi.fn(), + getViewSnapshot: vi.fn( + () => + ({ type: "task-input" }) as { + type: string; + taskId?: string; + }, + ), +})); + +vi.mock("@hooks/useAppView", () => ({ + getAppViewSnapshot: getViewSnapshot, +})); vi.mock("@renderer/trpc/client", () => ({ trpcClient: { @@ -43,9 +58,11 @@ const OTHER_TASK_ID = "task-999"; type View = { type: string; data?: { id: string }; taskId?: string }; function setView(view: View) { - useNavigationStore.setState({ - // biome-ignore lint/suspicious/noExplicitAny: test-only narrow cast - view: view as any, + // The notifications module now reads via getAppViewSnapshot which returns + // the view shape directly (no nesting under `view`). + getViewSnapshot.mockReturnValue({ + type: view.type, + taskId: view.taskId ?? view.data?.id, }); } diff --git a/apps/code/src/renderer/utils/notifications.ts b/apps/code/src/renderer/utils/notifications.ts index f29b278786..68d7905774 100644 --- a/apps/code/src/renderer/utils/notifications.ts +++ b/apps/code/src/renderer/utils/notifications.ts @@ -1,6 +1,6 @@ import { useSettingsStore } from "@features/settings/stores/settingsStore"; +import { getAppViewSnapshot } from "@hooks/useAppView"; import { trpcClient } from "@renderer/trpc/client"; -import { useNavigationStore } from "@stores/navigationStore"; import { logger } from "@utils/logger"; import { playCompletionSound } from "@utils/sounds"; @@ -16,9 +16,8 @@ function truncateTitle(title: string): string { function shouldNotifyForTask(taskId?: string): boolean { if (!document.hasFocus()) return true; if (!taskId) return false; - const view = useNavigationStore.getState().view; - const viewedTaskId = - view.type === "task-detail" ? (view.data?.id ?? view.taskId) : undefined; + const view = getAppViewSnapshot(); + const viewedTaskId = view.type === "task-detail" ? view.taskId : undefined; return viewedTaskId !== taskId; } diff --git a/apps/code/vite.renderer.config.mts b/apps/code/vite.renderer.config.mts index 93842e3cbc..2b285da606 100644 --- a/apps/code/vite.renderer.config.mts +++ b/apps/code/vite.renderer.config.mts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import tailwindcss from "@tailwindcss/vite"; +import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; import react from "@vitejs/plugin-react"; import { defineConfig, loadEnv } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; @@ -21,6 +22,15 @@ export default defineConfig(({ mode }) => { return { plugins: [ + TanStackRouterVite({ + target: "react", + autoCodeSplitting: true, + routesDirectory: path.resolve(__dirname, "src/renderer/routes"), + generatedRouteTree: path.resolve( + __dirname, + "src/renderer/routeTree.gen.ts", + ), + }), tailwindcss(), react(), tsconfigPaths(), diff --git a/biome.jsonc b/biome.jsonc index 5f382f031c..392b300663 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -118,6 +118,20 @@ "enabled": false } }, + { + // TanStack Router generated route tree — uses `as any` and its own + // formatting; the file header explicitly says not to lint/format it. + "includes": ["apps/code/src/renderer/routeTree.gen.ts"], + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + }, + "assist": { + "enabled": false + } + }, { // Disallow console.* in Code and agent source code - use logger instead "includes": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e7c6a229c..4abf04cc6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,13 +192,22 @@ importers: version: 3.3.0(@types/react-dom@19.2.3(@types/react@19.2.11))(@types/react@19.2.11)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.2(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/react-query': specifier: ^5.90.2 version: 5.90.20(react@19.1.0) + '@tanstack/react-router': + specifier: ^1.95.0 + version: 1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-router-devtools': + specifier: ^1.95.0 + version: 1.167.0(@tanstack/react-router@1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.171.6)(csstype@3.2.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tanstack/react-virtual': specifier: ^3.13.26 version: 3.13.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-plugin': + specifier: ^1.95.0 + version: 1.168.11(@tanstack/react-router@1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) '@tiptap/core': specifier: ^3.13.0 version: 3.19.0(@tiptap/pm@3.19.0) @@ -433,10 +442,10 @@ importers: version: 10.2.0(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) '@storybook/addon-docs': specifier: 10.2.0 - version: 10.2.0(@types/react@19.2.11)(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + version: 10.2.0(@types/react@19.2.11)(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) '@storybook/react-vite': specifier: 10.2.0 - version: 10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + version: 10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -466,7 +475,7 @@ importers: version: 7.7.1 '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/ui': specifier: ^4.0.10 version: 4.0.18(vitest@4.0.18) @@ -520,13 +529,13 @@ importers: version: 0.48.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) vitest: specifier: ^4.0.10 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) yaml: specifier: ^2.8.1 version: 2.8.2 @@ -538,10 +547,10 @@ importers: version: 0.2.0-beta.9(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.11)(react@19.1.0))(react@19.1.0) '@modelcontextprotocol/ext-apps': specifier: ^1.2.2 - version: 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6) + version: 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.4.3) '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(zod@4.3.6) + version: 1.29.0(zod@4.4.3) '@posthog/shared': specifier: workspace:* version: link:../../packages/shared @@ -792,7 +801,7 @@ importers: version: 2.12.8(@types/node@25.2.0)(typescript@5.9.3) tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) tsx: specifier: ^4.20.6 version: 4.21.0 @@ -828,7 +837,7 @@ importers: version: 5.9.3 vite: specifier: ^6.0.7 - version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^2.1.8 version: 2.1.9(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0) @@ -847,7 +856,7 @@ importers: version: 0.26.8 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -881,7 +890,7 @@ importers: devDependencies: tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -890,10 +899,10 @@ importers: devDependencies: '@agentclientprotocol/sdk': specifier: 0.19.0 - version: 0.19.0(zod@4.3.6) + version: 0.19.0(zod@4.4.3) tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -5037,6 +5046,10 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/history@1.162.0': + resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} + engines: {node: '>=20.19'} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} @@ -5045,15 +5058,90 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-router-devtools@1.167.0': + resolution: {integrity: sha512-nGw095EG7IHx0h5NtlEmzf6vcCTaFNPWdTSuDKazajhN0ct/v/TkekJ9J6KYUCeV1a8/2ZmToc58M+0rrOyn7w==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/react-router': ^1.170.0 + '@tanstack/router-core': ^1.170.0 + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + peerDependenciesMeta: + '@tanstack/router-core': + optional: true + + '@tanstack/react-router@1.170.8': + resolution: {integrity: sha512-Qw2ju6jjnIsMpuW+VrnHZWHuugqs592PWsnI56sG28qNhg14CgRLahOcNajfuJR9P4MxKGP94WVzmFKSYUz/ig==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.26': resolution: {integrity: sha512-DosdgjOxCLahkn0o+ilmZYwEjo1glfMGuRT/j3PQ18yr5XqA8N/BCaL9IJ3B5TRl+nnzyK2IOFgAILwzN3a9xQ==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.171.6': + resolution: {integrity: sha512-Ol6DQ+j6rf/rPVELIzo8LHwOQV2KL+zry3b+39kL/GKrt7YId52WJRAFMzuseY4XceSW+PU7sG/Cc1QkwJr0hg==} + engines: {node: '>=20.19'} + + '@tanstack/router-devtools-core@1.168.0': + resolution: {integrity: sha512-wQoQhlBK7nlZgqzaqdYXKWNTpdHdsaREdaPhFZVH0/Ador+F+eM3/NF2i3f2LPeS0GgKraZUQXe1Q/1+KHyEYg==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/router-core': ^1.170.0 + csstype: ^3.0.10 + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-generator@1.167.10': + resolution: {integrity: sha512-CjbjWRSo6djLU/C7ncb9IbKUcf4IwpdqhLGngkwKkXaVFXGxEAafA/uhvOCv/UEUVR7NI3tJqqQmxYXGcJPbjw==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.168.11': + resolution: {integrity: sha512-b2eom/8xCWL/OiWxKub8kYsr8p+kvmB/eXwYGqCWG8vilcJo+eQCSyp54nKt0AZ5k/ET1+eINc+4mwL3bVeAgg==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.8 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.162.1': + resolution: {integrity: sha512-62layyTGmclHDQS/eidwKRfN1hhCKwViG7iEBcVmL0MXgcAB3OOucWCEcDDGd9Cu11H6b4QQ5oOo47MWIqwz0A==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.16.0': resolution: {integrity: sha512-Er2N7q3WOiH6y2JLxsxNX+u2/sLqSsL0bxFgDjuiPiA7vKhZRm+IzcS17vRee3GNXr64UsesA5CAp9yTiIYw9A==} + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -5832,6 +5920,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.3.0: + resolution: {integrity: sha512-44mvgtPvohuU/70DdY5Oz2AIrLJ9k6/5x4KmoSvPwO+5Moijo0+N9D0fKbbYZQWP1hNm5CpOf+E01jhxG/r8xg==} + engines: {node: '>=14'} + any-base@1.1.0: resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} @@ -5926,6 +6018,9 @@ packages: axios@1.15.0: resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -6459,6 +6554,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -7851,6 +7949,11 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + goober@2.1.19: + resolution: {integrity: sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -8304,6 +8407,10 @@ packages: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} + isbot@5.1.40: + resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -8404,6 +8511,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.2.1: resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==} @@ -10800,6 +10911,16 @@ packages: serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + seroval-plugins@1.5.4: + resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.4: + resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} + engines: {node: '>=10'} + serve-static@1.16.3: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} @@ -11715,6 +11836,10 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} + unplugin@3.0.0: + resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==} + engines: {node: ^20.19.0 || >=22.12.0} + until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -12310,6 +12435,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@4.5.7: resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} engines: {node: '>=12.7.0'} @@ -12341,9 +12469,9 @@ snapshots: '@adobe/css-tools@4.4.4': {} - '@agentclientprotocol/sdk@0.19.0(zod@4.3.6)': + '@agentclientprotocol/sdk@0.19.0(zod@4.4.3)': dependencies: - zod: 4.3.6 + zod: 4.4.3 '@agentclientprotocol/sdk@0.22.1(zod@4.3.6)': dependencies: @@ -14969,11 +15097,11 @@ snapshots: '@jimp/types': 1.6.0 tinycolor2: 1.6.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: glob: 11.1.0 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) optionalDependencies: typescript: 5.9.3 @@ -15256,10 +15384,10 @@ snapshots: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6)': + '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.4.3)': dependencies: - '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) - zod: 4.3.6 + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 optionalDependencies: react: 19.1.0 react-dom: 19.1.0(react@19.1.0) @@ -15330,6 +15458,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.7) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.11.7 + jose: 6.2.1 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@mswjs/interceptors@0.41.0': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -16941,10 +17091,10 @@ snapshots: axe-core: 4.11.1 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/addon-docs@10.2.0(@types/react@19.2.11)(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.11)(react@19.1.0) - '@storybook/csf-plugin': 10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) '@storybook/icons': 2.0.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@storybook/react-dom-shim': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)) react: 19.1.0 @@ -16958,27 +17108,27 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/builder-vite@10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': dependencies: - '@storybook/csf-plugin': 10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) - '@vitest/mocker': 3.2.4(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@storybook/csf-plugin': 10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + '@vitest/mocker': 3.2.4(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) ts-dedent: 2.2.0 - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw - rollup - webpack - '@storybook/csf-plugin@10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/csf-plugin@10.2.0(esbuild@0.25.12)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': dependencies: storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.25.12 rollup: 4.57.1 - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) webpack: 5.105.0(esbuild@0.25.12) '@storybook/global@5.0.0': {} @@ -16994,11 +17144,11 @@ snapshots: react-dom: 19.1.0(react@19.1.0) storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) - '@storybook/react-vite@10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + '@storybook/react-vite@10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@storybook/builder-vite': 10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) + '@storybook/builder-vite': 10.2.0(esbuild@0.25.12)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rollup@4.57.1)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12)) '@storybook/react': 10.2.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(storybook@10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(typescript@5.9.3) empathic: 2.0.0 magic-string: 0.30.21 @@ -17008,7 +17158,7 @@ snapshots: resolve: 1.22.11 storybook: 10.2.0(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tsconfig-paths: 4.2.0 - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - esbuild - msw @@ -17174,12 +17324,14 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + '@tanstack/history@1.162.0': {} '@tanstack/query-core@5.90.20': {} @@ -17188,14 +17340,109 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.1.0 + '@tanstack/react-router-devtools@1.167.0(@tanstack/react-router@1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@tanstack/router-core@1.171.6)(csstype@3.2.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/react-router': 1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-devtools-core': 1.168.0(@tanstack/router-core@1.171.6)(csstype@3.2.3) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@tanstack/router-core': 1.171.6 + transitivePeerDependencies: + - csstype + + '@tanstack/react-router@1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/history': 1.162.0 + '@tanstack/react-store': 0.9.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/router-core': 1.171.6 + isbot: 5.1.40 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/react-store@0.9.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + use-sync-external-store: 1.6.0(react@19.1.0) + '@tanstack/react-virtual@3.13.26(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@tanstack/virtual-core': 3.16.0 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@tanstack/router-core@1.171.6': + dependencies: + '@tanstack/history': 1.162.0 + cookie-es: 3.1.1 + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) + + '@tanstack/router-devtools-core@1.168.0(@tanstack/router-core@1.171.6)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.171.6 + clsx: 2.1.1 + goober: 2.1.19(csstype@3.2.3) + optionalDependencies: + csstype: 3.2.3 + + '@tanstack/router-generator@1.167.10': + dependencies: + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.171.6 + '@tanstack/router-utils': 1.162.1 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 + prettier: 3.8.1 + zod: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.168.11(@tanstack/react-router@1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.105.0(esbuild@0.25.12))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.171.6 + '@tanstack/router-generator': 1.167.10 + '@tanstack/router-utils': 1.162.1 + '@tanstack/virtual-file-routes': 1.162.0 + chokidar: 5.0.0 + unplugin: 3.0.0 + zod: 4.4.3 + optionalDependencies: + '@tanstack/react-router': 1.170.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + webpack: 5.105.0(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.162.1': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + ansis: 4.3.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.3 + pathe: 2.0.3 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.16.0': {} + '@tanstack/virtual-file-routes@1.162.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -17690,7 +17937,7 @@ snapshots: '@urql/core': 5.2.0(graphql@16.12.0) wonka: 6.3.5 - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -17698,7 +17945,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -17782,23 +18029,23 @@ snapshots: msw: 2.12.8(@types/node@25.2.0)(typescript@5.9.3) vite: 5.4.21(@types/node@25.2.0)(lightningcss@1.32.0)(terser@5.46.0) - '@vitest/mocker@3.2.4(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@3.2.4(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.8(@types/node@24.12.0)(typescript@5.9.3) - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/mocker@4.0.18(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.8(@types/node@24.12.0)(typescript@5.9.3) - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/mocker@4.1.6(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: @@ -17880,7 +18127,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@2.1.9': dependencies: @@ -18104,6 +18351,8 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.3.0: {} + any-base@1.1.0: {} any-promise@1.3.0: {} @@ -18205,6 +18454,15 @@ snapshots: transitivePeerDependencies: - debug + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + babel-jest@29.7.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -18838,6 +19096,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -20329,6 +20589,10 @@ snapshots: globrex@0.1.2: {} + goober@2.1.19(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + gopd@1.2.0: {} got@11.8.6: @@ -20785,6 +21049,8 @@ snapshots: isbinaryfile@4.0.10: {} + isbot@5.1.40: {} + isexe@2.0.0: {} isexe@3.1.1: {} @@ -20960,6 +21226,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jose@6.2.1: {} joycon@3.1.1: {} @@ -22846,11 +23114,11 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 postcss: 8.5.6 tsx: 4.21.0 yaml: 2.8.2 @@ -23933,6 +24201,12 @@ snapshots: dependencies: randombytes: 2.1.0 + seroval-plugins@1.5.4(seroval@1.5.4): + dependencies: + seroval: 1.5.4 + + seroval@1.5.4: {} + serve-static@1.16.3: dependencies: encodeurl: 2.0.0 @@ -24671,7 +24945,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: bundle-require: 5.1.0(esbuild@0.27.2) cac: 6.7.14 @@ -24682,7 +24956,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) resolve-from: 5.0.0 rollup: 4.57.1 source-map: 0.7.6 @@ -24889,6 +25163,12 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 + unplugin@3.0.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + until-async@3.0.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): @@ -25029,13 +25309,13 @@ snapshots: - supports-color - terser - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript @@ -25062,7 +25342,7 @@ snapshots: lightningcss: 1.32.0 terser: 5.46.0 - vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -25073,7 +25353,7 @@ snapshots: optionalDependencies: '@types/node': 24.12.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.32.0 terser: 5.46.0 tsx: 4.21.0 @@ -25168,10 +25448,10 @@ snapshots: - supports-color - terser - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/ui@4.0.18)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(vite@6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -25188,7 +25468,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.12.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -25498,10 +25778,16 @@ snapshots: dependencies: zod: 4.3.6 + zod-to-json-schema@3.25.1(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} zod@4.3.6: {} + zod@4.4.3: {} + zustand@4.5.7(@types/react@19.2.11)(immer@11.1.3)(react@19.1.0): dependencies: use-sync-external-store: 1.6.0(react@19.1.0)