fix: lock workspace navigation tab sync
This commit is contained in:
@@ -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<string, unknown>).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,
|
||||
|
||||
@@ -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> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if (!(await toggle.isVisible().catch(() => false))) {
|
||||
|
||||
@@ -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<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: {
|
||||
page: Page;
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
|
||||
|
||||
@@ -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 (
|
||||
<HostRouteBootstrapBoundary>
|
||||
@@ -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<string | null>(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;
|
||||
|
||||
@@ -1009,6 +1009,9 @@ function WorkspaceRowInner({
|
||||
>
|
||||
<Pressable
|
||||
disabled={isArchiving}
|
||||
aria-selected={selected}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected }}
|
||||
style={({ pressed }) => [
|
||||
styles.workspaceRow,
|
||||
isDragging && styles.workspaceRowDragging,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user