From 43b9123c5c19bdf87c564e812c8a765ac38f135f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Apr 2026 16:19:43 +0700 Subject: [PATCH] fix(app): restore workspace tab focus on refresh --- .../settings-toggle-tab-regression.spec.ts | 172 ++++++++++++++++++ .../workspace/[workspaceId]/_layout.tsx | 17 +- .../workspace/workspace-desktop-tabs-row.tsx | 3 + .../workspace-empty-draft-seed.test.ts | 62 +++++++ .../workspace/workspace-empty-draft-seed.ts | 24 +++ .../screens/workspace/workspace-screen.tsx | 35 ++-- .../app/src/stores/workspace-layout-store.ts | 20 ++ 7 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 packages/app/e2e/settings-toggle-tab-regression.spec.ts create mode 100644 packages/app/src/screens/workspace/workspace-empty-draft-seed.test.ts create mode 100644 packages/app/src/screens/workspace/workspace-empty-draft-seed.ts diff --git a/packages/app/e2e/settings-toggle-tab-regression.spec.ts b/packages/app/e2e/settings-toggle-tab-regression.spec.ts new file mode 100644 index 000000000..603702e61 --- /dev/null +++ b/packages/app/e2e/settings-toggle-tab-regression.spec.ts @@ -0,0 +1,172 @@ +import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { expect, test, type Page } from "./fixtures"; +import { + archiveAgentFromDaemon, + connectArchiveTabDaemonClient, + createIdleAgent, + openWorkspaceWithAgents, +} from "./helpers/archive-tab"; +import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher"; +import { createTempGitRepo } from "./helpers/workspace"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +async function pressSettingsToggleShortcut(page: Page) { + const modifier = process.platform === "darwin" ? "Meta" : "Control"; + await page.keyboard.press(`${modifier}+Comma`); +} + +async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") { + await expect + .poll(async () => { + const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings")); + if (!raw) { + return null; + } + return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null; + }) + .toBe(expected); +} + +async function openAgentRouteAndExpectFocused(input: { + page: Page; + serverId: string; + workspaceId: string; + agentId: string; +}) { + const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`; + await input.page.goto( + buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId), + ); + await input.page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await waitForTabBar(input.page); + await expect( + input.page.getByTestId(expectedActiveTabId).filter({ visible: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId); +} + +test.describe("Settings toggle tab regression", () => { + test.describe.configure({ timeout: 180_000 }); + + test("toggling settings after changing a setting returns to the same workspace tab", async ({ + page, + }) => { + const serverId = getServerId(); + const client = await connectArchiveTabDaemonClient(); + const repo = await createTempGitRepo("settings-toggle-tab-"); + const agentIds: string[] = []; + + try { + const firstAgent = await createIdleAgent(client, { + cwd: repo.path, + title: `settings-toggle-a-${Date.now()}`, + }); + const secondAgent = await createIdleAgent(client, { + cwd: repo.path, + title: `settings-toggle-b-${Date.now()}`, + }); + agentIds.push(firstAgent.id, secondAgent.id); + + await openWorkspaceWithAgents(page, [firstAgent, secondAgent]); + await waitForTabBar(page); + + const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`; + await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + + await pressSettingsToggleShortcut(page); + await expect(page).toHaveURL(/\/settings\/general$/); + + await page.getByRole("button", { name: "Queue", exact: true }).click(); + await expectSendBehavior(page, "queue"); + await page.getByRole("button", { name: "Interrupt", exact: true }).click(); + await expectSendBehavior(page, "interrupt"); + + await pressSettingsToggleShortcut(page); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path)); + await waitForTabBar(page); + await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + + await page.reload(); + await waitForTabBar(page); + await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute( + "aria-selected", + "true", + ); + await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + } finally { + for (const agentId of agentIds) { + await archiveAgentFromDaemon(client, agentId).catch(() => undefined); + } + await client.close().catch(() => undefined); + await repo.cleanup(); + } + }); + + test("refresh after navigating between agent routes keeps the latest agent focused", async ({ + page, + }) => { + const serverId = getServerId(); + const client = await connectArchiveTabDaemonClient(); + const repo = await createTempGitRepo("agent-route-refresh-"); + const agentIds: string[] = []; + + try { + const firstAgent = await createIdleAgent(client, { + cwd: repo.path, + title: `agent-route-refresh-a-${Date.now()}`, + }); + const secondAgent = await createIdleAgent(client, { + cwd: repo.path, + title: `agent-route-refresh-b-${Date.now()}`, + }); + agentIds.push(firstAgent.id, secondAgent.id); + + await openAgentRouteAndExpectFocused({ + page, + serverId, + workspaceId: repo.path, + agentId: firstAgent.id, + }); + await openAgentRouteAndExpectFocused({ + page, + serverId, + workspaceId: repo.path, + agentId: secondAgent.id, + }); + + const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`; + for (let attempt = 0; attempt < 5; attempt += 1) { + await page.reload(); + await waitForTabBar(page); + await expect( + page.getByTestId(expectedActiveTabId).filter({ visible: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId); + } + } finally { + for (const agentId of agentIds) { + await archiveAgentFromDaemon(client, agentId).catch(() => undefined); + } + await client.close().catch(() => undefined); + await repo.cleanup(); + } + }); +}); 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 4a649976d..153d50432 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -15,6 +15,7 @@ import { } from "@/stores/navigation-active-workspace-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; +import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, @@ -88,6 +89,7 @@ function HostWorkspaceLayoutContent() { const navigation = useNavigation(); const router = useRouter(); const rootNavigationState = useRootNavigationState(); + const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); const consumedIntentRef = useRef(null); const [intentConsumed, setIntentConsumed] = useState(false); const params = useLocalSearchParams<{ @@ -128,6 +130,9 @@ function HostWorkspaceLayoutContent() { if (!rootNavigationState?.key) { return; } + if (!hasHydratedWorkspaceLayoutStore) { + return; + } const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; if (consumedIntentRef.current === consumptionKey) { @@ -163,9 +168,17 @@ function HostWorkspaceLayoutContent() { }); setIntentConsumed(true); - }, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]); + }, [ + hasHydratedWorkspaceLayoutStore, + navigation, + openValue, + rootNavigationState?.key, + router, + serverId, + workspaceId, + ]); - if (openValue && !intentConsumed) { + if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) { return null; } diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index a4e42f839..879f50c38 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -219,7 +219,10 @@ function TabChip({ onPress={() => { onNavigateTab(tab.tabId); }} + accessibilityRole="button" accessibilityLabel={tooltipLabel} + accessibilityState={{ selected: isActive }} + aria-selected={isActive} > {isActive && ( { + it("waits for refresh-time hydration before seeding a draft", () => { + expect( + shouldSeedEmptyWorkspaceDraft({ + ...readyEmptyWorkspace, + hasHydratedWorkspaceLayoutStore: false, + }), + ).toBe(false); + expect( + shouldSeedEmptyWorkspaceDraft({ + ...readyEmptyWorkspace, + hasHydratedAgents: false, + }), + ).toBe(false); + expect( + shouldSeedEmptyWorkspaceDraft({ + ...readyEmptyWorkspace, + hasLoadedTerminals: false, + }), + ).toBe(false); + }); + + it("does not seed when existing workspace content is known", () => { + expect( + shouldSeedEmptyWorkspaceDraft({ + ...readyEmptyWorkspace, + activeAgentCount: 1, + }), + ).toBe(false); + expect( + shouldSeedEmptyWorkspaceDraft({ + ...readyEmptyWorkspace, + terminalCount: 1, + }), + ).toBe(false); + expect( + shouldSeedEmptyWorkspaceDraft({ + ...readyEmptyWorkspace, + tabCount: 1, + }), + ).toBe(false); + }); + + it("seeds once an empty focused workspace is fully known", () => { + expect(shouldSeedEmptyWorkspaceDraft(readyEmptyWorkspace)).toBe(true); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts b/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts new file mode 100644 index 000000000..76c3678a9 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-empty-draft-seed.ts @@ -0,0 +1,24 @@ +export function shouldSeedEmptyWorkspaceDraft(input: { + isRouteFocused: boolean; + hasPersistenceKey: boolean; + hasWorkspaceDirectory: boolean; + hasHydratedWorkspaceLayoutStore: boolean; + hasHydratedAgents: boolean; + hasLoadedTerminals: boolean; + activeAgentCount: number; + terminalCount: number; + tabCount: number; +}): boolean { + if ( + !input.isRouteFocused || + !input.hasPersistenceKey || + !input.hasWorkspaceDirectory || + !input.hasHydratedWorkspaceLayoutStore || + !input.hasHydratedAgents || + !input.hasLoadedTerminals + ) { + return false; + } + + return input.activeAgentCount === 0 && input.terminalCount === 0 && input.tabCount === 0; +} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index f45d2fe82..4bba0398b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -55,6 +55,7 @@ import { buildWorkspaceTabPersistenceKey, collectAllTabs, useWorkspaceLayoutStore, + useWorkspaceLayoutStoreHydrated, } from "@/stores/workspace-layout-store"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; @@ -111,6 +112,7 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; +import { shouldSeedEmptyWorkspaceDraft } from "@/screens/workspace/workspace-empty-draft-seed"; import { buildBulkCloseConfirmationMessage, classifyBulkClosableTabs, @@ -977,6 +979,7 @@ function WorkspaceScreenContent({ const workspaceLayout = useWorkspaceLayoutStore((state) => persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null, ); + const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); const workspaceSetupSnapshot = useWorkspaceSetupStore((state) => persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null, ); @@ -1138,6 +1141,9 @@ function WorkspaceScreenContent({ if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) { return; } + if (!hasHydratedWorkspaceLayoutStore) { + return; + } const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => { if (tab.target.kind !== "draft") { @@ -1158,6 +1164,7 @@ function WorkspaceScreenContent({ }); }, [ hasHydratedAgents, + hasHydratedWorkspaceLayoutStore, isRouteFocused, pendingByDraftId, persistenceKey, @@ -1246,17 +1253,19 @@ function WorkspaceScreenContent({ ]); useEffect(() => { - if (!isRouteFocused) { - return; - } - if (!persistenceKey) { - return; - } - if (workspaceAgentVisibility.activeAgentIds.size > 0 || terminals.length > 0) { - emptyWorkspaceSeedRef.current = null; - return; - } - if (tabs.length > 0) { + if ( + !shouldSeedEmptyWorkspaceDraft({ + isRouteFocused, + hasPersistenceKey: Boolean(persistenceKey), + hasWorkspaceDirectory: Boolean(workspaceDirectory), + hasHydratedWorkspaceLayoutStore, + hasHydratedAgents, + hasLoadedTerminals: terminalsQuery.isSuccess, + activeAgentCount: workspaceAgentVisibility.activeAgentIds.size, + terminalCount: terminals.length, + tabCount: tabs.length, + }) + ) { emptyWorkspaceSeedRef.current = null; return; } @@ -1271,9 +1280,13 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, openWorkspaceDraftTab, persistenceKey, + hasHydratedAgents, + hasHydratedWorkspaceLayoutStore, isRouteFocused, terminals.length, + terminalsQuery.isSuccess, tabs.length, + workspaceDirectory, workspaceAgentVisibility.activeAgentIds.size, ]); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 6bcadef14..91585d504 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,4 +1,5 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useEffect, useState } from "react"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { @@ -719,3 +720,22 @@ export const useWorkspaceLayoutStore = create()( }, ), ); + +export function useWorkspaceLayoutStoreHydrated(): boolean { + const [hasHydrated, setHasHydrated] = useState(() => + useWorkspaceLayoutStore.persist.hasHydrated(), + ); + + useEffect(() => { + if (useWorkspaceLayoutStore.persist.hasHydrated()) { + setHasHydrated(true); + return; + } + + return useWorkspaceLayoutStore.persist.onFinishHydration(() => { + setHasHydrated(true); + }); + }, []); + + return hasHydrated; +}