From 947c87c491ce3da4fc9281cab8bdf918a3f41216 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Tue, 2 Jun 2026 12:17:53 +0300 Subject: [PATCH 1/8] fix(sidebar): show PR state on cloud task icons, stop constant pulse (#2346) --- .../sidebar/components/items/TaskIcon.tsx | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) 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; From 31a9acb301d5a83c5e86be3b8229b827cba469e4 Mon Sep 17 00:00:00 2001 From: Joshua Snyder Date: Tue, 2 Jun 2026 10:37:03 +0100 Subject: [PATCH 2/8] feat(agent): attribute LLM gateway events to the customer team (#2454) --- .../adapters/claude/session/options.test.ts | 65 ++++++++++++++++++- .../src/adapters/claude/session/options.ts | 26 ++++++-- ...agent-server.configure-environment.test.ts | 10 +++ packages/agent/src/server/agent-server.ts | 4 +- 4 files changed, 98 insertions(+), 7 deletions(-) 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, From 13d440d367f3bc646053ea2e244cdce29530dddb Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 2 Jun 2026 10:39:49 +0100 Subject: [PATCH 3/8] feat(code): convert navigationStore to router-derived facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the dual source of truth between Zustand and TanStack Router. `useNavigationStore` keeps the same public API surface for the ~91 existing consumers, but internally has no state of its own — `view` is derived from router state + the task query cache on every render. Actions delegate to `navigationBridge`. Persistence, history stack, and `hydrateTask` are gone (URL is the source of truth, hashHistory + cold-boot restore handle the rest). Notable changes: - `navigationStore.ts` rewritten as a custom hook (~200 lines vs ~400). Keeps `useNavigationStore()`, `.getState()`, and a no-op `.setState()` so consumers don't change. - `view.data` (the full Task object) now populates from `getCachedTask(taskId)` against the React Query cache. Consumers that guarded on `view.data` (HeaderRow, GlobalEventHandlers, ArchivedTasksView, etc.) keep working; the value is just undefined until tasks load, same as before for deep-link cases. - Transient TaskInput state (initialPrompt, reportAssociation, initialCloudRepository, etc.) moves to the existing `useTaskInputPrefillStore`. `navigateToTaskInput(options)` writes prefill then navigates to `/code`; the route reads prefill via the derived view. - `syncToRouter` deleted — no internal state to mirror. - Async side effects in `navigateToTask` (workspace + folder reconciliation) preserved unchanged. - `setActiveTaskAnalyticsContext` now fires from a `router.subscribe( "onResolved")` listener at module init, replacing the per-action call. - New `navigationBridge` accessors (`getCurrentMatches`, `getCurrentLocation`, `subscribeToRouterResolved`, `goForwardInHistory`) keep the store's router access cycle-free. - `routes/code/tasks/$taskId.tsx`: removed the URL→store sync effect; with the derived view, the sync is automatic. - `navigationStore.test.ts` rewritten — old tests targeted Zustand internals (persistence, history stack) that no longer exist. New 12 tests exercise view derivation per route and bridge delegation per action. - `notifications.test.ts` mocks `useNavigationStore.getState` directly instead of driving it via `setState` (which is now no-op). All 131 test files / 1594 tests pass. Typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/code/src/renderer/navigationBridge.ts | 20 + .../renderer/routes/code/tasks/$taskId.tsx | 26 +- .../renderer/stores/navigationStore.test.ts | 371 +++++------- .../src/renderer/stores/navigationStore.ts | 564 +++++++----------- .../src/renderer/utils/notifications.test.ts | 30 +- 5 files changed, 408 insertions(+), 603 deletions(-) diff --git a/apps/code/src/renderer/navigationBridge.ts b/apps/code/src/renderer/navigationBridge.ts index 213ee1610a..d160c1859a 100644 --- a/apps/code/src/renderer/navigationBridge.ts +++ b/apps/code/src/renderer/navigationBridge.ts @@ -69,3 +69,23 @@ export function isOnSettingsRoute(): boolean { export function goBackInHistory(): void { router.history.back(); } + +export function goForwardInHistory(): void { + router.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 router.state.matches; +} + +export function getCurrentLocation() { + return router.state.location; +} + +export function subscribeToRouterResolved(handler: () => void): () => void { + const unsub = router.subscribe("onResolved", handler); + return unsub; +} diff --git a/apps/code/src/renderer/routes/code/tasks/$taskId.tsx b/apps/code/src/renderer/routes/code/tasks/$taskId.tsx index 1321b8a630..b118e8bbc9 100644 --- a/apps/code/src/renderer/routes/code/tasks/$taskId.tsx +++ b/apps/code/src/renderer/routes/code/tasks/$taskId.tsx @@ -1,8 +1,6 @@ import { TaskDetail } from "@features/task-detail/components/TaskDetail"; import { useTasks } from "@features/tasks/hooks/useTasks"; -import { useNavigationStore } from "@stores/navigationStore"; import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; export const Route = createFileRoute("/code/tasks/$taskId")({ component: TaskDetailRoute, @@ -11,30 +9,8 @@ export const Route = createFileRoute("/code/tasks/$taskId")({ function TaskDetailRoute() { const { taskId } = Route.useParams(); const { data: tasks } = useTasks(); - const taskFromList = tasks?.find((t) => t.id === taskId); + const task = 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]); - - const task = taskFromList; if (!task) { return null; } diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts index f1773a568b..feff4f6233 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/apps/code/src/renderer/stores/navigationStore.test.ts @@ -1,32 +1,42 @@ 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(), +// Bridge mocks: assert the store calls navigationBridge for every action +// instead of trying to drive a real router instance from the test runner. +const bridgeMocks = vi.hoisted(() => ({ + navigateToCode: vi.fn(), + navigateToTaskDetail: vi.fn(), + navigateToTaskPending: vi.fn(), + navigateToFolderSettings: vi.fn(), + navigateToInbox: vi.fn(), + navigateToArchived: vi.fn(), + navigateToCommandCenter: vi.fn(), + navigateToSkills: vi.fn(), + navigateToMcpServers: vi.fn(), + navigateToSettings: vi.fn(), + goBackInHistory: vi.fn(), + goForwardInHistory: vi.fn(), + isOnSettingsRoute: vi.fn(() => false), + getCurrentMatches: vi.fn(() => [{ routeId: "/code/", params: {} }]), + getCurrentLocation: vi.fn(() => ({ pathname: "/code/" })), + subscribeToRouterResolved: vi.fn(() => () => {}), })); -vi.mock("@renderer/trpc/client", () => ({ - trpcClient: { - secureStore: { - getItem: { query: getItem }, - setItem: { query: setItem }, - removeItem: { query: vi.fn() }, - }, - }, -})); +vi.mock("@renderer/navigationBridge", () => bridgeMocks); 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() }) }, + logger: { scope: () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() }) }, +})); +vi.mock("@utils/queryClient", () => ({ + getCachedTask: vi.fn(() => undefined), })); vi.mock("@features/workspace/hooks/useWorkspace", () => ({ workspaceApi: { get: vi.fn().mockResolvedValue(null), - getAll: vi.fn().mockResolvedValue({}), create: vi.fn().mockResolvedValue(null), }, })); @@ -40,6 +50,7 @@ vi.mock("@hooks/useRepositoryDirectory", () => ({ getTaskDirectory: vi.fn().mockResolvedValue(null), })); +import { useTaskInputPrefillStore } from "@features/task-detail/stores/taskInputPrefillStore"; import { useNavigationStore } from "./navigationStore"; const mockTask: Task = { @@ -53,239 +64,135 @@ const mockTask: Task = { updated_at: "2024-01-01T00:00:00Z", }; -const getStore = () => useNavigationStore.getState(); -const getView = () => getStore().view; - -describe("navigationStore", () => { +describe("navigationStore (router-derived facade)", () => { beforeEach(() => { - getItem.mockReset(); - setItem.mockReset(); - getItem.mockResolvedValue(null); - setItem.mockResolvedValue(undefined); - useNavigationStore.setState({ - view: { type: "task-input" }, - history: [{ type: "task-input" }], - historyIndex: 0, - }); + for (const fn of Object.values(bridgeMocks)) { + if (typeof fn === "function" && "mockClear" in fn) fn.mockClear(); + } + useTaskInputPrefillStore.setState({ prefill: {} }); + bridgeMocks.getCurrentMatches.mockReturnValue([ + { routeId: "/code/", params: {} }, + ]); }); - 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({ + describe("view derivation", () => { + it("returns task-input for the /code/ route", () => { + expect(useNavigationStore.getState().view.type).toBe("task-input"); + }); + + it("returns task-detail with taskId from URL params", () => { + bridgeMocks.getCurrentMatches.mockReturnValue([ + { routeId: "/code/tasks/$taskId", params: { taskId: "task-99" } }, + ]); + const view = useNavigationStore.getState().view; + expect(view).toMatchObject({ type: "task-detail", taskId: "task-99" }); + }); + + it("returns inbox/archived/command-center/skills/mcp-servers per route", () => { + const cases: Array<[string, string]> = [ + ["/code/inbox", "inbox"], + ["/code/archived", "archived"], + ["/command-center", "command-center"], + ["/skills", "skills"], + ["/mcp-servers", "mcp-servers"], + ]; + for (const [routeId, expected] of cases) { + bridgeMocks.getCurrentMatches.mockReturnValue([ + { routeId, params: {} }, + ]); + expect(useNavigationStore.getState().view.type).toBe(expected); + } + }); + + it("pulls task-input prefill from useTaskInputPrefillStore", () => { + useTaskInputPrefillStore.setState({ + prefill: { initialPrompt: "hello", requestId: "req-1" }, + }); + const view = useNavigationStore.getState().view; + expect(view).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", + initialPrompt: "hello", + taskInputRequestId: "req-1", }); }); + }); - it("navigates to pending task with key", () => { - getStore().navigateToPendingTask("pending-key-123"); - expect(getView()).toMatchObject({ - type: "task-pending", - pendingTaskKey: "pending-key-123", + describe("actions delegate to navigationBridge", () => { + it("navigateToTask calls bridge with the task id", async () => { + await useNavigationStore.getState().navigateToTask(mockTask); + expect(bridgeMocks.navigateToTaskDetail).toHaveBeenCalledWith("task-123"); + }); + + it("navigateToTaskInput writes prefill and navigates to /code", () => { + useNavigationStore + .getState() + .navigateToTaskInput({ initialPrompt: "draft" }); + expect(useTaskInputPrefillStore.getState().prefill.initialPrompt).toBe( + "draft", + ); + expect(bridgeMocks.navigateToCode).toHaveBeenCalled(); + }); + + it("navigateToInbox/archived/etc. call the matching bridge function", () => { + const s = useNavigationStore.getState(); + s.navigateToInbox(); + s.navigateToArchived(); + s.navigateToCommandCenter(); + s.navigateToSkills(); + s.navigateToMcpServers(); + s.navigateToFolderSettings("folder-1"); + s.navigateToPendingTask("pending-1"); + expect(bridgeMocks.navigateToInbox).toHaveBeenCalled(); + expect(bridgeMocks.navigateToArchived).toHaveBeenCalled(); + expect(bridgeMocks.navigateToCommandCenter).toHaveBeenCalled(); + expect(bridgeMocks.navigateToSkills).toHaveBeenCalled(); + expect(bridgeMocks.navigateToMcpServers).toHaveBeenCalled(); + expect(bridgeMocks.navigateToFolderSettings).toHaveBeenCalledWith( + "folder-1", + ); + expect(bridgeMocks.navigateToTaskPending).toHaveBeenCalledWith( + "pending-1", + ); + }); + + it("goBack/goForward call router history", () => { + useNavigationStore.getState().goBack(); + useNavigationStore.getState().goForward(); + expect(bridgeMocks.goBackInHistory).toHaveBeenCalled(); + expect(bridgeMocks.goForwardInHistory).toHaveBeenCalled(); + }); + + it("clearTaskInputReportAssociation clears the prefill store", () => { + useTaskInputPrefillStore.setState({ + prefill: { + reportAssociation: { reportId: "r1", title: "t" }, + initialCloudRepository: "owner/repo", + }, }); - }); - - 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); + useNavigationStore.getState().clearTaskInputReportAssociation(); + const prefill = useTaskInputPrefillStore.getState().prefill; + expect(prefill.reportAssociation).toBeUndefined(); + expect(prefill.initialCloudRepository).toBeUndefined(); }); }); - 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("legacy compatibility shims", () => { + it("history/historyIndex stay stubbed", () => { + const s = useNavigationStore.getState(); + expect(s.history).toEqual([]); + expect(s.historyIndex).toBe(0); }); - }); - 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("hydrateTask is a no-op (URL is source of truth)", () => { + expect(() => + useNavigationStore.getState().hydrateTask([mockTask]), + ).not.toThrow(); }); - 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(); + it("setState logs a warning and does nothing", () => { + const before = useNavigationStore.getState().view; + useNavigationStore.setState({ view: { type: "inbox" } }); + expect(useNavigationStore.getState().view).toEqual(before); }); }); }); diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index acd9fa6500..8ad02945ab 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -1,55 +1,21 @@ import { foldersApi } from "@features/folders/hooks/useFolders"; +import { useTaskInputPrefillStore } from "@features/task-detail/stores/taskInputPrefillStore"; 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 { useRouterState } from "@tanstack/react-router"; import { setActiveTaskAnalyticsContext, track } from "@utils/analytics"; -import { electronStorage } from "@utils/electronStorage"; import { logger } from "@utils/logger"; +import { getCachedTask } from "@utils/queryClient"; 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; - } -}; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- type ViewType = | "task-detail" @@ -92,11 +58,12 @@ interface ViewState { interface NavigationStore { view: ViewState; + // history / historyIndex are router-owned now. Stubbed for back-compat. history: ViewState[]; historyIndex: number; taskInputReportAssociation?: TaskInputReportAssociation; taskInputCloudRepository?: string; - navigateToTask: (task: Task) => void; + navigateToTask: (task: Task) => Promise; navigateToPendingTask: (pendingTaskKey: string) => void; navigateToTaskInput: ( folderIdOrOptions?: string | TaskInputNavigationOptions, @@ -115,304 +82,233 @@ interface NavigationStore { 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; +// --------------------------------------------------------------------------- +// View derivation — pure function of router state + caches +// --------------------------------------------------------------------------- + +function deriveView(): ViewState { + const matches = nav.getCurrentMatches(); + const last = matches[matches.length - 1]; + if (!last) return { type: "task-input" }; + + const prefill = useTaskInputPrefillStore.getState().prefill; + + switch (last.routeId) { + case "/code/tasks/$taskId": { + const taskId = (last.params as { taskId?: string }).taskId; + if (!taskId) return { type: "task-input" }; + const data = getCachedTask(taskId); + return { type: "task-detail", taskId, data }; + } + case "/code/tasks/pending/$key": { + const key = (last.params as { key?: string }).key; + return { type: "task-pending", pendingTaskKey: key }; + } + case "/folders/$folderId": { + const folderId = (last.params as { folderId?: string }).folderId; + return { type: "folder-settings", 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" }; + default: + // /code/, /, or anything else → treat as task-input. Pull transient + // prefill so the new-task screen restores prompt/folder/etc. + return { + type: "task-input", + folderId: prefill.folderId, + initialPrompt: prefill.initialPrompt, + initialCloudRepository: prefill.initialCloudRepository, + initialModel: prefill.initialModel, + initialMode: prefill.initialMode, + reportAssociation: prefill.reportAssociation, + taskInputRequestId: prefill.requestId, + }; } - if (view1.type === "skills" && view2.type === "skills") { - return true; +} + +// --------------------------------------------------------------------------- +// Actions — call the navigation bridge; no internal state +// --------------------------------------------------------------------------- + +async function navigateToTask(task: Task): Promise { + 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; } - if (view1.type === "mcp-servers" && view2.type === "mcp-servers") { - return true; + + 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", + }); } - 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); - }; +function navigateToTaskInput( + 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(); +} - 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(); - } - }, - }; +function clearTaskInputReportAssociation(): void { + useTaskInputPrefillStore.getState().clearReportAssociation(); +} + +function navigateToCommandCenter(): void { + nav.navigateToCommandCenter(); + track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); +} + +// --------------------------------------------------------------------------- +// Snapshot — used by .getState() and per-render derivation +// --------------------------------------------------------------------------- + +function getSnapshot(): NavigationStore { + const view = deriveView(); + const prefill = useTaskInputPrefillStore.getState().prefill; + + return { + view, + history: [], + historyIndex: 0, + taskInputReportAssociation: prefill.reportAssociation, + taskInputCloudRepository: prefill.initialCloudRepository, + navigateToTask, + navigateToPendingTask: nav.navigateToTaskPending, + navigateToTaskInput, + clearTaskInputReportAssociation, + navigateToFolderSettings: nav.navigateToFolderSettings, + navigateToInbox: nav.navigateToInbox, + navigateToArchived: nav.navigateToArchived, + navigateToCommandCenter, + navigateToSkills: nav.navigateToSkills, + navigateToMcpServers: nav.navigateToMcpServers, + goBack: nav.goBackInHistory, + goForward: nav.goForwardInHistory, + canGoBack: () => true, + canGoForward: () => true, + hydrateTask: () => { + /* No-op: the URL is the source of truth now. */ }, - { - 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, - }, - }), + }; +} + +// --------------------------------------------------------------------------- +// React hook with .getState() / .setState() compatibility for legacy callers +// --------------------------------------------------------------------------- + +type Selector = (state: NavigationStore) => T; + +interface UseNavigationStore { + (selector?: Selector): T; + getState: () => NavigationStore; + /** + * @deprecated Setting nav state is a no-op now that view derives from the + * router. Use the navigate* actions, or write to + * useTaskInputPrefillStore for transient task-input prefill. + */ + setState: (partial: Partial) => void; +} + +const useNavigationStoreImpl = ( + selector?: Selector, +): T => { + // Re-render on every route change. + useRouterState({ select: (s) => s.location.pathname }); + // Re-render on prefill changes (so transient task-input fields propagate). + useTaskInputPrefillStore((s) => s.prefill); + const snapshot = getSnapshot(); + return (selector ? selector(snapshot) : snapshot) as T; +}; + +export const useNavigationStore: UseNavigationStore = Object.assign( + useNavigationStoreImpl, + { + getState: getSnapshot, + setState: (_partial: Partial) => { + log.warn( + "useNavigationStore.setState is a no-op; view derives from the router.", + ); }, - ), + }, ); + +// --------------------------------------------------------------------------- +// Side effects — keep analytics task context aligned with the active view +// --------------------------------------------------------------------------- + +nav.subscribeToRouterResolved(() => { + const view = deriveView(); + setActiveTaskAnalyticsContext( + view.type === "task-detail" ? (view.data ?? null) : null, + ); +}); diff --git a/apps/code/src/renderer/utils/notifications.test.ts b/apps/code/src/renderer/utils/notifications.test.ts index 98546573fa..a72dd83c02 100644 --- a/apps/code/src/renderer/utils/notifications.test.ts +++ b/apps/code/src/renderer/utils/notifications.test.ts @@ -1,14 +1,23 @@ 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, + getNavState, +} = vi.hoisted(() => ({ + sendMutate: vi.fn().mockResolvedValue(undefined), + showDockBadgeMutate: vi.fn().mockResolvedValue(undefined), + bounceDockMutate: vi.fn().mockResolvedValue(undefined), + playSound: vi.fn(), + getNavState: vi.fn(() => ({ view: { type: "task-input" } })), +})); + +vi.mock("@stores/navigationStore", () => ({ + useNavigationStore: { getState: getNavState }, +})); vi.mock("@renderer/trpc/client", () => ({ trpcClient: { @@ -43,10 +52,7 @@ 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, - }); + getNavState.mockReturnValue({ view }); } function setFocus(focused: boolean) { From fa2b6847d0573f338cc4358bb12549d70ace0ec6 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Tue, 2 Jun 2026 10:54:24 +0100 Subject: [PATCH 4/8] fix(inbox): track signal_report_id on Create PR task_created event (#2458) --- apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts index 5ebf3f564c..77203e8bfe 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useCreatePrReport.ts @@ -132,6 +132,7 @@ export function useCreatePrReport({ has_branch: false, cloud_run_source: "signal_report", cloud_pr_authorship_mode: "user", + signal_report_id: reportId, adapter, }); } else { From 40ef416d0b47ab5fc93f6e0fbe3ea41327160cfc Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 2 Jun 2026 10:54:53 +0100 Subject: [PATCH 5/8] fix(code): nav store re-render storm by backing facade with Zustand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure-hook facade had no per-selector memoization — every nav consumer re-rendered on every router event AND every prefill change, regardless of whether their selected slice changed. With ~91 consumers, this manifested as sluggish navigation and broken interactions (settings stopped opening correctly, task switching crawled). Fix: back the facade with `create(() => snapshot)` so Zustand's selector memoization kicks in. Update the store via subscriptions to: - router.subscribe("onResolved") — for URL changes - useTaskInputPrefillStore.subscribe — for transient prefill - queryClient.getQueryCache().subscribe — for view.data populating once useTasks resolves Updates are batched via queueMicrotask to coalesce burst events. Test fixture exposes a hoisted `_fireRouterResolved` shim so tests can drive the same subscriber path the runtime uses. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../renderer/stores/navigationStore.test.ts | 70 +++++++++++++------ .../src/renderer/stores/navigationStore.ts | 68 +++++++++++------- 2 files changed, 89 insertions(+), 49 deletions(-) diff --git a/apps/code/src/renderer/stores/navigationStore.test.ts b/apps/code/src/renderer/stores/navigationStore.test.ts index feff4f6233..da9a90791b 100644 --- a/apps/code/src/renderer/stores/navigationStore.test.ts +++ b/apps/code/src/renderer/stores/navigationStore.test.ts @@ -3,24 +3,36 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; // Bridge mocks: assert the store calls navigationBridge for every action // instead of trying to drive a real router instance from the test runner. -const bridgeMocks = vi.hoisted(() => ({ - navigateToCode: vi.fn(), - navigateToTaskDetail: vi.fn(), - navigateToTaskPending: vi.fn(), - navigateToFolderSettings: vi.fn(), - navigateToInbox: vi.fn(), - navigateToArchived: vi.fn(), - navigateToCommandCenter: vi.fn(), - navigateToSkills: vi.fn(), - navigateToMcpServers: vi.fn(), - navigateToSettings: vi.fn(), - goBackInHistory: vi.fn(), - goForwardInHistory: vi.fn(), - isOnSettingsRoute: vi.fn(() => false), - getCurrentMatches: vi.fn(() => [{ routeId: "/code/", params: {} }]), - getCurrentLocation: vi.fn(() => ({ pathname: "/code/" })), - subscribeToRouterResolved: vi.fn(() => () => {}), -})); +const bridgeMocks = vi.hoisted(() => { + const resolvedSubscribers: Array<() => void> = []; + return { + navigateToCode: vi.fn(), + navigateToTaskDetail: vi.fn(), + navigateToTaskPending: vi.fn(), + navigateToFolderSettings: vi.fn(), + navigateToInbox: vi.fn(), + navigateToArchived: vi.fn(), + navigateToCommandCenter: vi.fn(), + navigateToSkills: vi.fn(), + navigateToMcpServers: vi.fn(), + navigateToSettings: vi.fn(), + goBackInHistory: vi.fn(), + goForwardInHistory: vi.fn(), + isOnSettingsRoute: vi.fn(() => false), + getCurrentMatches: vi.fn(() => [{ routeId: "/code/", params: {} }]), + getCurrentLocation: vi.fn(() => ({ pathname: "/code/" })), + subscribeToRouterResolved: vi.fn((fn: () => void) => { + resolvedSubscribers.push(fn); + return () => { + const i = resolvedSubscribers.indexOf(fn); + if (i >= 0) resolvedSubscribers.splice(i, 1); + }; + }), + _fireRouterResolved: () => { + for (const fn of resolvedSubscribers) fn(); + }, + }; +}); vi.mock("@renderer/navigationBridge", () => bridgeMocks); @@ -33,6 +45,9 @@ vi.mock("@utils/logger", () => ({ })); vi.mock("@utils/queryClient", () => ({ getCachedTask: vi.fn(() => undefined), + queryClient: { + getQueryCache: () => ({ subscribe: vi.fn(() => () => {}) }), + }, })); vi.mock("@features/workspace/hooks/useWorkspace", () => ({ workspaceApi: { @@ -75,20 +90,29 @@ describe("navigationStore (router-derived facade)", () => { ]); }); + // Trigger the store's router-resolved subscriber after changing + // getCurrentMatches; the store batches via queueMicrotask so we await it. + async function flushRouterChange(): Promise { + bridgeMocks._fireRouterResolved(); + await Promise.resolve(); + } + describe("view derivation", () => { - it("returns task-input for the /code/ route", () => { + it("returns task-input for the /code/ route", async () => { + await flushRouterChange(); expect(useNavigationStore.getState().view.type).toBe("task-input"); }); - it("returns task-detail with taskId from URL params", () => { + it("returns task-detail with taskId from URL params", async () => { bridgeMocks.getCurrentMatches.mockReturnValue([ { routeId: "/code/tasks/$taskId", params: { taskId: "task-99" } }, ]); + await flushRouterChange(); const view = useNavigationStore.getState().view; expect(view).toMatchObject({ type: "task-detail", taskId: "task-99" }); }); - it("returns inbox/archived/command-center/skills/mcp-servers per route", () => { + it("returns inbox/archived/command-center/skills/mcp-servers per route", async () => { const cases: Array<[string, string]> = [ ["/code/inbox", "inbox"], ["/code/archived", "archived"], @@ -100,14 +124,16 @@ describe("navigationStore (router-derived facade)", () => { bridgeMocks.getCurrentMatches.mockReturnValue([ { routeId, params: {} }, ]); + await flushRouterChange(); expect(useNavigationStore.getState().view.type).toBe(expected); } }); - it("pulls task-input prefill from useTaskInputPrefillStore", () => { + it("pulls task-input prefill from useTaskInputPrefillStore", async () => { useTaskInputPrefillStore.setState({ prefill: { initialPrompt: "hello", requestId: "req-1" }, }); + await flushRouterChange(); const view = useNavigationStore.getState().view; expect(view).toMatchObject({ type: "task-input", diff --git a/apps/code/src/renderer/stores/navigationStore.ts b/apps/code/src/renderer/stores/navigationStore.ts index 8ad02945ab..74eab47aaa 100644 --- a/apps/code/src/renderer/stores/navigationStore.ts +++ b/apps/code/src/renderer/stores/navigationStore.ts @@ -5,11 +5,11 @@ 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 { useRouterState } from "@tanstack/react-router"; import { setActiveTaskAnalyticsContext, track } from "@utils/analytics"; import { logger } from "@utils/logger"; -import { getCachedTask } from "@utils/queryClient"; +import { getCachedTask, queryClient } from "@utils/queryClient"; import { getTaskRepository } from "@utils/repository"; +import { create } from "zustand"; const log = logger.scope("navigation-store"); @@ -263,37 +263,62 @@ function getSnapshot(): NavigationStore { } // --------------------------------------------------------------------------- -// React hook with .getState() / .setState() compatibility for legacy callers +// Backing store — Zustand for selector memoization, but write-only from our +// own subscriptions to the router / prefill / task cache. Components never +// write to it directly (setState is a no-op for back-compat). // --------------------------------------------------------------------------- +const baseStore = create(() => getSnapshot()); + +let refreshScheduled = false; +function refresh(): void { + if (refreshScheduled) return; + refreshScheduled = true; + queueMicrotask(() => { + refreshScheduled = false; + baseStore.setState(getSnapshot(), true); + }); +} + +// Trigger refresh on router navigations, prefill updates, and task cache +// changes (so view.data populates once useTasks resolves). +nav.subscribeToRouterResolved(() => { + refresh(); + const view = deriveView(); + setActiveTaskAnalyticsContext( + view.type === "task-detail" ? (view.data ?? null) : null, + ); +}); +useTaskInputPrefillStore.subscribe(refresh); +queryClient.getQueryCache().subscribe((event) => { + const key = event.query?.queryKey; + if (Array.isArray(key) && key[0] === "tasks") refresh(); +}); + type Selector = (state: NavigationStore) => T; interface UseNavigationStore { (selector?: Selector): T; getState: () => NavigationStore; /** - * @deprecated Setting nav state is a no-op now that view derives from the - * router. Use the navigate* actions, or write to - * useTaskInputPrefillStore for transient task-input prefill. + * @deprecated View state derives from the router and is read-only. Use the + * navigate* actions, or write to useTaskInputPrefillStore for transient + * task-input prefill. Calls are ignored. */ setState: (partial: Partial) => void; } -const useNavigationStoreImpl = ( +function useNavigationStoreImpl( selector?: Selector, -): T => { - // Re-render on every route change. - useRouterState({ select: (s) => s.location.pathname }); - // Re-render on prefill changes (so transient task-input fields propagate). - useTaskInputPrefillStore((s) => s.prefill); - const snapshot = getSnapshot(); - return (selector ? selector(snapshot) : snapshot) as T; -}; +): T { + if (selector) return baseStore(selector); + return baseStore() as T; +} export const useNavigationStore: UseNavigationStore = Object.assign( useNavigationStoreImpl, { - getState: getSnapshot, + getState: () => baseStore.getState(), setState: (_partial: Partial) => { log.warn( "useNavigationStore.setState is a no-op; view derives from the router.", @@ -301,14 +326,3 @@ export const useNavigationStore: UseNavigationStore = Object.assign( }, }, ); - -// --------------------------------------------------------------------------- -// Side effects — keep analytics task context aligned with the active view -// --------------------------------------------------------------------------- - -nav.subscribeToRouterResolved(() => { - const view = deriveView(); - setActiveTaskAnalyticsContext( - view.type === "task-detail" ? (view.data ?? null) : null, - ); -}); From a012fb2f206155ffe10942b21e0a9a91c7c58a7f Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 2 Jun 2026 11:24:38 +0100 Subject: [PATCH 6/8] feat(code): delete navigationStore + settingsDialogStore; finish router migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This completes the TanStack Router migration. The renderer no longer has parallel routing state — every navigation flows through the router. ## navigationStore deletion (~36 files) - New `apps/code/src/renderer/hooks/useAppView.ts` exposes the URL-derived view as `useAppView()` (subscribes to router state) and `getAppViewSnapshot()` (for non-React reads). Replaces every `useNavigationStore((s) => s.view)` consumer. - New `apps/code/src/renderer/hooks/useOpenTask.ts` exports `openTask(task)` (router.navigate + workspace/folder reconciliation side effects) and `openTaskInput(opts)` (writes prefill, navigates to /code). Replaces every `useNavigationStore((s) => s.navigateToTask / .navigateToTaskInput)` consumer. - Direct navigation callers (`navigateToInbox`, `navigateToArchived`, etc.) import from `@renderer/navigationBridge` directly — no hook needed. - Imperative `.getState().view` callers (`useArchiveTask`, `useSessionCallbacks`, `useAppBridge`, `notifications`, etc.) use `getAppViewSnapshot()`. - `navigationStore.ts` and its test are deleted. ## settingsDialogStore deletion (~22 files) - New `apps/code/src/renderer/features/settings/stores/settingsPageStore.ts` holds the UI-only state the dialog store used to own — `context`, `initialAction`, `formMode`. No routing in this store. - New `useOpenSettings.ts` exposes `openSettings(category, contextOrAction)`, `closeSettings()`, `useCloseSettings()`, `useIsSettingsOpen()`. These wrap the `navigationBridge` so consumers can stay router-unaware. - `SettingsDialog.tsx` is now a self-contained modal driver for the pre-router `AiApprovalScreen` shell only — exports `openSettingsDialog` / `closeSettingsDialog` for that case. Inside the main app, the `/settings/$category` route renders `SettingsPanel` directly. - `SettingsPanel` takes optional `activeCategory`, `onClose`, `onCategoryChange` props so it can be reused in both the route and the pre-router dialog shell. - `EnvironmentsSettings` reads its segment (local vs cloud) from the URL param via `useRouterState`. - `settingsDialogStore.ts` and its test are deleted; replaced by `settingsPageStore.test.ts`. ## Bridge expansion `navigationBridge.ts` gains `getCurrentMatches()`, `getCurrentLocation()`, `subscribeToRouterResolved()`, `goForwardInHistory()` so all non-React router access stays inside the bridge. ## Notes - Route loaders for TaskDetail/Inbox are deferred — they need the task-fetch pipeline extracted from `useAuthenticatedQuery` into plain query options that `loader` can call. Worthwhile but a separate refactor. - Cmd+[ / Cmd+] back/forward shortcuts now go through `router.history.back/forward` via the bridge (was the nav store's own history stack). - 1582 tests pass; renderer typecheck and biome lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/GlobalEventHandlers.tsx | 76 ++- .../src/renderer/components/HeaderRow.tsx | 4 +- .../components/AiApprovalScreen.tsx | 11 +- .../archive/components/ArchivedTasksView.tsx | 6 +- .../features/auth/hooks/authMutations.ts | 6 +- .../features/auth/stores/authStore.test.ts | 6 +- .../features/auth/stores/authStore.ts | 10 +- .../billing/components/SidebarUsageBar.tsx | 4 +- .../components/TokenSpendAnalysisBanner.tsx | 11 +- .../billing/components/UsageLimitModal.tsx | 4 +- .../features/billing/subscriptions.ts | 4 +- .../components/CommandCenterPanel.tsx | 7 +- .../components/TaskSelector.tsx | 7 +- .../command/components/CommandMenu.tsx | 28 +- .../components/EnvironmentSelector.tsx | 6 +- .../git-interaction/hooks/useFixWithAgent.ts | 7 +- .../inbox/components/InboxSignalsTab.tsx | 4 +- .../features/inbox/hooks/useCreatePrReport.ts | 6 +- .../features/inbox/hooks/useDiscussReport.ts | 6 +- .../features/inbox/hooks/useInboxDeepLink.ts | 5 +- .../features/mcp-apps/hooks/useAppBridge.ts | 6 +- .../onboarding/components/OnboardingFlow.tsx | 9 +- .../sessions/hooks/useSessionCallbacks.ts | 6 +- .../components/FolderSettingsView.tsx | 11 +- .../settings/components/SettingsDialog.tsx | 75 ++- .../settings/components/SettingsPanel.tsx | 35 +- .../components/sections/AdvancedSettings.tsx | 4 +- .../CloudEnvironmentsSettings.tsx | 6 +- .../environments/EnvironmentsSettings.tsx | 21 +- .../LocalEnvironmentsSettings.tsx | 8 +- .../sections/worktrees/WorktreeRow.tsx | 11 +- .../settings/hooks/useOpenSettings.ts | 55 +++ .../stores/settingsDialogStore.test.ts | 70 --- .../settings/stores/settingsDialogStore.ts | 86 ---- .../settings/stores/settingsPageStore.test.ts | 46 ++ .../settings/stores/settingsPageStore.ts | 46 ++ .../components/DiscoveredTaskDetailDialog.tsx | 5 +- .../sidebar/components/ProjectSwitcher.tsx | 4 +- .../sidebar/components/SidebarContent.tsx | 5 +- .../sidebar/components/SidebarMenu.tsx | 23 +- .../sidebar/components/TaskListView.tsx | 19 +- .../task-detail/components/TaskInput.tsx | 16 +- .../components/WorkspaceModeSelect.tsx | 5 +- .../task-detail/hooks/useTaskCreation.ts | 24 +- .../features/tasks/hooks/useArchiveTask.ts | 19 +- .../renderer/features/tasks/hooks/useTasks.ts | 11 +- .../features/tour/components/TourOverlay.tsx | 4 +- apps/code/src/renderer/hooks/useAppView.ts | 110 +++++ .../src/renderer/hooks/useNewTaskDeepLink.ts | 60 +-- apps/code/src/renderer/hooks/useOpenTask.ts | 124 +++++ .../src/renderer/hooks/useTaskDeepLink.ts | 7 +- apps/code/src/renderer/routeTree.gen.ts | 450 +++++++++--------- apps/code/src/renderer/routes/__root.tsx | 26 +- apps/code/src/renderer/routes/code/index.tsx | 38 +- .../renderer/routes/settings/$category.tsx | 33 +- .../renderer/stores/navigationStore.test.ts | 224 --------- .../src/renderer/stores/navigationStore.ts | 328 ------------- .../src/renderer/utils/notifications.test.ts | 21 +- apps/code/src/renderer/utils/notifications.ts | 7 +- 59 files changed, 957 insertions(+), 1319 deletions(-) create mode 100644 apps/code/src/renderer/features/settings/hooks/useOpenSettings.ts delete mode 100644 apps/code/src/renderer/features/settings/stores/settingsDialogStore.test.ts delete mode 100644 apps/code/src/renderer/features/settings/stores/settingsDialogStore.ts create mode 100644 apps/code/src/renderer/features/settings/stores/settingsPageStore.test.ts create mode 100644 apps/code/src/renderer/features/settings/stores/settingsPageStore.ts create mode 100644 apps/code/src/renderer/hooks/useAppView.ts create mode 100644 apps/code/src/renderer/hooks/useOpenTask.ts delete mode 100644 apps/code/src/renderer/stores/navigationStore.test.ts delete mode 100644 apps/code/src/renderer/stores/navigationStore.ts 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/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..798ebbe4ae 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) { @@ -166,7 +165,6 @@ export function useCreatePrReport({ reportTitle, getUserIntegrationIdForRepo, invalidateTasks, - navigateToTask, ]); return { createPrReport, isCreatingPr }; diff --git a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts index 2b660a1681..2da49cd517 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useDiscussReport.ts @@ -2,12 +2,12 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useSettingsStore } from "@features/settings/stores/settingsStore"; import { useCreateTask } from "@features/tasks/hooks/useTasks"; import { useUserRepositoryIntegration } from "@hooks/useIntegrations"; +import { openTask } from "@hooks/useOpenTask"; import { get } from "@renderer/di/container"; import { RENDERER_TOKENS } from "@renderer/di/tokens"; import { toast } from "@renderer/utils/toast"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; import { getCloudUrlFromRegion } from "@shared/utils/urls"; -import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { useCallback, useState } from "react"; @@ -47,7 +47,6 @@ export function useDiscussReport({ cloudRepository, }: UseDiscussReportOptions): UseDiscussReportReturn { const [isDiscussing, setIsDiscussing] = useState(false); - const { navigateToTask } = useNavigationStore(); const { getUserIntegrationIdForRepo } = useUserRepositoryIntegration(); const { invalidateTasks } = useCreateTask(); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); @@ -121,7 +120,7 @@ export function useDiscussReport({ const taskService = get(RENDERER_TOKENS.TaskService); const result = await taskService.createTask(input, (output) => { invalidateTasks(output.task); - navigateToTask(output.task); + void openTask(output.task); }); if (result.success) { @@ -170,7 +169,6 @@ export function useDiscussReport({ reportTitle, getUserIntegrationIdForRepo, invalidateTasks, - navigateToTask, ], ); diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts index aa619e4373..e7f1e86e07 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxDeepLink.ts @@ -7,8 +7,8 @@ import { reportKeys } from "@features/inbox/hooks/useInboxReports"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { setPendingInboxOpenMethod } from "@features/inbox/utils/pendingInboxOpenMethod"; +import { navigateToInbox } from "@renderer/navigationBridge"; import { trpcClient, useTRPC } from "@renderer/trpc"; -import { useNavigationStore } from "@stores/navigationStore"; import { useQueryClient } from "@tanstack/react-query"; import { useSubscription } from "@trpc/tanstack-react-query"; import { logger } from "@utils/logger"; @@ -40,7 +40,6 @@ export function useInboxDeepLink() { ); const pendingDrainedRef = useRef(false); - const navigateToInbox = useNavigationStore((s) => s.navigateToInbox); const setSelectedReportIds = useInboxReportSelectionStore( (s) => s.setSelectedReportIds, ); @@ -78,7 +77,7 @@ export function useInboxDeepLink() { toast.error("Failed to open report"); } }, - [client, navigateToInbox, queryClient, resetFilters, setSelectedReportIds], + [client, queryClient, resetFilters, setSelectedReportIds], ); useEffect(() => { diff --git a/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts b/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts index c70fa57e7c..5cf4ece09e 100644 --- a/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts +++ b/apps/code/src/renderer/features/mcp-apps/hooks/useAppBridge.ts @@ -1,5 +1,6 @@ import { useDraftStore } from "@features/message-editor/stores/draftStore"; import type { ToolCall } from "@features/sessions/types"; +import { getAppViewSnapshot } from "@hooks/useAppView"; import { AppBridge, type McpUiDisplayMode, @@ -14,7 +15,6 @@ import type { Tool, } from "@modelcontextprotocol/sdk/types.js"; import type { McpUiResource } from "@shared/types/mcp-apps"; -import { useNavigationStore } from "@stores/navigationStore"; import { logger } from "@utils/logger"; import { useCallback, useEffect, useRef } from "react"; import { @@ -213,10 +213,10 @@ export function useAppBridge(args: UseAppBridgeArgs): UseAppBridgeReturn { const message = textParts.join("\n"); if (message) { // Route to the current task's session, or "default" if not on a task - const view = useNavigationStore.getState().view; + const view = getAppViewSnapshot(); const sessionId = view.type === "task-detail" - ? (view.data?.id ?? "default") + ? (view.taskId ?? "default") : "default"; const { setPendingContent, requestFocus } = useDraftStore.getState().actions; diff --git a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx index 3ee898f3a9..89c07b7c9a 100644 --- a/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx +++ b/apps/code/src/renderer/features/onboarding/components/OnboardingFlow.tsx @@ -3,6 +3,7 @@ import { useLogoutMutation } from "@features/auth/hooks/authMutations"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore"; import { useUserGithubIntegrations } from "@hooks/useIntegrations"; +import { openTaskInput } from "@hooks/useOpenTask"; import { ArrowRight, SignOut } from "@phosphor-icons/react"; import { Button, Flex } from "@radix-ui/themes"; import { IS_DEV } from "@shared/constants/environment"; @@ -10,7 +11,6 @@ import { ANALYTICS_EVENTS, type OnboardingStepCompletedProperties, } from "@shared/types/analytics"; -import { useNavigationStore } from "@stores/navigationStore"; import { track } from "@utils/analytics"; import { shipIt } from "@utils/confetti"; import { AnimatePresence, LayoutGroup, motion } from "framer-motion"; @@ -49,9 +49,6 @@ export function OnboardingFlow() { (state) => state.completeOnboarding, ); const resetOnboarding = useOnboardingStore((state) => state.resetOnboarding); - const navigateToTaskInput = useNavigationStore( - (state) => state.navigateToTaskInput, - ); const logoutMutation = useLogoutMutation(); const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", @@ -147,7 +144,7 @@ export function OnboardingFlow() { }); shipIt(); completeOnboarding(); - navigateToTaskInput(); + openTaskInput(); }; const handleSkip = () => { @@ -157,7 +154,7 @@ export function OnboardingFlow() { reason: "dev_skip", }); completeOnboarding(); - navigateToTaskInput(); + openTaskInput(); }; const handleLogout = () => { diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts index ae236342fb..05f4975563 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionCallbacks.ts @@ -1,9 +1,9 @@ import { tryExecuteCodeCommand } from "@features/message-editor/commands"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; import { useTaskViewed } from "@features/sidebar/hooks/useTaskViewed"; +import { getAppViewSnapshot } from "@hooks/useAppView"; import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; -import { useNavigationStore } from "@stores/navigationStore"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; import { useCallback, useRef } from "react"; @@ -59,9 +59,9 @@ export function useSessionCallbacks({ markActivity(taskId); await getSessionService().sendPrompt(taskId, text); - const view = useNavigationStore.getState().view; + const view = getAppViewSnapshot(); const isViewingTask = - view?.type === "task-detail" && view?.data?.id === taskId; + view?.type === "task-detail" && view?.taskId === taskId; if (isViewingTask) { markAsViewed(taskId); } diff --git a/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx b/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx index 61ea0c4b75..f4fe4c5a21 100644 --- a/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx +++ b/apps/code/src/renderer/features/settings/components/FolderSettingsView.tsx @@ -1,4 +1,6 @@ import { useFolders } from "@features/folders/hooks/useFolders"; +import { useAppView } from "@hooks/useAppView"; +import { openTaskInput } from "@hooks/useOpenTask"; import { useSetHeaderContent } from "@hooks/useSetHeaderContent"; import { ArrowLeft, Warning } from "@phosphor-icons/react"; import { @@ -11,7 +13,6 @@ import { Heading, Text, } from "@radix-ui/themes"; -import { useNavigationStore } from "@renderer/stores/navigationStore"; import { logger } from "@utils/logger"; import { useState } from "react"; @@ -20,7 +21,7 @@ const log = logger.scope("folder-settings"); export function FolderSettingsView() { useSetHeaderContent(null); - const { view, navigateToTaskInput } = useNavigationStore(); + const view = useAppView(); const { folders, removeFolder } = useFolders(); const folderId = view.type === "folder-settings" ? view.folderId : undefined; @@ -32,7 +33,7 @@ export function FolderSettingsView() { if (!folderId) return; try { await removeFolder(folderId); - navigateToTaskInput(); + openTaskInput(); } catch (err) { log.error("Failed to remove folder:", err); setError(err instanceof Error ? err.message : "Failed to remove folder"); @@ -53,7 +54,7 @@ export function FolderSettingsView() { + + + + + + } + /> + 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 ( - + =18'} @@ -17176,6 +17188,14 @@ snapshots: '@tanstack/query-core': 5.90.20 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/virtual-core@3.16.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 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' From 45fa39692e5f33888d641ed59c6b986098c90bb6 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 2 Jun 2026 22:35:47 +0100 Subject: [PATCH 8/8] =?UTF-8?q?fix(code):=20stabilize=20router=20migration?= =?UTF-8?q?=20=E2=80=94=20break=20cycle,=20instant=20nav,=20kill=20render?= =?UTF-8?q?=20storm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent defects surfaced after the TanStack Router migration; all manifested as "routes stuck loading / navigation locked". 1. Circular import (router → routeTree → __root → hooks → navigationBridge → router) broke code-split route chunks via TDZ ("Cannot access 'rootRouteImport' before initialization"). Introduce routerRef, a leaf module holding the router singleton; navigationBridge reads it via getRouter() instead of importing the router directly, severing the only route-tree edge back to router.ts. 2. Infinite render storm: useAppView returned a fresh object every render (the old navigationStore.view was a stable ref), so SidebarMenu's [view] effect refired forever → markViewed mutation → cache write → re-render (~50x/2s), starving the UI thread and blocking navigation + session start. Memoize useAppView on the route's primitive values + stable prefill ref. SidebarMenu reads view.taskId (new shape; view.data may be undefined). 3. Blocking loader could hang the router: ensureQueryData(getTask) never resolves for optimistic/cloud-pending tasks, leaving the route pending and un-navigable. Make the task-detail loader synchronous + cache-only; move the cold-deep-link fetch + spinner into the component so a hang only affects that view. openTask seeds the detail cache so in-app opens never fetch. Also adds the per-route loading slot: router context (queryClient), defaultPendingMs: 0 + defaultPendingComponent (RoutePending), and createRootRouteWithContext. Navigation now commits instantly with a per-route pending UI that can later become skeletons. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/renderer/components/RoutePending.tsx | 18 +++++ .../sidebar/components/SidebarMenu.tsx | 2 +- .../src/renderer/features/tasks/queries.ts | 22 ++++++ apps/code/src/renderer/hooks/useAppView.ts | 67 +++++++++++++------ apps/code/src/renderer/hooks/useOpenTask.ts | 6 ++ apps/code/src/renderer/navigationBridge.ts | 50 +++++++------- apps/code/src/renderer/router.ts | 14 ++++ apps/code/src/renderer/routerRef.ts | 26 +++++++ apps/code/src/renderer/routes/__root.tsx | 10 ++- .../renderer/routes/code/tasks/$taskId.tsx | 34 +++++++++- 10 files changed, 197 insertions(+), 52 deletions(-) create mode 100644 apps/code/src/renderer/components/RoutePending.tsx create mode 100644 apps/code/src/renderer/features/tasks/queries.ts create mode 100644 apps/code/src/renderer/routerRef.ts 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/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 3fb71b18ac..c0bbb64b3a 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -94,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 && 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/hooks/useAppView.ts b/apps/code/src/renderer/hooks/useAppView.ts index 77dfb68260..72a0527441 100644 --- a/apps/code/src/renderer/hooks/useAppView.ts +++ b/apps/code/src/renderer/hooks/useAppView.ts @@ -3,6 +3,7 @@ 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" @@ -72,32 +73,56 @@ function deriveFromMatches(matches: Match[]): AppView { /** * 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 matches = useRouterState({ - select: (s) => - s.matches.map((m) => ({ - routeId: m.routeId, - params: m.params as Record, - })), + 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 view = deriveFromMatches(matches); - // /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; + 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]); } /** diff --git a/apps/code/src/renderer/hooks/useOpenTask.ts b/apps/code/src/renderer/hooks/useOpenTask.ts index 7821ca12a9..fb76777b9a 100644 --- a/apps/code/src/renderer/hooks/useOpenTask.ts +++ b/apps/code/src/renderer/hooks/useOpenTask.ts @@ -1,5 +1,6 @@ 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"; @@ -7,6 +8,7 @@ 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"; @@ -20,6 +22,10 @@ const log = logger.scope("open-task"); * 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 }); diff --git a/apps/code/src/renderer/navigationBridge.ts b/apps/code/src/renderer/navigationBridge.ts index d160c1859a..fd5ecdce5d 100644 --- a/apps/code/src/renderer/navigationBridge.ts +++ b/apps/code/src/renderer/navigationBridge.ts @@ -1,91 +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 { - router.history.forward(); + 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 router.state.matches; + return getRouter().state.matches; } export function getCurrentLocation() { - return router.state.location; + return getRouter().state.location; } export function subscribeToRouterResolved(handler: () => void): () => void { - const unsub = router.subscribe("onResolved", handler); + const unsub = getRouter().subscribe("onResolved", handler); return unsub; } 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 977487d12f..bf87501665 100644 --- a/apps/code/src/renderer/routes/__root.tsx +++ b/apps/code/src/renderer/routes/__root.tsx @@ -24,9 +24,9 @@ import { useTRPC } from "@renderer/trpc/client"; import { BILLING_FLAG, SYNC_CLOUD_TASKS_FLAG } from "@shared/constants"; import { useCommandMenuStore } from "@stores/commandMenuStore"; 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"; @@ -50,7 +50,11 @@ 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, }); diff --git a/apps/code/src/renderer/routes/code/tasks/$taskId.tsx b/apps/code/src/renderer/routes/code/tasks/$taskId.tsx index b118e8bbc9..929b05ede1 100644 --- a/apps/code/src/renderer/routes/code/tasks/$taskId.tsx +++ b/apps/code/src/renderer/routes/code/tasks/$taskId.tsx @@ -1,18 +1,48 @@ +import { RoutePending } from "@components/RoutePending"; import { TaskDetail } from "@features/task-detail/components/TaskDetail"; import { useTasks } from "@features/tasks/hooks/useTasks"; +import { taskDetailQuery } from "@features/tasks/queries"; +import type { Task } from "@shared/types"; +import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { getCachedTask } from "@utils/queryClient"; export const Route = createFileRoute("/code/tasks/$taskId")({ component: TaskDetailRoute, + // Synchronous + cache-only: return whatever is already cached (the detail + // entry seeded by openTask, or the sidebar list) and never await the network. + // A blocking loader would leave the route pending — and thus un-navigable — + // whenever the fetch is slow or never resolves (optimistic/cloud-pending + // tasks the API can't return). The cold-miss fetch + spinner live in the + // component instead, so navigation always commits instantly. + loader: ({ context, params }): Task | null => { + const key = taskDetailQuery(params.taskId).queryKey; + return ( + context.queryClient.getQueryData(key) ?? + getCachedTask(params.taskId) ?? + null + ); + }, }); function TaskDetailRoute() { const { taskId } = Route.useParams(); + const loaderTask = Route.useLoaderData(); const { data: tasks } = useTasks(); - const task = tasks?.find((t) => t.id === taskId); + const fromList = tasks?.find((t) => t.id === taskId); + + // Cold deep-link / URL restore: nothing cached. Fetch the single task here so + // a hang or 404 only affects this view's spinner, never the router. + const { data: fetched } = useQuery({ + ...taskDetailQuery(taskId), + enabled: !fromList && !loaderTask, + }); + + // Prefer the live list task (kept fresh by polling + subscriptions). + const task = fromList ?? loaderTask ?? fetched; if (!task) { - return null; + return ; } return ;