From 1444a55a79b450b03624e9c69cd9037b9126032e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 13 Jan 2026 11:43:22 -0800 Subject: [PATCH 1/4] Kinda nice cleanup, removes flicker --- apps/desktop/src/renderer/index.tsx | 4 ++ apps/desktop/src/renderer/lib/query-client.ts | 15 ++++++ .../providers/TRPCProvider/TRPCProvider.tsx | 20 +------- apps/desktop/src/renderer/routes/__root.tsx | 9 +++- .../settings/project/$projectId/page.tsx | 46 ++++++++++++------- .../settings/workspace/$workspaceId/page.tsx | 26 ++++++----- 6 files changed, 72 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/query-client.ts diff --git a/apps/desktop/src/renderer/index.tsx b/apps/desktop/src/renderer/index.tsx index af1f2c34a18..a49d4e6498a 100644 --- a/apps/desktop/src/renderer/index.tsx +++ b/apps/desktop/src/renderer/index.tsx @@ -9,6 +9,7 @@ import { RouterProvider, } from "@tanstack/react-router"; import ReactDom from "react-dom/client"; +import { queryClient } from "./lib/query-client"; import { routeTree } from "./routeTree.gen"; import "./globals.css"; @@ -19,6 +20,9 @@ const router = createRouter({ routeTree, history: hashHistory as RouterHistory, defaultPreload: "intent", + context: { + queryClient, + }, }); declare module "@tanstack/react-router" { diff --git a/apps/desktop/src/renderer/lib/query-client.ts b/apps/desktop/src/renderer/lib/query-client.ts new file mode 100644 index 00000000000..59023ff2f6c --- /dev/null +++ b/apps/desktop/src/renderer/lib/query-client.ts @@ -0,0 +1,15 @@ +import { QueryClient } from "@tanstack/react-query"; + +// Shared QueryClient instance for both TRPCProvider and router loaders +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + networkMode: "always", + retry: false, + }, + mutations: { + networkMode: "always", + retry: false, + }, + }, +}); diff --git a/apps/desktop/src/renderer/providers/TRPCProvider/TRPCProvider.tsx b/apps/desktop/src/renderer/providers/TRPCProvider/TRPCProvider.tsx index 13c2896860b..1d4473a9354 100644 --- a/apps/desktop/src/renderer/providers/TRPCProvider/TRPCProvider.tsx +++ b/apps/desktop/src/renderer/providers/TRPCProvider/TRPCProvider.tsx @@ -1,25 +1,9 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { trpc } from "lib/trpc"; -import { useState } from "react"; +import { queryClient } from "../../lib/query-client"; import { reactClient } from "../../lib/trpc-client"; export function TRPCProvider({ children }: { children: React.ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - networkMode: "always", - retry: false, - }, - mutations: { - networkMode: "always", - retry: false, - }, - }, - }), - ); - return ( {children} diff --git a/apps/desktop/src/renderer/routes/__root.tsx b/apps/desktop/src/renderer/routes/__root.tsx index 1716913be7b..08fbe30571b 100644 --- a/apps/desktop/src/renderer/routes/__root.tsx +++ b/apps/desktop/src/renderer/routes/__root.tsx @@ -1,8 +1,13 @@ -import { createRootRoute, Outlet } from "@tanstack/react-router"; +import type { QueryClient } from "@tanstack/react-query"; +import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; import { RootLayout } from "./layout"; import { NotFound } from "./not-found"; -export const Route = createRootRoute({ +interface RouterContext { + queryClient: QueryClient; +} + +export const Route = createRootRouteWithContext()({ component: RootComponent, notFoundComponent: NotFound, }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx index eb4dcba1e8a..e42812feebc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx @@ -1,9 +1,35 @@ import { createFileRoute } from "@tanstack/react-router"; +import { trpcClient } from "renderer/lib/trpc-client"; export const Route = createFileRoute( "/_authenticated/settings/project/$projectId/", )({ component: ProjectSettingsPage, + loader: async ({ params, context }) => { + const projectQueryKey = [ + ["projects", "get"], + { input: { id: params.projectId }, type: "query" }, + ]; + + const configQueryKey = [ + ["config", "getConfigFilePath"], + { input: { projectId: params.projectId }, type: "query" }, + ]; + + await Promise.all([ + context.queryClient.ensureQueryData({ + queryKey: projectQueryKey, + queryFn: () => trpcClient.projects.get.query({ id: params.projectId }), + }), + context.queryClient.ensureQueryData({ + queryKey: configQueryKey, + queryFn: () => + trpcClient.config.getConfigFilePath.query({ + projectId: params.projectId, + }), + }), + ]); + }, }); import { HiOutlineCog6Tooth, HiOutlineFolder } from "react-icons/hi2"; @@ -12,25 +38,13 @@ import { trpc } from "renderer/lib/trpc"; function ProjectSettingsPage() { const { projectId } = Route.useParams(); - const { data: project, isLoading } = trpc.projects.get.useQuery({ + const { data: project } = trpc.projects.get.useQuery({ id: projectId, }); - const { data: configFilePath } = trpc.config.getConfigFilePath.useQuery( - { projectId }, - { enabled: !!projectId }, - ); - - if (isLoading) { - return ( -
-
-
-
-
-
- ); - } + const { data: configFilePath } = trpc.config.getConfigFilePath.useQuery({ + projectId, + }); if (!project) { return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx index b09edbacbbf..7e4fc5e1e72 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx @@ -1,9 +1,22 @@ import { createFileRoute } from "@tanstack/react-router"; +import { trpcClient } from "renderer/lib/trpc-client"; export const Route = createFileRoute( "/_authenticated/settings/workspace/$workspaceId/", )({ component: WorkspaceSettingsPage, + loader: async ({ params, context }) => { + const queryKey = [ + ["workspaces", "get"], + { input: { id: params.workspaceId }, type: "query" }, + ]; + + await context.queryClient.ensureQueryData({ + queryKey, + queryFn: () => + trpcClient.workspaces.get.query({ id: params.workspaceId }), + }); + }, }); import { Input } from "@superset/ui/input"; @@ -14,23 +27,12 @@ import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRena function WorkspaceSettingsPage() { const { workspaceId } = Route.useParams(); - const { data: workspace, isLoading } = trpc.workspaces.get.useQuery({ + const { data: workspace } = trpc.workspaces.get.useQuery({ id: workspaceId, }); const rename = useWorkspaceRename(workspace?.id ?? "", workspace?.name ?? ""); - if (isLoading) { - return ( -
-
-
-
-
-
- ); - } - if (!workspace) { return (
From 198684faed553aabf27a51ea0485fca7e2f9bd34 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 13 Jan 2026 15:39:51 -0800 Subject: [PATCH 2/4] Done --- .../src/lib/trpc/routers/projects/projects.ts | 24 +- .../routers/workspaces/procedures/query.ts | 132 ++---- .../routers/workspaces/procedures/status.ts | 26 +- .../lib/trpc/routers/workspaces/workspaces.ts | 4 +- .../NewWorkspaceModal/NewWorkspaceModal.tsx | 14 +- .../renderer/hooks/useWorkspaceShortcuts.ts | 57 +-- .../renderer/react-query/workspaces/index.ts | 1 - .../workspaces/useCloseWorkspace.ts | 112 ++---- .../workspaces/useCreateBranchWorkspace.ts | 10 + .../workspaces/useCreateWorkspace.ts | 23 +- .../workspaces/useDeleteWorkspace.ts | 136 +++---- .../react-query/workspaces/useOpenWorktree.ts | 9 + .../workspaces/useSetActiveWorkspace.ts | 62 --- .../_authenticated/_dashboard/layout.tsx | 83 ++++ .../_authenticated/_dashboard/tasks/page.tsx | 10 + .../workspace/$workspaceId/page.tsx | 347 ++++++++++++++++ .../_dashboard/workspace/page.tsx | 49 +++ .../_dashboard/workspaces/page.tsx | 10 + .../renderer/routes/_authenticated/layout.tsx | 49 ++- .../settings/project/$projectId/page.tsx | 52 +-- .../settings/workspace/$workspaceId/page.tsx | 35 +- .../routes/_authenticated/tasks/page.tsx | 14 - .../routes/_authenticated/workspace/page.tsx | 10 - .../routes/_authenticated/workspaces/page.tsx | 14 - .../SidebarControl/SidebarControl.tsx | 10 +- .../screens/main/components/TopBar/index.tsx | 8 - .../MergedPortBadge/MergedPortBadge.tsx | 32 +- .../WorkspacePortGroup/WorkspacePortGroup.tsx | 29 +- .../PortsList/hooks/usePortsData.ts | 22 +- .../ProjectSection/ProjectHeader.tsx | 1 - .../ProjectSection/ProjectSection.tsx | 4 - .../WorkspaceListItem/WorkspaceListItem.tsx | 23 +- .../BranchSwitcher/BranchSwitcher.tsx | 10 +- .../WorkspaceSidebar/WorkspaceSidebar.tsx | 3 +- .../NewWorkspaceButton.tsx | 28 +- .../WorkspaceSidebarHeader.tsx | 37 +- .../TabsContent/GroupStrip/GroupStrip.tsx | 6 +- .../ContentView/TabsContent/TabView/index.tsx | 11 +- .../ContentView/TabsContent/index.tsx | 5 +- .../Sidebar/ChangesView/ChangesView.tsx | 15 +- .../WorkspaceView/Sidebar/index.tsx | 10 +- .../main/components/WorkspaceView/index.tsx | 187 --------- .../WorkspaceRow/WorkspaceRow.tsx | 13 +- .../WorkspacesListView/WorkspacesListView.tsx | 30 +- .../src/renderer/screens/main/index.tsx | 380 ------------------ apps/desktop/src/renderer/stores/app-state.ts | 54 --- apps/desktop/src/renderer/stores/index.ts | 1 - .../stores/tabs/useAgentHookListener.ts | 79 ++-- 48 files changed, 971 insertions(+), 1310 deletions(-) delete mode 100644 apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/index.tsx delete mode 100644 apps/desktop/src/renderer/stores/app-state.ts diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 33225e71129..12909bdc889 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -7,6 +7,7 @@ import { settings, workspaces, } from "@superset/local-db"; +import { TRPCError } from "@trpc/server"; import { desc, eq, inArray } from "drizzle-orm"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; @@ -144,14 +145,21 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return router({ get: publicProcedure .input(z.object({ id: z.string() })) - .query(({ input }): Project | null => { - return ( - localDb - .select() - .from(projects) - .where(eq(projects.id, input.id)) - .get() ?? null - ); + .query(({ input }): Project => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project ${input.id} not found`, + }); + } + + return project; }), getRecents: publicProcedure.query((): Project[] => { diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 0bfafba2053..1f7f34ee938 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -1,5 +1,6 @@ -import { projects, settings, workspaces, worktrees } from "@superset/local-db"; -import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { projects, workspaces, worktrees } from "@superset/local-db"; +import { TRPCError } from "@trpc/server"; +import { eq, isNotNull, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; @@ -16,7 +17,10 @@ export const createQueryProcedures = () => { .query(async ({ input }) => { const workspace = getWorkspace(input.id); if (!workspace) { - throw new Error(`Workspace ${input.id} not found`); + throw new TRPCError({ + code: "NOT_FOUND", + message: `Workspace ${input.id} not found`, + }); } const project = localDb @@ -192,104 +196,42 @@ export const createQueryProcedures = () => { ); }), - getActive: publicProcedure.query(async () => { - const settingsRow = localDb.select().from(settings).get(); - const lastActiveWorkspaceId = settingsRow?.lastActiveWorkspaceId; + getPreviousWorkspace: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ input }) => { + const allWorkspaces = localDb + .select() + .from(workspaces) + .where(isNull(workspaces.deletingAt)) + .all() + .sort((a, b) => a.tabOrder - b.tabOrder); - if (!lastActiveWorkspaceId) { - return null; - } + const currentIndex = allWorkspaces.findIndex((w) => w.id === input.id); + + if (currentIndex > 0) { + return allWorkspaces[currentIndex - 1].id; + } - const workspace = localDb - .select() - .from(workspaces) - .where( - and( - eq(workspaces.id, lastActiveWorkspaceId), - isNull(workspaces.deletingAt), - ), - ) - .get(); - if (!workspace) { - // Active workspace not found or is being deleted - return null - // The UI will handle showing another workspace or empty state return null; - } + }), - const project = localDb - .select() - .from(projects) - .where(eq(projects.id, workspace.projectId)) - .get(); - const worktree = workspace.worktreeId - ? localDb - .select() - .from(worktrees) - .where(eq(worktrees.id, workspace.worktreeId)) - .get() - : null; + getNextWorkspace: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ input }) => { + const allWorkspaces = localDb + .select() + .from(workspaces) + .where(isNull(workspaces.deletingAt)) + .all() + .sort((a, b) => a.tabOrder - b.tabOrder); - // Detect and persist base branch for existing worktrees that don't have it - // We use undefined to mean "not yet attempted" and null to mean "attempted but not found" - let baseBranch = worktree?.baseBranch; - if (worktree && baseBranch === undefined && project) { - // Only attempt detection if there's a remote origin - const hasRemote = await hasOriginRemote(project.mainRepoPath); - if (hasRemote) { - try { - const defaultBranch = project.defaultBranch || "main"; - const detected = await detectBaseBranch( - worktree.path, - worktree.branch, - defaultBranch, - ); - if (detected) { - baseBranch = detected; - } - // Persist the result (detected branch or null sentinel) - localDb - .update(worktrees) - .set({ baseBranch: detected ?? null }) - .where(eq(worktrees.id, worktree.id)) - .run(); - } catch { - // Detection failed, persist null to avoid retrying - localDb - .update(worktrees) - .set({ baseBranch: null }) - .where(eq(worktrees.id, worktree.id)) - .run(); - } - } else { - // No remote - persist null to avoid retrying - localDb - .update(worktrees) - .set({ baseBranch: null }) - .where(eq(worktrees.id, worktree.id)) - .run(); + const currentIndex = allWorkspaces.findIndex((w) => w.id === input.id); + + if (currentIndex !== -1 && currentIndex < allWorkspaces.length - 1) { + return allWorkspaces[currentIndex + 1].id; } - } - return { - ...workspace, - type: workspace.type as "worktree" | "branch", - worktreePath: getWorkspacePath(workspace) ?? "", - project: project - ? { - id: project.id, - name: project.name, - mainRepoPath: project.mainRepoPath, - } - : null, - worktree: worktree - ? { - branch: worktree.branch, - baseBranch, - // Normalize to null to ensure consistent "incomplete init" detection in UI - gitStatus: worktree.gitStatus ?? null, - } - : null, - }; - }), + return null; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts index 24253a5b058..b5a3aea6bd9 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts @@ -3,34 +3,10 @@ import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; -import { - getWorkspaceNotDeleting, - setLastActiveWorkspace, - touchWorkspace, -} from "../utils/db-helpers"; +import { getWorkspaceNotDeleting, touchWorkspace } from "../utils/db-helpers"; export const createStatusProcedures = () => { return router({ - setActive: publicProcedure - .input(z.object({ id: z.string() })) - .mutation(({ input }) => { - const workspace = getWorkspaceNotDeleting(input.id); - if (!workspace) { - throw new Error( - `Workspace ${input.id} not found or is being deleted`, - ); - } - - // Track if workspace was unread before clearing - const wasUnread = workspace.isUnread ?? false; - - // Auto-clear unread state when switching to workspace - touchWorkspace(input.id, { isUnread: false }); - setLastActiveWorkspace(input.id); - - return { success: true, wasUnread }; - }), - reorder: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 6ba9f0bf059..53918b32db0 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -13,10 +13,10 @@ import { createStatusProcedures } from "./procedures/status"; * Procedures are organized into logical groups: * - create: create, createBranchWorkspace, openWorktree * - delete: delete, close, canDelete - * - query: get, getAll, getAllGrouped, getActive + * - query: get, getAll, getAllGrouped * - branch: getBranches, switchBranchWorkspace * - git-status: refreshGitStatus, getGitHubStatus, getWorktreeInfo, getWorktreesByProject - * - status: setActive, reorder, update, setUnread + * - status: reorder, update, setUnread * - init: onInitProgress, retryInit, getInitProgress, getSetupCommands */ export const createWorkspacesRouter = () => { diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index 9d69a3857a1..7a1c9733792 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -94,7 +94,6 @@ export function NewWorkspaceModal() { debouncedSetTitle(value); // Debounced update for derived state }; - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); const { data: branchData, @@ -106,8 +105,6 @@ export function NewWorkspaceModal() { ); const createWorkspace = useCreateWorkspace(); - const currentProjectId = activeWorkspace?.projectId; - // Filter branches based on search const filteredBranches = useMemo(() => { if (!branchData?.branches) return []; @@ -118,15 +115,12 @@ export function NewWorkspaceModal() { ); }, [branchData?.branches, branchSearch]); - // Auto-select project when modal opens (prioritize pre-selected, then current) + // Auto-select project when modal opens (use pre-selected from NewWorkspaceButton) useEffect(() => { - if (isOpen && !selectedProjectId) { - const projectToSelect = preSelectedProjectId ?? currentProjectId; - if (projectToSelect) { - setSelectedProjectId(projectToSelect); - } + if (isOpen && !selectedProjectId && preSelectedProjectId) { + setSelectedProjectId(preSelectedProjectId); } - }, [isOpen, currentProjectId, selectedProjectId, preSelectedProjectId]); + }, [isOpen, selectedProjectId, preSelectedProjectId]); // Effective base branch - use explicit selection or fall back to default const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? null; diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index acd914cb1e4..75237813b41 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -1,9 +1,7 @@ +import { useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; -import { - useCreateBranchWorkspace, - useSetActiveWorkspace, -} from "renderer/react-query/workspaces"; +import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; import { useAppHotkey } from "renderer/stores/hotkeys"; /** @@ -11,15 +9,16 @@ import { useAppHotkey } from "renderer/stores/hotkeys"; * Used by WorkspaceSidebar for navigation between workspaces. * * It handles: - * - ⌘1-9 workspace switching shortcuts - * - Previous/next workspace shortcuts + * - ⌘1-9 workspace switching shortcuts (global) * - Auto-create main workspace for new projects + * + * Note: PREV/NEXT workspace shortcuts (⌘↑/⌘↓) are handled in the workspace + * page itself to avoid conflicts with terminal/editor shortcuts. */ export function useWorkspaceShortcuts() { const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id || null; - const setActiveWorkspace = useSetActiveWorkspace(); + const navigate = useNavigate(); + const createBranchWorkspace = useCreateBranchWorkspace(); // Track projects we've attempted to create workspaces for (persists across renders) @@ -66,10 +65,14 @@ export function useWorkspaceShortcuts() { (index: number) => { const workspace = allWorkspaces[index]; if (workspace) { - setActiveWorkspace.mutate({ id: workspace.id }); + localStorage.setItem("lastViewedWorkspaceId", workspace.id); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: workspace.id }, + }); } }, - [allWorkspaces, setActiveWorkspace], + [allWorkspaces, navigate], ); useAppHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0), undefined, [ @@ -100,40 +103,8 @@ export function useWorkspaceShortcuts() { switchToWorkspace, ]); - useAppHotkey( - "PREV_WORKSPACE", - () => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex > 0) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); - } - }, - undefined, - [activeWorkspaceId, allWorkspaces, setActiveWorkspace], - ); - - useAppHotkey( - "NEXT_WORKSPACE", - () => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex < allWorkspaces.length - 1) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); - } - }, - undefined, - [activeWorkspaceId, allWorkspaces, setActiveWorkspace], - ); - return { groups, allWorkspaces, - activeWorkspaceId, - setActiveWorkspace, }; } diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index b109fc6d1cb..48be214e316 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -5,6 +5,5 @@ export { useDeleteWorkspace } from "./useDeleteWorkspace"; export { useDeleteWorktree } from "./useDeleteWorktree"; export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; -export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; export { useWorkspaceDeleteHandler } from "./useWorkspaceDeleteHandler"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts index 27deddecacc..58ad7b6f4c0 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts @@ -1,3 +1,4 @@ +import { useNavigate, useParams } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; type CloseContext = { @@ -11,22 +12,20 @@ type CloseContext = { >["workspaces"]["getAll"]["getData"] extends () => infer R ? R : never; - previousActive: ReturnType< - typeof trpc.useUtils - >["workspaces"]["getActive"]["getData"] extends () => infer R - ? R - : never; }; /** * Mutation hook for closing a workspace without deleting the worktree * Uses optimistic updates to immediately remove workspace from UI, * then performs actual close in background. + * Automatically navigates away if the closed workspace is currently being viewed. */ export function useCloseWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); + const navigate = useNavigate(); + const params = useParams({ strict: false }); return trpc.workspaces.close.useMutation({ ...options, @@ -35,13 +34,11 @@ export function useCloseWorkspace( await Promise.all([ utils.workspaces.getAll.cancel(), utils.workspaces.getAllGrouped.cancel(), - utils.workspaces.getActive.cancel(), ]); // Snapshot previous values for rollback const previousGrouped = utils.workspaces.getAllGrouped.getData(); const previousAll = utils.workspaces.getAll.getData(); - const previousActive = utils.workspaces.getActive.getData(); // Optimistically remove workspace from getAllGrouped cache if (previousGrouped) { @@ -64,75 +61,8 @@ export function useCloseWorkspace( ); } - // Switch to next workspace to prevent "no workspace" flash - if (previousActive?.id === id) { - const remainingWorkspaces = previousAll - ?.filter((w) => w.id !== id) - .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); - - if (remainingWorkspaces && remainingWorkspaces.length > 0) { - // Find a workspace with full data available in previousGrouped - let selectedWorkspace = null; - let projectGroup = null; - let workspaceFromGrouped = null; - - for (const candidate of remainingWorkspaces) { - const group = previousGrouped?.find((g) => - g.workspaces.some((w) => w.id === candidate.id), - ); - if (group) { - selectedWorkspace = candidate; - projectGroup = group; - workspaceFromGrouped = group.workspaces.find( - (w) => w.id === candidate.id, - ); - break; - } - } - - if (selectedWorkspace && projectGroup && workspaceFromGrouped) { - const worktreeData = - workspaceFromGrouped.type === "worktree" - ? { - branch: selectedWorkspace.branch, - baseBranch: null, - gitStatus: { - branch: selectedWorkspace.branch, - needsRebase: false, - lastRefreshed: Date.now(), - }, - } - : null; - - utils.workspaces.getActive.setData(undefined, { - ...selectedWorkspace, - type: workspaceFromGrouped.type, - worktreePath: workspaceFromGrouped.worktreePath, - project: { - id: projectGroup.project.id, - name: projectGroup.project.name, - mainRepoPath: projectGroup.project.mainRepoPath, - }, - worktree: worktreeData, - }); - } else { - // Fallback: set minimal data to prevent StartView flash (refetch will populate full data) - const fallback = remainingWorkspaces[0]; - utils.workspaces.getActive.setData(undefined, { - ...fallback, - type: fallback.type === "branch" ? "branch" : "worktree", - worktreePath: "", - project: null, - worktree: null, - }); - } - } else { - utils.workspaces.getActive.setData(undefined, null); - } - } - // Return context for rollback - return { previousGrouped, previousAll, previousActive } as CloseContext; + return { previousGrouped, previousAll } as CloseContext; }, onError: (_err, _variables, context) => { // Rollback to previous state on error @@ -145,18 +75,40 @@ export function useCloseWorkspace( if (context?.previousAll !== undefined) { utils.workspaces.getAll.setData(undefined, context.previousAll); } - if (context?.previousActive !== undefined) { - utils.workspaces.getActive.setData(undefined, context.previousActive); - } }, - onSuccess: async (...args) => { + onSuccess: async (data, variables, ...rest) => { // Invalidate to ensure consistency with backend state await utils.workspaces.invalidate(); // Invalidate project queries since close updates project metadata await utils.projects.getRecents.invalidate(); + // If the closed workspace is currently being viewed, navigate away + if (params.workspaceId === variables.id) { + // Try to navigate to previous workspace first, then next + const prevWorkspaceId = + await utils.workspaces.getPreviousWorkspace.fetch({ + id: variables.id, + }); + const nextWorkspaceId = await utils.workspaces.getNextWorkspace.fetch({ + id: variables.id, + }); + + const targetWorkspaceId = prevWorkspaceId ?? nextWorkspaceId; + + if (targetWorkspaceId) { + localStorage.setItem("lastViewedWorkspaceId", targetWorkspaceId); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: targetWorkspaceId }, + }); + } else { + // No other workspaces, navigate to workspace index (shows StartView) + navigate({ to: "/workspace" }); + } + } + // Call user's onSuccess if provided - await options?.onSuccess?.(...args); + await options?.onSuccess?.(data, variables, ...rest); }, }); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts index dd77a99cfd3..baafbdd3e1a 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts @@ -1,3 +1,4 @@ +import { useNavigate } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -11,6 +12,7 @@ export function useCreateBranchWorkspace( typeof trpc.workspaces.createBranchWorkspace.useMutation >[0], ) { + const navigate = useNavigate(); const utils = trpc.useUtils(); return trpc.workspaces.createBranchWorkspace.useMutation({ @@ -25,6 +27,14 @@ export function useCreateBranchWorkspace( useTabsStore.getState().addTab(data.workspace.id); } + // Navigate to the workspace + // Branch workspaces don't need async initialization, so always navigate + localStorage.setItem("lastViewedWorkspaceId", data.workspace.id); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: data.workspace.id }, + }); + // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); }, diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index a9da12d19d7..df38d7ff7d2 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -1,3 +1,4 @@ +import { useNavigate } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import type { WorkspaceInitProgress } from "shared/types/workspace-init"; @@ -19,6 +20,7 @@ import type { WorkspaceInitProgress } from "shared/types/workspace-init"; export function useCreateWorkspace( options?: Parameters[0], ) { + const navigate = useNavigate(); const utils = trpc.useUtils(); const addPendingTerminalSetup = useWorkspaceInitStore( (s) => s.addPendingTerminalSetup, @@ -28,9 +30,9 @@ export function useCreateWorkspace( return trpc.workspaces.create.useMutation({ ...options, onSuccess: async (data, ...rest) => { - // Optimistically set init progress BEFORE query invalidation to prevent - // the "interrupted" state flash. The subscription will update with real - // progress, but this ensures isInitializing is true immediately. + // CRITICAL: Set optimistic progress BEFORE invalidation AND navigation + // to ensure isInitializing is true when workspace page first renders, + // preventing the "Setup incomplete" flash. if (data.isInitializing) { const optimisticProgress: WorkspaceInitProgress = { workspaceId: data.workspace.id, @@ -41,9 +43,6 @@ export function useCreateWorkspace( updateProgress(optimisticProgress); } - // Auto-invalidate all workspace queries - await utils.workspaces.invalidate(); - // Add to global pending store (WorkspaceInitEffects will handle terminal creation) // This survives dialog unmounts since it's stored in Zustand, not a hook-local ref addPendingTerminalSetup({ @@ -52,10 +51,22 @@ export function useCreateWorkspace( initialCommands: data.initialCommands, }); + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + // Handle race condition: if init already completed before we added to pending, // WorkspaceInitEffects will process it on next render when it sees the progress // is already "ready" and there's a matching pending setup. + // Navigate to the new workspace immediately + // The workspace exists in DB, so it's safe to navigate + // Git operations happen in background with progress shown via toast + localStorage.setItem("lastViewedWorkspaceId", data.workspace.id); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: data.workspace.id }, + }); + // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); }, diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts index bfa21e95f26..0689183a6fc 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts @@ -1,3 +1,4 @@ +import { useNavigate, useParams } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; type DeleteContext = { @@ -11,35 +12,62 @@ type DeleteContext = { >["workspaces"]["getAll"]["getData"] extends () => infer R ? R : never; - previousActive: ReturnType< - typeof trpc.useUtils - >["workspaces"]["getActive"]["getData"] extends () => infer R - ? R - : never; + wasViewingDeleted: boolean; + navigatedTo: string | null; }; /** * Mutation hook for deleting a workspace with optimistic updates. * Server marks `deletingAt` immediately so refetches stay correct during slow git operations. + * Optimistically navigates away immediately if the deleted workspace is currently being viewed. + * Navigates back on error to restore the user to the original workspace. */ export function useDeleteWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); + const navigate = useNavigate(); + const params = useParams({ strict: false }); return trpc.workspaces.delete.useMutation({ ...options, onMutate: async ({ id }) => { + // Check if we're viewing the workspace being deleted + const wasViewingDeleted = params.workspaceId === id; + let navigatedTo: string | null = null; + + // If viewing deleted workspace, get navigation target BEFORE optimistic update + if (wasViewingDeleted) { + const prevWorkspaceId = + await utils.workspaces.getPreviousWorkspace.fetch({ id }); + const nextWorkspaceId = await utils.workspaces.getNextWorkspace.fetch({ + id, + }); + const targetWorkspaceId = prevWorkspaceId ?? nextWorkspaceId; + + if (targetWorkspaceId) { + navigatedTo = targetWorkspaceId; + localStorage.setItem("lastViewedWorkspaceId", targetWorkspaceId); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: targetWorkspaceId }, + }); + } else { + navigatedTo = "/workspace"; + navigate({ to: "/workspace" }); + } + } + + // Cancel outgoing queries and get snapshots await Promise.all([ utils.workspaces.getAll.cancel(), utils.workspaces.getAllGrouped.cancel(), - utils.workspaces.getActive.cancel(), ]); const previousGrouped = utils.workspaces.getAllGrouped.getData(); const previousAll = utils.workspaces.getAll.getData(); - const previousActive = utils.workspaces.getActive.getData(); + // Optimistic update: remove workspace from cache if (previousGrouped) { utils.workspaces.getAllGrouped.setData( undefined, @@ -59,83 +87,23 @@ export function useDeleteWorkspace( ); } - // Switch to next workspace to prevent "no workspace" flash - if (previousActive?.id === id) { - const remainingWorkspaces = previousAll - ?.filter((w) => w.id !== id) - .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); - - if (remainingWorkspaces && remainingWorkspaces.length > 0) { - // Find a workspace with full data available in previousGrouped - let selectedWorkspace = null; - let projectGroup = null; - let workspaceFromGrouped = null; - - for (const candidate of remainingWorkspaces) { - const group = previousGrouped?.find((g) => - g.workspaces.some((w) => w.id === candidate.id), - ); - if (group) { - selectedWorkspace = candidate; - projectGroup = group; - workspaceFromGrouped = group.workspaces.find( - (w) => w.id === candidate.id, - ); - break; - } - } - - if (selectedWorkspace && projectGroup && workspaceFromGrouped) { - const worktreeData = - workspaceFromGrouped.type === "worktree" - ? { - branch: selectedWorkspace.branch, - baseBranch: null, - gitStatus: { - branch: selectedWorkspace.branch, - needsRebase: false, - lastRefreshed: Date.now(), - }, - } - : null; - - utils.workspaces.getActive.setData(undefined, { - ...selectedWorkspace, - type: workspaceFromGrouped.type, - worktreePath: workspaceFromGrouped.worktreePath, - project: { - id: projectGroup.project.id, - name: projectGroup.project.name, - mainRepoPath: projectGroup.project.mainRepoPath, - }, - worktree: worktreeData, - }); - } else { - // Fallback: set minimal data to prevent StartView flash (refetch will populate full data) - const fallback = remainingWorkspaces[0]; - utils.workspaces.getActive.setData(undefined, { - ...fallback, - type: fallback.type === "branch" ? "branch" : "worktree", - worktreePath: "", - project: null, - worktree: null, - }); - } - } else { - utils.workspaces.getActive.setData(undefined, null); - } - } - - return { previousGrouped, previousAll, previousActive } as DeleteContext; + return { + previousGrouped, + previousAll, + wasViewingDeleted, + navigatedTo, + } as DeleteContext; }, onSettled: async (...args) => { await utils.workspaces.invalidate(); await options?.onSettled?.(...args); }, - onSuccess: async (...args) => { - await options?.onSuccess?.(...args); + onSuccess: async (data, variables, ...rest) => { + // Navigation already handled in onMutate (optimistic) + await options?.onSuccess?.(data, variables, ...rest); }, - onError: async (_err, _variables, context, ...rest) => { + onError: async (_err, variables, context, ...rest) => { + // Rollback optimistic cache updates if (context?.previousGrouped !== undefined) { utils.workspaces.getAllGrouped.setData( undefined, @@ -145,11 +113,17 @@ export function useDeleteWorkspace( if (context?.previousAll !== undefined) { utils.workspaces.getAll.setData(undefined, context.previousAll); } - if (context?.previousActive !== undefined) { - utils.workspaces.getActive.setData(undefined, context.previousActive); + + // If we optimistically navigated away, navigate back to the deleted workspace + if (context?.wasViewingDeleted) { + localStorage.setItem("lastViewedWorkspaceId", variables.id); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: variables.id }, + }); } - await options?.onError?.(_err, _variables, context, ...rest); + await options?.onError?.(_err, variables, context, ...rest); }, }); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts index 4ef7e1632d1..c88b331ede9 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts @@ -1,4 +1,5 @@ import { toast } from "@superset/ui/sonner"; +import { useNavigate } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; import { useOpenConfigModal } from "renderer/stores/config-modal"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -12,6 +13,7 @@ import { useTabsStore } from "renderer/stores/tabs/store"; export function useOpenWorktree( options?: Parameters[0], ) { + const navigate = useNavigate(); const utils = trpc.useUtils(); const addTab = useTabsStore((state) => state.addTab); const setTabAutoTitle = useTabsStore((state) => state.setTabAutoTitle); @@ -60,6 +62,13 @@ export function useOpenWorktree( }); } + // Navigate to the opened workspace + localStorage.setItem("lastViewedWorkspaceId", data.workspace.id); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: data.workspace.id }, + }); + // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); }, diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts deleted file mode 100644 index debb3a08b2a..00000000000 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { toast } from "@superset/ui/sonner"; -import { trpc } from "renderer/lib/trpc"; - -/** - * Mutation hook for setting the active workspace - * Automatically invalidates getActive and getAll queries on success - * Shows undo toast if workspace was marked as unread (auto-cleared on switch) - */ -export function useSetActiveWorkspace( - options?: Parameters[0], -) { - const utils = trpc.useUtils(); - const setUnread = trpc.workspaces.setUnread.useMutation({ - onSuccess: () => { - utils.workspaces.getAllGrouped.invalidate(); - }, - onError: (error) => { - console.error("[workspace/setUnread] Failed to update unread status:", { - error: error.message, - }); - toast.error(`Failed to undo: ${error.message}`); - }, - }); - - return trpc.workspaces.setActive.useMutation({ - ...options, - onError: (error, variables, context, meta) => { - console.error("[workspace/setActive] Failed to set active workspace:", { - workspaceId: variables.id, - error: error.message, - }); - toast.error(`Failed to switch workspace: ${error.message}`); - options?.onError?.(error, variables, context, meta); - }, - onSuccess: async (data, variables, ...rest) => { - // Auto-invalidate active workspace and all workspaces queries - await Promise.all([ - utils.workspaces.getActive.invalidate(), - utils.workspaces.getAll.invalidate(), - utils.workspaces.getAllGrouped.invalidate(), - ]); - - // Show undo toast if workspace was marked as unread - if (data.wasUnread) { - toast("Marked as read", { - description: "Workspace unread marker cleared", - action: { - label: "Undo", - onClick: () => { - setUnread.mutate({ id: variables.id, isUnread: true }); - }, - }, - duration: 5000, - }); - } - - // Call user's onSuccess if provided - // biome-ignore lint/suspicious/noExplicitAny: spread args for compatibility - await (options?.onSuccess as any)?.(data, variables, ...rest); - }, - }); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx new file mode 100644 index 00000000000..67820b485c7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -0,0 +1,83 @@ +import { createFileRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; +import { TopBar } from "renderer/screens/main/components/TopBar"; +import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; +import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +import { + COLLAPSED_WORKSPACE_SIDEBAR_WIDTH, + MAX_WORKSPACE_SIDEBAR_WIDTH, + useWorkspaceSidebarStore, +} from "renderer/stores/workspace-sidebar-state"; + +export const Route = createFileRoute("/_authenticated/_dashboard")({ + component: DashboardLayout, +}); + +function DashboardLayout() { + const navigate = useNavigate(); + const openNewWorkspaceModal = useOpenNewWorkspaceModal(); + + const { + isOpen: isWorkspaceSidebarOpen, + toggleCollapsed: toggleWorkspaceSidebarCollapsed, + setOpen: setWorkspaceSidebarOpen, + width: workspaceSidebarWidth, + setWidth: setWorkspaceSidebarWidth, + isResizing: isWorkspaceSidebarResizing, + setIsResizing: setWorkspaceSidebarIsResizing, + isCollapsed: isWorkspaceSidebarCollapsed, + } = useWorkspaceSidebarStore(); + + // Global hotkeys for dashboard + useAppHotkey( + "SHOW_HOTKEYS", + () => navigate({ to: "/settings/keyboard" }), + undefined, + [navigate], + ); + + useAppHotkey( + "TOGGLE_WORKSPACE_SIDEBAR", + () => { + if (!isWorkspaceSidebarOpen) { + setWorkspaceSidebarOpen(true); + } else { + toggleWorkspaceSidebarCollapsed(); + } + }, + undefined, + [ + isWorkspaceSidebarOpen, + setWorkspaceSidebarOpen, + toggleWorkspaceSidebarCollapsed, + ], + ); + + useAppHotkey("NEW_WORKSPACE", () => openNewWorkspaceModal(), undefined, [ + openNewWorkspaceModal, + ]); + + return ( +
+ +
+ {isWorkspaceSidebarOpen && ( + + + + )} + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx new file mode 100644 index 00000000000..21012c51d70 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/tasks/page.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { TasksView } from "renderer/screens/main/components/TasksView"; + +export const Route = createFileRoute("/_authenticated/_dashboard/tasks/")({ + component: TasksPage, +}); + +function TasksPage() { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx new file mode 100644 index 00000000000..8fd25a47da1 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -0,0 +1,347 @@ +import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { trpcClient } from "renderer/lib/trpc-client"; +import { NotFound } from "renderer/routes/not-found"; +import { ContentView } from "renderer/screens/main/components/WorkspaceView/ContentView"; +import { WorkspaceInitializingView } from "renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView"; +import { useAppHotkey } from "renderer/stores/hotkeys"; +import { useSidebarStore } from "renderer/stores/sidebar-state"; +import { getPaneDimensions } from "renderer/stores/tabs/pane-refs"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; +import { + findPanePath, + getFirstPaneId, + getNextPaneId, + getPreviousPaneId, +} from "renderer/stores/tabs/utils"; +import { + useHasWorkspaceFailed, + useIsWorkspaceInitializing, +} from "renderer/stores/workspace-init"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/workspace/$workspaceId/", +)({ + component: WorkspacePage, + notFoundComponent: NotFound, + loader: async ({ params, context }) => { + const queryKey = [ + ["workspaces", "get"], + { input: { id: params.workspaceId }, type: "query" }, + ]; + + try { + await context.queryClient.ensureQueryData({ + queryKey, + queryFn: () => + trpcClient.workspaces.get.query({ id: params.workspaceId }), + }); + } catch (error) { + // If workspace not found, throw notFound() to render 404 page + if (error instanceof Error && error.message.includes("not found")) { + throw notFound(); + } + // Re-throw other errors + throw error; + } + }, +}); + +function WorkspacePage() { + const { workspaceId } = Route.useParams(); + const { data: workspace } = trpc.workspaces.get.useQuery({ id: workspaceId }); + const navigate = useNavigate(); + + // Check if workspace is initializing or failed + const isInitializing = useIsWorkspaceInitializing(workspaceId); + const hasFailed = useHasWorkspaceFailed(workspaceId); + + // Check for incomplete init after app restart + const gitStatus = workspace?.worktree?.gitStatus; + const hasIncompleteInit = + workspace?.type === "worktree" && + (gitStatus === null || gitStatus === undefined); + + // Show full-screen initialization view for: + // - Actively initializing workspaces (shows progress) + // - Failed workspaces (shows error with retry) + // - Interrupted workspaces that aren't currently initializing (shows resume option) + const showInitView = isInitializing || hasFailed || hasIncompleteInit; + + const allTabs = useTabsStore((s) => s.tabs); + const activeTabIds = useTabsStore((s) => s.activeTabIds); + const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const { addTab, splitPaneAuto, splitPaneVertical, splitPaneHorizontal } = + useTabsWithPresets(); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + const removePane = useTabsStore((s) => s.removePane); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const toggleSidebar = useSidebarStore((s) => s.toggleSidebar); + + const tabs = useMemo( + () => allTabs.filter((tab) => tab.workspaceId === workspaceId), + [workspaceId, allTabs], + ); + + const activeTabId = activeTabIds[workspaceId] ?? null; + + const activeTab = useMemo( + () => (activeTabId ? tabs.find((t) => t.id === activeTabId) : null), + [activeTabId, tabs], + ); + + const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; + + // Tab management shortcuts + useAppHotkey( + "NEW_GROUP", + () => { + addTab(workspaceId); + }, + undefined, + [workspaceId, addTab], + ); + + useAppHotkey( + "CLOSE_TERMINAL", + () => { + if (focusedPaneId) { + removePane(focusedPaneId); + } + }, + undefined, + [focusedPaneId, removePane], + ); + + // Switch between tabs + useAppHotkey( + "PREV_TERMINAL", + () => { + if (!activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index > 0) { + setActiveTab(workspaceId, tabs[index - 1].id); + } + }, + undefined, + [workspaceId, activeTabId, tabs, setActiveTab], + ); + + useAppHotkey( + "NEXT_TERMINAL", + () => { + if (!activeTabId) return; + const index = tabs.findIndex((t) => t.id === activeTabId); + if (index < tabs.length - 1) { + setActiveTab(workspaceId, tabs[index + 1].id); + } + }, + undefined, + [workspaceId, activeTabId, tabs, setActiveTab], + ); + + // Switch between panes within a tab + useAppHotkey( + "PREV_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const prevPaneId = getPreviousPaneId(activeTab.layout, focusedPaneId); + if (prevPaneId) { + setFocusedPane(activeTabId, prevPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); + + useAppHotkey( + "NEXT_PANE", + () => { + if (!activeTabId || !activeTab?.layout || !focusedPaneId) return; + const nextPaneId = getNextPaneId(activeTab.layout, focusedPaneId); + if (nextPaneId) { + setFocusedPane(activeTabId, nextPaneId); + } + }, + undefined, + [activeTabId, activeTab?.layout, focusedPaneId, setFocusedPane], + ); + + // Open in last used app shortcut + const { data: lastUsedApp = "cursor" } = + trpc.settings.getLastUsedApp.useQuery(); + const openInApp = trpc.external.openInApp.useMutation(); + useAppHotkey( + "OPEN_IN_APP", + () => { + if (workspace?.worktreePath) { + openInApp.mutate({ + path: workspace.worktreePath, + app: lastUsedApp, + }); + } + }, + undefined, + [workspace?.worktreePath, lastUsedApp], + ); + + // Copy path shortcut + const copyPath = trpc.external.copyPath.useMutation(); + useAppHotkey( + "COPY_PATH", + () => { + if (workspace?.worktreePath) { + copyPath.mutate(workspace.worktreePath); + } + }, + undefined, + [workspace?.worktreePath], + ); + + // Toggle changes sidebar (⌘L) + useAppHotkey("TOGGLE_SIDEBAR", () => toggleSidebar(), undefined, [ + toggleSidebar, + ]); + + // Pane splitting helper - resolves target pane for split operations + const resolveSplitTarget = useCallback( + (paneId: string, tabId: string, targetTab: Tab) => { + const path = findPanePath(targetTab.layout, paneId); + if (path !== null) return { path, paneId }; + + const firstPaneId = getFirstPaneId(targetTab.layout); + const firstPanePath = findPanePath(targetTab.layout, firstPaneId); + setFocusedPane(tabId, firstPaneId); + return { path: firstPanePath ?? [], paneId: firstPaneId }; + }, + [setFocusedPane], + ); + + // Pane splitting shortcuts + useAppHotkey( + "SPLIT_AUTO", + () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + const dimensions = getPaneDimensions(target.paneId); + if (dimensions) { + splitPaneAuto(activeTabId, target.paneId, dimensions, target.path); + } + } + }, + undefined, + [activeTabId, focusedPaneId, activeTab, splitPaneAuto, resolveSplitTarget], + ); + + useAppHotkey( + "SPLIT_RIGHT", + () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + splitPaneVertical(activeTabId, target.paneId, target.path); + } + }, + undefined, + [ + activeTabId, + focusedPaneId, + activeTab, + splitPaneVertical, + resolveSplitTarget, + ], + ); + + useAppHotkey( + "SPLIT_DOWN", + () => { + if (activeTabId && focusedPaneId && activeTab) { + const target = resolveSplitTarget( + focusedPaneId, + activeTabId, + activeTab, + ); + if (!target) return; + splitPaneHorizontal(activeTabId, target.paneId, target.path); + } + }, + undefined, + [ + activeTabId, + focusedPaneId, + activeTab, + splitPaneHorizontal, + resolveSplitTarget, + ], + ); + + // Navigate to previous workspace (⌘↑) + const getPreviousWorkspace = trpc.workspaces.getPreviousWorkspace.useQuery( + { id: workspaceId }, + { enabled: !!workspaceId }, + ); + useAppHotkey( + "PREV_WORKSPACE", + () => { + const prevWorkspaceId = getPreviousWorkspace.data; + if (prevWorkspaceId) { + localStorage.setItem("lastViewedWorkspaceId", prevWorkspaceId); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: prevWorkspaceId }, + }); + } + }, + undefined, + [getPreviousWorkspace.data, navigate], + ); + + // Navigate to next workspace (⌘↓) + const getNextWorkspace = trpc.workspaces.getNextWorkspace.useQuery( + { id: workspaceId }, + { enabled: !!workspaceId }, + ); + useAppHotkey( + "NEXT_WORKSPACE", + () => { + const nextWorkspaceId = getNextWorkspace.data; + if (nextWorkspaceId) { + localStorage.setItem("lastViewedWorkspaceId", nextWorkspaceId); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: nextWorkspaceId }, + }); + } + }, + undefined, + [getNextWorkspace.data, navigate], + ); + + return ( +
+
+ {showInitView ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx new file mode 100644 index 00000000000..64955567a72 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/page.tsx @@ -0,0 +1,49 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { StartView } from "renderer/screens/main/components/StartView"; + +export const Route = createFileRoute("/_authenticated/_dashboard/workspace/")({ + component: WorkspaceIndexPage, +}); + +function LoadingSpinner() { + return ( +
+
+
+ ); +} + +function WorkspaceIndexPage() { + const navigate = useNavigate(); + const { data: workspaces, isLoading } = + trpc.workspaces.getAllGrouped.useQuery(); + + const allWorkspaces = workspaces?.flatMap((group) => group.workspaces) ?? []; + const hasNoWorkspaces = !isLoading && allWorkspaces.length === 0; + + useEffect(() => { + if (isLoading || !workspaces) return; + if (allWorkspaces.length === 0) return; // Show StartView instead + + // Try to restore last viewed workspace + const lastViewedId = localStorage.getItem("lastViewedWorkspaceId"); + const targetWorkspace = + allWorkspaces.find((w) => w.id === lastViewedId) ?? allWorkspaces[0]; + + if (targetWorkspace) { + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: targetWorkspace.id }, + replace: true, + }); + } + }, [workspaces, isLoading, navigate, allWorkspaces]); + + if (hasNoWorkspaces) { + return ; + } + + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx new file mode 100644 index 00000000000..3687139e93f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspaces/page.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { WorkspacesListView } from "renderer/screens/main/components/WorkspacesListView"; + +export const Route = createFileRoute("/_authenticated/_dashboard/workspaces/")({ + component: WorkspacesPage, +}); + +function WorkspacesPage() { + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 3f9d97800d2..dd01b09d6f8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -1,7 +1,19 @@ -import { createFileRoute, Navigate, Outlet } from "@tanstack/react-router"; +import { + createFileRoute, + Navigate, + Outlet, + useNavigate, +} from "@tanstack/react-router"; import { DndProvider } from "react-dnd"; +import { NewWorkspaceModal } from "renderer/components/NewWorkspaceModal"; +import { useUpdateListener } from "renderer/components/UpdateToast"; import { dragDropManager } from "renderer/lib/dnd"; +import { trpc } from "renderer/lib/trpc"; import { useAuth } from "renderer/providers/AuthProvider"; +import { WorkspaceInitEffects } from "renderer/screens/main/components/WorkspaceInitEffects"; +import { useHotkeysSync } from "renderer/stores/hotkeys"; +import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; +import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { CollectionsProvider } from "./providers/CollectionsProvider"; import { OrganizationsProvider } from "./providers/OrganizationsProvider"; @@ -12,6 +24,39 @@ export const Route = createFileRoute("/_authenticated")({ function AuthenticatedLayout() { const { session, token } = useAuth(); const isSignedIn = !!token && !!session?.user; + const navigate = useNavigate(); + const utils = trpc.useUtils(); + + // Global hooks and subscriptions + useAgentHookListener(); + useUpdateListener(); + useHotkeysSync(); + + // Workspace initialization progress subscription + const updateInitProgress = useWorkspaceInitStore((s) => s.updateProgress); + trpc.workspaces.onInitProgress.useSubscription(undefined, { + onData: (progress) => { + updateInitProgress(progress); + if (progress.step === "ready" || progress.step === "failed") { + // Invalidate both the grouped list AND the specific workspace + utils.workspaces.getAllGrouped.invalidate(); + utils.workspaces.get.invalidate({ id: progress.workspaceId }); + } + }, + onError: (error) => { + console.error("[workspace-init-subscription] Subscription error:", error); + }, + }); + + // Menu navigation subscription + trpc.menu.subscribe.useSubscription(undefined, { + onData: (event) => { + if (event.type === "open-settings") { + const section = event.data.section || "account"; + navigate({ to: `/settings/${section}` as "/settings/account" }); + } + }, + }); if (!isSignedIn) { return ; @@ -22,6 +67,8 @@ function AuthenticatedLayout() { + + diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx index e42812feebc..531d4844c8b 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/page.tsx @@ -1,10 +1,12 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, notFound } from "@tanstack/react-router"; import { trpcClient } from "renderer/lib/trpc-client"; +import { NotFound } from "renderer/routes/not-found"; export const Route = createFileRoute( "/_authenticated/settings/project/$projectId/", )({ component: ProjectSettingsPage, + notFoundComponent: NotFound, loader: async ({ params, context }) => { const projectQueryKey = [ ["projects", "get"], @@ -16,19 +18,29 @@ export const Route = createFileRoute( { input: { projectId: params.projectId }, type: "query" }, ]; - await Promise.all([ - context.queryClient.ensureQueryData({ - queryKey: projectQueryKey, - queryFn: () => trpcClient.projects.get.query({ id: params.projectId }), - }), - context.queryClient.ensureQueryData({ - queryKey: configQueryKey, - queryFn: () => - trpcClient.config.getConfigFilePath.query({ - projectId: params.projectId, - }), - }), - ]); + try { + await Promise.all([ + context.queryClient.ensureQueryData({ + queryKey: projectQueryKey, + queryFn: () => + trpcClient.projects.get.query({ id: params.projectId }), + }), + context.queryClient.ensureQueryData({ + queryKey: configQueryKey, + queryFn: () => + trpcClient.config.getConfigFilePath.query({ + projectId: params.projectId, + }), + }), + ]); + } catch (error) { + // If project not found, throw notFound() to render 404 page + if (error instanceof Error && error.message.includes("not found")) { + throw notFound(); + } + // Re-throw other errors + throw error; + } }, }); @@ -46,17 +58,9 @@ function ProjectSettingsPage() { projectId, }); + // Project is guaranteed to exist here because loader handles 404s if (!project) { - return ( -
-
-

Project

-

- Project not found -

-
-
- ); + return null; } return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx index 7e4fc5e1e72..39037373f79 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx @@ -1,21 +1,32 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, notFound } from "@tanstack/react-router"; import { trpcClient } from "renderer/lib/trpc-client"; +import { NotFound } from "renderer/routes/not-found"; export const Route = createFileRoute( "/_authenticated/settings/workspace/$workspaceId/", )({ component: WorkspaceSettingsPage, + notFoundComponent: NotFound, loader: async ({ params, context }) => { const queryKey = [ ["workspaces", "get"], { input: { id: params.workspaceId }, type: "query" }, ]; - await context.queryClient.ensureQueryData({ - queryKey, - queryFn: () => - trpcClient.workspaces.get.query({ id: params.workspaceId }), - }); + try { + await context.queryClient.ensureQueryData({ + queryKey, + queryFn: () => + trpcClient.workspaces.get.query({ id: params.workspaceId }), + }); + } catch (error) { + // If workspace not found, throw notFound() to render 404 page + if (error instanceof Error && error.message.includes("not found")) { + throw notFound(); + } + // Re-throw other errors + throw error; + } }, }); @@ -33,17 +44,9 @@ function WorkspaceSettingsPage() { const rename = useWorkspaceRename(workspace?.id ?? "", workspace?.name ?? ""); + // Workspace is guaranteed to exist here because loader handles 404s if (!workspace) { - return ( -
-
-

Workspace

-

- Workspace not found -

-
-
- ); + return null; } return ( diff --git a/apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx deleted file mode 100644 index e14fce4ed55..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/tasks/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/tasks/")({ - component: TasksPage, -}); - -function TasksPage() { - return ( -
-

Tasks

-

Tasks page placeholder

-
- ); -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx deleted file mode 100644 index f6b57b8c1e9..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/workspace/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { MainScreen } from "renderer/screens/main"; - -export const Route = createFileRoute("/_authenticated/workspace/")({ - component: WorkspacePage, -}); - -function WorkspacePage() { - return ; -} diff --git a/apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx deleted file mode 100644 index caf541db21f..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/workspaces/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/_authenticated/workspaces/")({ - component: WorkspacesPage, -}); - -function WorkspacesPage() { - return ( -
-

Workspaces List

-

Workspaces list page placeholder

-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx index 7fc8eada5b4..bad10df4578 100644 --- a/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx @@ -1,6 +1,7 @@ import { Button } from "@superset/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { useParams } from "@tanstack/react-router"; import { useCallback } from "react"; import { LuGitCompareArrows } from "react-icons/lu"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; @@ -25,9 +26,12 @@ export function SidebarControl() { const { isSidebarOpen, toggleSidebar } = useSidebarStore(); // Get active workspace for file opening - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const workspaceId = activeWorkspace?.id; - const worktreePath = activeWorkspace?.worktreePath; + const { workspaceId } = useParams({ strict: false }); + const { data: workspace } = trpc.workspaces.get.useQuery( + { id: workspaceId ?? "" }, + { enabled: !!workspaceId }, + ); + const worktreePath = workspace?.worktreePath; // Get base branch for changes query const { baseBranch, selectFile } = useChangesStore(); diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index 8d1d46029ed..c75ac938a3a 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,11 +1,9 @@ import { trpc } from "renderer/lib/trpc"; -import { OpenInMenuButton } from "./OpenInMenuButton"; import { SupportMenu } from "./SupportMenu"; import { WindowControls } from "./WindowControls"; export function TopBar() { const { data: platform } = trpc.window.getPlatform.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); // Default to Mac layout while loading to avoid overlap with traffic lights const isMac = platform === undefined || platform === "darwin"; @@ -21,12 +19,6 @@ export function TopBar() {
- {activeWorkspace?.worktreePath && ( - - )} {!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 4aaeca42b7b..378af19cba9 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -1,23 +1,18 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useNavigate } from "@tanstack/react-router"; import { LuExternalLink } from "react-icons/lu"; -import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { MergedPort } from "shared/types"; import { STROKE_WIDTH } from "../../../constants"; interface MergedPortBadgeProps { port: MergedPort; - isCurrentWorkspace: boolean; } -export function MergedPortBadge({ - port, - isCurrentWorkspace, -}: MergedPortBadgeProps) { +export function MergedPortBadge({ port }: MergedPortBadgeProps) { + const navigate = useNavigate(); const setActiveTab = useTabsStore((s) => s.setActiveTab); const setFocusedPane = useTabsStore((s) => s.setFocusedPane); - const setActiveMutation = trpc.workspaces.setActive.useMutation(); - const utils = trpc.useUtils(); const portNumberColor = port.isActive ? "text-muted-foreground" @@ -36,17 +31,18 @@ export function MergedPortBadge({ const canJumpToTerminal = port.isActive && port.paneId; - const handleClick = async () => { + const handleClick = () => { if (!canJumpToTerminal || !port.paneId) return; - if (!isCurrentWorkspace) { - await setActiveMutation.mutateAsync({ id: port.workspaceId }); - await utils.workspaces.getActive.invalidate(); - } - const pane = useTabsStore.getState().panes[port.paneId]; if (!pane) return; + // Navigate to workspace, then focus the pane + localStorage.setItem("lastViewedWorkspaceId", port.workspaceId); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: port.workspaceId }, + }); setActiveTab(port.workspaceId, pane.tabId); setFocusedPane(pane.tabId, port.paneId); }; @@ -55,16 +51,10 @@ export function MergedPortBadge({ window.open(`http://localhost:${port.port}`, "_blank"); }; - const badgeClasses = isCurrentWorkspace - ? "bg-primary/10 text-primary hover:bg-primary/20" - : "bg-muted/50 text-muted-foreground hover:bg-muted"; - return ( -
+
{group.ports.map((port) => ( - + ))}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts index b9498c83797..f87e17c03ba 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/hooks/usePortsData.ts @@ -8,12 +8,10 @@ import { mergePorts } from "../utils"; export interface MergedWorkspaceGroup { workspaceId: string; workspaceName: string; - isCurrentWorkspace: boolean; ports: MergedPort[]; } export function usePortsData() { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const { data: allWorkspaces } = trpc.workspaces.getAll.useQuery(); const ports = usePortsStore((s) => s.ports); const setPorts = usePortsStore((s) => s.setPorts); @@ -24,10 +22,10 @@ export function usePortsData() { const { data: allStaticPortsData } = trpc.ports.getAllStatic.useQuery(); + // Subscribe to all static port changes trpc.ports.subscribeStatic.useSubscription( - { workspaceId: activeWorkspace?.id ?? "" }, + { workspaceId: "" }, { - enabled: !!activeWorkspace?.id, onData: () => { utils.ports.getAllStatic.invalidate(); }, @@ -113,26 +111,16 @@ export function usePortsData() { return { workspaceId, workspaceName: workspaceNames[workspaceId] || "Unknown", - isCurrentWorkspace: workspaceId === activeWorkspace?.id, ports: merged, }; }, ); - groups.sort((a, b) => { - if (a.isCurrentWorkspace && !b.isCurrentWorkspace) return -1; - if (!a.isCurrentWorkspace && b.isCurrentWorkspace) return 1; - return a.workspaceName.localeCompare(b.workspaceName); - }); + // Sort alphabetically by workspace name + groups.sort((a, b) => a.workspaceName.localeCompare(b.workspaceName)); return groups; - }, [ - allWorkspaceIds, - allStaticPortsData?.ports, - ports, - workspaceNames, - activeWorkspace?.id, - ]); + }, [allWorkspaceIds, allStaticPortsData?.ports, ports, workspaceNames]); const totalPortCount = workspacePortGroups.reduce( (sum, g) => sum + g.ports.length, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 6627b593376..0089daf1920 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -56,7 +56,6 @@ export function ProjectHeader({ const closeProject = trpc.projects.close.useMutation({ onSuccess: (data) => { utils.workspaces.getAllGrouped.invalidate(); - utils.workspaces.getActive.invalidate(); utils.projects.getRecents.invalidate(); if (data.terminalWarning) { toast.warning(data.terminalWarning); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index d7fdb7e34a2..8005b7ce681 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -22,7 +22,6 @@ interface ProjectSectionProps { githubOwner: string | null; mainRepoPath: string; workspaces: Workspace[]; - activeWorkspaceId: string | null; /** Base index for keyboard shortcuts (0-based) */ shortcutBaseIndex: number; /** Whether the sidebar is in collapsed mode */ @@ -36,7 +35,6 @@ export function ProjectSection({ githubOwner, mainRepoPath, workspaces, - activeWorkspaceId, shortcutBaseIndex, isCollapsed: isSidebarCollapsed = false, }: ProjectSectionProps) { @@ -85,7 +83,6 @@ export function ProjectSection({ name={workspace.name} branch={workspace.branch} type={workspace.type} - isActive={workspace.id === activeWorkspaceId} isUnread={workspace.isUnread} index={index} shortcutIndex={shortcutBaseIndex + index} @@ -134,7 +131,6 @@ export function ProjectSection({ name={workspace.name} branch={workspace.branch} type={workspace.type} - isActive={workspace.id === activeWorkspaceId} isUnread={workspace.isUnread} index={index} shortcutIndex={shortcutBaseIndex + index} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index f688b7979cb..39e44c4db20 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -15,6 +15,7 @@ import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; @@ -22,13 +23,11 @@ import { LuEye, LuEyeOff, LuFolder, LuFolderGit2 } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useReorderWorkspaces, - useSetActiveWorkspace, useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; -import { useCloseWorkspacesList } from "renderer/stores/app-state"; import { useTabsStore } from "renderer/stores/tabs/store"; import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; import { getHighestPriorityStatus } from "shared/tabs-types"; @@ -56,7 +55,6 @@ interface WorkspaceListItemProps { name: string; branch: string; type: "worktree" | "branch"; - isActive: boolean; isUnread?: boolean; index: number; shortcutIndex?: number; @@ -71,16 +69,15 @@ export function WorkspaceListItem({ name, branch, type, - isActive, isUnread = false, index, shortcutIndex, isCollapsed = false, }: WorkspaceListItemProps) { const isBranchWorkspace = type === "branch"; - const setActiveWorkspace = useSetActiveWorkspace(); + const navigate = useNavigate(); + const matchRoute = useMatchRoute(); const reorderWorkspaces = useReorderWorkspaces(); - const closeWorkspacesList = useCloseWorkspacesList(); const [hasHovered, setHasHovered] = useState(false); const rename = useWorkspaceRename(id, name); const tabs = useTabsStore((s) => s.tabs); @@ -89,6 +86,12 @@ export function WorkspaceListItem({ (s) => s.clearWorkspaceAttentionStatus, ); const utils = trpc.useUtils(); + + // Derive isActive from route + const isActive = !!matchRoute({ + to: "/workspace/$workspaceId", + params: { workspaceId: id }, + }); const openInFinder = trpc.external.openInFinder.useMutation({ onError: (error) => toast.error(`Failed to open: ${error.message}`), }); @@ -134,10 +137,12 @@ export function WorkspaceListItem({ const handleClick = () => { if (!rename.isRenaming) { - setActiveWorkspace.mutate({ id }); clearWorkspaceAttentionStatus(id); - // Close workspaces list view if open, to show the workspace's terminal view - closeWorkspacesList(); + localStorage.setItem("lastViewedWorkspaceId", id); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: id }, + }); } }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx index bc75bf43b42..ef81fa15e1a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx @@ -8,11 +8,11 @@ import { import { Input } from "@superset/ui/input"; import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; import { LuGitBranch, LuGitFork, LuLoader } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; import { STROKE_WIDTH } from "../../../constants"; interface BranchSwitcherProps { @@ -30,7 +30,7 @@ export function BranchSwitcher({ const [search, setSearch] = useState(""); const utils = trpc.useUtils(); - const setActiveWorkspace = useSetActiveWorkspace(); + const navigate = useNavigate(); // Fetch branches when dropdown opens const { data: branchesData, isLoading } = @@ -97,7 +97,11 @@ export function BranchSwitcher({ // If branch is in use by a worktree, jump to that workspace const worktreeWorkspaceId = inUseWorkspaces[branch]; if (worktreeWorkspaceId) { - setActiveWorkspace.mutate({ id: worktreeWorkspaceId }); + localStorage.setItem("lastViewedWorkspaceId", worktreeWorkspaceId); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: worktreeWorkspaceId }, + }); setIsOpen(false); return; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 2515bdfc862..006ef868bdb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -12,7 +12,7 @@ interface WorkspaceSidebarProps { export function WorkspaceSidebar({ isCollapsed = false, }: WorkspaceSidebarProps) { - const { groups, activeWorkspaceId } = useWorkspaceShortcuts(); + const { groups } = useWorkspaceShortcuts(); // Calculate shortcut base indices for each project group using cumulative offsets const projectShortcutIndices = useMemo( @@ -41,7 +41,6 @@ export function WorkspaceSidebar({ githubOwner={group.project.githubOwner} mainRepoPath={group.project.mainRepoPath} workspaces={group.workspaces} - activeWorkspaceId={activeWorkspaceId} shortcutBaseIndex={projectShortcutIndices[index]} isCollapsed={isCollapsed} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx index 1d36399b047..adefae76cca 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/NewWorkspaceButton.tsx @@ -1,4 +1,5 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useMatchRoute } from "@tanstack/react-router"; import { LuPlus } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; @@ -12,13 +13,26 @@ export function NewWorkspaceButton({ isCollapsed = false, }: NewWorkspaceButtonProps) { const openModal = useOpenNewWorkspaceModal(); - const { data: activeWorkspace, isLoading } = - trpc.workspaces.getActive.useQuery(); + + // Derive current workspace from route to pre-select project in modal + const matchRoute = useMatchRoute(); + const currentWorkspaceMatch = matchRoute({ + to: "/workspace/$workspaceId", + fuzzy: true, + }); + const currentWorkspaceId = currentWorkspaceMatch + ? currentWorkspaceMatch.workspaceId + : null; + + const { data: currentWorkspace } = trpc.workspaces.get.useQuery( + { id: currentWorkspaceId ?? "" }, + { enabled: !!currentWorkspaceId }, + ); const handleClick = () => { - // projectId may be undefined if no workspace is active or query failed + // projectId may be undefined if no workspace is active in route // openModal handles undefined by opening without a pre-selected project - const projectId = activeWorkspace?.projectId; + const projectId = currentWorkspace?.projectId; openModal(projectId); }; @@ -29,8 +43,7 @@ export function NewWorkspaceButton({ -
- - - ); - } - - return ( - <> - - - {showStartView ? ( - - ) : ( -
- -
- {isWorkspaceSidebarOpen && ( - - - - )} - {renderContent()} -
-
- )} -
- - - - - ); -} diff --git a/apps/desktop/src/renderer/stores/app-state.ts b/apps/desktop/src/renderer/stores/app-state.ts deleted file mode 100644 index e1dd4843560..00000000000 --- a/apps/desktop/src/renderer/stores/app-state.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { create } from "zustand"; -import { devtools } from "zustand/middleware"; - -export type AppView = "workspace" | "tasks" | "workspaces-list"; - -interface AppState { - currentView: AppView; - isTasksTabOpen: boolean; - isWorkspacesListOpen: boolean; - setView: (view: AppView) => void; - openTasks: () => void; - closeTasks: () => void; - openWorkspacesList: () => void; - closeWorkspacesList: () => void; -} - -export const useAppStore = create()( - devtools( - (set) => ({ - currentView: "workspace", - isTasksTabOpen: false, - isWorkspacesListOpen: false, - - setView: (view) => { - set({ currentView: view }); - }, - - openTasks: () => { - set({ currentView: "tasks", isTasksTabOpen: true }); - }, - - closeTasks: () => { - set({ currentView: "workspace", isTasksTabOpen: false }); - }, - - openWorkspacesList: () => { - set({ currentView: "workspaces-list", isWorkspacesListOpen: true }); - }, - - closeWorkspacesList: () => { - set({ currentView: "workspace", isWorkspacesListOpen: false }); - }, - }), - { name: "AppStore" }, - ), -); - -// Convenience hooks -export const useCurrentView = () => useAppStore((state) => state.currentView); -export const useOpenTasks = () => useAppStore((state) => state.openTasks); -export const useOpenWorkspacesList = () => - useAppStore((state) => state.openWorkspacesList); -export const useCloseWorkspacesList = () => - useAppStore((state) => state.closeWorkspacesList); diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index 824bd51079e..82454b735cd 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -1,4 +1,3 @@ -export * from "./app-state"; export * from "./hotkeys"; export * from "./markdown-preferences"; export * from "./ports"; diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 19e44b17ad2..ca3f2c690cc 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -1,9 +1,8 @@ +import { useNavigate } from "@tanstack/react-router"; import { useRef } from "react"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveWorkspace } from "renderer/react-query/workspaces/useSetActiveWorkspace"; import { NOTIFICATION_EVENTS } from "shared/constants"; import { debugLog } from "shared/debug"; -import { useAppStore } from "../app-state"; import { useTabsStore } from "./store"; import { resolveNotificationTarget } from "./utils/resolve-notification-target"; @@ -33,12 +32,21 @@ import { resolveNotificationTarget } from "./utils/resolve-notification-target"; * for clearing stuck indicators when agent hooks fail to fire. */ export function useAgentHookListener() { - const setActiveWorkspace = useSetActiveWorkspace(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - - // Use ref to avoid stale closure in subscription callback - const activeWorkspaceRef = useRef(activeWorkspace); - activeWorkspaceRef.current = activeWorkspace; + const navigate = useNavigate(); + + // Track current workspace from router to avoid stale closure + const currentWorkspaceIdRef = useRef(null); + + // We need to update this ref from the workspace page, but for now + // we'll use router state. This is called from _authenticated/layout + // so we check the current route + try { + const location = window.location; + const match = location.pathname.match(/\/workspace\/([^/]+)/); + currentWorkspaceIdRef.current = match ? match[1] : null; + } catch { + currentWorkspaceIdRef.current = null; + } trpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { @@ -55,7 +63,7 @@ export function useAgentHookListener() { eventType: event.data?.eventType, paneId, workspaceId, - activeWorkspace: activeWorkspaceRef.current?.id, + currentWorkspace: currentWorkspaceIdRef.current, }); if (!paneId) return; @@ -77,7 +85,7 @@ export function useAgentHookListener() { const focusedPaneId = activeTabId && state.focusedPaneIds[activeTabId]; const isAlreadyActive = - activeWorkspaceRef.current?.id === workspaceId && + currentWorkspaceIdRef.current === workspaceId && focusedPaneId === paneId; debugLog("agent-hooks", "Stop event:", { @@ -97,38 +105,29 @@ export function useAgentHookListener() { } } } else if (event.type === NOTIFICATION_EVENTS.FOCUS_TAB) { - const appState = useAppStore.getState(); - if (appState.currentView !== "workspace") { - appState.setView("workspace"); - } + // Navigate to the workspace and focus the tab/pane + localStorage.setItem("lastViewedWorkspaceId", workspaceId); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + }); + + // Set active tab and focused pane after navigation + // (router navigation is async, but state updates are immediate) + const freshState = useTabsStore.getState(); + const freshTarget = resolveNotificationTarget(event.data, freshState); + if (!freshTarget?.tabId) return; - setActiveWorkspace.mutate( - { id: workspaceId }, - { - onSuccess: () => { - const freshState = useTabsStore.getState(); - const freshTarget = resolveNotificationTarget( - event.data, - freshState, - ); - if (!freshTarget?.tabId) return; - - const freshTab = freshState.tabs.find( - (t) => t.id === freshTarget.tabId, - ); - if (!freshTab || freshTab.workspaceId !== workspaceId) return; - - freshState.setActiveTab(workspaceId, freshTarget.tabId); - - if (freshTarget.paneId && freshState.panes[freshTarget.paneId]) { - freshState.setFocusedPane( - freshTarget.tabId, - freshTarget.paneId, - ); - } - }, - }, + const freshTab = freshState.tabs.find( + (t) => t.id === freshTarget.tabId, ); + if (!freshTab || freshTab.workspaceId !== workspaceId) return; + + freshState.setActiveTab(workspaceId, freshTarget.tabId); + + if (freshTarget.paneId && freshState.panes[freshTarget.paneId]) { + freshState.setFocusedPane(freshTarget.tabId, freshTarget.paneId); + } } }, }); From 586eaa9323bb68099b4bc95ac44a167c63f448ec Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 13 Jan 2026 16:30:44 -0800 Subject: [PATCH 3/4] feat(desktop): auto-create workspaces + close project improvements Backend changes: - Move auto-create main workspace logic from client to server - Add ensureMainWorkspace() helper called after project open/init/clone - Ensures every project automatically gets a branch workspace on first open Frontend changes: - Simplify useWorkspaceShortcuts hook (now only handles keyboard shortcuts) - Add confirmation dialog for close project with workspace count warning - Navigate away when closing project if currently viewing its workspace - Fix error when closing project with only default workspace All workspaces now created server-side, eliminating client-side race conditions --- .../src/lib/trpc/routers/projects/projects.ts | 115 +++++++++++++++++- .../renderer/hooks/useWorkspaceShortcuts.ts | 51 +------- .../ProjectSection/CloseProjectDialog.tsx | 73 +++++++++++ .../ProjectSection/ProjectHeader.tsx | 78 +++++++++++- 4 files changed, 262 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 12909bdc889..8ccf2de713d 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -8,7 +8,7 @@ import { workspaces, } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; -import { desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, isNotNull, isNull, not } from "drizzle-orm"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { track } from "main/lib/analytics"; @@ -19,9 +19,17 @@ import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { + activateProject, + getBranchWorkspace, + setLastActiveWorkspace, + touchWorkspace, +} from "../workspaces/utils/db-helpers"; +import { + getCurrentBranch, getDefaultBranch, getGitRoot, refreshDefaultBranch, + safeCheckoutBranch, } from "../workspaces/utils/git"; import { getDefaultProjectColor } from "./utils/colors"; import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; @@ -80,6 +88,96 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project { return project; } +/** + * Ensures a project has a main (branch) workspace. + * If one doesn't exist, creates it automatically. + * This is called after opening/creating a project to provide a default workspace. + */ +async function ensureMainWorkspace(project: Project): Promise { + const existingBranchWorkspace = getBranchWorkspace(project.id); + + // If branch workspace already exists, just touch it and return + if (existingBranchWorkspace) { + touchWorkspace(existingBranchWorkspace.id); + setLastActiveWorkspace(existingBranchWorkspace.id); + return; + } + + // Get current branch from main repo + const branch = await getCurrentBranch(project.mainRepoPath); + if (!branch) { + console.warn( + `[ensureMainWorkspace] Could not determine current branch for project ${project.id}`, + ); + return; + } + + // Insert new branch workspace with conflict handling for race conditions + // The unique partial index (projectId WHERE type='branch') prevents duplicates + const insertResult = localDb + .insert(workspaces) + .values({ + projectId: project.id, + type: "branch", + branch, + name: branch, + tabOrder: 0, + }) + .onConflictDoNothing() + .returning() + .all(); + + const wasExisting = insertResult.length === 0; + + // Only shift existing workspaces if we successfully inserted + if (!wasExisting) { + const newWorkspaceId = insertResult[0].id; + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, project.id), + not(eq(workspaces.id, newWorkspaceId)), + isNull(workspaces.deletingAt), + ), + ) + .all(); + + for (const ws of projectWorkspaces) { + localDb + .update(workspaces) + .set({ tabOrder: ws.tabOrder + 1 }) + .where(eq(workspaces.id, ws.id)) + .run(); + } + } + + // Get the workspace (either newly created or existing from race condition) + const workspace = insertResult[0] ?? getBranchWorkspace(project.id); + + if (!workspace) { + console.warn( + `[ensureMainWorkspace] Failed to create or find branch workspace for project ${project.id}`, + ); + return; + } + + setLastActiveWorkspace(workspace.id); + + if (!wasExisting) { + activateProject(project); + + track("workspace_opened", { + workspace_id: workspace.id, + project_id: project.id, + type: "branch", + was_existing: false, + auto_created: true, + }); + } +} + // Safe filename regex: letters, numbers, dots, underscores, hyphens, spaces, and common unicode // Allows most valid Git repo names while avoiding path traversal characters const SAFE_REPO_NAME_REGEX = /^[a-zA-Z0-9._\- ]+$/; @@ -319,6 +417,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const defaultBranch = await getDefaultBranch(mainRepoPath); const project = upsertProject(mainRepoPath, defaultBranch); + // Auto-create main workspace if it doesn't exist + await ensureMainWorkspace(project); + track("project_opened", { project_id: project.id, method: "open", @@ -374,6 +475,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const project = upsertProject(input.path, defaultBranch); + // Auto-create main workspace if it doesn't exist + await ensureMainWorkspace(project); + track("project_opened", { project_id: project.id, method: "init", @@ -449,6 +553,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .where(eq(projects.id, existingProject.id)) .run(); + // Auto-create main workspace if it doesn't exist + await ensureMainWorkspace({ + ...existingProject, + lastOpenedAt: Date.now(), + }); + track("project_opened", { project_id: existingProject.id, method: "clone", @@ -495,6 +605,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { .returning() .get(); + // Auto-create main workspace if it doesn't exist + await ensureMainWorkspace(project); + track("project_opened", { project_id: project.id, method: "clone", diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index 75237813b41..76399180a01 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -1,63 +1,18 @@ import { useNavigate } from "@tanstack/react-router"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback } from "react"; import { trpc } from "renderer/lib/trpc"; -import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; import { useAppHotkey } from "renderer/stores/hotkeys"; /** - * Shared hook for workspace keyboard shortcuts and auto-creation logic. + * Shared hook for workspace keyboard shortcuts. * Used by WorkspaceSidebar for navigation between workspaces. * - * It handles: - * - ⌘1-9 workspace switching shortcuts (global) - * - Auto-create main workspace for new projects - * - * Note: PREV/NEXT workspace shortcuts (⌘↑/⌘↓) are handled in the workspace - * page itself to avoid conflicts with terminal/editor shortcuts. + * Handles ⌘1-9 workspace switching shortcuts (global). */ export function useWorkspaceShortcuts() { const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); const navigate = useNavigate(); - const createBranchWorkspace = useCreateBranchWorkspace(); - - // Track projects we've attempted to create workspaces for (persists across renders) - const attemptedProjectsRef = useRef>(new Set()); - const [isCreating, setIsCreating] = useState(false); - - // Auto-create main workspace for new projects (one-time per project) - useEffect(() => { - if (isCreating) return; - - for (const group of groups) { - const projectId = group.project.id; - const hasMainWorkspace = group.workspaces.some( - (w) => w.type === "branch", - ); - - // Skip if already has main workspace or we've already attempted this project - if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { - continue; - } - - // Mark as attempted before creating (prevents retries) - attemptedProjectsRef.current.add(projectId); - setIsCreating(true); - - // Auto-create fails silently - this is a background convenience feature - createBranchWorkspace.mutate( - { projectId }, - { - onSettled: () => { - setIsCreating(false); - }, - }, - ); - // Only create one at a time - break; - } - }, [groups, isCreating, createBranchWorkspace]); - // Flatten workspaces for keyboard navigation const allWorkspaces = groups.flatMap((group) => group.workspaces); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx new file mode 100644 index 00000000000..a01601b7f19 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx @@ -0,0 +1,73 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; + +interface CloseProjectDialogProps { + projectName: string; + workspaceCount: number; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +} + +export function CloseProjectDialog({ + projectName, + workspaceCount, + open, + onOpenChange, + onConfirm, +}: CloseProjectDialogProps) { + const handleConfirm = () => { + onOpenChange(false); + onConfirm(); + }; + + return ( + + + + + Close project "{projectName}"? + + +
+ + This will close {workspaceCount} workspace + {workspaceCount !== 1 ? "s" : ""} and kill all active terminals in + this project. + + + Your files and git history will remain on disk. + +
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 0089daf1920..5d0d3ef071c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -11,7 +11,8 @@ import { import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useParams } from "@tanstack/react-router"; +import { useState } from "react"; import { HiChevronRight, HiMiniPlus } from "react-icons/hi2"; import { LuFolderOpen, LuPalette, LuSettings, LuX } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; @@ -20,6 +21,7 @@ import { PROJECT_COLOR_DEFAULT, PROJECT_COLORS, } from "shared/constants/project-colors"; +import { CloseProjectDialog } from "./CloseProjectDialog"; import { STROKE_WIDTH } from "../constants"; import { ProjectThumbnail } from "./ProjectThumbnail"; @@ -52,11 +54,51 @@ export function ProjectHeader({ }: ProjectHeaderProps) { const utils = trpc.useUtils(); const navigate = useNavigate(); + const params = useParams({ strict: false }) as { workspaceId?: string }; + const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false); const closeProject = trpc.projects.close.useMutation({ - onSuccess: (data) => { + onMutate: async ({ id }) => { + // Check if we're viewing a workspace from this project BEFORE closing + let shouldNavigate = false; + + if (params.workspaceId) { + try { + const currentWorkspace = await utils.workspaces.get.fetch({ + id: params.workspaceId, + }); + shouldNavigate = currentWorkspace?.projectId === id; + } catch { + // Workspace might not exist, skip navigation + } + } + + return { shouldNavigate }; + }, + onSuccess: async (data, { id }, context) => { utils.workspaces.getAllGrouped.invalidate(); utils.projects.getRecents.invalidate(); + + // Navigate away if we were viewing a workspace from the closed project + if (context?.shouldNavigate) { + // Find a workspace from a different project to navigate to + const groups = await utils.workspaces.getAllGrouped.fetch(); + const otherWorkspace = groups + .flatMap((group) => group.workspaces) + .find((w) => w.projectId !== id); + + if (otherWorkspace) { + localStorage.setItem("lastViewedWorkspaceId", otherWorkspace.id); + navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId: otherWorkspace.id }, + }); + } else { + // No other workspaces exist - go to workspace index + navigate({ to: "/workspace" }); + } + } + if (data.terminalWarning) { toast.warning(data.terminalWarning); } @@ -71,6 +113,10 @@ export function ProjectHeader({ }); const handleCloseProject = () => { + setIsCloseDialogOpen(true); + }; + + const handleConfirmClose = () => { closeProject.mutate({ id: projectId }); }; @@ -127,7 +173,8 @@ export function ProjectHeader({ // Collapsed sidebar: show just the thumbnail with tooltip and context menu if (isSidebarCollapsed) { return ( - + <> + @@ -175,13 +222,23 @@ export function ProjectHeader({ {closeProject.isPending ? "Closing..." : "Close Project"} - + + + + ); } return ( - - + <> + +
+ + + ); } From 751adb1eb725f30e839c1ec0fc75275a81812712 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 13 Jan 2026 16:48:02 -0800 Subject: [PATCH 4/4] refactor(desktop): centralize workspace navigation with utility function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created navigateToWorkspace() utility to ensure consistent workspace navigation and localStorage persistence across the entire app. Changes: - Created workspace-navigation.ts utility in dashboard route utils - Utility combines navigate() + localStorage.setItem() in single call - Updated 13 files to use the new utility: - 5 mutation hooks (useOpenWorktree, useCreateWorkspace, useCreateBranchWorkspace, useDeleteWorkspace, useCloseWorkspace) - 8 component/page files (ProjectHeader, useAgentHookListener, WorkspacesListView, BranchSwitcher, WorkspaceListItem, WorkspacePortGroup, MergedPortBadge, workspace page) Benefits: - Prevents forgetting to persist lastViewedWorkspaceId when navigating - Reduces code duplication (2 lines → 1 line per navigation) - Co-located in dashboard route for better organization - Type-safe navigation with proper router params --- .../src/lib/trpc/routers/projects/projects.ts | 3 +- .../renderer/hooks/useWorkspaceShortcuts.ts | 7 +- .../workspaces/useCloseWorkspace.ts | 7 +- .../workspaces/useCreateBranchWorkspace.ts | 7 +- .../workspaces/useCreateWorkspace.ts | 7 +- .../workspaces/useDeleteWorkspace.ts | 13 +- .../react-query/workspaces/useOpenWorktree.ts | 7 +- .../_dashboard/utils/workspace-navigation.ts | 25 ++ .../workspace/$workspaceId/page.tsx | 13 +- .../MergedPortBadge/MergedPortBadge.tsx | 7 +- .../WorkspacePortGroup/WorkspacePortGroup.tsx | 7 +- .../ProjectSection/CloseProjectDialog.tsx | 4 +- .../ProjectSection/ProjectHeader.tsx | 274 +++++++++--------- .../WorkspaceListItem/WorkspaceListItem.tsx | 7 +- .../BranchSwitcher/BranchSwitcher.tsx | 7 +- .../WorkspacesListView/WorkspacesListView.tsx | 13 +- .../stores/tabs/useAgentHookListener.ts | 7 +- 17 files changed, 194 insertions(+), 221 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 8ccf2de713d..22c81aa78e5 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -8,7 +8,7 @@ import { workspaces, } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; -import { and, desc, eq, inArray, isNotNull, isNull, not } from "drizzle-orm"; +import { and, desc, eq, inArray, isNull, not } from "drizzle-orm"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { track } from "main/lib/analytics"; @@ -29,7 +29,6 @@ import { getDefaultBranch, getGitRoot, refreshDefaultBranch, - safeCheckoutBranch, } from "../workspaces/utils/git"; import { getDefaultProjectColor } from "./utils/colors"; import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts index 76399180a01..48eb7c2ecca 100644 --- a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -1,6 +1,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useAppHotkey } from "renderer/stores/hotkeys"; /** @@ -20,11 +21,7 @@ export function useWorkspaceShortcuts() { (index: number) => { const workspace = allWorkspaces[index]; if (workspace) { - localStorage.setItem("lastViewedWorkspaceId", workspace.id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: workspace.id }, - }); + navigateToWorkspace(workspace.id, navigate); } }, [allWorkspaces, navigate], diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts index 58ad7b6f4c0..4a77524cb50 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCloseWorkspace.ts @@ -1,5 +1,6 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; type CloseContext = { previousGrouped: ReturnType< @@ -96,11 +97,7 @@ export function useCloseWorkspace( const targetWorkspaceId = prevWorkspaceId ?? nextWorkspaceId; if (targetWorkspaceId) { - localStorage.setItem("lastViewedWorkspaceId", targetWorkspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: targetWorkspaceId }, - }); + navigateToWorkspace(targetWorkspaceId, navigate); } else { // No other workspaces, navigate to workspace index (shows StartView) navigate({ to: "/workspace" }); diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts index baafbdd3e1a..bf2701701ac 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateBranchWorkspace.ts @@ -1,5 +1,6 @@ import { useNavigate } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; /** @@ -29,11 +30,7 @@ export function useCreateBranchWorkspace( // Navigate to the workspace // Branch workspaces don't need async initialization, so always navigate - localStorage.setItem("lastViewedWorkspaceId", data.workspace.id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: data.workspace.id }, - }); + navigateToWorkspace(data.workspace.id, navigate); // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index df38d7ff7d2..556e04a621e 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -1,5 +1,6 @@ import { useNavigate } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import type { WorkspaceInitProgress } from "shared/types/workspace-init"; @@ -61,11 +62,7 @@ export function useCreateWorkspace( // Navigate to the new workspace immediately // The workspace exists in DB, so it's safe to navigate // Git operations happen in background with progress shown via toast - localStorage.setItem("lastViewedWorkspaceId", data.workspace.id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: data.workspace.id }, - }); + navigateToWorkspace(data.workspace.id, navigate); // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts index 0689183a6fc..f0fdaa215b5 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts @@ -1,5 +1,6 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; type DeleteContext = { previousGrouped: ReturnType< @@ -47,11 +48,7 @@ export function useDeleteWorkspace( if (targetWorkspaceId) { navigatedTo = targetWorkspaceId; - localStorage.setItem("lastViewedWorkspaceId", targetWorkspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: targetWorkspaceId }, - }); + navigateToWorkspace(targetWorkspaceId, navigate); } else { navigatedTo = "/workspace"; navigate({ to: "/workspace" }); @@ -116,11 +113,7 @@ export function useDeleteWorkspace( // If we optimistically navigated away, navigate back to the deleted workspace if (context?.wasViewingDeleted) { - localStorage.setItem("lastViewedWorkspaceId", variables.id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: variables.id }, - }); + navigateToWorkspace(variables.id, navigate); } await options?.onError?.(_err, variables, context, ...rest); diff --git a/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts index c88b331ede9..a6b33fd73a3 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useOpenWorktree.ts @@ -1,6 +1,7 @@ import { toast } from "@superset/ui/sonner"; import { useNavigate } from "@tanstack/react-router"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useOpenConfigModal } from "renderer/stores/config-modal"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -63,11 +64,7 @@ export function useOpenWorktree( } // Navigate to the opened workspace - localStorage.setItem("lastViewedWorkspaceId", data.workspace.id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: data.workspace.id }, - }); + navigateToWorkspace(data.workspace.id, navigate); // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts new file mode 100644 index 00000000000..9a43959ac4b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/utils/workspace-navigation.ts @@ -0,0 +1,25 @@ +import type { + NavigateOptions, + UseNavigateResult, +} from "@tanstack/react-router"; + +/** + * Navigate to a workspace and update localStorage to remember it as the last viewed workspace. + * This ensures the workspace will be restored when the app is reopened. + * + * @param workspaceId - The ID of the workspace to navigate to + * @param navigate - The navigate function from useNavigate() + * @param options - Optional navigation options (replace, resetScroll, etc.) + */ +export function navigateToWorkspace( + workspaceId: string, + navigate: UseNavigateResult, + options?: Omit, +): Promise { + localStorage.setItem("lastViewedWorkspaceId", workspaceId); + return navigate({ + to: "/workspace/$workspaceId", + params: { workspaceId }, + ...options, + }); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx index 8fd25a47da1..261f37f6ea7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/workspace/$workspaceId/page.tsx @@ -2,6 +2,7 @@ import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; import { trpcClient } from "renderer/lib/trpc-client"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { NotFound } from "renderer/routes/not-found"; import { ContentView } from "renderer/screens/main/components/WorkspaceView/ContentView"; import { WorkspaceInitializingView } from "renderer/screens/main/components/WorkspaceView/WorkspaceInitializingView"; @@ -297,11 +298,7 @@ function WorkspacePage() { () => { const prevWorkspaceId = getPreviousWorkspace.data; if (prevWorkspaceId) { - localStorage.setItem("lastViewedWorkspaceId", prevWorkspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: prevWorkspaceId }, - }); + navigateToWorkspace(prevWorkspaceId, navigate); } }, undefined, @@ -318,11 +315,7 @@ function WorkspacePage() { () => { const nextWorkspaceId = getNextWorkspace.data; if (nextWorkspaceId) { - localStorage.setItem("lastViewedWorkspaceId", nextWorkspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: nextWorkspaceId }, - }); + navigateToWorkspace(nextWorkspaceId, navigate); } }, undefined, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx index 378af19cba9..9283261dfb7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/MergedPortBadge/MergedPortBadge.tsx @@ -1,6 +1,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate } from "@tanstack/react-router"; import { LuExternalLink } from "react-icons/lu"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { MergedPort } from "shared/types"; import { STROKE_WIDTH } from "../../../constants"; @@ -38,11 +39,7 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) { if (!pane) return; // Navigate to workspace, then focus the pane - localStorage.setItem("lastViewedWorkspaceId", port.workspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: port.workspaceId }, - }); + navigateToWorkspace(port.workspaceId, navigate); setActiveTab(port.workspaceId, pane.tabId); setFocusedPane(pane.tabId, port.paneId); }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx index dcb3fbe3916..2786d89baaa 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/components/WorkspacePortGroup/WorkspacePortGroup.tsx @@ -1,4 +1,5 @@ import { useNavigate } from "@tanstack/react-router"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import type { MergedWorkspaceGroup } from "../../hooks/usePortsData"; import { MergedPortBadge } from "../MergedPortBadge"; @@ -10,11 +11,7 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) { const navigate = useNavigate(); const handleWorkspaceClick = () => { - localStorage.setItem("lastViewedWorkspaceId", group.workspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: group.workspaceId }, - }); + navigateToWorkspace(group.workspaceId, navigate); }; return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx index a01601b7f19..4d5cd2303bf 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/CloseProjectDialog.tsx @@ -39,8 +39,8 @@ export function CloseProjectDialog({
This will close {workspaceCount} workspace - {workspaceCount !== 1 ? "s" : ""} and kill all active terminals in - this project. + {workspaceCount !== 1 ? "s" : ""} and kill all active terminals + in this project. Your files and git history will remain on disk. diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 5d0d3ef071c..27f8e850ae4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -17,12 +17,13 @@ import { HiChevronRight, HiMiniPlus } from "react-icons/hi2"; import { LuFolderOpen, LuPalette, LuSettings, LuX } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; import { useUpdateProject } from "renderer/react-query/projects/useUpdateProject"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { PROJECT_COLOR_DEFAULT, PROJECT_COLORS, } from "shared/constants/project-colors"; -import { CloseProjectDialog } from "./CloseProjectDialog"; import { STROKE_WIDTH } from "../constants"; +import { CloseProjectDialog } from "./CloseProjectDialog"; import { ProjectThumbnail } from "./ProjectThumbnail"; interface ProjectHeaderProps { @@ -88,11 +89,7 @@ export function ProjectHeader({ .find((w) => w.projectId !== id); if (otherWorkspace) { - localStorage.setItem("lastViewedWorkspaceId", otherWorkspace.id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: otherWorkspace.id }, - }); + navigateToWorkspace(otherWorkspace.id, navigate); } else { // No other workspaces exist - go to workspace index navigate({ to: "/workspace" }); @@ -175,33 +172,134 @@ export function ProjectHeader({ return ( <> - - - - + + + + {projectName} + + {workspaceCount} workspace{workspaceCount !== 1 ? "s" : ""} + + + + + + + Open in Finder + + + + Project Settings + + {colorPickerSubmenu} + + + + {closeProject.isPending ? "Closing..." : "Close Project"} + + + + + + + ); + } + + return ( + <> + + +
+ {/* Main clickable area */} + + + {/* Add workspace button */} + + + + + + New workspace + + + + {/* Collapse chevron */} + - - - - {projectName} - - {workspaceCount} workspace{workspaceCount !== 1 ? "s" : ""} - - - + /> + +
+
@@ -222,113 +320,15 @@ export function ProjectHeader({ {closeProject.isPending ? "Closing..." : "Close Project"} -
- - - - ); - } - - return ( - <> - - -
- {/* Main clickable area */} - - - {/* Add workspace button */} - - - - - - New workspace - - - - {/* Collapse chevron */} - -
-
- - - - Open in Finder - - - - Project Settings - - {colorPickerSubmenu} - - - - {closeProject.isPending ? "Closing..." : "Close Project"} - - -
+ - + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx index 39e44c4db20..c593b2bc607 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -25,6 +25,7 @@ import { useReorderWorkspaces, useWorkspaceDeleteHandler, } from "renderer/react-query/workspaces"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; @@ -138,11 +139,7 @@ export function WorkspaceListItem({ const handleClick = () => { if (!rename.isRenaming) { clearWorkspaceAttentionStatus(id); - localStorage.setItem("lastViewedWorkspaceId", id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: id }, - }); + navigateToWorkspace(id, navigate); } }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx index ef81fa15e1a..a8848c8cd4b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx @@ -13,6 +13,7 @@ import { useMemo, useState } from "react"; import { HiCheck, HiChevronUpDown } from "react-icons/hi2"; import { LuGitBranch, LuGitFork, LuLoader } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { STROKE_WIDTH } from "../../../constants"; interface BranchSwitcherProps { @@ -97,11 +98,7 @@ export function BranchSwitcher({ // If branch is in use by a worktree, jump to that workspace const worktreeWorkspaceId = inUseWorkspaces[branch]; if (worktreeWorkspaceId) { - localStorage.setItem("lastViewedWorkspaceId", worktreeWorkspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: worktreeWorkspaceId }, - }); + navigateToWorkspace(worktreeWorkspaceId, navigate); setIsOpen(false); return; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx index 21d00b1854c..e3ee94c94f0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspacesListView/WorkspacesListView.tsx @@ -6,6 +6,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { LuSearch, LuX } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import type { FilterMode, ProjectGroup, WorkspaceItem } from "./types"; import { WorkspaceRow } from "./WorkspaceRow"; @@ -37,11 +38,7 @@ export function WorkspacesListView() { utils.workspaces.getAllGrouped.invalidate(); // Navigate to the newly opened workspace if (data.workspace?.id) { - localStorage.setItem("lastViewedWorkspaceId", data.workspace.id); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: data.workspace.id }, - }); + navigateToWorkspace(data.workspace.id, navigate); } }, onError: (error) => { @@ -166,11 +163,7 @@ export function WorkspacesListView() { const handleSwitch = (item: WorkspaceItem) => { if (item.workspaceId) { - localStorage.setItem("lastViewedWorkspaceId", item.workspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId: item.workspaceId }, - }); + navigateToWorkspace(item.workspaceId, navigate); } }; diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index ca3f2c690cc..e6390a24cce 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -1,6 +1,7 @@ import { useNavigate } from "@tanstack/react-router"; import { useRef } from "react"; import { trpc } from "renderer/lib/trpc"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; import { NOTIFICATION_EVENTS } from "shared/constants"; import { debugLog } from "shared/debug"; import { useTabsStore } from "./store"; @@ -106,11 +107,7 @@ export function useAgentHookListener() { } } else if (event.type === NOTIFICATION_EVENTS.FOCUS_TAB) { // Navigate to the workspace and focus the tab/pane - localStorage.setItem("lastViewedWorkspaceId", workspaceId); - navigate({ - to: "/workspace/$workspaceId", - params: { workspaceId }, - }); + navigateToWorkspace(workspaceId, navigate); // Set active tab and focused pane after navigation // (router navigation is async, but state updates are immediate)