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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
40 changes: 29 additions & 11 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -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);
}
Expand All @@ -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 (
Expand Down
47 changes: 47 additions & 0 deletions src/browser/utils/ui/workspaceDomNav.ts
Original file line number Diff line number Diff line change
@@ -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;
}
114 changes: 112 additions & 2 deletions tests/ui/workspaceLifecycle.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Loading