Keep composer autocomplete visible after route hops (#1851)

* fix(app): keep composer autocomplete visible after route hops

Workspace callers now express only the target workspace. The navigation helper chooses whether to pop to a mounted host route or fall back, and active-workspace tracking ignores stale hidden route params while app-wide routes are foregrounded.

* test(app): address workspace navigation review

* fix(app): parse decoded legacy workspace routes

* fix(app): ignore decoded legacy tab routes

* test(app): use canonical offline workspace route
This commit is contained in:
Mohamed Boudra
2026-07-01 21:55:17 +02:00
committed by GitHub
parent 48d07dedb5
commit d3e8f77914
19 changed files with 196 additions and 103 deletions

View File

@@ -47,18 +47,27 @@ dynamic params exist before any nested workspace leaf is selected.
## App-Wide Route Hops ## App-Wide Route Hops
When app-wide routes such as `/new` navigate back into a host workspace, use When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the into a host workspace, express only the destination with `navigateToWorkspace()`.
leaf workspace URL. Do not make the caller branch on its current route.
The root stack owns `h/[serverId]`; the host stack owns The root stack owns `h/[serverId]`; the host stack owns
`workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the `workspace/[workspaceId]/index`. Repeated global-route hops must `POP_TO` the
root host route and pass the nested workspace screen, or Expo Router can append root host route and pass the nested workspace screen when a host route is
extra hidden workspace deck entries. already mounted, or Expo Router can append extra hidden workspace deck entries.
The workspace navigation helper inspects the mounted navigation state to make
that decision; if no host route is mounted yet, it falls back to ordinary route
navigation.
Those hidden entries are not harmless: composer floating panels can measure Those hidden entries are not harmless: composer floating panels can measure
against the wrong deck and disappear offscreen. against the wrong deck and disappear offscreen.
Hidden host routes may keep their local params while an app-wide route is
foregrounded. Active-workspace observers must prefer the current pathname and
only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
## Params ## Params
Required dynamic params belong to the matched route. Required dynamic params belong to the matched route.
@@ -112,8 +121,7 @@ Before landing route changes:
- [ ] Did you change `packages/app/src/app`? Re-read this file. - [ ] Did you change `packages/app/src/app`? Re-read this file.
- [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`. - [ ] Did you touch remembered workspace restore? Keep root on `/h/[serverId]`.
- [ ] Did an app-wide route return to a workspace? Use - [ ] Did any route return to a workspace? Use `navigateToWorkspace()`.
`navigateToHostWorkspaceRoute()`.
- [ ] Did you add a route? Register it in the layout that directly owns it. - [ ] Did you add a route? Register it in the layout that directly owns it.
- [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree. - [ ] Did `useLocalSearchParams()` lose a required param? Fix the route tree.
- [ ] Did native show a blank screen without a crash? Suspect route ownership - [ ] Did native show a blank screen without a crash? Suspect route ownership

View File

@@ -5,7 +5,7 @@ import {
seedMockAgentWorkspace, seedMockAgentWorkspace,
type MockAgentWorkspace, type MockAgentWorkspace,
} from "./helpers/mock-agent"; } from "./helpers/mock-agent";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab"; import { expectWorkspaceTabVisible, openSessions } from "./helpers/archive-tab";
import { daemonWsRoutePattern } from "./helpers/daemon-port"; import { daemonWsRoutePattern } from "./helpers/daemon-port";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
@@ -153,6 +153,13 @@ async function openAppWideNewWorkspace(page: Page): Promise<void> {
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 }); await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
} }
async function openSettingsThenBackToWorkspace(page: Page): Promise<void> {
await page.getByTestId("sidebar-settings").filter({ visible: true }).first().click();
await expect(page).toHaveURL(/\/settings\/general$/, { timeout: 30_000 });
await page.getByTestId("settings-back-to-workspace").click();
await page.waitForURL((url) => url.pathname.includes("/workspace/"), { timeout: 30_000 });
}
async function expectSingleCurrentWorkspaceDeckEntry( async function expectSingleCurrentWorkspaceDeckEntry(
page: Page, page: Page,
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string }, input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
@@ -351,7 +358,7 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
} }
test.describe("Composer autocomplete", () => { test.describe("Composer autocomplete", () => {
test("stays visible after returning from the app-wide new workspace route", async ({ page }) => { test("stays visible after returning from app-wide routes", async ({ page }) => {
await installListCommandsStub(page); await installListCommandsStub(page);
const serverId = getServerId(); const serverId = getServerId();
const sessions: MockAgentWorkspace[] = []; const sessions: MockAgentWorkspace[] = [];
@@ -384,10 +391,28 @@ test.describe("Composer autocomplete", () => {
await openAppWideNewWorkspace(page); await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId }); await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 }); await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openAppWideNewWorkspace(page); await openSettingsThenBackToWorkspace(page);
await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: 2,
serverId,
workspaceId: second.workspaceId,
});
await openSessions(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId }); await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
await expectComposerVisible(page, { timeout: 30_000 }); await expectComposerVisible(page, { timeout: 30_000 });
await expectSingleCurrentWorkspaceDeckEntry(page, {
expectedDeckEntryCount: sessions.length,
serverId,
workspaceId: third.workspaceId,
});
await openAppWideNewWorkspace(page); await openAppWideNewWorkspace(page);
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId }); await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });

View File

@@ -256,9 +256,7 @@ test.describe("Workspace navigation regression", () => {
await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." }); await ws.close({ code: 1008, reason: "Blocked cold offline workspace route test." });
}); });
await page.goto( await page.goto(buildHostWorkspaceRoute(serverId, "/tmp/paseo-missing-workspace"));
`/h/${encodeURIComponent(serverId)}/workspace/${encodeURIComponent("/tmp/paseo-missing-workspace")}`,
);
await expectHostConnectingOrOffline(page); await expectHostConnectingOrOffline(page);
await expectMenuButtonVisible(page); await expectMenuButtonVisible(page);

View File

@@ -134,14 +134,13 @@ const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
function PushNotificationRouter() { function PushNotificationRouter() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const lastHandledIdRef = useRef<string | null>(null); const lastHandledIdRef = useRef<string | null>(null);
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => { const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
const target = resolveNotificationTarget(data); const target = resolveNotificationTarget(data);
const serverId = target.serverId; const serverId = target.serverId;
const agentId = target.agentId; const agentId = target.agentId;
if (serverId && agentId) { if (serverId && agentId) {
navigateToAgent({ serverId, agentId, currentPathname: pathname, pin: true }); navigateToAgent({ serverId, agentId, pin: true });
return; return;
} }

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router"; import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -17,7 +17,6 @@ export default function HostAgentReadyRoute() {
function HostAgentReadyRouteContent() { function HostAgentReadyRouteContent() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ const params = useLocalSearchParams<{
serverId?: string; serverId?: string;
agentId?: string; agentId?: string;
@@ -53,10 +52,9 @@ function HostAgentReadyRouteContent() {
navigateToAgent({ navigateToAgent({
serverId, serverId,
agentId, agentId,
currentPathname: pathname,
}); });
} }
}, [agentId, pathname, resolvedWorkspaceId, router, serverId]); }, [agentId, resolvedWorkspaceId, router, serverId]);
useEffect(() => { useEffect(() => {
if (redirectedRef.current) { if (redirectedRef.current) {
@@ -96,7 +94,6 @@ function HostAgentReadyRouteContent() {
serverId, serverId,
agentId, agentId,
workspaceId, workspaceId,
currentPathname: pathname,
}); });
return; return;
} }
@@ -114,7 +111,7 @@ function HostAgentReadyRouteContent() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [agentId, client, isConnected, pathname, router, serverId]); }, [agentId, client, isConnected, router, serverId]);
return null; return null;
} }

View File

@@ -1749,14 +1749,13 @@ function WorkspaceRowItem({
isDragging = false, isDragging = false,
dragHandleProps, dragHandleProps,
}: WorkspaceRowItemProps) { }: WorkspaceRowItemProps) {
const currentPathname = usePathname();
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
if (!workspace.serverId) { if (!workspace.serverId) {
return; return;
} }
onWorkspacePress?.(); onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname }); navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]); }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
return ( return (
<WorkspaceRow <WorkspaceRow

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useMemo, useState } from "react"; import { memo, useCallback, useMemo, useState } from "react";
import { usePathname } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { NestableScrollContainer } from "react-native-draggable-flatlist";
@@ -334,7 +333,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
}) { }) {
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId); const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection(); const activeWorkspaceSelection = useActiveWorkspaceSelection();
const currentPathname = usePathname();
const selected = const selected =
activeWorkspaceSelection?.serverId === workspace.serverId && activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId; activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
@@ -342,8 +340,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
if (!workspace.serverId) return; if (!workspace.serverId) return;
onWorkspacePress?.(); onWorkspacePress?.();
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname }); navigateToWorkspace(workspace.serverId, workspace.workspaceId);
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]); }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
if (!workspaceEntry) return null; if (!workspaceEntry) return null;

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native"; import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router"; import { router, type Href } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
@@ -136,7 +136,6 @@ function resolveActionShortcutKeys(
export function useCommandCenter() { export function useCommandCenter() {
const { t } = useTranslation(); const { t } = useTranslation();
const pathname = usePathname();
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
@@ -223,10 +222,9 @@ export function useCommandCenter() {
navigateToAgent({ navigateToAgent({
serverId: agent.serverId, serverId: agent.serverId,
agentId: agent.id, agentId: agent.id,
currentPathname: pathname,
}); });
}, },
[pathname, setOpen], [setOpen],
); );
const openProjectPicker = useOpenProjectPicker(); const openProjectPicker = useOpenProjectPicker();

View File

@@ -120,7 +120,7 @@ export function useKeyboardShortcuts({
serverId: action.serverId, serverId: action.serverId,
workspaceId: action.workspaceId, workspaceId: action.workspaceId,
}; };
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname }); navigateToWorkspace(action.serverId, action.workspaceId);
return true; return true;
case "navigate-last-workspace": case "navigate-last-workspace":
return navigateToLastWorkspace(); return navigateToLastWorkspace();

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NavigationContainerRefWithCurrent } from "@react-navigation/native";
import {
navigateToHostWorkspaceRoute,
registerWorkspaceRouteNavigationRef,
} from "./workspace-route-navigation";
function createNavigationRef(rootState: unknown, options: { ready?: boolean } = {}) {
const dispatch = vi.fn();
const navigationRef = {
current: {
isReady: () => options.ready ?? true,
getRootState: () => rootState,
dispatch,
},
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>;
return { navigationRef, dispatch };
}
describe("navigateToHostWorkspaceRoute", () => {
beforeEach(() => {
vi.clearAllMocks();
registerWorkspaceRouteNavigationRef({
current: null,
} as unknown as NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>)();
});
it("falls back to route navigation when no host route is mounted yet", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routeNames: ["index", "settings/[section]", "h/[serverId]"],
routes: [{ key: "settings-general", name: "settings/[section]" }],
});
registerWorkspaceRouteNavigationRef(navigationRef);
const dismissTo = vi.fn();
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
expect(dispatch).not.toHaveBeenCalled();
expect(dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
});
it("pops to the mounted host route and targets the requested workspace", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routeNames: ["index", "settings/[section]", "h/[serverId]"],
routes: [
{
key: "host-server-1",
name: "h/[serverId]",
params: { serverId: "server-1" },
},
{ key: "settings-general", name: "settings/[section]" },
],
});
registerWorkspaceRouteNavigationRef(navigationRef);
const dismissTo = vi.fn();
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a", { dismissTo });
expect(dismissTo).not.toHaveBeenCalled();
expect(dispatch).toHaveBeenCalledWith({
type: "POP_TO",
target: "root-stack",
payload: {
name: "h/[serverId]",
params: {
serverId: "server-1",
screen: "workspace/[workspaceId]/index",
params: {
serverId: "server-1",
workspaceId: "workspace-a",
},
pop: true,
},
},
});
});
});

View File

@@ -8,13 +8,17 @@ import {
const ROOT_HOST_ROUTE_NAME = "h/[serverId]"; const ROOT_HOST_ROUTE_NAME = "h/[serverId]";
const HOST_WORKSPACE_ROUTE_NAME = "workspace/[workspaceId]/index"; const HOST_WORKSPACE_ROUTE_NAME = "workspace/[workspaceId]/index";
interface NavigateToHostWorkspaceRouteDeps {
dismissTo(route: string): void;
}
const defaultNavigateToHostWorkspaceRouteDeps: NavigateToHostWorkspaceRouteDeps = {
dismissTo: (route) => router.dismissTo(route as Href),
};
let rootNavigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null = let rootNavigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null =
null; null;
interface NavigateToHostWorkspaceRouteOptions {
popToExistingHostRoute?: boolean;
}
export function registerWorkspaceRouteNavigationRef( export function registerWorkspaceRouteNavigationRef(
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>, ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
): () => void { ): () => void {
@@ -26,33 +30,38 @@ export function registerWorkspaceRouteNavigationRef(
}; };
} }
function findStackKeyWithRouteName(state: unknown, routeName: string): string | null { function findStackKeyWithMountedRouteName(state: unknown, routeName: string): string | null {
if (!state || typeof state !== "object") { if (!state || typeof state !== "object") {
return null; return null;
} }
const candidate = state as { const candidate = state as {
key?: unknown; key?: unknown;
routeNames?: unknown;
routes?: unknown; routes?: unknown;
}; };
if (
typeof candidate.key === "string" &&
Array.isArray(candidate.routeNames) &&
candidate.routeNames.includes(routeName)
) {
return candidate.key;
}
if (!Array.isArray(candidate.routes)) { if (!Array.isArray(candidate.routes)) {
return null; return null;
} }
if (
typeof candidate.key === "string" &&
candidate.routes.some(
(route) =>
!!route && typeof route === "object" && (route as { name?: unknown }).name === routeName,
)
) {
return candidate.key;
}
for (const route of candidate.routes) { for (const route of candidate.routes) {
if (!route || typeof route !== "object") { if (!route || typeof route !== "object") {
continue; continue;
} }
const childKey = findStackKeyWithRouteName((route as { state?: unknown }).state, routeName); const childKey = findStackKeyWithMountedRouteName(
(route as { state?: unknown }).state,
routeName,
);
if (childKey) { if (childKey) {
return childKey; return childKey;
} }
@@ -69,7 +78,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
} }
const rootState = navigation.getRootState(); const rootState = navigation.getRootState();
const target = findStackKeyWithRouteName(rootState, ROOT_HOST_ROUTE_NAME); const target = findStackKeyWithMountedRouteName(rootState, ROOT_HOST_ROUTE_NAME);
if (!target) { if (!target) {
return false; return false;
} }
@@ -99,11 +108,11 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
export function navigateToHostWorkspaceRoute( export function navigateToHostWorkspaceRoute(
route: string, route: string,
options: NavigateToHostWorkspaceRouteOptions = {}, deps: NavigateToHostWorkspaceRouteDeps = defaultNavigateToHostWorkspaceRouteDeps,
): void { ): void {
if (options.popToExistingHostRoute && dispatchHostWorkspacePopTo(route)) { if (dispatchHostWorkspacePopTo(route)) {
return; return;
} }
router.dismissTo(route as Href); deps.dismissTo(route);
} }

View File

@@ -1147,7 +1147,6 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
navigateToPreparedWorkspaceTab({ navigateToPreparedWorkspaceTab({
serverId, serverId,
workspaceId, workspaceId,
currentPathname: "/new",
target: submission.target, target: submission.target,
}); });
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" }); useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
@@ -1994,7 +1993,7 @@ export function NewWorkspaceScreen({
ensureWorkspace, ensureWorkspace,
serverId: selectedServerId, serverId: selectedServerId,
navigate: (targetServerId, workspaceId) => navigate: (targetServerId, workspaceId) =>
navigateToWorkspace(targetServerId, workspaceId, { currentPathname: "/new" }), navigateToWorkspace(targetServerId, workspaceId),
}); });
return; return;
} }

View File

@@ -20,10 +20,6 @@ import { navigateToHostWorkspaceRoute } from "@/navigation/workspace-route-navig
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection"; export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
interface NavigateToWorkspaceOptions {
currentPathname?: string | null;
}
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
@@ -33,13 +29,7 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
lastWorkspaceSelectionStorage, lastWorkspaceSelectionStorage,
); );
function shouldPopToExistingHostRoute(options: NavigateToWorkspaceOptions): boolean { function navigateDeps(): NavigateToWorkspaceDeps {
// Only /new needs POP_TO to avoid hidden deck entries; regular workspace switches
// should use router navigation so route observers like the sidebar selection update.
return options.currentPathname === "/new";
}
function navigateDeps(options: NavigateToWorkspaceOptions): NavigateToWorkspaceDeps {
return { return {
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
getSessionAgents: (serverId) => getSessionAgents: (serverId) =>
@@ -49,9 +39,7 @@ function navigateDeps(options: NavigateToWorkspaceOptions): NavigateToWorkspaceD
}, },
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection), rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
navigateToRoute: (route) => { navigateToRoute: (route) => {
navigateToHostWorkspaceRoute(route, { navigateToHostWorkspaceRoute(route);
popToExistingHostRoute: shouldPopToExistingHostRoute(options),
});
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit(); stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
}, },
}; };
@@ -69,17 +57,13 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
return lastWorkspaceSelectionStore.isHydrated(); return lastWorkspaceSelectionStore.isHydrated();
} }
export function navigateToWorkspace( export function navigateToWorkspace(serverId: string, workspaceId: string) {
serverId: string, navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
workspaceId: string,
options: NavigateToWorkspaceOptions = {},
) {
navigateToWorkspacePure(serverId, workspaceId, navigateDeps(options));
} }
export function navigateToLastWorkspace(): boolean { export function navigateToLastWorkspace(): boolean {
return navigateToLastWorkspacePure({ return navigateToLastWorkspacePure({
...navigateDeps({}), ...navigateDeps(),
getLastWorkspaceSelection: () => lastWorkspaceSelectionStore.getSelection(), getLastWorkspaceSelection: () => lastWorkspaceSelectionStore.getSelection(),
}); });
} }

View File

@@ -115,6 +115,18 @@ describe("workspace navigation", () => {
}); });
}); });
it("ignores stale workspace route params while an app-wide route is active", () => {
const selection = parseActiveWorkspaceSelection({
pathname: "/settings/general",
params: {
serverId: "server-1",
workspaceId: "workspace-a",
},
});
expect(selection).toBeNull();
});
it("navigates to the last workspace once a route observation has been remembered", () => { it("navigates to the last workspace once a route observation has been remembered", () => {
const { deps, navigations } = createLastSelectionDeps(null); const { deps, navigations } = createLastSelectionDeps(null);

View File

@@ -58,10 +58,16 @@ function parseWorkspaceSelectionFromRouteParams(params: {
export function parseActiveWorkspaceSelection( export function parseActiveWorkspaceSelection(
input: RouteSelectionInput, input: RouteSelectionInput,
): ActiveWorkspaceSelection | null { ): ActiveWorkspaceSelection | null {
return ( const routeSelection = parseHostWorkspaceRouteFromPathname(input.pathname);
parseHostWorkspaceRouteFromPathname(input.pathname) ?? if (routeSelection) {
parseWorkspaceSelectionFromRouteParams(input.params) return routeSelection;
); }
if (input.pathname !== "/" && input.pathname !== "") {
return null;
}
return parseWorkspaceSelectionFromRouteParams(input.params);
} }
export function navigateToWorkspace( export function navigateToWorkspace(

View File

@@ -69,7 +69,6 @@ describe("resolveNavigateToAgent", () => {
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID }, target: { kind: "agent", agentId: AGENT_ID },
currentPathname: undefined,
pin: true, pin: true,
}, },
]); ]);
@@ -107,7 +106,6 @@ describe("resolveNavigateToAgent", () => {
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: AGENT_ID }, target: { kind: "agent", agentId: AGENT_ID },
currentPathname: undefined,
pin: undefined, pin: undefined,
}, },
]); ]);

View File

@@ -8,7 +8,6 @@ export interface NavigateToAgentInput {
// Used as the workspace target when the agent is not yet in the session store // Used as the workspace target when the agent is not yet in the session store
// (cold deep-links). Otherwise the workspace is read from the store. // (cold deep-links). Otherwise the workspace is read from the store.
workspaceId?: string | null; workspaceId?: string | null;
currentPathname?: string | null;
pin?: boolean; pin?: boolean;
} }
@@ -54,7 +53,6 @@ export function resolveNavigateToAgent(
serverId: input.serverId, serverId: input.serverId,
workspaceId, workspaceId,
target: { kind: "agent", agentId: input.agentId }, target: { kind: "agent", agentId: input.agentId },
currentPathname: input.currentPathname,
pin: input.pin, pin: input.pin,
}); });
} }

View File

@@ -12,9 +12,7 @@ export interface PrepareWorkspaceTabInput {
pin?: boolean; pin?: boolean;
} }
export interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { export type NavigateToPreparedWorkspaceTabInput = PrepareWorkspaceTabInput;
currentPathname?: string | null;
}
export interface PrepareWorkspaceTabDeps { export interface PrepareWorkspaceTabDeps {
openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null; openTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
@@ -22,11 +20,7 @@ export interface PrepareWorkspaceTabDeps {
} }
export interface NavigateToPreparedWorkspaceTabDeps extends PrepareWorkspaceTabDeps { export interface NavigateToPreparedWorkspaceTabDeps extends PrepareWorkspaceTabDeps {
navigateToWorkspace: ( navigateToWorkspace: (serverId: string, workspaceId: string) => void;
serverId: string,
workspaceId: string,
options: { currentPathname?: string | null },
) => void;
} }
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
@@ -61,8 +55,6 @@ export function navigateToPreparedWorkspaceTab(
deps: NavigateToPreparedWorkspaceTabDeps, deps: NavigateToPreparedWorkspaceTabDeps,
): string { ): string {
const route = prepareWorkspaceTab(input, deps); const route = prepareWorkspaceTab(input, deps);
deps.navigateToWorkspace(input.serverId, input.workspaceId, { deps.navigateToWorkspace(input.serverId, input.workspaceId);
currentPathname: input.currentPathname,
});
return route; return route;
} }

View File

@@ -19,7 +19,6 @@ interface RecordedPin {
interface RecordedNavigation { interface RecordedNavigation {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
currentPathname?: string | null;
} }
function createFakeLayout() { function createFakeLayout() {
@@ -42,12 +41,8 @@ function createFakeNavigator() {
const navigations: RecordedNavigation[] = []; const navigations: RecordedNavigation[] = [];
return { return {
navigations, navigations,
navigateToWorkspace: ( navigateToWorkspace: (serverId: string, workspaceId: string) => {
serverId: string, navigations.push({ serverId, workspaceId });
workspaceId: string,
options: { currentPathname?: string | null },
) => {
navigations.push({ serverId, workspaceId, currentPathname: options.currentPathname });
}, },
}; };
} }
@@ -89,8 +84,6 @@ describe("prepareWorkspaceTab", () => {
expect(layout.openedTabs).toEqual([ expect(layout.openedTabs).toEqual([
{ key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } }, { key: "server-1:/repo/worktree", target: { kind: "agent", agentId: AGENT_ID } },
]); ]);
expect(navigator.navigations).toEqual([ expect(navigator.navigations).toEqual([{ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }]);
{ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, currentPathname: undefined },
]);
}); });
}); });