diff --git a/apps/code/package.json b/apps/code/package.json index 5910321df0..cf39116137 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -146,6 +146,7 @@ "@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", 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..036d83689c 100644 --- a/apps/code/src/renderer/components/HeaderRow.tsx +++ b/apps/code/src/renderer/components/HeaderRow.tsx @@ -11,13 +11,13 @@ import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger"; import { useSidebarStore } from "@features/sidebar/stores/sidebarStore"; import { SkillButtonsMenu } from "@features/skill-buttons/components/SkillButtonsMenu"; 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,7 +108,7 @@ 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); 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.tsx b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx index 2dfce464d4..9c7b074f4c 100644 --- a/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx +++ b/apps/code/src/renderer/features/ai-approval/components/AiApprovalScreen.tsx @@ -1,8 +1,10 @@ import { FullScreenLayout } from "@components/FullScreenLayout"; import { useLogoutMutation } from "@features/auth/hooks/authMutations"; import { useAuthStateValue } 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 { ArrowSquareOut, GearSix, @@ -27,7 +29,6 @@ interface AiApprovalScreenProps { export function AiApprovalScreen({ orgName, isAdmin }: AiApprovalScreenProps) { const logoutMutation = useLogoutMutation(); - const openSettings = useSettingsDialogStore((s) => s.open); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); // biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount; later isAdmin changes from query resolution should not re-fire @@ -35,7 +36,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, }); @@ -54,7 +55,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 a371710d5d..b5dc19bbcc 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(); }, }); } @@ -82,7 +82,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 f5d0ec9518..8ddd305d87 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -78,10 +78,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 8de660445c..3156d2ace3 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,11 +1,11 @@ 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 { PostHogAPIClient } from "@renderer/api/posthogClient"; 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, @@ -240,14 +240,14 @@ export const useAuthStore = create((set) => ({ sessionResetCallback?.(); await trpcClient.auth.selectProject.mutate({ projectId }); 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, @@ -267,7 +267,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 5ebf3f564c..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) { @@ -132,6 +131,7 @@ export function useCreatePrReport({ has_branch: false, cloud_run_source: "signal_report", cloud_pr_authorship_mode: "user", + signal_report_id: reportId, adapter, }); } else { @@ -166,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/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 4e54828720..bbf9cb2e25 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -285,7 +285,7 @@ export const PromptInput = forwardRef( ( > - + 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/components/ContextUsageIndicator.tsx b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx index f1ac3c11ba..87f1df8760 100644 --- a/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx +++ b/apps/code/src/renderer/features/sessions/components/ContextUsageIndicator.tsx @@ -58,7 +58,7 @@ export function ContextUsageIndicator({ usage }: ContextUsageIndicatorProps) { strokeLinecap="round" /> - + {formatTokensCompact(used)}/{formatTokensCompact(size)} ·{" "} {percentage}% diff --git a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx index 4afb50fd67..85f72405da 100644 --- a/apps/code/src/renderer/features/sessions/components/ConversationView.tsx +++ b/apps/code/src/renderer/features/sessions/components/ConversationView.tsx @@ -14,7 +14,13 @@ import { SkillButtonActionMessage } from "@features/skill-buttons/components/Ski import { ArrowDown, XCircle } from "@phosphor-icons/react"; import { WorkerPoolContextProvider } from "@pierre/diffs/react"; import WorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"; -import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { + Button, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; +import { Box, Flex, Text } from "@radix-ui/themes"; import type { Task } from "@shared/types"; import type { AcpMessage } from "@shared/types/session-events"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -250,7 +256,7 @@ export function ConversationView({ poolOptions={DIFFS_POOL_OPTIONS} highlighterOptions={DIFFS_HIGHLIGHTER_OPTIONS} > -
+
{showScrollButton && ( - - + + + + + + } + /> + Scroll to bottom + )}
diff --git a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx b/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx index 6b988222b4..7910864837 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionFooter.tsx @@ -33,7 +33,7 @@ export function SessionFooter({ usage, }: SessionFooterProps) { const rightSide = ( - + {task && } @@ -41,16 +41,16 @@ export function SessionFooter({ if (isPromptPending && !isCompacting) { if (hasPendingPermission) { return ( - + - + Awaiting permission... @@ -61,7 +61,7 @@ export function SessionFooter({ } return ( - + {queuedCount > 0 && ( - + ({queuedCount} queued) )} @@ -89,19 +89,18 @@ export function SessionFooter({ !wasCancelled; return ( - + {showDuration && ( Generated in {formatDuration(lastGenerationDuration)} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index 49b9cdfa95..9175280bf1 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -476,7 +476,7 @@ export function SessionView({ /> ) : hideInput ? null : firstPendingPermission ? ( - + ) : ( - + { items: T[]; @@ -28,6 +29,9 @@ export interface VirtualizedListHandle { } const AT_BOTTOM_THRESHOLD = 50; +const ESTIMATED_ROW_SIZE = 80; +const OVERSCAN = 6; +const FOOTER_KEY = "__virtualized_footer__"; function VirtualizedListInner( { @@ -43,106 +47,203 @@ function VirtualizedListInner( }: VirtualizedListProps, ref: React.ForwardedRef, ) { - const listRef = useRef(null); - const isAtBottomRef = useRef(true); + const parentRef = useRef(null); const initializedRef = useRef(false); + const isAtBottomRef = useRef(true); + const settlingRef = useRef(false); + const settleRafRef = useRef(null); const onScrollStateChangeRef = useRef(onScrollStateChange); onScrollStateChangeRef.current = onScrollStateChange; - const itemCountRef = useRef(items.length); - itemCountRef.current = items.length; + + const hasFooter = footer != null; + const totalCount = items.length + (hasFooter ? 1 : 0); + + const virtualizer = useVirtualizer({ + count: totalCount, + getScrollElement: () => parentRef.current, + estimateSize: () => ESTIMATED_ROW_SIZE, + overscan: OVERSCAN, + anchorTo: "end", + followOnAppend: true, + scrollEndThreshold: AT_BOTTOM_THRESHOLD, + getItemKey: (index) => { + if (hasFooter && index === items.length) return FOOTER_KEY; + const item = items[index]; + return getItemKey ? getItemKey(item, index) : index; + }, + }); + + const settleAtEnd = useCallback(() => { + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + settleRafRef.current = null; + } + settlingRef.current = true; + isAtBottomRef.current = true; + let attempts = 0; + const step = () => { + virtualizer.scrollToEnd(); + if (virtualizer.isAtEnd(AT_BOTTOM_THRESHOLD)) { + settlingRef.current = false; + settleRafRef.current = null; + if (initializedRef.current) { + onScrollStateChangeRef.current?.(true); + } + return; + } + if (++attempts > 12) { + settlingRef.current = false; + settleRafRef.current = null; + return; + } + settleRafRef.current = requestAnimationFrame(step); + }; + step(); + }, [virtualizer]); useImperativeHandle( ref, () => ({ - scrollToBottom: () => { - const handle = listRef.current; - if (handle) { - handle.scrollTo(handle.scrollSize); - isAtBottomRef.current = true; - } - }, + scrollToBottom: settleAtEnd, scrollToIndex: (index: number) => { - const handle = listRef.current; - if (handle) { - isAtBottomRef.current = false; - handle.scrollToIndex(index, { align: "center" }); + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + settleRafRef.current = null; + settlingRef.current = false; } + isAtBottomRef.current = false; + virtualizer.scrollToIndex(index, { align: "center" }); }, }), - [], + [virtualizer, settleAtEnd], ); + useEffect(() => { + return () => { + if (settleRafRef.current !== null) { + cancelAnimationFrame(settleRafRef.current); + } + }; + }, []); + useLayoutEffect(() => { - const handle = listRef.current; - if (!handle) return; + if (initializedRef.current || totalCount === 0) return; + virtualizer.scrollToEnd(); + requestAnimationFrame(() => { + initializedRef.current = true; + }); + }, [totalCount, virtualizer]); - if (items.length > 0 && !initializedRef.current) { - handle.scrollToIndex(items.length - 1, { align: "end" }); + // Safety net: streaming tokens grow an existing row in place; neither + // followOnAppend (count-based) nor anchorTo='end' (above-viewport-resize) + // covers in-place growth of the last row. Re-pin to end when at-bottom. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-run on items mutation, including streaming text updates + useEffect(() => { + if (!initializedRef.current) return; + if (!isAtBottomRef.current) return; + virtualizer.scrollToEnd(); + }, [items, virtualizer]); - requestAnimationFrame(() => { - initializedRef.current = true; - }); - } - }, [items.length]); + const handleScroll = useCallback(() => { + const atBottom = virtualizer.isAtEnd(AT_BOTTOM_THRESHOLD); + isAtBottomRef.current = atBottom; + if (!initializedRef.current) return; + // Suppress intermediate "not at bottom" pings while a programmatic + // scrollToEnd is still settling after row remeasure. + if (settlingRef.current && !atBottom) return; + onScrollStateChangeRef.current?.(atBottom); + }, [virtualizer]); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally re-run when items change for streaming scroll - useEffect(() => { - if (isAtBottomRef.current) { - const handle = listRef.current; - if (handle) { - // Use scrollToIndex for reliable positioning after measurements settle - const totalChildren = itemCountRef.current + (footer ? 1 : 0); - if (totalChildren > 0) { - handle.scrollToIndex(totalChildren - 1, { align: "end" }); - } - } - } - }, [items, footer]); - - const handleScroll = useCallback((offset: number) => { - const handle = listRef.current; - if (!handle) return; - const distanceFromBottom = handle.scrollSize - offset - handle.viewportSize; - const atBottom = distanceFromBottom < AT_BOTTOM_THRESHOLD; - if (isAtBottomRef.current !== atBottom) { - isAtBottomRef.current = atBottom; - } - // Skip reporting during initialization to avoid flashing the - // scroll-to-bottom button before measurements settle. - if (initializedRef.current) { - onScrollStateChangeRef.current?.(atBottom); - } - }, []); + const virtualItems = virtualizer.getVirtualItems(); + + const renderedIndices = useMemo(() => { + const set = new Set(); + for (const v of virtualItems) set.add(v.index); + return set; + }, [virtualItems]); + + const orphanKeepIndices = useMemo(() => { + if (!keepMounted || keepMounted.length === 0) return []; + return keepMounted.filter( + (i) => i >= 0 && i < items.length && !renderedIndices.has(i), + ); + }, [keepMounted, renderedIndices, items.length]); return ( -
- +
- {items.map((item, index) => { - const key = getItemKey ? getItemKey(item, index) : index; - return ( -
- {renderItem(item, index)} -
- ); - })} - {footer && ( -
- {footer} -
- )} - +
+ {virtualItems.map((virtualItem) => { + const isFooter = hasFooter && virtualItem.index === items.length; + const item = isFooter ? null : items[virtualItem.index]; + const itemKey = isFooter + ? FOOTER_KEY + : getItemKey + ? getItemKey(item as T, virtualItem.index) + : virtualItem.index; + return ( +
+
+ {isFooter ? footer : renderItem(item as T, virtualItem.index)} +
+
+ ); + })} + {orphanKeepIndices.map((index) => { + const item = items[index]; + const k = getItemKey ? getItemKey(item, index) : index; + return ( +
+
+ {renderItem(item, index)} +
+
+ ); + })} +
+
); } diff --git a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx index 0cf7d00083..ff11486e3e 100644 --- a/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx +++ b/apps/code/src/renderer/features/sessions/components/session-update/AgentMessage.tsx @@ -149,7 +149,7 @@ export const AgentMessage = memo(function AgentMessage({ }, [content]); return ( - + navigateToTaskInput()} + onClick={() => openTaskInput()} className="self-start" > @@ -145,7 +146,7 @@ export function FolderSettingsView() {
); } diff --git a/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx b/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx index edbf809dda..f881a27d12 100644 --- a/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx +++ b/apps/code/src/renderer/features/settings/components/SettingsPanel.tsx @@ -5,10 +5,9 @@ import { useCurrentUser, } from "@features/auth/hooks/authQueries"; import { getUserInitials } from "@features/auth/utils/userInitials"; -import { - type SettingsCategory, - useSettingsDialogStore, -} from "@features/settings/stores/settingsDialogStore"; +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 { @@ -31,6 +30,7 @@ import { 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"; @@ -113,9 +113,30 @@ const CATEGORY_COMPONENTS: Record = { advanced: AdvancedSettings, }; -export function SettingsPanel() { - const { activeCategory, close, setCategory, formMode } = - useSettingsDialogStore(); +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)); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); 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 6d936ffd46..0000000000 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@renderer/router", () => ({ - router: { - navigate: vi.fn(), - state: { matches: [] }, - history: { back: vi.fn() }, - }, -})); - -import { useSettingsDialogStore } from "./settingsDialogStore"; - -describe("settingsDialogStore", () => { - beforeEach(() => { - 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 21c4c43e7e..0000000000 --- a/apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { SettingsCategory } from "@features/settings/types"; -import { - goBackInHistory, - isOnSettingsRoute, - navigateToSettings, -} from "@renderer/navigationBridge"; -import { create } from "zustand"; - -export type { SettingsCategory }; - -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) => { - const isAction = typeof contextOrAction === "string"; - const nextCategory = category ?? get().activeCategory; - set({ - isOpen: true, - activeCategory: nextCategory, - context: isAction ? {} : (contextOrAction ?? {}), - initialAction: isAction ? contextOrAction : null, - formMode: false, - }); - // Router push handles browser-history integration; we no longer need a - // manual window.history.pushState (which was colliding with hashHistory). - navigateToSettings(nextCategory); - }, - close: () => { - const wasOpen = get().isOpen; - set({ - isOpen: false, - context: {}, - initialAction: null, - formMode: false, - }); - if (!wasOpen) return; - if (isOnSettingsRoute()) { - // Prefer history.back() so the user returns to their prior context - // (e.g. /code/inbox), not a hard reset to /code. - goBackInHistory(); - } - }, - setCategory: (category) => { - set({ activeCategory: category, initialAction: null, formMode: false }); - navigateToSettings(category); - }, - 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/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 7aaa897d27..353ede0141 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -5,7 +5,7 @@ import { import { useAuthStateValue } 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, @@ -89,8 +89,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..c0bbb64b3a 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -12,13 +12,20 @@ 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, +} 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 +47,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 +94,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 +111,7 @@ function SidebarMenuComponent() { }, [view, markAsViewed]); const handleNewTaskClick = () => { - navigateToTaskInput(); + openTaskInput(); }; const handleInboxClick = () => { @@ -211,7 +210,7 @@ function SidebarMenuComponent() { clearSelection(); const task = taskMap.get(taskId); if (task) { - navigateToTask(task); + void openTask(task); } }; 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/components/items/TaskIcon.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx index de44afcd4c..562e24059a 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx @@ -104,11 +104,11 @@ function CloudStatusIcon({ const link = meta && threadUrl ? threadUrl : undefined; const ariaLabel = link ? `Open ${sourceLabel} thread` : undefined; - if (taskRunStatus === "queued" || taskRunStatus === "in_progress") { + if (taskRunStatus === "queued") { return ( @@ -120,6 +120,22 @@ function CloudStatusIcon({ ); } + if (taskRunStatus === "in_progress") { + return ( + + {renderIconSpan({ + icon: , + link, + ariaLabel, + })} + + ); + } if (taskRunStatus === "completed") { return ( ; } - if (isCloudTask) { - return ( - - ); - } if (isSuspended) { return ( @@ -320,6 +326,16 @@ export function TaskIcon({ if (isPinned) { return ; } + if (isCloudTask) { + return ( + + ); + } if (originProductMeta) { const { Icon, label } = originProductMeta; const link = slackThreadUrl; 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/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..72a0527441 --- /dev/null +++ b/apps/code/src/renderer/hooks/useAppView.ts @@ -0,0 +1,135 @@ +import { useTaskInputPrefillStore } from "@features/task-detail/stores/taskInputPrefillStore"; +import { getCurrentMatches } from "@renderer/navigationBridge"; +import type { Task } from "@shared/types"; +import { useRouterState } from "@tanstack/react-router"; +import { getCachedTask } from "@utils/queryClient"; +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 TaskInputReportAssociation { + reportId: string; + title: string; +} + +export interface AppView { + type: AppViewType; + data?: Task; + 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" }; + return { type: "task-detail", taskId, data: getCachedTask(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 index 213ee1610a..fd5ecdce5d 100644 --- a/apps/code/src/renderer/navigationBridge.ts +++ b/apps/code/src/renderer/navigationBridge.ts @@ -1,71 +1,91 @@ import type { SettingsCategory } from "@features/settings/types"; -import { router } from "@renderer/router"; +import { getRouter } from "@renderer/routerRef"; -// This bridge isolates router calls used by Zustand stores so the stores -// don't import the router directly. Importing `@renderer/router` from a store -// would create a cycle through routeTree.gen.ts → __root.tsx → store, which -// works today only because ES module bindings are live. -// -// Once the navigationStore is deleted, the only consumer here is the settings -// store helpers — and ideally those go too as settings consumers move to -// useNavigate/. +// 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. export function navigateToCode(): void { - void router.navigate({ to: "/code" }); + void getRouter().navigate({ to: "/code" }); } export function navigateToTaskDetail(taskId: string): void { - void router.navigate({ + void getRouter().navigate({ to: "/code/tasks/$taskId", params: { taskId }, }); } export function navigateToTaskPending(key: string): void { - void router.navigate({ + void getRouter().navigate({ to: "/code/tasks/pending/$key", params: { key }, }); } export function navigateToFolderSettings(folderId: string): void { - void router.navigate({ + void getRouter().navigate({ to: "/folders/$folderId", params: { folderId }, }); } export function navigateToInbox(): void { - void router.navigate({ to: "/code/inbox" }); + void getRouter().navigate({ to: "/code/inbox" }); } export function navigateToArchived(): void { - void router.navigate({ to: "/code/archived" }); + void getRouter().navigate({ to: "/code/archived" }); } export function navigateToCommandCenter(): void { - void router.navigate({ to: "/command-center" }); + void getRouter().navigate({ to: "/command-center" }); } export function navigateToSkills(): void { - void router.navigate({ to: "/skills" }); + void getRouter().navigate({ to: "/skills" }); } export function navigateToMcpServers(): void { - void router.navigate({ to: "/mcp-servers" }); + void getRouter().navigate({ to: "/mcp-servers" }); } export function navigateToSettings(category: SettingsCategory): void { - void router.navigate({ + void getRouter().navigate({ to: "/settings/$category", params: { category }, }); } export function isOnSettingsRoute(): boolean { - return router.state.matches.some((m) => m.routeId.startsWith("/settings")); + return getRouter().state.matches.some((m) => + m.routeId.startsWith("/settings"), + ); } export function goBackInHistory(): void { - router.history.back(); + getRouter().history.back(); +} + +export function goForwardInHistory(): void { + getRouter().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 getRouter().state.matches; +} + +export function getCurrentLocation() { + return getRouter().state.location; +} + +export function subscribeToRouterResolved(handler: () => void): () => void { + const unsub = getRouter().subscribe("onResolved", handler); + return unsub; } diff --git a/apps/code/src/renderer/routeTree.gen.ts b/apps/code/src/renderer/routeTree.gen.ts index e6d4d9846e..23bc9b33c7 100644 --- a/apps/code/src/renderer/routeTree.gen.ts +++ b/apps/code/src/renderer/routeTree.gen.ts @@ -8,270 +8,270 @@ // 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' +import { Route as rootRouteImport } from "./routes/__root"; +import { Route as CodeArchivedRouteImport } from "./routes/code/archived"; +import { Route as CodeInboxRouteImport } from "./routes/code/inbox"; +import { Route as CodeIndexRouteImport } from "./routes/code/index"; +import { Route as CodeTasksTaskIdRouteImport } from "./routes/code/tasks/$taskId"; +import { Route as CodeTasksPendingKeyRouteImport } from "./routes/code/tasks/pending.$key"; +import { Route as CommandCenterRouteImport } from "./routes/command-center"; +import { Route as FoldersFolderIdRouteImport } from "./routes/folders/$folderId"; +import { Route as IndexRouteImport } from "./routes/index"; +import { Route as McpServersRouteImport } from "./routes/mcp-servers"; +import { Route as SettingsCategoryRouteImport } from "./routes/settings/$category"; +import { Route as SettingsIndexRouteImport } from "./routes/settings/index"; +import { Route as SkillsRouteImport } from "./routes/skills"; const SkillsRoute = SkillsRouteImport.update({ - id: '/skills', - path: '/skills', + id: "/skills", + path: "/skills", getParentRoute: () => rootRouteImport, -} as any) +} as any); const McpServersRoute = McpServersRouteImport.update({ - id: '/mcp-servers', - path: '/mcp-servers', + id: "/mcp-servers", + path: "/mcp-servers", getParentRoute: () => rootRouteImport, -} as any) +} as any); const CommandCenterRoute = CommandCenterRouteImport.update({ - id: '/command-center', - path: '/command-center', + id: "/command-center", + path: "/command-center", getParentRoute: () => rootRouteImport, -} as any) +} as any); const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', + id: "/", + path: "/", getParentRoute: () => rootRouteImport, -} as any) +} as any); const SettingsIndexRoute = SettingsIndexRouteImport.update({ - id: '/settings/', - path: '/settings/', + id: "/settings/", + path: "/settings/", getParentRoute: () => rootRouteImport, -} as any) +} as any); const CodeIndexRoute = CodeIndexRouteImport.update({ - id: '/code/', - path: '/code/', + id: "/code/", + path: "/code/", getParentRoute: () => rootRouteImport, -} as any) +} as any); const SettingsCategoryRoute = SettingsCategoryRouteImport.update({ - id: '/settings/$category', - path: '/settings/$category', + id: "/settings/$category", + path: "/settings/$category", getParentRoute: () => rootRouteImport, -} as any) +} as any); const FoldersFolderIdRoute = FoldersFolderIdRouteImport.update({ - id: '/folders/$folderId', - path: '/folders/$folderId', + id: "/folders/$folderId", + path: "/folders/$folderId", getParentRoute: () => rootRouteImport, -} as any) +} as any); const CodeInboxRoute = CodeInboxRouteImport.update({ - id: '/code/inbox', - path: '/code/inbox', + id: "/code/inbox", + path: "/code/inbox", getParentRoute: () => rootRouteImport, -} as any) +} as any); const CodeArchivedRoute = CodeArchivedRouteImport.update({ - id: '/code/archived', - path: '/code/archived', + id: "/code/archived", + path: "/code/archived", getParentRoute: () => rootRouteImport, -} as any) +} as any); const CodeTasksTaskIdRoute = CodeTasksTaskIdRouteImport.update({ - id: '/code/tasks/$taskId', - path: '/code/tasks/$taskId', + id: "/code/tasks/$taskId", + path: "/code/tasks/$taskId", getParentRoute: () => rootRouteImport, -} as any) +} as any); const CodeTasksPendingKeyRoute = CodeTasksPendingKeyRouteImport.update({ - id: '/code/tasks/pending/$key', - path: '/code/tasks/pending/$key', + id: "/code/tasks/pending/$key", + path: "/code/tasks/pending/$key", getParentRoute: () => rootRouteImport, -} as any) +} 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 + "/": 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 + "/": 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 + __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 + 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 + | "/" + | "/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' + | "/" + | "/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 + | "__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 + 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' { +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 - } + "/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; + }; } } @@ -288,7 +288,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsIndexRoute: SettingsIndexRoute, CodeTasksTaskIdRoute: CodeTasksTaskIdRoute, CodeTasksPendingKeyRoute: CodeTasksPendingKeyRoute, -} +}; export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) - ._addFileTypes() + ._addFileTypes(); diff --git a/apps/code/src/renderer/router.ts b/apps/code/src/renderer/router.ts index 2bc8b933bd..afda0453a7 100644 --- a/apps/code/src/renderer/router.ts +++ b/apps/code/src/renderer/router.ts @@ -2,6 +2,9 @@ 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"; @@ -24,10 +27,21 @@ if (typeof window !== "undefined" && !window.location.hash) { 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", () => { diff --git a/apps/code/src/renderer/routerRef.ts b/apps/code/src/renderer/routerRef.ts new file mode 100644 index 0000000000..47bd9f77af --- /dev/null +++ b/apps/code/src/renderer/routerRef.ts @@ -0,0 +1,26 @@ +// 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; +} diff --git a/apps/code/src/renderer/routes/__root.tsx b/apps/code/src/renderer/routes/__root.tsx index ef95f70055..bf87501665 100644 --- a/apps/code/src/renderer/routes/__root.tsx +++ b/apps/code/src/renderer/routes/__root.tsx @@ -15,17 +15,18 @@ 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 { - createRootRoute, + createRootRouteWithContext, Outlet, useRouterState, } from "@tanstack/react-router"; @@ -49,13 +50,16 @@ import { useTaskDeepLink } from "../hooks/useTaskDeepLink"; const log = logger.scope("root-route"); -export const Route = createRootRoute({ +export interface RouterContext { + queryClient: QueryClient; +} + +export const Route = createRootRouteWithContext()({ component: RootLayout, }); function RootLayout() { - const { view, hydrateTask, navigateToTaskInput, navigateToTask } = - useNavigationStore(); + const view = useAppView(); const { isOpen: commandMenuOpen, setOpen: setCommandMenuOpen, @@ -77,7 +81,7 @@ function RootLayout() { 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(); @@ -85,11 +89,8 @@ function RootLayout() { 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; @@ -126,11 +127,8 @@ function RootLayout() { 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(); @@ -180,8 +178,8 @@ function RootLayout() { activeTaskId={activeTaskId} allTasks={tasks ?? []} isOnNewTask={view.type === "task-input" || view.type === "task-pending"} - onNavigateToTask={navigateToTask} - onNewTask={navigateToTaskInput} + onNavigateToTask={openTask} + onNewTask={openTaskInput} /> s.view); - const taskInputReportAssociation = useNavigationStore( - (s) => s.taskInputReportAssociation, - ); - const taskInputCloudRepository = useNavigationStore( - (s) => s.taskInputCloudRepository, - ); - - const initialPrompt = - view.type === "task-input" ? view.initialPrompt : undefined; - const initialPromptKey = - view.type === "task-input" ? view.taskInputRequestId : undefined; - const initialCloudRepository = - view.type === "task-input" - ? (view.initialCloudRepository ?? taskInputCloudRepository) - : taskInputCloudRepository; - const initialModel = - view.type === "task-input" ? view.initialModel : undefined; - const initialMode = view.type === "task-input" ? view.initialMode : undefined; - const reportAssociation = - view.type === "task-input" - ? (view.reportAssociation ?? taskInputReportAssociation) - : taskInputReportAssociation; + 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 index 1321b8a630..929b05ede1 100644 --- a/apps/code/src/renderer/routes/code/tasks/$taskId.tsx +++ b/apps/code/src/renderer/routes/code/tasks/$taskId.tsx @@ -1,42 +1,48 @@ +import { RoutePending } from "@components/RoutePending"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useNavigationStore } from "@stores/navigationStore"; +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 { useEffect } from "react"; +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 taskFromList = tasks?.find((t) => t.id === taskId); + const fromList = tasks?.find((t) => t.id === taskId); - // Silent sync of nav store to URL. Reads/writes via getState/setState so we - // don't trigger the store's navigate() helper, which would call - // router.navigate and fight with whatever navigation just landed us here. - useEffect(() => { - if (!taskFromList) return; - const state = useNavigationStore.getState(); - if ( - state.view.type === "task-detail" && - state.view.data?.id === taskFromList.id - ) { - return; - } - useNavigationStore.setState({ - view: { - type: "task-detail", - data: taskFromList, - taskId: taskFromList.id, - }, - }); - }, [taskFromList]); + // 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; - const task = taskFromList; if (!task) { - return null; + return ; } return ; diff --git a/apps/code/src/renderer/routes/settings/$category.tsx b/apps/code/src/renderer/routes/settings/$category.tsx index cc21a78d5e..c059a33626 100644 --- a/apps/code/src/renderer/routes/settings/$category.tsx +++ b/apps/code/src/renderer/routes/settings/$category.tsx @@ -1,8 +1,5 @@ import { SettingsPanel } from "@features/settings/components/SettingsPanel"; -import { - type SettingsCategory, - useSettingsDialogStore, -} from "@features/settings/stores/settingsDialogStore"; +import { useSettingsPageStore } from "@features/settings/stores/settingsPageStore"; import { isSettingsCategory } from "@features/settings/types"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect } from "react"; @@ -13,28 +10,14 @@ export const Route = createFileRoute("/settings/$category")({ function SettingsRoute() { const { category } = Route.useParams(); - const cat: SettingsCategory = isSettingsCategory(category) - ? category - : "general"; + const cat = isSettingsCategory(category) ? category : "general"; - // Sync the settings store's category to the URL param. Components nested in - // SettingsPanel still read activeCategory from the store; this keeps both in - // sync when navigation lands here from a deep link or back/forward. + // 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(() => { - const state = useSettingsDialogStore.getState(); - if (state.activeCategory !== cat || !state.isOpen) { - useSettingsDialogStore.setState({ - isOpen: true, - activeCategory: cat, - formMode: false, - }); - } - return () => { - // Clear the open flag when leaving the settings route so legacy - // consumers reading `isOpen` don't see a stale value. - useSettingsDialogStore.setState({ isOpen: false, formMode: false }); - }; - }, [cat]); + return () => useSettingsPageStore.getState().reset(); + }, []); - return ; + return ; } 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 acd9fa6500..0000000000 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ /dev/null @@ -1,418 +0,0 @@ -import { foldersApi } from "@features/folders/hooks/useFolders"; -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 { 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"); - -// Mirror nav store actions to the router URL so deep-links, back/forward, and -// future per-route logic stay coherent. This store is a transitional shim -// until consumers are ported to use router APIs directly. -const syncToRouter = (view: ViewState) => { - switch (view.type) { - case "task-input": - nav.navigateToCode(); - return; - case "task-detail": { - const taskId = view.taskId ?? view.data?.id; - if (!taskId) return; - nav.navigateToTaskDetail(taskId); - return; - } - case "task-pending": - if (view.pendingTaskKey) nav.navigateToTaskPending(view.pendingTaskKey); - return; - case "folder-settings": - if (view.folderId) nav.navigateToFolderSettings(view.folderId); - return; - case "inbox": - nav.navigateToInbox(); - return; - case "archived": - nav.navigateToArchived(); - return; - case "command-center": - nav.navigateToCommandCenter(); - return; - case "skills": - nav.navigateToSkills(); - return; - case "mcp-servers": - nav.navigateToMcpServers(); - return; - } -}; - -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, - ); - syncToRouter(newView); - }; - - 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, - ); - syncToRouter(newView); - } - }, - - 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, - ); - syncToRouter(newView); - } - }, - - 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/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 7c843dc593..e412e7e124 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -1,6 +1,6 @@ import * as os from "node:os"; import * as path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { Logger } from "../../../utils/logger"; import { SUBAGENT_REWRITES } from "../hooks"; import { buildSessionOptions } from "./options"; @@ -70,4 +70,67 @@ describe("buildSessionOptions", () => { expect(options.agents?.["ph-explore"]).toEqual(override); }); + + describe("ANTHROPIC_CUSTOM_HEADERS", () => { + const originalProjectId = process.env.POSTHOG_PROJECT_ID; + const originalCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; + + beforeEach(() => { + delete process.env.POSTHOG_PROJECT_ID; + delete process.env.ANTHROPIC_CUSTOM_HEADERS; + }); + + afterEach(() => { + for (const [key, value] of [ + ["POSTHOG_PROJECT_ID", originalProjectId], + ["ANTHROPIC_CUSTOM_HEADERS", originalCustomHeaders], + ] as const) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + it.each([ + { + name: "omits the team_id header when POSTHOG_PROJECT_ID is unset", + projectId: undefined, + existingHeaders: undefined, + expected: "x-posthog-use-bedrock-fallback: true", + }, + { + name: "forwards POSTHOG_PROJECT_ID as the team_id attribution header", + projectId: "42", + existingHeaders: undefined, + expected: [ + "x-posthog-property-team_id: 42", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), + }, + { + name: "preserves pre-existing custom headers ahead of the team_id header", + projectId: "42", + existingHeaders: "x-posthog-property-task_id: task-abc", + expected: [ + "x-posthog-property-task_id: task-abc", + "x-posthog-property-team_id: 42", + "x-posthog-use-bedrock-fallback: true", + ].join("\n"), + }, + ])("$name", ({ projectId, existingHeaders, expected }) => { + if (projectId !== undefined) { + process.env.POSTHOG_PROJECT_ID = projectId; + } + if (existingHeaders !== undefined) { + process.env.ANTHROPIC_CUSTOM_HEADERS = existingHeaders; + } + + const headers = buildSessionOptions(makeParams()).env + ?.ANTHROPIC_CUSTOM_HEADERS; + + expect(headers).toBe(expected); + }); + }); }); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index b3c2683a7a..c87fb4f096 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -112,11 +112,28 @@ function buildMcpServers( } function buildEnvironment(): Record { - const bedrockFallbackHeader = "x-posthog-use-bedrock-fallback: true"; + // Custom HTTP headers reach the model only through the Claude CLI subprocess, + // which reads them from this env var (newline-delimited `name: value` lines) + // — the SDK has no direct header option. We finalize them here, the single + // chokepoint every session (desktop and cloud) funnels through. + const headerLines: string[] = []; const existingCustomHeaders = process.env.ANTHROPIC_CUSTOM_HEADERS; - const customHeaders = existingCustomHeaders - ? `${existingCustomHeaders}\n${bedrockFallbackHeader}` - : bedrockFallbackHeader; + if (existingCustomHeaders) { + headerLines.push(existingCustomHeaders); + } + // Attribute every captured $ai_generation event to the customer's team. The + // gateway authenticates with a shared key, so without this the spend lands on + // the key owner's team. The gateway lifts `x-posthog-property-*` headers onto + // the event; both entrypoints export POSTHOG_PROJECT_ID before this runs + // (apps/code auth-adapter.ts, server/agent-server.ts). Mirrors django's + // get_llm_client(team_id=...). + const projectId = process.env.POSTHOG_PROJECT_ID; + if (projectId) { + headerLines.push(`x-posthog-property-team_id: ${projectId}`); + } + // Route to AWS Bedrock as a fallback when Anthropic returns 5xx + headerLines.push("x-posthog-use-bedrock-fallback: true"); + const customHeaders = headerLines.join("\n"); // SDK 0.3.142 made MCP servers connect in the background by default. That // default is what we want: a slow or unreachable user MCP server (PostHog @@ -136,7 +153,6 @@ function buildEnvironment(): Record { ...(mcpNonblocking !== undefined && { MCP_CONNECTION_NONBLOCKING: mcpNonblocking, }), - // Route to AWS Bedrock as a fallback when Anthropic returns 5xx ANTHROPIC_CUSTOM_HEADERS: customHeaders, }; } diff --git a/packages/agent/src/server/agent-server.configure-environment.test.ts b/packages/agent/src/server/agent-server.configure-environment.test.ts index fd81726ad8..69871e5cb9 100644 --- a/packages/agent/src/server/agent-server.configure-environment.test.ts +++ b/packages/agent/src/server/agent-server.configure-environment.test.ts @@ -18,6 +18,7 @@ const ENV_KEYS_UNDER_TEST = [ "ANTHROPIC_BASE_URL", "OPENAI_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", + "POSTHOG_PROJECT_ID", ] as const; describe("AgentServer.configureEnvironment", () => { @@ -75,6 +76,15 @@ describe("AgentServer.configureEnvironment", () => { ); }); + // The Claude session builder reads POSTHOG_PROJECT_ID to emit the + // `x-posthog-property-team_id` attribution header (see + // adapters/claude/session/options.ts), so the cloud path must export it. + it("exports POSTHOG_PROJECT_ID for the team_id attribution header", () => { + buildServer("background").configureEnvironment({ isInternal: false }); + + expect(process.env.POSTHOG_PROJECT_ID).toBe("1"); + }); + it("tags as posthog_code when isInternal is omitted (getTask failure fallback)", () => { buildServer("background").configureEnvironment(); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index c5307e44ff..8c0f4aa72b 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1893,7 +1893,9 @@ ${signedCommitInstructions} // Forward task metadata as `x-posthog-property-*` headers so the gateway // lifts them onto the $ai_generation event. Routes through the Anthropic // SDK's ANTHROPIC_CUSTOM_HEADERS env var; the OpenAI/codex path has no - // equivalent today. + // equivalent today. (The `team_id` attribution header is added downstream + // in the Claude session builder from POSTHOG_PROJECT_ID — see + // adapters/claude/session/options.ts.) const customHeaders = buildGatewayPropertyHeaders({ task_origin_product: originProduct, task_internal: isInternal, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71f68fad34..4abf04cc6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: '@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)) @@ -5080,6 +5083,12 @@ packages: 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'} @@ -5126,6 +5135,9 @@ packages: '@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'} @@ -17355,6 +17367,12 @@ snapshots: 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 @@ -17421,6 +17439,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.16.0': {} + '@tanstack/virtual-file-routes@1.162.0': {} '@testing-library/dom@10.4.1': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ebaf87ae6..a161e7ff82 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,6 +23,8 @@ minimumReleaseAgeExclude: - '@pierre/diffs' - '@posthog/quill' - '@posthog/quill-tokens' + - '@tanstack/react-virtual' + - '@tanstack/virtual-core' onlyBuiltDependencies: - '@parcel/watcher'