Fix cold workspace URL sidebar navigation
Directly opening a workspace URL created a different React Navigation state shape than entering from /. Expo Router parsed the URL as a workspace layout route with a nested index child whose route.path and params pointed at the original workspace. Later sidebar navigation used router.dismissTo for another workspace. React Navigation's POP_TO reused the existing workspace route by name and updated the parent params, but preserved the nested child state. The result was a hybrid state: parent workspace params for B, child index path for A. Expo Router serialized the focused child path, so the address bar stayed on A and sidebar clicks looked like no-ops. The fix is to stop using workspace/[workspaceId]/_layout.tsx as the actual screen. That layout did not render a Slot, so it only added an extra navigation level for Expo Router to preserve. Move the workspace UI, bootstrap boundary, open-intent consumption, and retained WorkspaceDeck into the leaf index route, making /h/:serverId/workspace/:workspaceId one real rendered route. Keep workspace switches on navigateToWorkspace/router.dismissTo. This is not another replace workaround: replace previously created duplicate mounted workspace shells. The route-shape fix removes the stale nested child state that made dismissTo fail after cold deep links. Also route archive/new-agent and notification flows through the centralized workspace navigation path so they do not bypass the same route contract. Regression coverage: cold-load workspace A, click workspace B in the sidebar, and assert the URL changes to B. Verified with workspace-navigation-regression.spec.ts, settings-navigation.spec.ts, npm run typecheck, and npm run lint.
This commit is contained in:
@@ -271,6 +271,48 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("cold workspace URL keeps sidebar workspace navigation functional", async ({ page }) => {
|
||||||
|
const serverId = process.env.E2E_SERVER_ID;
|
||||||
|
if (!serverId) {
|
||||||
|
throw new Error("E2E_SERVER_ID is not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||||
|
const workspaceIds = new Set<string>();
|
||||||
|
const firstRepo = await createTempGitRepo("workspace-cold-url-a-");
|
||||||
|
const secondRepo = await createTempGitRepo("workspace-cold-url-b-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
||||||
|
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
||||||
|
workspaceIds.add(firstWorkspace.workspaceId);
|
||||||
|
workspaceIds.add(secondWorkspace.workspaceId);
|
||||||
|
|
||||||
|
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondRow = page.getByTestId(
|
||||||
|
`sidebar-workspace-row-${serverId}:${secondWorkspace.workspaceId}`,
|
||||||
|
);
|
||||||
|
await expect(secondRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await secondRow.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
for (const workspaceId of workspaceIds) {
|
||||||
|
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await workspaceClient.close().catch(() => undefined);
|
||||||
|
await secondRepo.cleanup();
|
||||||
|
await firstRepo.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useNavigation } from "@react-navigation/native";
|
|
||||||
import { StyleSheet, View } from "react-native";
|
|
||||||
import {
|
|
||||||
useGlobalSearchParams,
|
|
||||||
useLocalSearchParams,
|
|
||||||
useRouter,
|
|
||||||
useRootNavigationState,
|
|
||||||
} from "expo-router";
|
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
|
||||||
import {
|
|
||||||
type ActiveWorkspaceSelection,
|
|
||||||
useActiveWorkspaceSelection,
|
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
|
||||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
|
||||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
|
||||||
import {
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
decodeWorkspaceIdFromPathSegment,
|
|
||||||
parseWorkspaceOpenIntent,
|
|
||||||
type WorkspaceOpenIntent,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined): string {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
const firstValue = value[0];
|
|
||||||
return typeof firstValue === "string" ? firstValue.trim() : "";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
|
||||||
if (openIntent.kind === "agent") {
|
|
||||||
return { kind: "agent", agentId: openIntent.agentId };
|
|
||||||
}
|
|
||||||
if (openIntent.kind === "terminal") {
|
|
||||||
return { kind: "terminal", terminalId: openIntent.terminalId };
|
|
||||||
}
|
|
||||||
if (openIntent.kind === "file") {
|
|
||||||
return { kind: "file", path: openIntent.path };
|
|
||||||
}
|
|
||||||
if (openIntent.kind === "setup") {
|
|
||||||
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
|
||||||
}
|
|
||||||
return { kind: "draft", draftId: openIntent.draftId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripOpenSearchParamFromBrowserUrl() {
|
|
||||||
if (!isWeb || typeof window === "undefined") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
if (!url.searchParams.has("open")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
url.searchParams.delete("open");
|
|
||||||
window.history.replaceState(null, "", url.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearConsumedOpenIntent(input: {
|
|
||||||
navigation: { setParams: (params: { open?: string | undefined }) => void };
|
|
||||||
router: ReturnType<typeof useRouter>;
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}) {
|
|
||||||
input.navigation.setParams({ open: undefined });
|
|
||||||
if (isWeb) {
|
|
||||||
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
|
|
||||||
stripOpenSearchParamFromBrowserUrl();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HostWorkspaceLayout() {
|
|
||||||
return (
|
|
||||||
<HostRouteBootstrapBoundary>
|
|
||||||
<HostWorkspaceLayoutContent />
|
|
||||||
</HostRouteBootstrapBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HostWorkspaceLayoutContent() {
|
|
||||||
const navigation = useNavigation();
|
|
||||||
const router = useRouter();
|
|
||||||
const rootNavigationState = useRootNavigationState();
|
|
||||||
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
|
||||||
const [intentConsumed, setIntentConsumed] = useState(false);
|
|
||||||
const params = useLocalSearchParams<{
|
|
||||||
serverId?: string | string[];
|
|
||||||
workspaceId?: string | string[];
|
|
||||||
}>();
|
|
||||||
const globalParams = useGlobalSearchParams<{
|
|
||||||
open?: string | string[];
|
|
||||||
}>();
|
|
||||||
const serverId = getParamValue(params.serverId);
|
|
||||||
const workspaceValue = getParamValue(params.workspaceId);
|
|
||||||
const workspaceId = workspaceValue
|
|
||||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
|
||||||
: "";
|
|
||||||
const openValue = getParamValue(globalParams.open);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!openValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!rootNavigationState?.key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!hasHydratedWorkspaceLayoutStore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
|
||||||
if (consumedIntentRef.current === consumptionKey) {
|
|
||||||
clearConsumedOpenIntent({
|
|
||||||
navigation: navigation as unknown as {
|
|
||||||
setParams: (params: { open?: string | undefined }) => void;
|
|
||||||
},
|
|
||||||
router,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
setIntentConsumed(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
consumedIntentRef.current = consumptionKey;
|
|
||||||
|
|
||||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
|
||||||
if (openIntent) {
|
|
||||||
prepareWorkspaceTab({
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
target: getOpenIntentTarget(openIntent),
|
|
||||||
pin: openIntent.kind === "agent",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
|
||||||
// skips search params). Strip ?open from the browser URL directly so the
|
|
||||||
// address bar reflects the clean workspace route.
|
|
||||||
clearConsumedOpenIntent({
|
|
||||||
navigation: navigation as unknown as {
|
|
||||||
setParams: (params: { open?: string | undefined }) => void;
|
|
||||||
},
|
|
||||||
router,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
setIntentConsumed(true);
|
|
||||||
}, [
|
|
||||||
hasHydratedWorkspaceLayoutStore,
|
|
||||||
navigation,
|
|
||||||
openValue,
|
|
||||||
rootNavigationState?.key,
|
|
||||||
router,
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <WorkspaceDeck />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function areWorkspaceSelectionsEqual(
|
|
||||||
left: ActiveWorkspaceSelection | null,
|
|
||||||
right: ActiveWorkspaceSelection | null,
|
|
||||||
): boolean {
|
|
||||||
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceDeck() {
|
|
||||||
const activeSelection = useActiveWorkspaceSelection();
|
|
||||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
|
||||||
activeSelection ? [activeSelection] : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeSelection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setMountedSelections((current) => {
|
|
||||||
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return [...current, activeSelection];
|
|
||||||
});
|
|
||||||
}, [activeSelection]);
|
|
||||||
|
|
||||||
if (!activeSelection) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.deck}>
|
|
||||||
{mountedSelections.map((selection) => {
|
|
||||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={`${selection.serverId}:${selection.workspaceId}`}
|
|
||||||
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
|
||||||
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
|
||||||
>
|
|
||||||
<WorkspaceScreen
|
|
||||||
serverId={selection.serverId}
|
|
||||||
workspaceId={selection.workspaceId}
|
|
||||||
isRouteFocused={isActive}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
deck: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
activeDeckEntry: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
inactiveDeckEntry: {
|
|
||||||
display: "none",
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,217 @@
|
|||||||
export default function HostWorkspaceIndexRoute() {
|
import { useEffect, useRef, useState } from "react";
|
||||||
return null;
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { StyleSheet, View } from "react-native";
|
||||||
|
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
|
||||||
|
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||||
|
import {
|
||||||
|
type ActiveWorkspaceSelection,
|
||||||
|
useActiveWorkspaceSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
|
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
|
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||||
|
import {
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
|
parseWorkspaceOpenIntent,
|
||||||
|
type WorkspaceOpenIntent,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
|
||||||
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const firstValue = value[0];
|
||||||
|
return typeof firstValue === "string" ? firstValue.trim() : "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget {
|
||||||
|
if (openIntent.kind === "agent") {
|
||||||
|
return { kind: "agent", agentId: openIntent.agentId };
|
||||||
|
}
|
||||||
|
if (openIntent.kind === "terminal") {
|
||||||
|
return { kind: "terminal", terminalId: openIntent.terminalId };
|
||||||
|
}
|
||||||
|
if (openIntent.kind === "file") {
|
||||||
|
return { kind: "file", path: openIntent.path };
|
||||||
|
}
|
||||||
|
if (openIntent.kind === "setup") {
|
||||||
|
return { kind: "setup", workspaceId: openIntent.workspaceId };
|
||||||
|
}
|
||||||
|
return { kind: "draft", draftId: openIntent.draftId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripOpenSearchParamFromBrowserUrl() {
|
||||||
|
if (!isWeb || typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (!url.searchParams.has("open")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
url.searchParams.delete("open");
|
||||||
|
window.history.replaceState(null, "", url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConsumedOpenIntent(input: {
|
||||||
|
navigation: { setParams: (params: { open?: string | undefined }) => void };
|
||||||
|
}) {
|
||||||
|
input.navigation.setParams({ open: undefined });
|
||||||
|
if (isWeb) {
|
||||||
|
stripOpenSearchParamFromBrowserUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HostWorkspaceIndexRoute() {
|
||||||
|
return (
|
||||||
|
<HostRouteBootstrapBoundary>
|
||||||
|
<HostWorkspaceRouteContent />
|
||||||
|
</HostRouteBootstrapBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HostWorkspaceRouteContent() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const rootNavigationState = useRootNavigationState();
|
||||||
|
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
|
||||||
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
|
const [intentConsumed, setIntentConsumed] = useState(false);
|
||||||
|
const params = useLocalSearchParams<{
|
||||||
|
serverId?: string | string[];
|
||||||
|
workspaceId?: string | string[];
|
||||||
|
}>();
|
||||||
|
const globalParams = useGlobalSearchParams<{
|
||||||
|
open?: string | string[];
|
||||||
|
}>();
|
||||||
|
const serverId = getParamValue(params.serverId);
|
||||||
|
const workspaceValue = getParamValue(params.workspaceId);
|
||||||
|
const workspaceId = workspaceValue
|
||||||
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||||
|
: "";
|
||||||
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rootNavigationState?.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasHydratedWorkspaceLayoutStore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||||
|
if (consumedIntentRef.current === consumptionKey) {
|
||||||
|
clearConsumedOpenIntent({
|
||||||
|
navigation: navigation as unknown as {
|
||||||
|
setParams: (params: { open?: string | undefined }) => void;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setIntentConsumed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
consumedIntentRef.current = consumptionKey;
|
||||||
|
|
||||||
|
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||||
|
if (openIntent) {
|
||||||
|
prepareWorkspaceTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
target: getOpenIntentTarget(openIntent),
|
||||||
|
pin: openIntent.kind === "agent",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expo Router's replace ignores query-param-only changes (findDivergentState
|
||||||
|
// skips search params). Strip ?open from the browser URL directly so the
|
||||||
|
// address bar reflects the clean workspace route.
|
||||||
|
clearConsumedOpenIntent({
|
||||||
|
navigation: navigation as unknown as {
|
||||||
|
setParams: (params: { open?: string | undefined }) => void;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setIntentConsumed(true);
|
||||||
|
}, [
|
||||||
|
hasHydratedWorkspaceLayoutStore,
|
||||||
|
navigation,
|
||||||
|
openValue,
|
||||||
|
rootNavigationState?.key,
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <WorkspaceDeck />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function areWorkspaceSelectionsEqual(
|
||||||
|
left: ActiveWorkspaceSelection | null,
|
||||||
|
right: ActiveWorkspaceSelection | null,
|
||||||
|
): boolean {
|
||||||
|
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkspaceDeck() {
|
||||||
|
const activeSelection = useActiveWorkspaceSelection();
|
||||||
|
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||||
|
activeSelection ? [activeSelection] : [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMountedSelections((current) => {
|
||||||
|
if (current.some((selection) => areWorkspaceSelectionsEqual(selection, activeSelection))) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return [...current, activeSelection];
|
||||||
|
});
|
||||||
|
}, [activeSelection]);
|
||||||
|
|
||||||
|
if (!activeSelection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.deck}>
|
||||||
|
{mountedSelections.map((selection) => {
|
||||||
|
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={`${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
style={isActive ? styles.activeDeckEntry : styles.inactiveDeckEntry}
|
||||||
|
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
|
||||||
|
>
|
||||||
|
<WorkspaceScreen
|
||||||
|
serverId={selection.serverId}
|
||||||
|
workspaceId={selection.workspaceId}
|
||||||
|
isRouteFocused={isActive}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
deck: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
activeDeckEntry: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
inactiveDeckEntry: {
|
||||||
|
display: "none",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
resolveStartupRedirectRoute,
|
resolveStartupRedirectRoute,
|
||||||
|
resolveStartupWorkspaceSelection,
|
||||||
startHostRuntimeBootstrap,
|
startHostRuntimeBootstrap,
|
||||||
WELCOME_ROUTE,
|
WELCOME_ROUTE,
|
||||||
} from "./host-runtime-bootstrap";
|
} from "./host-runtime-bootstrap";
|
||||||
@@ -150,14 +151,24 @@ describe("resolveStartupRedirectRoute", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("scenario: saved-host-online", () => {
|
describe("scenario: saved-host-online", () => {
|
||||||
it("redirects to the workspace route when the online host matches the persisted workspace", () => {
|
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||||
const route = resolveStartupRedirectRoute({
|
const route = resolveStartupRedirectRoute({
|
||||||
...baseInput,
|
...baseInput,
|
||||||
anyOnlineHostServerId: "server-1",
|
anyOnlineHostServerId: "server-1",
|
||||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(route).toBe("/h/server-1/workspace/workspace-a");
|
expect(route).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the persisted workspace when the online host matches it", () => {
|
||||||
|
const selection = resolveStartupWorkspaceSelection({
|
||||||
|
...baseInput,
|
||||||
|
anyOnlineHostServerId: "server-1",
|
||||||
|
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
||||||
@@ -192,14 +203,14 @@ describe("resolveStartupRedirectRoute", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("scenario: both-succeed", () => {
|
describe("scenario: both-succeed", () => {
|
||||||
it("redirects to the host that the host runtime selects as earliest online", () => {
|
it("leaves matching persisted workspace navigation to the workspace navigator", () => {
|
||||||
const route = resolveStartupRedirectRoute({
|
const route = resolveStartupRedirectRoute({
|
||||||
...baseInput,
|
...baseInput,
|
||||||
anyOnlineHostServerId: "server-saved",
|
anyOnlineHostServerId: "server-saved",
|
||||||
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
workspaceSelection: { serverId: "server-saved", workspaceId: "workspace-a" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(route).toBe("/h/server-saved/workspace/workspace-a");
|
expect(route).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
import type { DaemonStartResult } from "@/runtime/daemon-start-service";
|
||||||
import type { Href } from "expo-router";
|
import type { Href } from "expo-router";
|
||||||
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
export interface HostRuntimeBootstrapStore {
|
export interface HostRuntimeBootstrapStore {
|
||||||
boot: () => void;
|
boot: () => void;
|
||||||
@@ -66,8 +66,31 @@ export interface ResolveStartupRedirectInput {
|
|||||||
hasGivenUpWaitingForHost: boolean;
|
hasGivenUpWaitingForHost: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isIndexPathname(pathname: string) {
|
||||||
|
return pathname === "/" || pathname === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStartupWorkspaceSelection(
|
||||||
|
input: ResolveStartupRedirectInput,
|
||||||
|
): ActiveWorkspaceSelection | null {
|
||||||
|
if (!isIndexPathname(input.pathname)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!input.isWorkspaceSelectionLoaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!input.anyOnlineHostServerId ||
|
||||||
|
!input.workspaceSelection ||
|
||||||
|
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return input.workspaceSelection;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
|
export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput): Href | null {
|
||||||
if (input.pathname !== "/" && input.pathname !== "") {
|
if (!isIndexPathname(input.pathname)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!input.isWorkspaceSelectionLoaded) {
|
if (!input.isWorkspaceSelectionLoaded) {
|
||||||
@@ -75,14 +98,8 @@ export function resolveStartupRedirectRoute(input: ResolveStartupRedirectInput):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.anyOnlineHostServerId) {
|
if (input.anyOnlineHostServerId) {
|
||||||
if (
|
if (resolveStartupWorkspaceSelection(input)) {
|
||||||
input.workspaceSelection &&
|
return null;
|
||||||
input.workspaceSelection.serverId === input.anyOnlineHostServerId
|
|
||||||
) {
|
|
||||||
return buildHostWorkspaceRoute(
|
|
||||||
input.workspaceSelection.serverId,
|
|
||||||
input.workspaceSelection.workspaceId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return buildHostRootRoute(input.anyOnlineHostServerId);
|
return buildHostRootRoute(input.anyOnlineHostServerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import React from "react";
|
|||||||
import { Redirect, usePathname } from "expo-router";
|
import { Redirect, usePathname } from "expo-router";
|
||||||
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
import { StartupSplashScreen } from "@/screens/startup-splash-screen";
|
||||||
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
import { useEarliestOnlineHostServerId, useHostRuntimeBootstrapState } from "@/app/_layout";
|
||||||
import { resolveStartupRedirectRoute } from "@/app/host-runtime-bootstrap";
|
import {
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
resolveStartupRedirectRoute,
|
||||||
|
resolveStartupWorkspaceSelection,
|
||||||
|
} from "@/app/host-runtime-bootstrap";
|
||||||
|
import {
|
||||||
|
navigateToWorkspace,
|
||||||
|
useActiveWorkspaceSelection,
|
||||||
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
const isDesktop = shouldUseDesktopDaemon();
|
const isDesktop = shouldUseDesktopDaemon();
|
||||||
@@ -21,6 +27,26 @@ export default function Index() {
|
|||||||
isWorkspaceSelectionLoaded: true,
|
isWorkspaceSelectionLoaded: true,
|
||||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||||
});
|
});
|
||||||
|
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||||
|
pathname,
|
||||||
|
anyOnlineHostServerId,
|
||||||
|
workspaceSelection,
|
||||||
|
isWorkspaceSelectionLoaded: true,
|
||||||
|
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!startupWorkspaceSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigateToWorkspace(startupWorkspaceSelection.serverId, startupWorkspaceSelection.workspaceId, {
|
||||||
|
currentPathname: pathname,
|
||||||
|
});
|
||||||
|
}, [pathname, startupWorkspaceSelection]);
|
||||||
|
|
||||||
|
if (startupWorkspaceSelection) {
|
||||||
|
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (redirectRoute) {
|
if (redirectRoute) {
|
||||||
return <Redirect href={redirectRoute} />;
|
return <Redirect href={redirectRoute} />;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -87,7 +86,8 @@ import { GitHubIcon } from "@/components/icons/github-icon";
|
|||||||
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
||||||
import { lineNumberGutterWidth } from "@/components/code-insets";
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||||
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -1874,7 +1874,6 @@ export function GitDiffPane({
|
|||||||
const isMobile = useIsCompactFormFactor();
|
const isMobile = useIsCompactFormFactor();
|
||||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||||
const canUseSplitLayout = isWeb && !isMobile;
|
const canUseSplitLayout = isWeb && !isMobile;
|
||||||
const router = useRouter();
|
|
||||||
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
|
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
@@ -2360,9 +2359,9 @@ export function GitDiffPane({
|
|||||||
|
|
||||||
const handleArchiveSuccess = useCallback(
|
const handleArchiveSuccess = useCallback(
|
||||||
(targetWorkingDir: string) => {
|
(targetWorkingDir: string) => {
|
||||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
navigateToWorkspace(serverId, targetWorkingDir);
|
||||||
},
|
},
|
||||||
[router, serverId],
|
[serverId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toastError = useCallback(
|
const toastError = useCallback(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
||||||
import { useRouter } from "expo-router";
|
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import {
|
import {
|
||||||
type CheckoutGitActionStatus,
|
type CheckoutGitActionStatus,
|
||||||
@@ -14,9 +13,10 @@ import {
|
|||||||
useCheckoutPrStatusQuery,
|
useCheckoutPrStatusQuery,
|
||||||
} from "@/hooks/use-checkout-pr-status-query";
|
} from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
||||||
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
|
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||||
|
|
||||||
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
||||||
|
|
||||||
@@ -225,7 +225,6 @@ interface UseGitActionsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||||
const router = useRouter();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
@@ -432,13 +431,13 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
|||||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||||
.then(() => {
|
.then(() => {
|
||||||
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
navigateToWorkspace(serverId, targetWorkingDir);
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toastActionError(err, "Failed to archive worktree");
|
toastActionError(err, "Failed to archive worktree");
|
||||||
});
|
});
|
||||||
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||||
|
|
||||||
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
|
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
|
||||||
const derived = deriveGitActionsState({
|
const derived = deriveGitActionsState({
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export function navigateToWorkspace(
|
|||||||
_options: NavigateToWorkspaceOptions = {},
|
_options: NavigateToWorkspaceOptions = {},
|
||||||
) {
|
) {
|
||||||
lastWorkspaceSelection = { serverId, workspaceId };
|
lastWorkspaceSelection = { serverId, workspaceId };
|
||||||
router.dismissTo(buildHostWorkspaceRoute(serverId, workspaceId) as Href);
|
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
|
||||||
|
router.dismissTo(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToLastWorkspace(): boolean {
|
export function navigateToLastWorkspace(): boolean {
|
||||||
|
|||||||
@@ -2,25 +2,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||||
import {
|
import {
|
||||||
buildNewAgentRoute,
|
|
||||||
parseAgentKey,
|
parseAgentKey,
|
||||||
resolveNewAgentWorkingDir,
|
resolveNewAgentWorkingDir,
|
||||||
resolveSelectedAgentForNewAgent,
|
resolveSelectedAgentForNewAgent,
|
||||||
} from "./new-agent-routing";
|
} from "./new-agent-routing";
|
||||||
|
|
||||||
describe("buildNewAgentRoute", () => {
|
|
||||||
it("falls back to server workspace route with dot workspace when no working directory is provided", () => {
|
|
||||||
expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/workspace/.");
|
|
||||||
expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/workspace/.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("encodes the working directory as a workspace path segment", () => {
|
|
||||||
expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe(
|
|
||||||
"/h/srv-1/workspace/b64_L1VzZXJzL21lL2Rldi9wYXNlbw",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveNewAgentWorkingDir", () => {
|
describe("resolveNewAgentWorkingDir", () => {
|
||||||
it("returns the current cwd for regular checkouts", () => {
|
it("returns the current cwd for regular checkouts", () => {
|
||||||
expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path");
|
expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path");
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||||
import {
|
import {
|
||||||
parseHostWorkspaceOpenIntentFromPathname,
|
parseHostWorkspaceOpenIntentFromPathname,
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -68,8 +67,3 @@ export function resolveNewAgentWorkingDir(
|
|||||||
|
|
||||||
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
|
return inferMainRepoRootFromPaseoWorktreePath(cwd) ?? cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildNewAgentRoute(serverId: string, workingDir?: string | null) {
|
|
||||||
const trimmedWorkingDir = workingDir?.trim();
|
|
||||||
return buildHostWorkspaceRoute(serverId, trimmedWorkingDir || ".");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ describe("resolveNotificationTarget", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("buildNotificationRoute", () => {
|
describe("buildNotificationRoute", () => {
|
||||||
it("routes to workspace with one-shot open intent when workspace id is present", () => {
|
it("routes to the agent path when workspace id is present", () => {
|
||||||
expect(
|
expect(
|
||||||
buildNotificationRoute({
|
buildNotificationRoute({
|
||||||
serverId: "srv-1",
|
serverId: "srv-1",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
workspaceId: "ws-main",
|
workspaceId: "ws-main",
|
||||||
}),
|
}),
|
||||||
).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1");
|
).toBe("/h/srv-1/agent/agent-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes directly to server-scoped agent path when both ids are present", () => {
|
it("routes directly to server-scoped agent path when both ids are present", () => {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { Href } from "expo-router";
|
import type { Href } from "expo-router";
|
||||||
import {
|
import { buildHostAgentDetailRoute, buildHostRootRoute } from "@/utils/host-routes";
|
||||||
buildHostAgentDetailRoute,
|
|
||||||
buildHostRootRoute,
|
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
type NotificationData = Record<string, unknown> | null | undefined;
|
type NotificationData = Record<string, unknown> | null | undefined;
|
||||||
type NotificationRoute = Extract<Href, string>;
|
type NotificationRoute = Extract<Href, string>;
|
||||||
@@ -30,12 +26,8 @@ export function resolveNotificationTarget(data: NotificationData): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
|
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
|
||||||
const { serverId, agentId, workspaceId } = resolveNotificationTarget(data);
|
const { serverId, agentId } = resolveNotificationTarget(data);
|
||||||
if (serverId && agentId) {
|
if (serverId && agentId) {
|
||||||
if (workspaceId) {
|
|
||||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
|
||||||
return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}` as const;
|
|
||||||
}
|
|
||||||
return buildHostAgentDetailRoute(serverId, agentId);
|
return buildHostAgentDetailRoute(serverId, agentId);
|
||||||
}
|
}
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user