Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ out/
storybook-static
bin/


# Environment
.env
.env.local
Expand Down
1 change: 1 addition & 0 deletions apps/code/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/renderer/routeTree.gen.ts linguist-generated=true
4 changes: 4 additions & 0 deletions apps/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"build-icons": "bash scripts/generate-icns.sh",
"typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.web.json --noEmit",
"generate-client": "tsx scripts/update-openapi-client.ts",
"generate-routes": "node scripts/generate-routes.mjs",
"test": "vitest run",
"test:e2e": "playwright test --config=tests/e2e/playwright.config.ts",
"test:e2e:headed": "playwright test --config=tests/e2e/playwright.config.ts --headed",
Expand Down Expand Up @@ -143,7 +144,10 @@
"@radix-ui/themes": "^3.2.1",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-router": "^1.95.0",
"@tanstack/react-router-devtools": "^1.95.0",
"@tanstack/react-virtual": "^3.13.26",
"@tanstack/router-plugin": "^1.95.0",
"@tiptap/core": "^3.13.0",
"@tiptap/extension-mention": "^3.13.0",
"@tiptap/extension-placeholder": "^3.13.0",
Expand Down
20 changes: 20 additions & 0 deletions apps/code/scripts/generate-routes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Generator, getConfig } from "@tanstack/router-generator";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, "..");

const config = getConfig(
{
target: "react",
autoCodeSplitting: true,
routesDirectory: path.resolve(root, "src/renderer/routes"),
generatedRouteTree: path.resolve(root, "src/renderer/routeTree.gen.ts"),
},
root,
);

const generator = new Generator({ config, root });
await generator.run();
console.log("Generated routeTree.gen.ts");
5 changes: 3 additions & 2 deletions apps/code/src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { ErrorBoundary } from "@components/ErrorBoundary";
import { LoginTransition } from "@components/LoginTransition";
import { MainLayout } from "@components/MainLayout";
import { ScopeReauthPrompt } from "@components/ScopeReauthPrompt";
import { AiApprovalScreen } from "@features/ai-approval/components/AiApprovalScreen";
import { AuthScreen } from "@features/auth/components/AuthScreen";
Expand All @@ -18,6 +17,7 @@ import { OnboardingFlow } from "@features/onboarding/components/OnboardingFlow";
import { useOnboardingStore } from "@features/onboarding/stores/onboardingStore";
import { Flex, Spinner, Text } from "@radix-ui/themes";
import { initializeConnectivityToast } from "@renderer/features/connectivity/connectivityToast";
import { router } from "@renderer/router";
import { initializeConnectivityStore } from "@renderer/stores/connectivityStore";
import { useFocusStore } from "@renderer/stores/focusStore";
import { useThemeStore } from "@renderer/stores/themeStore";
Expand All @@ -26,6 +26,7 @@ import { trpcClient, useTRPC } from "@renderer/trpc/client";
import { isNotAuthenticatedError } from "@shared/errors";
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
import { useQueryClient } from "@tanstack/react-query";
import { RouterProvider } from "@tanstack/react-router";
import { useSubscription } from "@trpc/tanstack-react-query";
import { initializePostHog, registerAppVersion, track } from "@utils/analytics";
import { logger } from "@utils/logger";
Expand Down Expand Up @@ -292,7 +293,7 @@ function App() {
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: showTransition ? 0.5 : 0 }}
>
<MainLayout />
<RouterProvider router={router} />
</motion.div>
);
};
Expand Down
76 changes: 33 additions & 43 deletions apps/code/src/renderer/components/GlobalEventHandlers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand All @@ -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 ?? "",
);
Expand All @@ -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) => {
Expand Down Expand Up @@ -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, {
Expand Down
31 changes: 21 additions & 10 deletions apps/code/src/renderer/components/HeaderRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import { useHandoffDialogStore } from "@features/sessions/stores/handoffDialogSt
import { SidebarTrigger } from "@features/sidebar/components/SidebarTrigger";
import { useSidebarStore } from "@features/sidebar/stores/sidebarStore";
import { SkillButtonsMenu } from "@features/skill-buttons/components/SkillButtonsMenu";
import { useTasks } from "@features/tasks/hooks/useTasks";
import { useWorkspace } from "@features/workspace/hooks/useWorkspace";
import { useAppView } from "@hooks/useAppView";
import { useFeatureFlag } from "@hooks/useFeatureFlag";
import { Cloud, Spinner } from "@phosphor-icons/react";
import { Button as QuillButton } from "@posthog/quill";
import { Box, Flex } from "@radix-ui/themes";
import type { Task } from "@shared/types";
import { useHeaderStore } from "@stores/headerStore";
import { useNavigationStore } from "@stores/navigationStore";
import { isWindows } from "@utils/platform";
import { useState } from "react";

Expand Down Expand Up @@ -108,14 +109,21 @@ const WINDOWS_TITLEBAR_INSET = 140;

export function HeaderRow() {
const content = useHeaderStore((state) => state.content);
const view = useNavigationStore((state) => state.view);
const view = useAppView();

const sidebarOpen = useSidebarStore((state) => state.open);
const sidebarWidth = useSidebarStore((state) => state.width);
const isResizing = useSidebarStore((state) => state.isResizing);
const setIsResizing = useSidebarStore((state) => state.setIsResizing);

const activeTaskId = view.type === "task-detail" ? view.data?.id : undefined;
const activeTaskId = view.type === "task-detail" ? view.taskId : undefined;
// Read the live task from the list cache instead of a stale snapshot off the
// memoized view, so header content (diff stats, status) stays current while
// the user remains on the task.
const { data: tasks } = useTasks();
const activeTask = activeTaskId
? tasks?.find((t) => t.id === activeTaskId)
: undefined;
const activeWorkspace = useWorkspace(activeTaskId);
const isCloudTask = activeWorkspace?.mode === "cloud";
const showTaskSection = view.type === "task-detail";
Expand Down Expand Up @@ -172,7 +180,7 @@ export function HeaderRow() {
</Flex>
)}

{showTaskSection && view.type === "task-detail" && view.data && (
{showTaskSection && view.type === "task-detail" && activeTask && (
<Flex
align="center"
justify="end"
Expand All @@ -182,7 +190,7 @@ export function HeaderRow() {
className="h-full max-w-[50%] shrink-0 overflow-hidden"
>
<div className="no-drag">
<SkillButtonsMenu taskId={view.data.id} />
<SkillButtonsMenu taskId={activeTask.id} />
</div>
{activeWorkspace &&
(activeWorkspace.branchName || activeWorkspace.baseBranch) && (
Expand All @@ -198,18 +206,21 @@ export function HeaderRow() {
activeWorkspace.baseBranch ??
null
}
taskId={view.data.id}
taskId={activeTask.id}
/>
</div>
)}
<DiffStatsBadge task={view.data} />
<DiffStatsBadge task={activeTask} />

{isCloudTask ? (
<CloudGitInteractionHeader taskId={view.data.id} task={view.data} />
<CloudGitInteractionHeader
taskId={activeTask.id}
task={activeTask}
/>
) : (
<LocalHandoffButton taskId={view.data.id} task={view.data} />
<LocalHandoffButton taskId={activeTask.id} task={activeTask} />
)}
<TaskActionsMenu taskId={view.data.id} isCloud={isCloudTask} />
<TaskActionsMenu taskId={activeTask.id} isCloud={isCloudTask} />
</Flex>
)}
</Flex>
Expand Down
18 changes: 18 additions & 0 deletions apps/code/src/renderer/components/RoutePending.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex align="center" justify="center" height="100%" width="100%">
<Spinner size="3" />
</Flex>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";

const approveAiDataProcessing = vi.fn();
const logoutMutate = vi.fn();
const openSettings = vi.fn();
// Hoisted so the spies are initialized before the hoisted vi.mock factories
// that reference them run.
const { approveAiDataProcessing, logoutMutate, openSettings } = vi.hoisted(
() => ({
approveAiDataProcessing: vi.fn(),
logoutMutate: vi.fn(),
openSettings: vi.fn(),
}),
);

vi.mock("@features/auth/hooks/authClient", () => ({
useAuthenticatedClient: () => ({ approveAiDataProcessing }),
Expand All @@ -22,12 +28,7 @@ vi.mock("@features/auth/hooks/authQueries", () => ({

vi.mock("@features/settings/components/SettingsDialog", () => ({
SettingsDialog: () => null,
}));

vi.mock("@features/settings/stores/settingsDialogStore", () => ({
useSettingsDialogStore: (
selector: (state: { open: typeof openSettings }) => unknown,
) => selector({ open: openSettings }),
openSettingsDialog: openSettings,
}));

vi.mock("@utils/analytics", () => ({ track: vi.fn() }));
Expand Down
Loading
Loading