diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 38bd31761..d9c0647d8 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -271,6 +271,48 @@ test.describe("Workspace navigation regression", () => { await expect(page).toHaveURL(/\/settings\/general$/); }); + test("cold workspace URL keeps sidebar workspace navigation functional", 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 workspaceIds = new Set(); + const firstRepo = await createTempGitRepo("workspace-cold-url-a-"); + const secondRepo = await createTempGitRepo("workspace-cold-url-b-"); + + try { + const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path); + const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path); + workspaceIds.add(firstWorkspace.workspaceId); + workspaceIds.add(secondWorkspace.workspaceId); + + await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId)); + await waitForSidebarHydration(page); + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), { + timeout: 30_000, + }); + + const secondRow = page.getByTestId( + `sidebar-workspace-row-${serverId}:${secondWorkspace.workspaceId}`, + ); + await expect(secondRow).toBeVisible({ timeout: 30_000 }); + await secondRow.click(); + + await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { + timeout: 30_000, + }); + } finally { + for (const workspaceId of workspaceIds) { + await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined); + } + await workspaceClient.close().catch(() => undefined); + await secondRepo.cleanup(); + await firstRepo.cleanup(); + } + }); + test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({ page, }) => { diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx deleted file mode 100644 index 65b797c20..000000000 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useNavigation } from "@react-navigation/native"; -import { StyleSheet, View } from "react-native"; -import { - useGlobalSearchParams, - useLocalSearchParams, - useRouter, - useRootNavigationState, -} from "expo-router"; -import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; -import { - type ActiveWorkspaceSelection, - useActiveWorkspaceSelection, -} 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, - parseWorkspaceOpenIntent, - type WorkspaceOpenIntent, -} from "@/utils/host-routes"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; -import { isWeb } from "@/constants/platform"; - -function getParamValue(value: string | string[] | undefined): string { - if (typeof value === "string") { - return value.trim(); - } - if (Array.isArray(value)) { - const firstValue = value[0]; - return typeof firstValue === "string" ? firstValue.trim() : ""; - } - return ""; -} - -function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget { - if (openIntent.kind === "agent") { - return { kind: "agent", agentId: openIntent.agentId }; - } - if (openIntent.kind === "terminal") { - return { kind: "terminal", terminalId: openIntent.terminalId }; - } - if (openIntent.kind === "file") { - return { kind: "file", path: openIntent.path }; - } - if (openIntent.kind === "setup") { - return { kind: "setup", workspaceId: openIntent.workspaceId }; - } - 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: (params: { open?: string | undefined }) => void }; - router: ReturnType; - serverId: string; - workspaceId: string; -}) { - input.navigation.setParams({ open: undefined }); - if (isWeb) { - input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId)); - stripOpenSearchParamFromBrowserUrl(); - } -} - -export default function HostWorkspaceLayout() { - return ( - - - - ); -} - -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<{ - serverId?: string | string[]; - workspaceId?: string | string[]; - }>(); - const globalParams = useGlobalSearchParams<{ - open?: string | string[]; - }>(); - const serverId = getParamValue(params.serverId); - const workspaceValue = getParamValue(params.workspaceId); - const workspaceId = workspaceValue - ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") - : ""; - const openValue = getParamValue(globalParams.open); - useEffect(() => { - if (!openValue) { - return; - } - if (!rootNavigationState?.key) { - return; - } - if (!hasHydratedWorkspaceLayoutStore) { - return; - } - - const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; - if (consumedIntentRef.current === consumptionKey) { - clearConsumedOpenIntent({ - navigation: navigation as unknown as { - setParams: (params: { open?: string | undefined }) => void; - }, - router, - serverId, - workspaceId, - }); - setIntentConsumed(true); - return; - } - consumedIntentRef.current = consumptionKey; - - const openIntent = parseWorkspaceOpenIntent(openValue); - if (openIntent) { - prepareWorkspaceTab({ - serverId, - workspaceId, - target: getOpenIntentTarget(openIntent), - pin: openIntent.kind === "agent", - }); - } - - // 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. - clearConsumedOpenIntent({ - navigation: navigation as unknown as { - setParams: (params: { open?: string | undefined }) => void; - }, - router, - serverId, - workspaceId, - }); - - setIntentConsumed(true); - }, [ - hasHydratedWorkspaceLayoutStore, - navigation, - openValue, - rootNavigationState?.key, - router, - serverId, - workspaceId, - ]); - - if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) { - return null; - } - - return ; -} - -function areWorkspaceSelectionsEqual( - left: ActiveWorkspaceSelection | null, - right: ActiveWorkspaceSelection | null, -): boolean { - return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId; -} - -function WorkspaceDeck() { - const activeSelection = useActiveWorkspaceSelection(); - const [mountedSelections, setMountedSelections] = useState(() => - activeSelection ? [activeSelection] : [], - ); - - useEffect(() => { - if (!activeSelection) { - return; - } - setMountedSelections((current) => { - if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) { - return current; - } - return [...current, activeSelection]; - }); - }, [activeSelection]); - - if (!activeSelection) { - return null; - } - - return ( - - {mountedSelections.map((selection) => { - const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); - return ( - - - - ); - })} - - ); -} - -const styles = StyleSheet.create({ - deck: { - flex: 1, - }, - activeDeckEntry: { - flex: 1, - }, - inactiveDeckEntry: { - display: "none", - flex: 1, - }, -}); diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx index bb863288a..d05d95ee3 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/index.tsx @@ -1,3 +1,217 @@ -export default function HostWorkspaceIndexRoute() { - return null; +import { useEffect, useRef, useState } from "react"; +import { useNavigation } from "@react-navigation/native"; +import { StyleSheet, View } from "react-native"; +import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router"; +import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { + type ActiveWorkspaceSelection, + useActiveWorkspaceSelection, +} 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 { + decodeWorkspaceIdFromPathSegment, + parseWorkspaceOpenIntent, + type WorkspaceOpenIntent, +} from "@/utils/host-routes"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { isWeb } from "@/constants/platform"; + +function getParamValue(value: string | string[] | undefined): string { + if (typeof value === "string") { + return value.trim(); + } + if (Array.isArray(value)) { + const firstValue = value[0]; + return typeof firstValue === "string" ? firstValue.trim() : ""; + } + return ""; } + +function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget { + if (openIntent.kind === "agent") { + return { kind: "agent", agentId: openIntent.agentId }; + } + if (openIntent.kind === "terminal") { + return { kind: "terminal", terminalId: openIntent.terminalId }; + } + if (openIntent.kind === "file") { + return { kind: "file", path: openIntent.path }; + } + if (openIntent.kind === "setup") { + return { kind: "setup", workspaceId: openIntent.workspaceId }; + } + 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: (params: { open?: string | undefined }) => void }; +}) { + input.navigation.setParams({ open: undefined }); + if (isWeb) { + stripOpenSearchParamFromBrowserUrl(); + } +} + +export default function HostWorkspaceIndexRoute() { + return ( + + + + ); +} + +function HostWorkspaceRouteContent() { + const navigation = useNavigation(); + const rootNavigationState = useRootNavigationState(); + const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); + const consumedIntentRef = useRef(null); + const [intentConsumed, setIntentConsumed] = useState(false); + const params = useLocalSearchParams<{ + serverId?: string | string[]; + workspaceId?: string | string[]; + }>(); + const globalParams = useGlobalSearchParams<{ + open?: string | string[]; + }>(); + const serverId = getParamValue(params.serverId); + const workspaceValue = getParamValue(params.workspaceId); + const workspaceId = workspaceValue + ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") + : ""; + const openValue = getParamValue(globalParams.open); + useEffect(() => { + if (!openValue) { + return; + } + if (!rootNavigationState?.key) { + return; + } + if (!hasHydratedWorkspaceLayoutStore) { + return; + } + + const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; + if (consumedIntentRef.current === consumptionKey) { + clearConsumedOpenIntent({ + navigation: navigation as unknown as { + setParams: (params: { open?: string | undefined }) => void; + }, + }); + setIntentConsumed(true); + return; + } + consumedIntentRef.current = consumptionKey; + + const openIntent = parseWorkspaceOpenIntent(openValue); + if (openIntent) { + prepareWorkspaceTab({ + serverId, + workspaceId, + target: getOpenIntentTarget(openIntent), + pin: openIntent.kind === "agent", + }); + } + + // 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. + clearConsumedOpenIntent({ + navigation: navigation as unknown as { + setParams: (params: { open?: string | undefined }) => void; + }, + }); + + setIntentConsumed(true); + }, [ + hasHydratedWorkspaceLayoutStore, + navigation, + openValue, + rootNavigationState?.key, + serverId, + workspaceId, + ]); + + if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) { + return null; + } + + return ; +} + +function areWorkspaceSelectionsEqual( + left: ActiveWorkspaceSelection | null, + right: ActiveWorkspaceSelection | null, +): boolean { + return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId; +} + +function WorkspaceDeck() { + const activeSelection = useActiveWorkspaceSelection(); + const [mountedSelections, setMountedSelections] = useState(() => + activeSelection ? [activeSelection] : [], + ); + + useEffect(() => { + if (!activeSelection) { + return; + } + setMountedSelections((current) => { + if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) { + return current; + } + return [...current, activeSelection]; + }); + }, [activeSelection]); + + if (!activeSelection) { + return null; + } + + return ( + + {mountedSelections.map((selection) => { + const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); + return ( + + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + deck: { + flex: 1, + }, + activeDeckEntry: { + flex: 1, + }, + inactiveDeckEntry: { + display: "none", + flex: 1, + }, +}); diff --git a/packages/app/src/app/host-runtime-bootstrap.test.ts b/packages/app/src/app/host-runtime-bootstrap.test.ts index e3968ef37..fba5b504e 100644 --- a/packages/app/src/app/host-runtime-bootstrap.test.ts +++ b/packages/app/src/app/host-runtime-bootstrap.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveStartupRedirectRoute, + resolveStartupWorkspaceSelection, startHostRuntimeBootstrap, WELCOME_ROUTE, } from "./host-runtime-bootstrap"; @@ -150,14 +151,24 @@ describe("resolveStartupRedirectRoute", () => { }); describe("scenario: saved-host-online", () => { - it("redirects to the workspace route when the online host matches the persisted workspace", () => { + it("leaves matching persisted workspace navigation to the workspace navigator", () => { const route = resolveStartupRedirectRoute({ ...baseInput, anyOnlineHostServerId: "server-1", workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, }); - expect(route).toBe("/h/server-1/workspace/workspace-a"); + expect(route).toBeNull(); + }); + + it("resolves the persisted workspace when the online host matches it", () => { + const selection = resolveStartupWorkspaceSelection({ + ...baseInput, + anyOnlineHostServerId: "server-1", + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + }); + + expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" }); }); it("redirects to the host root when the persisted workspace targets a different server", () => { @@ -192,14 +203,14 @@ describe("resolveStartupRedirectRoute", () => { }); describe("scenario: both-succeed", () => { - it("redirects to the host that the host runtime selects as earliest online", () => { + it("leaves matching persisted workspace navigation to the workspace navigator", () => { const route = resolveStartupRedirectRoute({ ...baseInput, anyOnlineHostServerId: "server-saved", workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" }, }); - expect(route).toBe("/h/server-saved/workspace/workspace-a"); + expect(route).toBeNull(); }); }); diff --git a/packages/app/src/app/host-runtime-bootstrap.ts b/packages/app/src/app/host-runtime-bootstrap.ts index b75cd01fe..45c821f6e 100644 --- a/packages/app/src/app/host-runtime-bootstrap.ts +++ b/packages/app/src/app/host-runtime-bootstrap.ts @@ -1,7 +1,7 @@ import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import type { DaemonStartResult } from "@/runtime/daemon-start-service"; import type { Href } from "expo-router"; -import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { buildHostRootRoute } from "@/utils/host-routes"; export interface HostRuntimeBootstrapStore { boot: () => void; @@ -66,8 +66,31 @@ export interface ResolveStartupRedirectInput { hasGivenUpWaitingForHost: boolean; } +function isIndexPathname(pathname: string) { + return pathname === "/" || pathname === ""; +} + +export function resolveStartupWorkspaceSelection( + input: ResolveStartupRedirectInput, +): ActiveWorkspaceSelection | null { + if (!isIndexPathname(input.pathname)) { + return null; + } + if (!input.isWorkspaceSelectionLoaded) { + return null; + } + if ( + !input.anyOnlineHostServerId || + !input.workspaceSelection || + input.workspaceSelection.serverId !== input.anyOnlineHostServerId + ) { + return null; + } + return input.workspaceSelection; +} + export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null { - if (input.pathname !== "/" && input.pathname !== "") { + if (!isIndexPathname(input.pathname)) { return null; } if (!input.isWorkspaceSelectionLoaded) { @@ -75,14 +98,8 @@ export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): } if (input.anyOnlineHostServerId) { - if ( - input.workspaceSelection && - input.workspaceSelection.serverId === input.anyOnlineHostServerId - ) { - return buildHostWorkspaceRoute( - input.workspaceSelection.serverId, - input.workspaceSelection.workspaceId, - ); + if (resolveStartupWorkspaceSelection(input)) { + return null; } return buildHostRootRoute(input.anyOnlineHostServerId); } diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index d9af23398..08ecb638c 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -2,8 +2,14 @@ import React from "react"; import { Redirect, usePathname } from "expo-router"; import { StartupSplashScreen } from "@/screens/startup-splash-screen"; import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout"; -import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap"; -import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { + resolveStartupRedirectRoute, + resolveStartupWorkspaceSelection, +} from "@/app/host-runtime-bootstrap"; +import { + navigateToWorkspace, + useActiveWorkspaceSelection, +} from "@/stores/navigation-active-workspace-store"; import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; const isDesktop = shouldUseDesktopDaemon(); @@ -21,6 +27,26 @@ export default function Index() { isWorkspaceSelectionLoaded: true, hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, }); + const startupWorkspaceSelection = resolveStartupWorkspaceSelection({ + pathname, + anyOnlineHostServerId, + workspaceSelection, + isWorkspaceSelectionLoaded: true, + hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost, + }); + + React.useEffect(() => { + if (!startupWorkspaceSelection) { + return; + } + navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, { + currentPathname: pathname, + }); + }, [pathname, startupWorkspaceSelection]); + + if (startupWorkspaceSelection) { + return ; + } if (redirectRoute) { return ; diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 2b9522b5b..bdcaf8c58 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -9,7 +9,6 @@ import { type ReactNode, type RefObject, } from "react"; -import { useRouter } from "expo-router"; import { DiffStat } from "@/components/diff-stat"; import { View, @@ -87,7 +86,8 @@ import { GitHubIcon } from "@/components/icons/github-icon"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; -import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; +import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; +import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { openExternalUrl } from "@/utils/open-external-url"; import { GitActionsSplitButton } from "@/components/git-actions-split-button"; import { usePanelStore } from "@/stores/panel-store"; @@ -1874,7 +1874,6 @@ export function GitDiffPane({ const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = isWeb && !isMobile; const canUseSplitLayout = isWeb && !isMobile; - const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); @@ -2360,9 +2359,9 @@ export function GitDiffPane({ const handleArchiveSuccess = useCallback( (targetWorkingDir: string) => { - router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); + navigateToWorkspace(serverId, targetWorkingDir); }, - [router, serverId], + [serverId], ); const toastError = useCallback( diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 7ae1a6d7f..58ada44f7 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -1,5 +1,4 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react"; -import { useRouter } from "expo-router"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { type CheckoutGitActionStatus, @@ -14,9 +13,10 @@ import { useCheckoutPrStatusQuery, } from "@/hooks/use-checkout-pr-status-query"; import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; -import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; +import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; +import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; @@ -225,7 +225,6 @@ interface UseGitActionsResult { } export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult { - const router = useRouter(); const toast = useToast(); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); @@ -432,13 +431,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { - router.replace(buildNewAgentRoute(serverId, targetWorkingDir)); + navigateToWorkspace(serverId, targetWorkingDir); return; }) .catch((err) => { toastActionError(err, "Failed to archive worktree"); }); - }, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]); + }, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]); const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]); const derived = deriveGitActionsState({ diff --git a/packages/app/src/stores/navigation-active-workspace-store.ts b/packages/app/src/stores/navigation-active-workspace-store.ts index 867a81ee3..c6f51cd21 100644 --- a/packages/app/src/stores/navigation-active-workspace-store.ts +++ b/packages/app/src/stores/navigation-active-workspace-store.ts @@ -46,7 +46,8 @@ export function navigateToWorkspace( _options: NavigateToWorkspaceOptions = {}, ) { lastWorkspaceSelection = { serverId, workspaceId }; - router.dismissTo(buildHostWorkspaceRoute(serverId, workspaceId) as Href); + const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href; + router.dismissTo(route); } export function navigateToLastWorkspace(): boolean { diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index 998beacd0..409318f60 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -2,25 +2,11 @@ import { describe, expect, it } from "vitest"; import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import { - buildNewAgentRoute, parseAgentKey, resolveNewAgentWorkingDir, resolveSelectedAgentForNewAgent, } from "./new-agent-routing"; -describe("buildNewAgentRoute", () => { - it("falls back to server workspace route with dot workspace when no working directory is provided", () => { - expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/workspace/."); - expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/workspace/."); - }); - - it("encodes the working directory as a workspace path segment", () => { - expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe( - "/h/srv-1/workspace/b64_L1VzZXJzL21lL2Rldi9wYXNlbw", - ); - }); -}); - describe("resolveNewAgentWorkingDir", () => { it("returns the current cwd for regular checkouts", () => { expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path"); diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts index cd62ed8e0..3785b1e4e 100644 --- a/packages/app/src/utils/new-agent-routing.ts +++ b/packages/app/src/utils/new-agent-routing.ts @@ -1,7 +1,6 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import { parseHostWorkspaceOpenIntentFromPathname, - buildHostWorkspaceRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; @@ -68,8 +67,3 @@ export function resolveNewAgentWorkingDir( return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd; } - -export function buildNewAgentRoute(serverId: string, workingDir?: string | null) { - const trimmedWorkingDir = workingDir?.trim(); - return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || "."); -} diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index 33859b5fc..7ecbbc716 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -45,14 +45,14 @@ describe("resolveNotificationTarget", () => { }); describe("buildNotificationRoute", () => { - it("routes to workspace with one-shot open intent when workspace id is present", () => { + it("routes to the agent path when workspace id is present", () => { expect( buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1", workspaceId: "ws-main", }), - ).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1"); + ).toBe("/h/srv-1/agent/agent-1"); }); it("routes directly to server-scoped agent path when both ids are present", () => { diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index ff5c90e02..ac69cef68 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,9 +1,5 @@ import type { Href } from "expo-router"; -import { - buildHostAgentDetailRoute, - buildHostRootRoute, - buildHostWorkspaceRoute, -} from "@/utils/host-routes"; +import { buildHostAgentDetailRoute, buildHostRootRoute } from "@/utils/host-routes"; type NotificationData = Record | null | undefined; type NotificationRoute = Extract; @@ -30,12 +26,8 @@ export function resolveNotificationTarget(data: NotificationData): { } export function buildNotificationRoute(data: NotificationData): NotificationRoute { - const { serverId, agentId, workspaceId } = resolveNotificationTarget(data); + const { serverId, agentId } = resolveNotificationTarget(data); if (serverId && agentId) { - if (workspaceId) { - const base = buildHostWorkspaceRoute(serverId, workspaceId); - return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}` as const; - } return buildHostAgentDetailRoute(serverId, agentId); } if (serverId) {