app: hard-cut workspace routing to workspace-only URLs

This commit is contained in:
Mohamed Boudra
2026-03-04 22:35:30 +07:00
parent ee8e2ab691
commit 73fce89edd
22 changed files with 300 additions and 608 deletions
@@ -1,89 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildHostWorkspaceRoute,
buildHostWorkspaceTabRoute,
} from "@/utils/host-routes";
import { resolveWorkspaceTabCanonicalPath } from "@/screens/workspace/workspace-route-canonicalization";
describe("resolveWorkspaceTabCanonicalPath", () => {
it("canonicalizes workspace root routes to /tab/<tabId> when a tab is active", () => {
const serverId = "local";
const workspaceId = "/tmp/repo";
expect(
resolveWorkspaceTabCanonicalPath({
activeTabId: "draft_abc123",
pathname: buildHostWorkspaceRoute(serverId, workspaceId),
serverId,
workspaceId,
})
).toBe(buildHostWorkspaceTabRoute(serverId, workspaceId, "draft_abc123"));
});
it("does not skip canonical path detection due to query-string tabId", () => {
const serverId = "local";
const workspaceId = "/tmp/repo";
expect(
resolveWorkspaceTabCanonicalPath({
activeTabId: "draft_abc123",
pathname: `${buildHostWorkspaceRoute(serverId, workspaceId)}?tabId=draft_abc123`,
serverId,
workspaceId,
})
).toBe(buildHostWorkspaceTabRoute(serverId, workspaceId, "draft_abc123"));
});
it("strips redundant query params from canonical /tab routes", () => {
const serverId = "local";
const workspaceId = "/tmp/repo";
const canonicalPath = buildHostWorkspaceTabRoute(
serverId,
workspaceId,
"draft_abc123"
);
expect(
resolveWorkspaceTabCanonicalPath({
activeTabId: "draft_abc123",
pathname: `${canonicalPath}?tabId=not_used`,
serverId,
workspaceId,
})
).toBe(canonicalPath);
});
it("does nothing when the pathname is exactly canonical", () => {
const serverId = "local";
const workspaceId = "/tmp/repo";
const canonicalPath = buildHostWorkspaceTabRoute(
serverId,
workspaceId,
"draft_abc123"
);
expect(
resolveWorkspaceTabCanonicalPath({
activeTabId: "draft_abc123",
pathname: canonicalPath,
serverId,
workspaceId,
})
).toBeNull();
});
it("does not canonicalize when the pathname belongs to another workspace", () => {
const serverId = "local";
const workspaceId = "/tmp/repo";
const otherWorkspacePath = buildHostWorkspaceRoute(serverId, "/tmp/other");
expect(
resolveWorkspaceTabCanonicalPath({
activeTabId: "draft_abc123",
pathname: otherWorkspacePath,
serverId,
workspaceId,
})
).toBeNull();
});
});
@@ -1,55 +0,0 @@
import {
buildHostWorkspaceTabRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
function stripSearchAndHash(pathname: string): string {
const hashIndex = pathname.indexOf("#");
const queryIndex = pathname.indexOf("?");
const end = [hashIndex, queryIndex]
.filter((index) => index >= 0)
.reduce((min, index) => Math.min(min, index), pathname.length);
return pathname.slice(0, end);
}
type ResolveWorkspaceTabCanonicalPathInput = {
activeTabId: string | null;
pathname: string;
serverId: string;
workspaceId: string;
};
export function resolveWorkspaceTabCanonicalPath(
input: ResolveWorkspaceTabCanonicalPathInput
): string | null {
const { activeTabId, pathname, serverId, workspaceId } = input;
if (!activeTabId || !serverId || !workspaceId) {
return null;
}
const canonicalPath = buildHostWorkspaceTabRoute(serverId, workspaceId, activeTabId);
if (canonicalPath === "/") {
return null;
}
const activeWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
if (
!activeWorkspaceRoute ||
activeWorkspaceRoute.serverId !== serverId ||
activeWorkspaceRoute.workspaceId !== workspaceId
) {
return null;
}
const pathOnly = stripSearchAndHash(pathname);
if (pathOnly !== canonicalPath) {
return canonicalPath;
}
// Keep workspace tab URLs clean: no query/hash source-of-truth for tab routing.
if (pathname !== canonicalPath) {
return canonicalPath;
}
return null;
}
@@ -8,7 +8,6 @@ import {
View,
} from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { usePathname, useRouter } from "expo-router";
import * as Clipboard from "expo-clipboard";
import {
Bot,
@@ -50,9 +49,9 @@ import {
useWorkspaceTabsStore,
} from "@/stores/workspace-tabs-store";
import {
buildHostWorkspaceTabRoute,
buildWorkspaceOpenIntentParam,
type WorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
encodeWorkspaceIdForPathSegment,
} from "@/utils/host-routes";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
@@ -82,7 +81,6 @@ import {
import {
deriveWorkspaceTabModel,
} from "@/screens/workspace/workspace-tab-model";
import { resolveWorkspaceTabCanonicalPath } from "@/screens/workspace/workspace-route-canonicalization";
const TERMINALS_QUERY_STALE_TIME = 5_000;
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
@@ -93,7 +91,7 @@ const EMPTY_TAB_ORDER: string[] = [];
type WorkspaceScreenProps = {
serverId: string;
workspaceId: string;
routeTabId?: string | null;
openIntent?: WorkspaceOpenIntent | null;
};
function trimNonEmpty(value: string | null | undefined): string | null {
@@ -112,38 +110,17 @@ function decodeSegment(value: string): string {
}
}
function buildWorkspaceTabHref(
serverId: string,
workspaceId: string,
tabId: string
): { pathname: string; params: { serverId: string; workspaceId: string; tabId: string } } | null {
const encodedWorkspaceId = encodeWorkspaceIdForPathSegment(workspaceId);
const trimmedTabId = tabId.trim();
const trimmedServerId = serverId.trim();
if (!encodedWorkspaceId || !trimmedTabId || !trimmedServerId) {
return null;
}
return {
pathname: "/h/[serverId]/workspace/[workspaceId]/tab/[tabId]",
params: {
serverId: trimmedServerId,
workspaceId: encodedWorkspaceId,
tabId: trimmedTabId,
},
};
}
export function WorkspaceScreen({
serverId,
workspaceId,
routeTabId,
openIntent,
}: WorkspaceScreenProps) {
return (
<ExplorerSidebarAnimationProvider>
<WorkspaceScreenContent
serverId={serverId}
workspaceId={workspaceId}
routeTabId={routeTabId}
openIntent={openIntent}
/>
</ExplorerSidebarAnimationProvider>
);
@@ -152,16 +129,10 @@ export function WorkspaceScreen({
function WorkspaceScreenContent({
serverId,
workspaceId,
routeTabId,
openIntent,
}: WorkspaceScreenProps) {
const { theme } = useUnistyles();
const toast = useToast();
const router = useRouter();
const routePathname = usePathname();
const pathname =
Platform.OS === "web" && typeof window !== "undefined"
? `${window.location.pathname}${window.location.search}${window.location.hash}`
: routePathname;
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -241,15 +212,11 @@ function WorkspaceScreenContent({
target: { kind: "terminal", terminalId: createdTerminal.id },
});
if (tabId) {
const href = buildWorkspaceTabHref(
normalizedServerId,
normalizedWorkspaceId,
tabId
);
if (!href) {
return;
}
router.replace(href as any);
useWorkspaceTabsStore.getState().focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
}
}
},
@@ -441,23 +408,37 @@ function WorkspaceScreenContent({
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab);
const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent);
const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
const [pendingRouteTabId, setPendingRouteTabId] = useState<string | null>(null);
const consumedOpenIntentsRef = useRef(new Set<string>());
useEffect(() => {
const normalized = typeof routeTabId === "string" ? routeTabId.trim() : "";
if (!normalized || !persistenceKey) {
if (!openIntent || !persistenceKey) {
return;
}
if (normalized.startsWith("file_")) {
const path = normalized.slice("file_".length).trim();
if (!path) {
return;
}
const tabId = openOrFocusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target: { kind: "file", path },
});
const openParam = buildWorkspaceOpenIntentParam(openIntent);
if (!openParam) {
return;
}
const intentKey = `${normalizedServerId}:${normalizedWorkspaceId}:${openParam}`;
if (consumedOpenIntentsRef.current.has(intentKey)) {
return;
}
consumedOpenIntentsRef.current.add(intentKey);
if (openIntent.kind === "draft") {
const draftId = openIntent.draftId.trim();
const tabId =
draftId === "new"
? openDraftTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
draftId: generateDraftId(),
})
: openDraftTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
draftId,
});
if (tabId) {
focusTab({
serverId: normalizedServerId,
@@ -465,33 +446,36 @@ function WorkspaceScreenContent({
tabId,
});
}
return;
}
const tabId = openOrFocusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
target:
openIntent.kind === "agent"
? { kind: "agent", agentId: openIntent.agentId }
: openIntent.kind === "terminal"
? { kind: "terminal", terminalId: openIntent.terminalId }
: { kind: "file", path: openIntent.path },
});
if (tabId) {
focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
}
}, [
focusTab,
openDraftTab,
openIntent,
openOrFocusTab,
persistenceKey,
normalizedServerId,
normalizedWorkspaceId,
routeTabId,
]);
const normalizedRouteTabId = trimNonEmpty(routeTabId);
const effectiveRouteTabId = pendingRouteTabId ?? normalizedRouteTabId;
useEffect(() => {
if (!pendingRouteTabId || !normalizedRouteTabId) {
return;
}
if (normalizedRouteTabId === pendingRouteTabId) {
setPendingRouteTabId(null);
}
}, [normalizedRouteTabId, pendingRouteTabId]);
useEffect(() => {
setPendingRouteTabId(null);
}, [normalizedServerId, normalizedWorkspaceId]);
const tabModel = useMemo(
() =>
deriveWorkspaceTabModel({
@@ -500,9 +484,8 @@ function WorkspaceScreenContent({
uiTabs,
tabOrder,
focusedTabId,
routeTabId: effectiveRouteTabId,
}),
[effectiveRouteTabId, focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents]
[focusedTabId, tabOrder, terminals, uiTabs, workspaceAgents]
);
const activeTabId = tabModel.activeTabId;
@@ -513,77 +496,6 @@ function WorkspaceScreenContent({
focusTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, tabId: activeTabId });
}, [activeTabId, focusTab, normalizedServerId, normalizedWorkspaceId, persistenceKey]);
useEffect(() => {
if (!pendingRouteTabId || !activeTabId) {
return;
}
if (pendingRouteTabId === activeTabId) {
setPendingRouteTabId(null);
}
}, [activeTabId, pendingRouteTabId]);
const lastCanonicalPathRef = useRef<string | null>(null);
useEffect(() => {
const hasRouteIdentitySearchParams =
Platform.OS === "web" &&
typeof window !== "undefined" &&
(() => {
const params = new URLSearchParams(window.location.search);
return (
params.has("tabId") ||
params.has("workspaceId") ||
params.has("serverId")
);
})();
const derivedCanonicalPath = resolveWorkspaceTabCanonicalPath({
activeTabId,
pathname,
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
});
const canonicalPath =
derivedCanonicalPath ??
(hasRouteIdentitySearchParams && activeTabId
? buildHostWorkspaceTabRoute(
normalizedServerId,
normalizedWorkspaceId,
activeTabId
)
: null);
if (!canonicalPath) {
lastCanonicalPathRef.current = null;
return;
}
if (lastCanonicalPathRef.current === canonicalPath) {
return;
}
lastCanonicalPathRef.current = canonicalPath;
if (Platform.OS === "web" && typeof window !== "undefined") {
if (
window.location.pathname !== canonicalPath ||
window.location.search.length > 0 ||
window.location.hash.length > 0
) {
window.history.replaceState(window.history.state, "", canonicalPath);
}
return;
}
const href = activeTabId
? buildWorkspaceTabHref(normalizedServerId, normalizedWorkspaceId, activeTabId)
: null;
if (href) {
router.replace(href as any);
return;
}
router.replace(canonicalPath as any);
}, [
activeTabId,
normalizedServerId,
normalizedWorkspaceId,
pathname,
router,
]);
const activeTab = tabModel.activeTab;
const tabs = useMemo<WorkspaceTabDescriptor[]>(
@@ -607,23 +519,13 @@ function WorkspaceScreenContent({
if (!tabId || !normalizedServerId || !normalizedWorkspaceId) {
return;
}
setPendingRouteTabId(tabId);
focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId,
});
const href = buildWorkspaceTabHref(
normalizedServerId,
normalizedWorkspaceId,
tabId
);
if (!href) {
return;
}
router.replace(href as any);
},
[focusTab, normalizedServerId, normalizedWorkspaceId, router]
[focusTab, normalizedServerId, normalizedWorkspaceId]
);
const emptyWorkspaceSeedRef = useRef<string | null>(null);
@@ -631,9 +533,6 @@ function WorkspaceScreenContent({
if (!persistenceKey) {
return;
}
if (effectiveRouteTabId) {
return;
}
if (tabs.length > 0) {
emptyWorkspaceSeedRef.current = null;
return;
@@ -658,7 +557,6 @@ function WorkspaceScreenContent({
normalizedWorkspaceId,
openDraftTab,
persistenceKey,
effectiveRouteTabId,
tabs.length,
]);
@@ -114,7 +114,7 @@ describe("deriveWorkspaceTabModel", () => {
]);
});
it("prefers route tab when present, then focused tab, then first tab for active selection", () => {
it("uses focused tab when present, otherwise falls back to first tab", () => {
const base = {
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [],
@@ -133,42 +133,10 @@ describe("deriveWorkspaceTabModel", () => {
deriveWorkspaceTabModel({
...base,
focusedTabId: "agent_agent-b",
routeTabId: "agent_agent-a",
}).activeTabId
).toBe("agent_agent-a");
expect(
deriveWorkspaceTabModel({
...base,
focusedTabId: "agent_agent-b",
routeTabId: "agent_agent-missing",
}).activeTabId
).toBe("agent_agent-b");
});
it("switches active tab target when route tab id changes", () => {
const modelA = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [{ id: "term-1", name: "shell" }],
uiTabs: [],
tabOrder: ["agent_agent-a", "terminal_term-1", "agent_agent-b"],
routeTabId: "terminal_term-1",
});
const modelB = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [{ id: "term-1", name: "shell" }],
uiTabs: [],
tabOrder: ["agent_agent-a", "terminal_term-1", "agent_agent-b"],
routeTabId: "agent_agent-b",
});
expect(modelA.activeTab?.descriptor.tabId).toBe("terminal_term-1");
expect(modelA.activeTab?.target.kind).toBe("terminal");
expect(modelB.activeTab?.descriptor.tabId).toBe("agent_agent-b");
expect(modelB.activeTab?.target.kind).toBe("agent");
});
it("re-resolves active content for a new workspace when prior focused tab is not available", () => {
const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
@@ -176,7 +144,6 @@ describe("deriveWorkspaceTabModel", () => {
uiTabs: [],
tabOrder: ["agent_workspace-b-agent"],
focusedTabId: "agent_workspace-a-agent",
routeTabId: null,
});
expect(model.activeTabId).toBe("agent_workspace-b-agent");
@@ -108,7 +108,6 @@ export function deriveWorkspaceTabModel(input: {
uiTabs: WorkspaceTab[];
tabOrder: string[];
focusedTabId?: string | null;
routeTabId?: string | null;
}): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>();
@@ -209,15 +208,12 @@ export function deriveWorkspaceTabModel(input: {
.filter((tab): tab is WorkspaceDerivedTab => tab !== null);
const openTabIds = new Set(tabs.map((tab) => tab.descriptor.tabId));
const routeTabId = trimNonEmpty(input.routeTabId);
const focusedTabId = trimNonEmpty(input.focusedTabId);
const activeTabId =
routeTabId && openTabIds.has(routeTabId)
? routeTabId
: focusedTabId && openTabIds.has(focusedTabId)
? focusedTabId
: tabs[0]?.descriptor.tabId ?? null;
focusedTabId && openTabIds.has(focusedTabId)
? focusedTabId
: tabs[0]?.descriptor.tabId ?? null;
const activeTab = activeTabId
? tabs.find((tab) => tab.descriptor.tabId === activeTabId) ?? null