From 74c99a827da05c653f68932bdec435921c0fb180 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Mar 2026 16:18:19 +0700 Subject: [PATCH] Fix workspace tab focus from route open intent --- ...aft-to-existing-workspace-redirect.spec.ts | 219 ++++++++++++++++++ packages/app/e2e/helpers/workspace.ts | 1 + packages/app/src/app/_layout.tsx | 10 +- packages/app/src/app/h/[serverId]/index.tsx | 14 +- .../workspace/[workspaceId]/_layout.tsx | 11 +- packages/app/src/app/index.tsx | 8 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 7 +- .../screens/workspace/workspace-screen.tsx | 12 +- .../workspace/workspace-tab-model.test.ts | 37 +++ .../screens/workspace/workspace-tab-model.ts | 33 ++- 10 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 packages/app/e2e/global-draft-to-existing-workspace-redirect.spec.ts diff --git a/packages/app/e2e/global-draft-to-existing-workspace-redirect.spec.ts b/packages/app/e2e/global-draft-to-existing-workspace-redirect.spec.ts new file mode 100644 index 000000000..429db7c2d --- /dev/null +++ b/packages/app/e2e/global-draft-to-existing-workspace-redirect.spec.ts @@ -0,0 +1,219 @@ +import { test, expect } from './fixtures' +import type { Page } from '@playwright/test' +import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app' +import { createTempGitRepo } from './helpers/workspace' +import { getWorkspaceTabTestIds } from './helpers/workspace-tabs' + +function parseWorkspaceContextFromUrl(rawUrl: string): { workspaceToken: string | null; agentId: string | null } { + const url = new URL(rawUrl) + const pathMatch = url.pathname.match(/\/workspace\/([^/?#]+)(?:\/agent\/([^/?#]+))?/) ?? null + const workspaceToken = pathMatch?.[1] ?? null + let agentId = pathMatch?.[2] ?? null + + if (!agentId) { + const open = url.searchParams.get('open') + const openMatch = open?.match(/^agent:(.+)$/) ?? null + if (openMatch?.[1]) { + agentId = openMatch[1] + } + } + + return { workspaceToken, agentId } +} + +function getOpenAgentIdFromUrl(rawUrl: string): string | null { + const url = new URL(rawUrl) + const open = url.searchParams.get('open') + const openMatch = open?.match(/^agent:(.+)$/) ?? null + return openMatch?.[1] ?? null +} + +async function preferSlowerThinkingOption(page: Page): Promise { + await page.keyboard.press('Escape').catch(() => undefined) + const thinkingTrigger = page.getByTestId('agent-thinking-selector').first() + if (!(await thinkingTrigger.isVisible().catch(() => false))) { + return + } + await thinkingTrigger.click({ force: true }) + const menu = page.getByTestId('agent-thinking-menu').first() + if (!(await menu.isVisible().catch(() => false))) { + return + } + + const preferred = ['high', 'max', 'deep', 'long', 'medium'] + for (const label of preferred) { + const option = menu.getByRole('button', { name: new RegExp(`^${label}$`, 'i') }).first() + if (await option.isVisible().catch(() => false)) { + await option.click({ force: true }) + await expect(menu).not.toBeVisible({ timeout: 5_000 }) + return + } + } + + const options = menu.getByRole('button') + const count = await options.count() + if (count > 0) { + await options.last().click({ force: true }) + } + await expect(menu).not.toBeVisible({ timeout: 5_000 }) +} + +async function isAnyStopVisible(page: Page): Promise { + const candidates = page.getByRole('button', { name: /stop|cancel/i }) + const count = await candidates.count() + for (let index = 0; index < count; index += 1) { + if (await candidates.nth(index).isVisible().catch(() => false)) { + return true + } + } + return false +} + +async function isRunningControlVisible(page: Page): Promise { + if (await isAnyStopVisible(page)) { + return true + } + const interruptSendButton = page.getByRole('button', { name: /interrupt agent|send and interrupt/i }) + const count = await interruptSendButton.count() + for (let index = 0; index < count; index += 1) { + if (await interruptSendButton.nth(index).isVisible().catch(() => false)) { + return true + } + } + return false +} + +async function hasVisibleText(page: Page, matcher: RegExp | string): Promise { + const locator = page.getByText(matcher).first() + const matches = page.getByText(matcher) + const count = await matches.count() + for (let index = 0; index < count; index += 1) { + if (await matches.nth(index).isVisible().catch(() => false)) { + return true + } + } + return await locator.isVisible().catch(() => false) +} + +test('global draft create in existing workspace redirects to that workspace with created agent tab and settles to idle', async ({ + page, +}) => { + test.setTimeout(360_000) + const serverId = process.env.E2E_SERVER_ID + if (!serverId) { + throw new Error('E2E_SERVER_ID is not set.') + } + + const repo = await createTempGitRepo('paseo-e2e-global-existing-workspace-') + const id = `${Date.now()}` + const seedPrompt = `hello seed-${id}. please reply exactly: ACK_SEED_${id}` + const secondPrompt = `hello second-${id}. please reply exactly: ACK_SECOND_${id}` + const lifecyclePrompt = `hello lifecycle-${id}. wait 8 seconds, then reply exactly: ACK_LIFECYCLE_${id}` + const seedToken = `ACK_SEED_${id}` + const secondToken = `ACK_SECOND_${id}` + expect(seedPrompt).not.toBe(secondPrompt) + expect(seedToken).not.toBe(secondToken) + + try { + await gotoHome(page) + await ensureHostSelected(page) + await page.goto(`/h/${serverId}/new-agent`) + await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({ + timeout: 30_000, + }) + + // 1) Seed workspace with an existing agent. + await setWorkingDirectory(page, repo.path) + const seedComposer = page.getByRole('textbox', { name: 'Message agent...' }).first() + await seedComposer.fill(seedPrompt) + await seedComposer.press('Enter') + + await expect(page.getByText(seedPrompt, { exact: true }).first()).toBeVisible({ timeout: 30_000 }) + await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 }) + const seededContext = parseWorkspaceContextFromUrl(page.url()) + if (!seededContext.workspaceToken || !seededContext.agentId) { + throw new Error(`Expected seeded workspace token and agent id in URL, got: ${page.url()}`) + } + const seededAgentId = seededContext.agentId + expect(getOpenAgentIdFromUrl(page.url())).toBe(seededAgentId) + await expect(page.getByText(new RegExp(seedToken)).first()).toBeVisible({ timeout: 180_000 }) + + // 2) Use global New Agent entry and 3) select same workspace. + await page.getByText('New agent', { exact: true }).first().click() + await expect(page).toHaveURL(new RegExp(`/h/${serverId}/new-agent`), { timeout: 30_000 }) + await expect(page.locator('[data-testid="working-directory-select"]:visible').first()).toBeVisible({ + timeout: 30_000, + }) + + await setWorkingDirectory(page, repo.path) + await preferSlowerThinkingOption(page) + await page.keyboard.press('Escape').catch(() => undefined) + + // 4) Create second agent from global draft. + const composer = page.getByRole('textbox', { name: 'Message agent...' }).first() + await composer.fill(secondPrompt) + await composer.press('Enter') + await page.waitForTimeout(500) + if (page.url().includes('/new-agent')) { + const sendButton = page.getByRole('button', { name: /send message/i }).first() + await expect(sendButton).toBeVisible({ timeout: 10_000 }) + await expect(sendButton).toBeEnabled({ timeout: 10_000 }) + await sendButton.click({ force: true }) + } + + // 5) Assert redirect workspace context + created agent tab is active/open. + await expect(page).toHaveURL(/\/workspace\//, { timeout: 60_000 }) + const createdContext = parseWorkspaceContextFromUrl(page.url()) + expect(createdContext.workspaceToken).toBe(seededContext.workspaceToken) + if (!createdContext.agentId) { + throw new Error(`Expected created agent id in URL, got: ${page.url()}`) + } + const createdAgentId = createdContext.agentId + expect(createdAgentId).toBeTruthy() + expect(createdAgentId).not.toBe(seededAgentId) + expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId) + expect(getOpenAgentIdFromUrl(page.url())).not.toBe(seededAgentId) + + const createdTabTestId = `workspace-tab-agent_${createdAgentId}` + await expect + .poll(async () => { + const finalTabIds = await getWorkspaceTabTestIds(page) + return finalTabIds.includes(createdTabTestId) + }) + .toBe(true) + expect(getOpenAgentIdFromUrl(page.url())).toBe(createdAgentId) + + // 6) Response assertions are scoped to created-agent active context. + await expect + .poll(async () => hasVisibleText(page, new RegExp(secondToken)), { timeout: 240_000 }) + .toBe(true) + await expect(page.getByText(seedToken, { exact: true }).first()).not.toBeVisible({ timeout: 30_000 }) + + // 7) Lifecycle assertions on created agent only: running first, then idle. + const lifecycleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first() + await lifecycleComposer.fill(lifecyclePrompt) + await lifecycleComposer.press('Enter') + await expect + .poll(async () => hasVisibleText(page, lifecyclePrompt), { timeout: 30_000 }) + .toBe(true) + + let sawRunning = false + for (let attempt = 0; attempt < 1200; attempt += 1) { + if (await isRunningControlVisible(page)) { + sawRunning = true + break + } + await page.waitForTimeout(50) + } + expect(sawRunning).toBe(true) + + await expect + .poll(async () => (await isRunningControlVisible(page)) === false, { timeout: 240_000 }) + .toBe(true) + const idleComposer = page.getByRole('textbox', { name: 'Message agent...' }).first() + await expect(idleComposer).toBeVisible({ timeout: 30_000 }) + await expect(idleComposer).toBeEditable({ timeout: 30_000 }) + } finally { + await repo.cleanup() + } +}) diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index 494b4bad5..37d6f0813 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -20,6 +20,7 @@ export const createTempGitRepo = async ( execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' }); execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' }); execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' }); + execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' }); await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n'); execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' }); execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' }); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index a3c0961bc..d44ddeeef 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -1,6 +1,6 @@ import "@/styles/unistyles"; import { polyfillCrypto } from "@/polyfills/crypto"; -import { Stack, usePathname, useRouter } from "expo-router"; +import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -47,7 +47,7 @@ import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildHostRootRoute, parseHostAgentRouteFromPathname, - parseHostWorkspaceOpenIntentFromPathname, + parseWorkspaceOpenIntent, } from "@/utils/host-routes"; import { getTauri } from "@/utils/tauri"; import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics"; @@ -376,6 +376,7 @@ function OfferLinkListener({ function AppWithSidebar({ children }: { children: ReactNode }) { const pathname = usePathname(); + const params = useGlobalSearchParams<{ open?: string | string[] }>(); useFaviconStatus(); // Parse selectedAgentKey directly from pathname @@ -383,7 +384,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) { const selectedAgentKey = useMemo(() => { const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/); const workspaceServerId = workspaceMatch?.[1]?.trim() ?? ""; - const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname); + const openValue = Array.isArray(params.open) ? params.open[0] : params.open; + const openIntent = parseWorkspaceOpenIntent(openValue); if (workspaceServerId && openIntent?.kind === "agent") { const agentId = openIntent.agentId.trim(); return agentId ? `${workspaceServerId}:${agentId}` : undefined; @@ -391,7 +393,7 @@ function AppWithSidebar({ children }: { children: ReactNode }) { const match = parseHostAgentRouteFromPathname(pathname); return match ? `${match.serverId}:${match.agentId}` : undefined; - }, [pathname]); + }, [params.open, pathname]); return ( {children} diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 10dd25192..ddbca8018 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -1,6 +1,5 @@ import { useEffect } from "react"; import { useLocalSearchParams, usePathname, useRouter } from "expo-router"; -import { Platform } from "react-native"; import { useSessionStore } from "@/stores/session-store"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { @@ -13,11 +12,7 @@ const HOST_ROOT_REDIRECT_DELAY_MS = 300; export default function HostIndexRoute() { const router = useRouter(); - const routerPathname = usePathname(); - const pathname = - Platform.OS === "web" && typeof window !== "undefined" - ? window.location.pathname - : routerPathname; + const pathname = usePathname(); const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const { preferences, isLoading: preferencesLoading } = useFormPreferences(); @@ -37,11 +32,8 @@ export default function HostIndexRoute() { return; } const timer = setTimeout(() => { - if (Platform.OS === "web" && typeof window !== "undefined") { - const currentPathname = window.location.pathname; - if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) { - return; - } + if (pathname !== rootRoute && pathname !== `${rootRoute}/`) { + return; } const visibleAgents = sessionAgents 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 d7f66acca..8b763cffb 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,17 +1,18 @@ -import { usePathname } from 'expo-router' +import { useGlobalSearchParams, usePathname } from 'expo-router' import { WorkspaceScreen } from '@/screens/workspace/workspace-screen' import { parseHostWorkspaceRouteFromPathname, - parseHostWorkspaceOpenIntentFromPathname, + parseWorkspaceOpenIntent, } from '@/utils/host-routes' export default function HostWorkspaceLayout() { const expoPathname = usePathname() - const resolvedPathname = expoPathname - const activeRoute = parseHostWorkspaceRouteFromPathname(resolvedPathname) + const params = useGlobalSearchParams<{ open?: string | string[] }>() + const activeRoute = parseHostWorkspaceRouteFromPathname(expoPathname) const serverId = activeRoute?.serverId ?? '' const workspaceId = activeRoute?.workspaceId ?? '' - const openIntent = parseHostWorkspaceOpenIntentFromPathname(resolvedPathname) + const openValue = Array.isArray(params.open) ? params.open[0] : params.open + const openIntent = parseWorkspaceOpenIntent(openValue) return ( (); const { theme } = useUnistyles(); const { daemons, isLoading: registryLoading } = useDaemonRegistry(); diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index e478e4615..a77b626c5 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -38,11 +38,7 @@ export function useKeyboardShortcuts({ toggleFileExplorer?: () => void; }) { const router = useRouter(); - const routerPathname = usePathname(); - const pathname = - Platform.OS === "web" && typeof window !== "undefined" - ? window.location.pathname - : routerPathname; + const pathname = usePathname(); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); useEffect(() => { @@ -327,7 +323,6 @@ export function useKeyboardShortcuts({ pathname, resetModifiers, router, - routerPathname, selectedAgentId, toggleAgentList, toggleFileExplorer, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index dfc09152b..81c6420f0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -576,8 +576,18 @@ function WorkspaceScreenContent({ tabs: uiTabs, tabOrder, focusedTabId, + preferredTarget: + openIntent?.kind === "agent" + ? { kind: "agent", agentId: openIntent.agentId } + : openIntent?.kind === "terminal" + ? { kind: "terminal", terminalId: openIntent.terminalId } + : openIntent?.kind === "draft" + ? { kind: "draft", draftId: openIntent.draftId } + : openIntent?.kind === "file" + ? { kind: "file", path: openIntent.path } + : null, }), - [focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents] + [focusedTabId, openIntent, tabOrder, terminals, uiTabs, workspaceAgents] ); const activeTabId = tabModel.activeTabId; diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index 6502522bc..19b64365e 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -151,6 +151,23 @@ describe("deriveWorkspaceTabModel", () => { ).toBe("agent_agent-b"); }); + it("prefers the route-selected target over stale focused tab state", () => { + const model = deriveWorkspaceTabModel({ + workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], + terminals: [], + tabs: [ + { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, + ], + tabOrder: ["agent_agent-a", "agent_agent-b"], + focusedTabId: "agent_agent-a", + preferredTarget: { kind: "agent", agentId: "agent-b" }, + }); + + expect(model.activeTabId).toBe("agent_agent-b"); + expect(model.activeTab?.target).toEqual({ kind: "agent", agentId: "agent-b" }); + }); + it("re-resolves active content for a new workspace when prior focused tab is not available", () => { const model = deriveWorkspaceTabModel({ workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })], @@ -237,4 +254,24 @@ describe("deriveWorkspaceTabModel", () => { expect(upgradedDescriptor.label).toBe("Ready title"); } }); + + it("prefers a retargeted tab when the route selects its upgraded agent target", () => { + const model = deriveWorkspaceTabModel({ + workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })], + terminals: [], + tabs: [ + { + tabId: "draft_abc", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 1, + }, + ], + tabOrder: ["draft_abc"], + focusedTabId: "some_other_tab", + preferredTarget: { kind: "agent", agentId: "agent-1" }, + }); + + expect(model.activeTabId).toBe("draft_abc"); + expect(model.activeTab?.descriptor.tabId).toBe("draft_abc"); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index 0299a8085..bf011b010 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -26,6 +26,25 @@ function trimNonEmpty(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "draft" && right.kind === "draft") { + return left.draftId === right.draftId; + } + if (left.kind === "agent" && right.kind === "agent") { + return left.agentId === right.agentId; + } + if (left.kind === "terminal" && right.kind === "terminal") { + return left.terminalId === right.terminalId; + } + if (left.kind === "file" && right.kind === "file") { + return left.path === right.path; + } + return false; +} + function formatProviderLabel(provider: Agent["provider"]): string { if (provider === "claude") { return "Claude"; @@ -130,6 +149,7 @@ export function deriveWorkspaceTabModel(input: { tabs: WorkspaceTab[]; tabOrder: string[]; focusedTabId?: string | null; + preferredTarget?: WorkspaceTabTarget | null; }): WorkspaceTabModel { const tabsById = new Map(); const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent])); @@ -234,9 +254,20 @@ export function deriveWorkspaceTabModel(input: { const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId)); const focusedTabId = trimNonEmpty(input.focusedTabId); + const preferredTarget = input.preferredTarget ?? null; + const preferredTabId = (() => { + if (!preferredTarget) { + return null; + } + const matchingTab = + tabs.find((tab) => tabTargetsEqual(tab.target, preferredTarget)) ?? null; + return matchingTab?.descriptor.tabId ?? buildWorkspaceTabId(preferredTarget); + })(); const activeTabId = - focusedTabId && openTabIds.has(focusedTabId) + preferredTabId && openTabIds.has(preferredTabId) + ? preferredTabId + : focusedTabId && openTabIds.has(focusedTabId) ? focusedTabId : tabs[0]?.descriptor.tabId ?? null;