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:
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -58,6 +64,7 @@ import {
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
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() {
|
||||
return (
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
|
||||
@@ -1749,13 +1749,14 @@ function WorkspaceRowItem({
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: WorkspaceRowItemProps) {
|
||||
const currentPathname = usePathname();
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) {
|
||||
return;
|
||||
}
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useCallback, useMemo, useState } from "react";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
@@ -333,6 +334,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
}) {
|
||||
const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const currentPathname = usePathname();
|
||||
const selected =
|
||||
activeWorkspaceSelection?.serverId === workspace.serverId &&
|
||||
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
|
||||
@@ -340,8 +342,8 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
const handlePress = useCallback(() => {
|
||||
if (!workspace.serverId) return;
|
||||
onWorkspacePress?.();
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
}, [onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname });
|
||||
}, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]);
|
||||
|
||||
if (!workspaceEntry) return null;
|
||||
|
||||
|
||||
@@ -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({
|
||||
serverId,
|
||||
workspaceId,
|
||||
currentPathname: "/new",
|
||||
target: { kind: "draft", draftId },
|
||||
});
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle: "sent" });
|
||||
@@ -1781,7 +1782,8 @@ export function NewWorkspaceScreen({
|
||||
payload,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
navigate: navigateToWorkspace,
|
||||
navigate: (targetServerId, workspaceId) =>
|
||||
navigateToWorkspace(targetServerId, workspaceId, { currentPathname: "/new" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
createLastWorkspaceSelectionStore,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit } from "@/utils/host-route-browser";
|
||||
import { navigateToHostWorkspaceRoute } from "@/navigation/workspace-route-navigation";
|
||||
|
||||
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
@@ -33,7 +34,13 @@ const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
|
||||
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 {
|
||||
getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces,
|
||||
getSessionAgents: (serverId) =>
|
||||
@@ -43,7 +50,9 @@ function navigateDeps(): NavigateToWorkspaceDeps {
|
||||
},
|
||||
rememberLastWorkspace: (selection) => lastWorkspaceSelectionStore.remember(selection),
|
||||
navigateToRoute: (route) => {
|
||||
router.dismissTo(route as Href);
|
||||
navigateToHostWorkspaceRoute(route, {
|
||||
popToExistingHostRoute: shouldPopToExistingHostRoute(options),
|
||||
});
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit();
|
||||
},
|
||||
};
|
||||
@@ -64,14 +73,14 @@ export function getIsLastWorkspaceSelectionHydrated(): boolean {
|
||||
export function navigateToWorkspace(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
_options: NavigateToWorkspaceOptions = {},
|
||||
options: NavigateToWorkspaceOptions = {},
|
||||
) {
|
||||
navigateToWorkspacePure(serverId, workspaceId, navigateDeps());
|
||||
navigateToWorkspacePure(serverId, workspaceId, navigateDeps(options));
|
||||
}
|
||||
|
||||
export function navigateToLastWorkspace(): boolean {
|
||||
return navigateToLastWorkspacePure({
|
||||
...navigateDeps(),
|
||||
...navigateDeps({}),
|
||||
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", () => {
|
||||
expect(
|
||||
stripHostWorkspaceRouteEchoSearch(
|
||||
|
||||
@@ -326,6 +326,11 @@ export function stripHostWorkspaceRouteEchoSearch(route: string): string {
|
||||
didStrip = true;
|
||||
}
|
||||
|
||||
if (params.get("pop") === "true") {
|
||||
params.delete("pop");
|
||||
didStrip = true;
|
||||
}
|
||||
|
||||
if (!didStrip) {
|
||||
return route;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user