From d0be7c3ee7072f1f8e9c3235dc57eaf5b491c51f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Apr 2026 15:35:16 +0700 Subject: [PATCH] fix: lock workspace navigation tab sync --- packages/app/e2e/helpers/archive-tab.ts | 22 ++- packages/app/e2e/helpers/workspace-tabs.ts | 22 +++ packages/app/e2e/helpers/workspace-ui.ts | 23 +++ .../workspace-navigation-regression.spec.ts | 164 ++++++++++++++++++ packages/app/src/app/_layout.tsx | 12 +- .../workspace/[workspaceId]/_layout.tsx | 54 +++++- .../src/components/sidebar-workspace-list.tsx | 3 + .../screens/workspace/workspace-screen.tsx | 58 +------ 8 files changed, 291 insertions(+), 67 deletions(-) create mode 100644 packages/app/e2e/workspace-navigation-regression.spec.ts diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 2eed4b6c5..aae05eb31 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -27,10 +27,15 @@ type ArchiveTabDaemonClient = { modeId: string; cwd: string; title: string; - initialPrompt: string; + initialPrompt?: string; }): Promise<{ id: string }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>; + waitForAgentUpsert( + agentId: string, + predicate: (snapshot: { status: string }) => boolean, + timeout?: number, + ): Promise<{ status: string }>; }; function getDaemonPort(): string { @@ -110,16 +115,17 @@ export async function createIdleAgent( const created = await client.createAgent({ provider: "opencode", model: "opencode/gpt-5-nano", - modeId: "default", + modeId: "bypassPermissions", cwd: input.cwd, title: input.title, - initialPrompt: "Reply with exactly READY.", }); - const finished = await client.waitForFinish(created.id, 120_000); - if (finished.status !== "idle") { - throw new Error( - `Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record).error ?? "unknown")}`, - ); + const snapshot = await client.waitForAgentUpsert( + created.id, + (agent) => agent.status === "idle", + 30_000, + ); + if (snapshot.status !== "idle") { + throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`); } return { id: created.id, diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts index 9415a8d91..5ce7907dd 100644 --- a/packages/app/e2e/helpers/workspace-tabs.ts +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -22,6 +22,28 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise { }); } +export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise { + const allTabIds = await getWorkspaceTabTestIds(page); + return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_")); +} + +export async function expectOnlyWorkspaceAgentTabsVisible( + page: Page, + expectedAgentIds: string[], +): Promise { + const expected = new Set(expectedAgentIds.map((id) => `workspace-tab-agent_${id}`)); + const visible = await getVisibleWorkspaceAgentTabIds(page); + const unexpected = visible.filter((id) => !expected.has(id)); + + expect(unexpected).toEqual([]); + expect(visible.length).toBe(expected.size); + for (const expectedId of expectedAgentIds) { + await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({ + timeout: 30_000, + }); + } +} + export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise { const toggle = page.getByTestId("workspace-explorer-toggle").first(); if (!(await toggle.isVisible().catch(() => false))) { diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index 03e1fac51..5bb66a400 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -46,6 +46,29 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string return page.locator(ids.join(",")).first(); } +export async function expectSidebarWorkspaceSelected(input: { + page: Page; + serverId: string; + workspaceId: string; + selected?: boolean; +}): Promise { + const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId); + await expect(row).toBeVisible({ timeout: 30_000 }); + const expected = input.selected === false ? "false" : "true"; + + const hasDataSelected = await row.getAttribute("data-selected"); + if (hasDataSelected !== null) { + await expect(row).toHaveAttribute("data-selected", expected, { + timeout: 30_000, + }); + return; + } + + await expect(row).toHaveAttribute("aria-selected", expected, { + timeout: 30_000, + }); +} + export async function switchWorkspaceViaSidebar(input: { page: Page; serverId: string; diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts new file mode 100644 index 000000000..1d8da9030 --- /dev/null +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -0,0 +1,164 @@ +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + archiveAgentFromDaemon, + connectArchiveTabDaemonClient, + createIdleAgent, + expectWorkspaceTabHidden, + expectWorkspaceTabVisible, + openWorkspaceWithAgents, +} from "./helpers/archive-tab"; +import { + archiveLocalWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + getVisibleWorkspaceAgentTabIds, + expectOnlyWorkspaceAgentTabsVisible, + waitForWorkspaceTabsVisible, +} from "./helpers/workspace-tabs"; +import { + expectSidebarWorkspaceSelected, + expectWorkspaceHeader, + switchWorkspaceViaSidebar, + waitForSidebarHydration, +} from "./helpers/workspace-ui"; + +test.describe("Workspace navigation regression", () => { + test.describe.configure({ timeout: 240_000 }); + + test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ + page, + }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const workspaceClient = await connectNewWorkspaceDaemonClient(); + const archiveClient = await connectArchiveTabDaemonClient(); + const workspaceIds = new Set(); + const agentIds: string[] = []; + const firstRepo = await createTempGitRepo("workspace-nav-reg-a-"); + const secondRepo = await createTempGitRepo("workspace-nav-reg-b-"); + + try { + const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path); + const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path); + workspaceIds.add(firstWorkspace.workspaceId); + workspaceIds.add(secondWorkspace.workspaceId); + + const firstAgent = await createIdleAgent(archiveClient, { + cwd: firstRepo.path, + title: `workspace-nav-a-${Date.now()}`, + }); + const secondAgent = await createIdleAgent(archiveClient, { + cwd: secondRepo.path, + title: `workspace-nav-b-${Date.now()}`, + }); + agentIds.push(firstAgent.id, secondAgent.id); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: firstWorkspace.workspaceId, + }); + await waitForWorkspaceTabsVisible(page); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { + timeout: 30_000, + }); + await expectSidebarWorkspaceSelected({ + page, + serverId, + workspaceId: firstWorkspace.workspaceId, + }); + await expectSidebarWorkspaceSelected({ + page, + serverId, + workspaceId: secondWorkspace.workspaceId, + selected: false, + }); + await expectWorkspaceHeader(page, { + title: firstWorkspace.workspaceName, + subtitle: firstWorkspace.projectDisplayName, + }); + await expectWorkspaceTabVisible(page, firstAgent.id); + await expectWorkspaceTabHidden(page, secondAgent.id); + await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]); + await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ + `workspace-tab-agent_${firstAgent.id}`, + ]); + + await switchWorkspaceViaSidebar({ + page, + serverId, + targetWorkspacePath: secondWorkspace.workspaceId, + }); + await waitForWorkspaceTabsVisible(page); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { + timeout: 30_000, + }); + await expectSidebarWorkspaceSelected({ + page, + serverId, + workspaceId: secondWorkspace.workspaceId, + }); + await expectSidebarWorkspaceSelected({ + page, + serverId, + workspaceId: firstWorkspace.workspaceId, + selected: false, + }); + await expectWorkspaceHeader(page, { + title: secondWorkspace.workspaceName, + subtitle: secondWorkspace.projectDisplayName, + }); + await expectWorkspaceTabVisible(page, secondAgent.id); + await expectWorkspaceTabHidden(page, firstAgent.id); + await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); + await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ + `workspace-tab-agent_${secondAgent.id}`, + ]); + + await page.reload(); + await waitForSidebarHydration(page); + await waitForWorkspaceTabsVisible(page); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { + timeout: 30_000, + }); + await expectSidebarWorkspaceSelected({ + page, + serverId, + workspaceId: secondWorkspace.workspaceId, + }); + await expectWorkspaceHeader(page, { + title: secondWorkspace.workspaceName, + subtitle: secondWorkspace.projectDisplayName, + }); + await expectWorkspaceTabVisible(page, secondAgent.id); + await expectWorkspaceTabHidden(page, firstAgent.id); + await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]); + await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([ + `workspace-tab-agent_${secondAgent.id}`, + ]); + } finally { + for (const agentId of agentIds) { + await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined); + } + for (const workspaceId of workspaceIds) { + await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined); + } + await archiveClient.close().catch(() => undefined); + await workspaceClient.close().catch(() => undefined); + await secondRepo.cleanup(); + await firstRepo.cleanup(); + } + }); +}); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c212b7e87..66c4a1d7f 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -811,7 +811,17 @@ function RootStack() { getId={({ params }) => { const serverId = getRouteParamValue(params?.serverId); const workspaceId = getRouteParamValue(params?.workspaceId); - return serverId && workspaceId ? `${serverId}:${workspaceId}` : undefined; + const openIntent = getRouteParamValue( + params && typeof params === "object" && "open" in params + ? (params as { open?: string | string[] }).open + : undefined, + ); + if (!serverId || !workspaceId) { + return undefined; + } + return openIntent + ? `${serverId}:${workspaceId}:open:${openIntent}` + : `${serverId}:${workspaceId}`; }} /> diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 6962ef4ab..336e2bf38 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,14 +1,17 @@ import { useEffect, useRef, useState } from "react"; +import { useIsFocused, useNavigation } from "@react-navigation/native"; import { useGlobalSearchParams, useLocalSearchParams, usePathname, + useRouter, useRootNavigationState, } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { + buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, parseHostWorkspaceRouteFromPathname, parseWorkspaceOpenIntent, @@ -44,6 +47,29 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge return { kind: "draft", draftId: openIntent.draftId }; } +function stripOpenSearchParamFromBrowserUrl() { + if (!isWeb || typeof window === "undefined") { + return; + } + const url = new URL(window.location.href); + if (!url.searchParams.has("open")) { + return; + } + url.searchParams.delete("open"); + window.history.replaceState(null, "", url.toString()); +} + +function clearConsumedOpenIntent(input: { + navigation: { setParams: (...args: any[]) => void }; + router: { replace: (...args: any[]) => void }; + serverId: string; + workspaceId: string; +}) { + input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId)); + input.navigation.setParams({ open: undefined }); + stripOpenSearchParamFromBrowserUrl(); +} + export default function HostWorkspaceLayout() { return ( @@ -53,6 +79,9 @@ export default function HostWorkspaceLayout() { } function HostWorkspaceLayoutContent() { + const isFocused = useIsFocused(); + const navigation = useNavigation(); + const router = useRouter(); const rootNavigationState = useRootNavigationState(); const consumedIntentRef = useRef(null); const [intentConsumed, setIntentConsumed] = useState(false); @@ -73,12 +102,22 @@ function HostWorkspaceLayoutContent() { if (!openValue) { return; } + if (!isFocused) { + return; + } if (!rootNavigationState?.key) { return; } const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; if (consumedIntentRef.current === consumptionKey) { + clearConsumedOpenIntent({ + navigation, + router, + serverId, + workspaceId, + }); + setIntentConsumed(true); return; } consumedIntentRef.current = consumptionKey; @@ -96,16 +135,15 @@ function HostWorkspaceLayoutContent() { // Expo Router's replace ignores query-param-only changes (findDivergentState // skips search params). Strip ?open from the browser URL directly so the // address bar reflects the clean workspace route. - if (isWeb && typeof window !== "undefined") { - const url = new URL(window.location.href); - if (url.searchParams.has("open")) { - url.searchParams.delete("open"); - window.history.replaceState(null, "", url.toString()); - } - } + clearConsumedOpenIntent({ + navigation, + router, + serverId, + workspaceId, + }); setIntentConsumed(true); - }, [openValue, rootNavigationState?.key, serverId, workspaceId]); + }, [isFocused, navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]); if (openValue && !intentConsumed) { return null; diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 2112060e3..5cb365f8d 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1009,6 +1009,9 @@ function WorkspaceRowInner({ > [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f3c034709..9244959db 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -99,7 +99,6 @@ import { } from "@/screens/workspace/workspace-header-source"; import { deriveWorkspaceAgentVisibility, - shouldPruneWorkspaceAgentTab, workspaceAgentVisibilityEqual, } from "@/screens/workspace/workspace-agent-visibility"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; @@ -1010,7 +1009,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return; } - const terminalIds = new Set(terminals.map((terminal) => terminal.id)); const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => { if (tab.target.kind !== "draft") { return false; @@ -1019,56 +1017,16 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) return pending?.serverId === normalizedServerId && pending.lifecycle === "active"; }); - for (const agentId of workspaceAgentVisibility.activeAgentIds) { - if (hiddenAgentIds.has(agentId)) { - continue; - } - const representedByTarget = uiTabs.some( - (tab) => tab.target.kind === "agent" && tab.target.agentId === agentId, - ); - const representedByDeterministicTabId = uiTabs.some( - (tab) => tab.tabId === `agent_${agentId}`, - ); - if ( - hasActivePendingDraftCreateInWorkspace && - !representedByTarget && - !representedByDeterministicTabId - ) { - continue; - } - ensureWorkspaceTab({ kind: "agent", agentId }); - } - for (const terminal of terminals) { - ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id }); - } - - const canPruneAgentTabs = hasHydratedAgents; - const canPruneTerminalTabs = terminalsQuery.isSuccess; - for (const tab of uiTabs) { - if ( - canPruneAgentTabs && - tab.target.kind === "agent" && - !pinnedAgentIds.has(tab.target.agentId) && - shouldPruneWorkspaceAgentTab({ - agentId: tab.target.agentId, - agentsHydrated: hasHydratedAgents, - knownAgentIds: workspaceAgentVisibility.knownAgentIds, - activeAgentIds: workspaceAgentVisibility.activeAgentIds, - }) - ) { - closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target }); - } - if ( - canPruneTerminalTabs && - tab.target.kind === "terminal" && - !terminalIds.has(tab.target.terminalId) - ) { - closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target }); - } - } + reconcileWorkspaceTabs(persistenceKey, { + agentsHydrated: hasHydratedAgents, + terminalsHydrated: terminalsQuery.isSuccess, + activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds), + knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds), + standaloneTerminalIds: terminals.map((terminal) => terminal.id), + hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace, + }); }, [ hasHydratedAgents, - hiddenAgentIds, pendingByDraftId, persistenceKey, reconcileWorkspaceTabs,