mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user