Fix cold workspace URL sidebar navigation

Directly opening a workspace URL created a different React Navigation state shape than entering from /. Expo Router parsed the URL as a workspace layout route with a nested index child whose route.path and params pointed at the original workspace. Later sidebar navigation used router.dismissTo for another workspace. React Navigation's POP_TO reused the existing workspace route by name and updated the parent params, but preserved the nested child state. The result was a hybrid state: parent workspace params for B, child index path for A. Expo Router serialized the focused child path, so the address bar stayed on A and sidebar clicks looked like no-ops.

The fix is to stop using workspace/[workspaceId]/_layout.tsx as the actual screen. That layout did not render a Slot, so it only added an extra navigation level for Expo Router to preserve. Move the workspace UI, bootstrap boundary, open-intent consumption, and retained WorkspaceDeck into the leaf index route, making /h/:serverId/workspace/:workspaceId one real rendered route.

Keep workspace switches on navigateToWorkspace/router.dismissTo. This is not another replace workaround: replace previously created duplicate mounted workspace shells. The route-shape fix removes the stale nested child state that made dismissTo fail after cold deep links.

Also route archive/new-agent and notification flows through the centralized workspace navigation path so they do not bypass the same route contract.

Regression coverage: cold-load workspace A, click workspace B in the sidebar, and assert the URL changes to B. Verified with workspace-navigation-regression.spec.ts, settings-navigation.spec.ts, npm run typecheck, and npm run lint.
This commit is contained in:
Mohamed Boudra
2026-05-06 15:13:53 +07:00
parent 868479c493
commit 5e5fc97798
13 changed files with 342 additions and 296 deletions
@@ -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<string>();
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,
}) => {
@@ -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<typeof useRouter>;
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 (
<HostRouteBootstrapBoundary>
<HostWorkspaceLayoutContent />
</HostRouteBootstrapBoundary>
);
}
function HostWorkspaceLayoutContent() {
const navigation = useNavigation();
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(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 <WorkspaceDeck />;
}
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<ActiveWorkspaceSelection[]>(() =>
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 (
<View style={styles.deck}>
{mountedSelections.map((selection) => {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
return (
<View
key={`${selection.serverId}:${selection.workspaceId}`}
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});
@@ -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 (
<HostRouteBootstrapBoundary>
<HostWorkspaceRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostWorkspaceRouteContent() {
const navigation = useNavigation();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(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 <WorkspaceDeck />;
}
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<ActiveWorkspaceSelection[]>(() =>
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 (
<View style={styles.deck}>
{mountedSelections.map((selection) => {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
return (
<View
key={`${selection.serverId}:${selection.workspaceId}`}
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={isActive}
/>
</View>
);
})}
</View>
);
}
const styles = StyleSheet.create({
deck: {
flex: 1,
},
activeDeckEntry: {
flex: 1,
},
inactiveDeckEntry: {
display: "none",
flex: 1,
},
});
@@ -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();
});
});
+27 -10
View File
@@ -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);
}
+28 -2
View File
@@ -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 <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
if (redirectRoute) {
return <Redirect href={redirectRoute} />;
@@ -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<ReviewDraftMode | null>(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(
+4 -5
View File
@@ -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({
@@ -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 {
@@ -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");
@@ -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 || ".");
}
@@ -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", () => {
+2 -10
View File
@@ -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<string, unknown> | null | undefined;
type NotificationRoute = Extract<Href, string>;
@@ -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) {