fix: lock workspace navigation tab sync

This commit is contained in:
Mohamed Boudra
2026-04-14 15:35:16 +07:00
parent 1e0f62976d
commit d0be7c3ee7
8 changed files with 291 additions and 67 deletions
+14 -8
View File
@@ -27,10 +27,15 @@ type ArchiveTabDaemonClient = {
modeId: string; modeId: string;
cwd: string; cwd: string;
title: string; title: string;
initialPrompt: string; initialPrompt?: string;
}): Promise<{ id: string }>; }): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: 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 { function getDaemonPort(): string {
@@ -110,16 +115,17 @@ export async function createIdleAgent(
const created = await client.createAgent({ const created = await client.createAgent({
provider: "opencode", provider: "opencode",
model: "opencode/gpt-5-nano", model: "opencode/gpt-5-nano",
modeId: "default", modeId: "bypassPermissions",
cwd: input.cwd, cwd: input.cwd,
title: input.title, title: input.title,
initialPrompt: "Reply with exactly READY.",
}); });
const finished = await client.waitForFinish(created.id, 120_000); const snapshot = await client.waitForAgentUpsert(
if (finished.status !== "idle") { created.id,
throw new Error( (agent) => agent.status === "idle",
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`, 30_000,
); );
if (snapshot.status !== "idle") {
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
} }
return { return {
id: created.id, id: created.id,
@@ -22,6 +22,28 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
}); });
} }
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const allTabIds = await getWorkspaceTabTestIds(page);
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
}
export async function expectOnlyWorkspaceAgentTabsVisible(
page: Page,
expectedAgentIds: string[],
): Promise<void> {
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<void> { export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
const toggle = page.getByTestId("workspace-explorer-toggle").first(); const toggle = page.getByTestId("workspace-explorer-toggle").first();
if (!(await toggle.isVisible().catch(() => false))) { if (!(await toggle.isVisible().catch(() => false))) {
+23
View File
@@ -46,6 +46,29 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string
return page.locator(ids.join(",")).first(); return page.locator(ids.join(",")).first();
} }
export async function expectSidebarWorkspaceSelected(input: {
page: Page;
serverId: string;
workspaceId: string;
selected?: boolean;
}): Promise<void> {
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: { export async function switchWorkspaceViaSidebar(input: {
page: Page; page: Page;
serverId: string; serverId: string;
@@ -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<string>();
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();
}
});
});
+11 -1
View File
@@ -811,7 +811,17 @@ function RootStack() {
getId={({ params }) => { getId={({ params }) => {
const serverId = getRouteParamValue(params?.serverId); const serverId = getRouteParamValue(params?.serverId);
const workspaceId = getRouteParamValue(params?.workspaceId); 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}`;
}} }}
/> />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} /> <Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
@@ -1,14 +1,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useIsFocused, useNavigation } from "@react-navigation/native";
import { import {
useGlobalSearchParams, useGlobalSearchParams,
useLocalSearchParams, useLocalSearchParams,
usePathname, usePathname,
useRouter,
useRootNavigationState, useRootNavigationState,
} from "expo-router"; } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import { import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
parseWorkspaceOpenIntent, parseWorkspaceOpenIntent,
@@ -44,6 +47,29 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
return { kind: "draft", draftId: openIntent.draftId }; 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() { export default function HostWorkspaceLayout() {
return ( return (
<HostRouteBootstrapBoundary> <HostRouteBootstrapBoundary>
@@ -53,6 +79,9 @@ export default function HostWorkspaceLayout() {
} }
function HostWorkspaceLayoutContent() { function HostWorkspaceLayoutContent() {
const isFocused = useIsFocused();
const navigation = useNavigation();
const router = useRouter();
const rootNavigationState = useRootNavigationState(); const rootNavigationState = useRootNavigationState();
const consumedIntentRef = useRef<string | null>(null); const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false); const [intentConsumed, setIntentConsumed] = useState(false);
@@ -73,12 +102,22 @@ function HostWorkspaceLayoutContent() {
if (!openValue) { if (!openValue) {
return; return;
} }
if (!isFocused) {
return;
}
if (!rootNavigationState?.key) { if (!rootNavigationState?.key) {
return; return;
} }
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) { if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation,
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
return; return;
} }
consumedIntentRef.current = consumptionKey; consumedIntentRef.current = consumptionKey;
@@ -96,16 +135,15 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState // Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the // skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route. // address bar reflects the clean workspace route.
if (isWeb && typeof window !== "undefined") { clearConsumedOpenIntent({
const url = new URL(window.location.href); navigation,
if (url.searchParams.has("open")) { router,
url.searchParams.delete("open"); serverId,
window.history.replaceState(null, "", url.toString()); workspaceId,
} });
}
setIntentConsumed(true); setIntentConsumed(true);
}, [openValue, rootNavigationState?.key, serverId, workspaceId]); }, [isFocused, navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
if (openValue && !intentConsumed) { if (openValue && !intentConsumed) {
return null; return null;
@@ -1009,6 +1009,9 @@ function WorkspaceRowInner({
> >
<Pressable <Pressable
disabled={isArchiving} disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={{ selected }}
style={({ pressed }) => [ style={({ pressed }) => [
styles.workspaceRow, styles.workspaceRow,
isDragging && styles.workspaceRowDragging, isDragging && styles.workspaceRowDragging,
@@ -99,7 +99,6 @@ import {
} from "@/screens/workspace/workspace-header-source"; } from "@/screens/workspace/workspace-header-source";
import { import {
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
shouldPruneWorkspaceAgentTab,
workspaceAgentVisibilityEqual, workspaceAgentVisibilityEqual,
} from "@/screens/workspace/workspace-agent-visibility"; } from "@/screens/workspace/workspace-agent-visibility";
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state"; import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
@@ -1010,7 +1009,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return; return;
} }
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => { const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
if (tab.target.kind !== "draft") { if (tab.target.kind !== "draft") {
return false; return false;
@@ -1019,56 +1017,16 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
return pending?.serverId === normalizedServerId && pending.lifecycle === "active"; return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
}); });
for (const agentId of workspaceAgentVisibility.activeAgentIds) { reconcileWorkspaceTabs(persistenceKey, {
if (hiddenAgentIds.has(agentId)) { agentsHydrated: hasHydratedAgents,
continue; terminalsHydrated: terminalsQuery.isSuccess,
} activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds),
const representedByTarget = uiTabs.some( knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds),
(tab) => tab.target.kind === "agent" && tab.target.agentId === agentId, standaloneTerminalIds: terminals.map((terminal) => terminal.id),
); hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
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 });
}
}
}, [ }, [
hasHydratedAgents, hasHydratedAgents,
hiddenAgentIds,
pendingByDraftId, pendingByDraftId,
persistenceKey, persistenceKey,
reconcileWorkspaceTabs, reconcileWorkspaceTabs,