mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Keep slash commands visible after New Workspace (#1760)
* fix(app): keep slash commands visible after New Workspace Repeated returns from the app-wide New Workspace route were using dismissTo with the workspace leaf URL. That updated the root URL without popping the nested host stack, so hidden duplicate workspace deck entries could remain mounted and steal composer popover measurements. Dispatch a root-stack POP_TO into the host workspace route instead, with a Playwright regression for the slash-command popover. * test(app): tighten workspace autocomplete regression Derive the expected deck count from the seeded workspaces and make the duplicate-deck assertion hard. Keep the Expo Router pop hint documented because removing it reproduces the hidden deck entries.
This commit is contained in:
@@ -76,6 +76,15 @@ workspace for that host after the workspace-selection store hydrates unless the
|
|||||||
host's hydrated workspace list proves that workspace is gone; hosts without a
|
host's hydrated workspace list proves that workspace is gone; hosts without a
|
||||||
remembered workspace go to `open-project`.
|
remembered workspace go to `open-project`.
|
||||||
|
|
||||||
|
When app-wide routes such as `/new` navigate back into a host workspace, use
|
||||||
|
`navigateToHostWorkspaceRoute()` instead of calling `router.dismissTo()` with the
|
||||||
|
leaf workspace URL. The root stack owns `h/[serverId]`; the host stack owns
|
||||||
|
`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
|
||||||
|
extra hidden workspace deck entries. Those hidden entries are not harmless:
|
||||||
|
composer floating panels can measure against the wrong deck and disappear
|
||||||
|
offscreen.
|
||||||
|
|
||||||
Keep workspace identity and retention outside native-stack `getId`/
|
Keep workspace identity and retention outside native-stack `getId`/
|
||||||
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
`dangerouslySingular`. Expo Router maps `dangerouslySingular` to React
|
||||||
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
Navigation `getId`, and `getId` has broken Android native-stack/Fabric by
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { expect, test, type Page } from "./fixtures";
|
import { expect, test, type Page } from "./fixtures";
|
||||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
import {
|
||||||
|
openAgentRoute,
|
||||||
|
seedMockAgentWorkspace,
|
||||||
|
type MockAgentWorkspace,
|
||||||
|
} from "./helpers/mock-agent";
|
||||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||||
|
import { getServerId } from "./helpers/server-id";
|
||||||
|
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||||
|
|
||||||
const TEST_COMMANDS = [
|
const TEST_COMMANDS = [
|
||||||
{
|
{
|
||||||
@@ -142,6 +148,43 @@ async function installListCommandsStub(page: Page): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAppWideNewWorkspace(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId("sidebar-global-new-workspace").first().click();
|
||||||
|
await page.waitForURL((url) => url.pathname === "/new", { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSingleCurrentWorkspaceDeckEntry(
|
||||||
|
page: Page,
|
||||||
|
input: { expectedDeckEntryCount: number; serverId: string; workspaceId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const summary = await page
|
||||||
|
.locator('[data-testid^="workspace-deck-entry-"]')
|
||||||
|
.evaluateAll((elements, target) => {
|
||||||
|
const currentTestId = `workspace-deck-entry-${target.serverId}:${target.workspaceId}`;
|
||||||
|
const entries = elements.map((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
testId: element.getAttribute("data-testid"),
|
||||||
|
hasLayout: rect.width > 0 && rect.height > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const currentEntries = entries.filter((entry) => entry.testId === currentTestId);
|
||||||
|
return {
|
||||||
|
totalDeckEntryCount: entries.length,
|
||||||
|
currentWorkspaceEntryCount: currentEntries.length,
|
||||||
|
visibleCurrentWorkspaceEntryCount: currentEntries.filter((entry) => entry.hasLayout).length,
|
||||||
|
hiddenCurrentWorkspaceEntryCount: currentEntries.filter((entry) => !entry.hasLayout).length,
|
||||||
|
};
|
||||||
|
}, input);
|
||||||
|
|
||||||
|
expect(summary).toEqual({
|
||||||
|
totalDeckEntryCount: input.expectedDeckEntryCount,
|
||||||
|
currentWorkspaceEntryCount: 1,
|
||||||
|
visibleCurrentWorkspaceEntryCount: 1,
|
||||||
|
hiddenCurrentWorkspaceEntryCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function openReadyMockAgent(
|
async function openReadyMockAgent(
|
||||||
page: Page,
|
page: Page,
|
||||||
options?: { expectWorkspaceTab?: boolean },
|
options?: { expectWorkspaceTab?: boolean },
|
||||||
@@ -308,6 +351,64 @@ 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 }) => {
|
||||||
|
await installListCommandsStub(page);
|
||||||
|
const serverId = getServerId();
|
||||||
|
const sessions: MockAgentWorkspace[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessions.push(
|
||||||
|
await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "autocomplete-new-route-a-",
|
||||||
|
title: "Autocomplete new route A",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sessions.push(
|
||||||
|
await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "autocomplete-new-route-b-",
|
||||||
|
title: "Autocomplete new route B",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sessions.push(
|
||||||
|
await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "autocomplete-new-route-c-",
|
||||||
|
title: "Autocomplete new route C",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [first, second, third] = sessions;
|
||||||
|
|
||||||
|
await openAgentRoute(page, first);
|
||||||
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
|
|
||||||
|
await openAppWideNewWorkspace(page);
|
||||||
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: second.workspaceId });
|
||||||
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
|
|
||||||
|
await openAppWideNewWorkspace(page);
|
||||||
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: third.workspaceId });
|
||||||
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
|
|
||||||
|
await openAppWideNewWorkspace(page);
|
||||||
|
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: first.workspaceId });
|
||||||
|
await expectComposerVisible(page, { timeout: 30_000 });
|
||||||
|
await expectSingleCurrentWorkspaceDeckEntry(page, {
|
||||||
|
expectedDeckEntryCount: sessions.length,
|
||||||
|
serverId,
|
||||||
|
workspaceId: first.workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await composerLocator(page).fill("/");
|
||||||
|
const popover = page
|
||||||
|
.getByTestId("composer-autocomplete-popover")
|
||||||
|
.filter({ hasText: "/help", visible: true })
|
||||||
|
.first();
|
||||||
|
await expect(popover).toBeInViewport({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled(sessions.map((session) => session.cleanup()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import * as Notifications from "expo-notifications";
|
import * as Notifications from "expo-notifications";
|
||||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
import {
|
||||||
|
Stack,
|
||||||
|
useGlobalSearchParams,
|
||||||
|
useNavigationContainerRef,
|
||||||
|
usePathname,
|
||||||
|
useRouter,
|
||||||
|
} from "expo-router";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -58,6 +64,7 @@ import {
|
|||||||
startHostRuntimeBootstrap,
|
startHostRuntimeBootstrap,
|
||||||
type StartupBlocker,
|
type StartupBlocker,
|
||||||
} from "@/navigation/host-runtime-bootstrap";
|
} from "@/navigation/host-runtime-bootstrap";
|
||||||
|
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||||
@@ -949,12 +956,23 @@ function RootStack() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WorkspaceRouteNavigationBridge() {
|
||||||
|
const navigationRef = useNavigationContainerRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return registerWorkspaceRouteNavigationRef(navigationRef);
|
||||||
|
}, [navigationRef]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
return (
|
return (
|
||||||
<SidebarAnimationProvider>
|
<SidebarAnimationProvider>
|
||||||
<HorizontalScrollProvider>
|
<HorizontalScrollProvider>
|
||||||
<OpenProjectListener />
|
<OpenProjectListener />
|
||||||
<AppWithSidebar>
|
<AppWithSidebar>
|
||||||
|
<WorkspaceRouteNavigationBridge />
|
||||||
<RootStack />
|
<RootStack />
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
|
|||||||
@@ -1749,13 +1749,14 @@ 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);
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -333,6 +334,7 @@ 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;
|
||||||
@@ -340,8 +342,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);
|
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||||
|
|
||||||
if (!workspaceEntry) return null;
|
if (!workspaceEntry) return null;
|
||||||
|
|
||||||
|
|||||||
109
packages/app/src/navigation/workspace-route-navigation.ts
Normal file
109
packages/app/src/navigation/workspace-route-navigation.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react-navigation/native";
|
||||||
|
import { router, type Href } from "expo-router";
|
||||||
|
import {
|
||||||
|
encodeWorkspaceIdForPathSegment,
|
||||||
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
|
const ROOT_HOST_ROUTE_NAME = "h/[serverId]";
|
||||||
|
const HOST_WORKSPACE_ROUTE_NAME = "workspace/[workspaceId]/index";
|
||||||
|
|
||||||
|
let rootNavigationRef: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
interface NavigateToHostWorkspaceRouteOptions {
|
||||||
|
popToExistingHostRoute?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerWorkspaceRouteNavigationRef(
|
||||||
|
ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
|
||||||
|
): () => void {
|
||||||
|
rootNavigationRef = ref;
|
||||||
|
return () => {
|
||||||
|
if (rootNavigationRef === ref) {
|
||||||
|
rootNavigationRef = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStackKeyWithRouteName(state: unknown, routeName: string): string | null {
|
||||||
|
if (!state || typeof state !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = state as {
|
||||||
|
key?: unknown;
|
||||||
|
routeNames?: unknown;
|
||||||
|
routes?: unknown;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof candidate.key === "string" &&
|
||||||
|
Array.isArray(candidate.routeNames) &&
|
||||||
|
candidate.routeNames.includes(routeName)
|
||||||
|
) {
|
||||||
|
return candidate.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(candidate.routes)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const route of candidate.routes) {
|
||||||
|
if (!route || typeof route !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const childKey = findStackKeyWithRouteName((route as { state?: unknown }).state, routeName);
|
||||||
|
if (childKey) {
|
||||||
|
return childKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchHostWorkspacePopTo(route: string): boolean {
|
||||||
|
const selection = parseHostWorkspaceRouteFromPathname(route);
|
||||||
|
const navigation = rootNavigationRef?.current;
|
||||||
|
if (!selection || !navigation?.isReady()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootState = navigation.getRootState();
|
||||||
|
const target = findStackKeyWithRouteName(rootState, ROOT_HOST_ROUTE_NAME);
|
||||||
|
if (!target) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action: NavigationAction = {
|
||||||
|
type: "POP_TO",
|
||||||
|
target,
|
||||||
|
payload: {
|
||||||
|
name: ROOT_HOST_ROUTE_NAME,
|
||||||
|
params: {
|
||||||
|
serverId: selection.serverId,
|
||||||
|
screen: HOST_WORKSPACE_ROUTE_NAME,
|
||||||
|
params: {
|
||||||
|
serverId: selection.serverId,
|
||||||
|
workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId),
|
||||||
|
},
|
||||||
|
// React Navigation consumes this nested hint when resolving the host child screen.
|
||||||
|
// The browser-route canonicalizer strips the resulting ?pop=true URL artifact.
|
||||||
|
// Removing it lets repeated /new -> workspace hops append hidden deck entries.
|
||||||
|
pop: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
navigation.dispatch(action);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigateToHostWorkspaceRoute(
|
||||||
|
route: string,
|
||||||
|
options: NavigateToHostWorkspaceRouteOptions = {},
|
||||||
|
): void {
|
||||||
|
if (options.popToExistingHostRoute && dispatchHostWorkspacePopTo(route)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.dismissTo(route as Href);
|
||||||
|
}
|
||||||
@@ -967,6 +967,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
|||||||
navigateToPreparedWorkspaceTab({
|
navigateToPreparedWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
currentPathname: "/new",
|
||||||
target: { kind: "draft", draftId },
|
target: { kind: "draft", draftId },
|
||||||
});
|
});
|
||||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||||
@@ -1781,7 +1782,8 @@ export function NewWorkspaceScreen({
|
|||||||
payload,
|
payload,
|
||||||
ensureWorkspace,
|
ensureWorkspace,
|
||||||
serverId: selectedServerId,
|
serverId: selectedServerId,
|
||||||
navigate: navigateToWorkspace,
|
navigate: (targetServerId, workspaceId) =>
|
||||||
|
navigateToWorkspace(targetServerId, workspaceId, { currentPathname: "/new" }),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
|
import { useLocalSearchParams, usePathname } from "expo-router";
|
||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useSyncExternalStore } from "react";
|
||||||
import {
|
import {
|
||||||
createLastWorkspaceSelectionStore,
|
createLastWorkspaceSelectionStore,
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit } from "@/utils/host-route-browser";
|
import { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit } from "@/utils/host-route-browser";
|
||||||
|
import { navigateToHostWorkspaceRoute } from "@/navigation/workspace-route-navigation";
|
||||||
|
|
||||||
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||||
|
|
||||||
@@ -33,7 +34,13 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
|
|||||||
lastWorkspaceSelectionStorage,
|
lastWorkspaceSelectionStorage,
|
||||||
);
|
);
|
||||||
|
|
||||||
function navigateDeps(): NavigateToWorkspaceDeps {
|
function shouldPopToExistingHostRoute(options: NavigateToWorkspaceOptions): boolean {
|
||||||
|
// 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) =>
|
||||||
@@ -43,7 +50,9 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
|||||||
},
|
},
|
||||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||||
navigateToRoute: (route) => {
|
navigateToRoute: (route) => {
|
||||||
router.dismissTo(route as Href);
|
navigateToHostWorkspaceRoute(route, {
|
||||||
|
popToExistingHostRoute: shouldPopToExistingHostRoute(options),
|
||||||
|
});
|
||||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -64,14 +73,14 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
|
|||||||
export function navigateToWorkspace(
|
export function navigateToWorkspace(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
_options: NavigateToWorkspaceOptions = {},
|
options: NavigateToWorkspaceOptions = {},
|
||||||
) {
|
) {
|
||||||
navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
|
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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ describe("workspace route parsing", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips the React Navigation nested pop hint from workspace route search params", () => {
|
||||||
|
expect(stripHostWorkspaceRouteEchoSearch("/h/local/workspace/164?pop=true")).toBe(
|
||||||
|
"/h/local/workspace/164",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stripHostWorkspaceRouteEchoSearch("/h/local/workspace/164?pop=true&open=agent%3Aagent-1"),
|
||||||
|
).toBe("/h/local/workspace/164?open=agent%3Aagent-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-navigation pop search params", () => {
|
||||||
|
expect(stripHostWorkspaceRouteEchoSearch("/h/local/workspace/164?pop=false")).toBe(
|
||||||
|
"/h/local/workspace/164?pop=false",
|
||||||
|
);
|
||||||
|
expect(stripHostWorkspaceRouteEchoSearch("/new?pop=true")).toBe("/new?pop=true");
|
||||||
|
});
|
||||||
|
|
||||||
it("strips encoded workspace route echoes", () => {
|
it("strips encoded workspace route echoes", () => {
|
||||||
expect(
|
expect(
|
||||||
stripHostWorkspaceRouteEchoSearch(
|
stripHostWorkspaceRouteEchoSearch(
|
||||||
|
|||||||
@@ -326,6 +326,11 @@ export function stripHostWorkspaceRouteEchoSearch(route: string): string {
|
|||||||
didStrip = true;
|
didStrip = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.get("pop") === "true") {
|
||||||
|
params.delete("pop");
|
||||||
|
didStrip = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!didStrip) {
|
if (!didStrip) {
|
||||||
return route;
|
return route;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user