diff --git a/src/browser/App.tsx b/src/browser/App.tsx index ded73f790e..0eb0c51799 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -20,6 +20,7 @@ import { useResizableSidebar } from "./hooks/useResizableSidebar"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { handleLayoutSlotHotkeys } from "./utils/ui/layoutSlotHotkeys"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; +import { getVisibleWorkspaceIds } from "./utils/ui/workspaceDomNav"; import { useResumeManager } from "./hooks/useResumeManager"; import { useUnreadTracking } from "./hooks/useUnreadTracking"; import { useWorkspaceStoreRaw, useWorkspaceRecency } from "./stores/WorkspaceStore"; @@ -296,11 +297,8 @@ function AppInner() { const handleNavigateWorkspace = useCallback( (direction: "next" | "prev") => { - // Read actual rendered workspace order from DOM - impossible to drift from sidebar - // Use compound selector to target only row elements (not archive buttons or edit inputs) - const els = document.querySelectorAll("[data-workspace-id][data-workspace-path]"); - const visibleIds = Array.from(els).map((el) => el.getAttribute("data-workspace-id")!); - + // Read actual rendered workspace order from DOM — impossible to drift from sidebar. + const visibleIds = getVisibleWorkspaceIds(); if (visibleIds.length === 0) return; const currentIndex = selectedWorkspace diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 4cbf08cf79..c751ac9c94 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -52,6 +52,7 @@ import { getProjectRouteId } from "@/common/utils/projectRouteId"; import { resolveProjectPathFromProjectQuery } from "@/common/utils/deepLink"; import { shouldApplyWorkspaceAiSettingsFromBackend } from "@/browser/utils/workspaceAiSettingsSync"; import { isAbortError } from "@/browser/utils/isAbortError"; +import { findAdjacentWorkspaceId } from "@/browser/utils/ui/workspaceDomNav"; import { useRouter } from "@/browser/contexts/RouterContext"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -835,6 +836,20 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [navigateToWorkspace, navigateToHome] ); + /** + * Clear the workspace selection and navigate to a specific project page + * instead of home. Use this when deselecting a workspace where we know + * which project the user was working in (archive, delete fallback, etc.). + */ + const clearSelectionToProject = useCallback( + (projectPath: string) => { + selectedWorkspaceRef.current = null; + updatePersistedState(SELECTED_WORKSPACE_KEY, null); + navigateToProject(projectPath); + }, + [navigateToProject] + ); + // Used by async subscription handlers to safely access the most recent metadata map // without triggering render-phase state updates. const workspaceMetadataRef = useRef(workspaceMetadata); @@ -993,12 +1008,20 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // If the currently-selected workspace is being archived, navigate away *before* // removing it from the active metadata map. Otherwise we can briefly render the // welcome screen while still on `/workspace/:id`. + // + // Prefer the next workspace in sidebar DOM order (like Ctrl+J) so the user + // stays in flow; fall back to the project page when no siblings remain. if (meta !== null && isNowArchived) { const currentSelection = selectedWorkspaceRef.current; if (currentSelection?.workspaceId === event.workspaceId) { - selectedWorkspaceRef.current = null; - updatePersistedState(SELECTED_WORKSPACE_KEY, null); - navigateToProject(meta.projectPath); + const nextId = findAdjacentWorkspaceId(event.workspaceId); + const nextMeta = nextId ? workspaceMetadataRef.current.get(nextId) : null; + + if (nextMeta) { + setSelectedWorkspace(toWorkspaceSelection(nextMeta)); + } else { + clearSelectionToProject(meta.projectPath); + } } } @@ -1068,14 +1091,9 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { ); if (fallbackMeta) { - setSelectedWorkspace({ - workspaceId: fallbackMeta.id, - projectPath: fallbackMeta.projectPath, - projectName: fallbackMeta.projectName, - namedWorkspacePath: fallbackMeta.namedWorkspacePath, - }); + setSelectedWorkspace(toWorkspaceSelection(fallbackMeta)); } else if (projectPath) { - navigateToProject(projectPath); + clearSelectionToProject(projectPath); } else { setSelectedWorkspace(null); } @@ -1091,7 +1109,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { return () => { controller.abort(); }; - }, [navigateToProject, refreshProjects, setSelectedWorkspace, setWorkspaceMetadata, api]); + }, [clearSelectionToProject, refreshProjects, setSelectedWorkspace, setWorkspaceMetadata, api]); const createWorkspace = useCallback( async ( diff --git a/src/browser/utils/ui/workspaceDomNav.ts b/src/browser/utils/ui/workspaceDomNav.ts new file mode 100644 index 0000000000..d13e067c0a --- /dev/null +++ b/src/browser/utils/ui/workspaceDomNav.ts @@ -0,0 +1,47 @@ +/** + * DOM-based workspace navigation utilities. + * + * Reads the rendered sidebar to determine workspace ordering. This is the + * canonical source of truth for "next/previous workspace" because it reflects + * the exact visual order the user sees (respecting sort, sections, collapsed + * projects, etc.). + * + * Shared by Ctrl+J/K navigation and the archive-then-navigate behaviour. + */ + +/** Compound selector that targets only workspace *row* elements. */ +const WORKSPACE_ROW_SELECTOR = "[data-workspace-id][data-workspace-path]"; + +/** Return all visible workspace IDs in DOM (sidebar) order. */ +export function getVisibleWorkspaceIds(): string[] { + const els = document.querySelectorAll(WORKSPACE_ROW_SELECTOR); + return Array.from(els).map((el) => el.getAttribute("data-workspace-id")!); +} + +/** + * Given a workspace that is about to be removed (archived / deleted), return + * the ID of the workspace the user should land on next. + * + * Prefers the item immediately *after* {@link currentWorkspaceId} (so the list + * feels like it scrolled up to fill the gap), falling back to the item before + * it. When the current workspace isn't rendered at all (e.g. its project or + * section is collapsed), returns the first visible workspace — matching how + * Ctrl+J picks a target when the selection is off-screen. + * + * Returns `null` only when no other workspaces are visible in the sidebar. + */ +export function findAdjacentWorkspaceId(currentWorkspaceId: string): string | null { + const ids = getVisibleWorkspaceIds(); + const idx = ids.indexOf(currentWorkspaceId); + + if (idx === -1) { + // Current workspace not rendered (collapsed project/section) — pick the + // first visible workspace that isn't the one being removed. + return ids.find((id) => id !== currentWorkspaceId) ?? null; + } + + // Prefer next (below), then previous (above). + if (idx + 1 < ids.length) return ids[idx + 1]; + if (idx - 1 >= 0) return ids[idx - 1]; + return null; +} diff --git a/tests/ui/workspaceLifecycle.integration.test.ts b/tests/ui/workspaceLifecycle.integration.test.ts index 3d7a3a11a6..f76c8af090 100644 --- a/tests/ui/workspaceLifecycle.integration.test.ts +++ b/tests/ui/workspaceLifecycle.integration.test.ts @@ -110,8 +110,117 @@ describeIntegration("Workspace Archive (UI)", () => { await cleanupSharedRepo(); }); - test("archiving the active workspace navigates to project page, not home", async () => { - // Use withSharedWorkspace to get a properly initialized workspace + test("archiving the active workspace navigates to the next sibling workspace", async () => { + // When a project has multiple workspaces, archiving the active one should + // navigate to the next workspace in DOM order (like Ctrl+J), not the project page. + const env = getSharedEnv(); + const projectPath = getSharedRepoPath(); + const trunkBranch = await detectDefaultTrunkBranch(projectPath); + + const firstBranch = generateBranchName("test-archive-nav-first"); + const secondBranch = generateBranchName("test-archive-nav-second"); + + const firstResult = await env.orpc.workspace.create({ + projectPath, + branchName: firstBranch, + trunkBranch, + }); + if (!firstResult.success) throw new Error(firstResult.error); + const firstWorkspace = firstResult.metadata; + + const secondResult = await env.orpc.workspace.create({ + projectPath, + branchName: secondBranch, + trunkBranch, + }); + if (!secondResult.success) throw new Error(secondResult.error); + const secondWorkspace = secondResult.metadata; + const firstDisplayTitle = firstWorkspace.title ?? firstWorkspace.name; + + const cleanupDom = installDom(); + const view = renderApp({ + apiClient: env.orpc, + metadata: firstWorkspace, + }); + + try { + // Navigate to the first workspace (make it active) + await setupWorkspaceView(view, firstWorkspace, firstWorkspace.id); + + // Verify second workspace is also visible in sidebar + await waitFor( + () => { + const el = view.container.querySelector(`[data-workspace-id="${secondWorkspace.id}"]`); + if (!el) throw new Error("Second workspace not in sidebar"); + }, + { timeout: 5_000 } + ); + + // Archive the first workspace via sidebar menu + const menuButton = await waitFor( + () => { + const btn = view.container.querySelector( + `[aria-label="Workspace actions for ${firstDisplayTitle}"]` + ) as HTMLElement; + if (!btn) throw new Error("Workspace actions menu button not found"); + return btn; + }, + { timeout: 5_000 } + ); + fireEvent.click(menuButton); + + const archiveButton = await waitFor( + () => { + const buttons = Array.from(document.querySelectorAll("button")); + const archiveBtn = buttons.find((b) => b.textContent?.includes("Archive chat")); + if (!archiveBtn) throw new Error("Archive button not found in menu"); + return archiveBtn as HTMLElement; + }, + { timeout: 5_000 } + ); + fireEvent.click(archiveButton); + + // Wait for the archived workspace to disappear from sidebar + await waitFor( + () => { + const wsEl = view.container.querySelector(`[data-workspace-id="${firstWorkspace.id}"]`); + if (wsEl) throw new Error("Archived workspace still in sidebar"); + }, + { timeout: 5_000 } + ); + + // KEY ASSERTION: Should navigate to the second workspace, NOT the project page. + // The second workspace should now be the active one (its chat view is shown). + await waitFor( + () => { + // The URL should point to the second workspace + if (!window.location.pathname.includes(secondWorkspace.id)) { + throw new Error( + `Expected to navigate to second workspace (${secondWorkspace.id}), ` + + `but URL is ${window.location.pathname}` + ); + } + }, + { timeout: 5_000 } + ); + + // Should NOT be on project page (no creation textarea as main content) + const homeScreen = view.container.querySelector('[data-testid="home-screen"]'); + expect(homeScreen).toBeNull(); + } finally { + await env.orpc.workspace + .remove({ workspaceId: firstWorkspace.id, options: { force: true } }) + .catch(() => {}); + await env.orpc.workspace + .remove({ workspaceId: secondWorkspace.id, options: { force: true } }) + .catch(() => {}); + await cleanupView(view, cleanupDom); + } + }, 60_000); + + test("archiving the only workspace in a project falls back to project page", async () => { + // When there are no sibling workspaces to navigate to, archiving should + // fall back to the project page (not home). await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { const projectPath = metadata.projectPath; const displayTitle = metadata.title ?? metadata.name; @@ -178,6 +287,7 @@ describeIntegration("Workspace Archive (UI)", () => { expect(homeScreen).toBeNull(); // Should be on the project page (has creation textarea for new workspace) + // When there are no other workspaces, archiving falls back to the project page. await waitFor( () => { const creationTextarea = view.container.querySelector("textarea");