From 73fce89edd09854351ea1f3e324798c121d8d885 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 22:35:30 +0700 Subject: [PATCH] app: hard-cut workspace routing to workspace-only URLs --- packages/app/src/app/_layout.tsx | 12 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 6 +- packages/app/src/app/h/[serverId]/index.tsx | 44 +--- .../workspace/[workspaceId]/_layout.tsx | 21 +- .../workspace/[workspaceId]/tab/[tabId].tsx | 3 - packages/app/src/components/agent-list.tsx | 4 +- .../app/src/components/agent-stream-view.tsx | 4 +- packages/app/src/hooks/use-command-center.ts | 4 +- .../keyboard-shortcut-routing.test.ts | 16 +- .../src/keyboard/keyboard-shortcut-routing.ts | 19 +- .../src/screens/agent/draft-agent-screen.tsx | 4 +- .../workspace-route-canonicalization.test.ts | 89 ------- .../workspace-route-canonicalization.ts | 55 ---- .../screens/workspace/workspace-screen.tsx | 222 +++++----------- .../workspace/workspace-tab-model.test.ts | 35 +-- .../screens/workspace/workspace-tab-model.ts | 10 +- packages/app/src/utils/host-routes.test.ts | 89 ++++--- packages/app/src/utils/host-routes.ts | 249 ++++++++++-------- .../app/src/utils/new-agent-routing.test.ts | 2 +- packages/app/src/utils/new-agent-routing.ts | 12 +- .../src/utils/notification-routing.test.ts | 4 +- .../app/src/utils/notification-routing.ts | 4 +- 22 files changed, 300 insertions(+), 608 deletions(-) delete mode 100644 packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx delete mode 100644 packages/app/src/screens/workspace/workspace-route-canonicalization.test.ts delete mode 100644 packages/app/src/screens/workspace/workspace-route-canonicalization.ts diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 294943631..a3c0961bc 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -47,7 +47,7 @@ import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildHostRootRoute, parseHostAgentRouteFromPathname, - parseHostWorkspaceTabRouteFromPathname, + parseHostWorkspaceOpenIntentFromPathname, } from "@/utils/host-routes"; import { getTauri } from "@/utils/tauri"; import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics"; @@ -381,10 +381,12 @@ function AppWithSidebar({ children }: { children: ReactNode }) { // Parse selectedAgentKey directly from pathname // useLocalSearchParams doesn't update when navigating between same-pattern routes const selectedAgentKey = useMemo(() => { - const workspaceTab = parseHostWorkspaceTabRouteFromPathname(pathname); - if (workspaceTab?.tabId?.startsWith("agent_")) { - const agentId = workspaceTab.tabId.slice("agent_".length).trim(); - return agentId ? `${workspaceTab.serverId}:${agentId}` : undefined; + const workspaceMatch = pathname.match(/^\/h\/([^/]+)\/workspace\/[^/]+(?:\/|$)/); + const workspaceServerId = workspaceMatch?.[1]?.trim() ?? ""; + const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname); + if (workspaceServerId && openIntent?.kind === "agent") { + const agentId = openIntent.agentId.trim(); + return agentId ? `${workspaceServerId}:${agentId}` : undefined; } const match = parseHostAgentRouteFromPathname(pathname); diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7413bae15..4b7fb7401 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -4,7 +4,7 @@ import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { buildHostRootRoute, - buildHostWorkspaceAgentTabRoute, + buildHostWorkspaceAgentRoute, } from "@/utils/host-routes"; export default function HostAgentReadyRoute() { @@ -38,7 +38,7 @@ export default function HostAgentReadyRoute() { if (normalizedCwd) { redirectedRef.current = true; router.replace( - buildHostWorkspaceAgentTabRoute(serverId, normalizedCwd, agentId) as any + buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any ); } }, [agentCwd, agentId, router, serverId]); @@ -77,7 +77,7 @@ export default function HostAgentReadyRoute() { const cwd = result?.agent?.cwd?.trim(); redirectedRef.current = true; if (cwd) { - router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any); + router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any); return; } router.replace(buildHostRootRoute(serverId) as any); diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 72f64412b..10dd25192 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -5,30 +5,12 @@ import { useSessionStore } from "@/stores/session-store"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { buildHostRootRoute, + buildHostWorkspaceAgentRoute, buildHostWorkspaceRoute, - buildHostWorkspaceAgentTabRoute, - buildHostWorkspaceTabRoute, - parseHostWorkspaceTabRouteFromPathname, } from "@/utils/host-routes"; const HOST_ROOT_REDIRECT_DELAY_MS = 300; -function readInitialNavigationPathname(): string | null { - if (Platform.OS !== "web" || typeof window === "undefined") { - return null; - } - const entry = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; - const name = typeof entry?.name === "string" ? entry.name : null; - if (!name) { - return null; - } - try { - return new URL(name).pathname; - } catch { - return null; - } -} - export default function HostIndexRoute() { const router = useRouter(); const routerPathname = usePathname(); @@ -60,28 +42,6 @@ export default function HostIndexRoute() { if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) { return; } - - const initialPathname = readInitialNavigationPathname(); - const initialWorkspaceTabRoute = initialPathname - ? parseHostWorkspaceTabRouteFromPathname(initialPathname) - : null; - if ( - initialWorkspaceTabRoute && - initialWorkspaceTabRoute.serverId === serverId - ) { - const canonicalInitialTabPath = buildHostWorkspaceTabRoute( - initialWorkspaceTabRoute.serverId, - initialWorkspaceTabRoute.workspaceId, - initialWorkspaceTabRoute.tabId - ); - if ( - canonicalInitialTabPath !== "/" && - window.location.pathname !== canonicalInitialTabPath - ) { - router.replace(canonicalInitialTabPath as any); - } - return; - } } const visibleAgents = sessionAgents @@ -96,7 +56,7 @@ export default function HostIndexRoute() { const primaryAgent = visibleAgents[0]; if (primaryAgent?.cwd?.trim()) { router.replace( - buildHostWorkspaceAgentTabRoute( + buildHostWorkspaceAgentRoute( serverId, primaryAgent.cwd.trim(), primaryAgent.id diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index b21bad146..c8adcbf3b 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,10 +1,9 @@ -import { useEffect } from "react"; import { usePathname } from "expo-router"; import { Platform } from "react-native"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { + parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, - parseHostWorkspaceTabRouteFromPathname, } from "@/utils/host-routes"; export default function HostWorkspaceLayout() { @@ -13,29 +12,17 @@ export default function HostWorkspaceLayout() { Platform.OS === "web" && typeof window !== "undefined" ? `${window.location.pathname}${window.location.search}${window.location.hash}` : pathname; - const tabRoute = parseHostWorkspaceTabRouteFromPathname(resolvedPathname); - const activeRoute = tabRoute ?? parseHostWorkspaceRouteFromPathname(resolvedPathname); + const activeRoute = parseHostWorkspaceRouteFromPathname(resolvedPathname); const serverId = activeRoute?.serverId ?? ""; const workspaceId = activeRoute?.workspaceId ?? ""; - const routeTabId = tabRoute?.tabId ?? null; - - useEffect(() => { - if (Platform.OS !== "web" || !tabRoute || typeof window === "undefined") { - return; - } - const params = new URLSearchParams(window.location.search); - if (!params.has("tabId") && !params.has("workspaceId") && !params.has("serverId")) { - return; - } - window.history.replaceState(window.history.state, "", window.location.pathname); - }, [resolvedPathname, tabRoute]); + const openIntent = parseHostWorkspaceOpenIntentFromPathname(resolvedPathname); return ( ); } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx deleted file mode 100644 index 85de1f2e1..000000000 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/tab/[tabId].tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function HostWorkspaceTabRoute() { - return null; -} diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index a8e779d75..ce8219b91 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -33,7 +33,7 @@ import { startNavigationTiming, } from "@/utils/navigation-timing"; import { - buildHostWorkspaceAgentTabRoute, + buildHostWorkspaceAgentRoute, } from "@/utils/host-routes"; interface AgentListProps { @@ -189,7 +189,7 @@ export function AgentList({ onAgentSelect?.(); - const route: Href = buildHostWorkspaceAgentTabRoute( + const route: Href = buildHostWorkspaceAgentRoute( serverId, agent.cwd, agentId diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 58bc9b34f..df7908374 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -82,7 +82,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf"; import { getMarkdownListMarker } from "@/utils/markdown-list"; -import { buildHostWorkspaceFileTabRoute } from "@/utils/host-routes"; +import { buildHostWorkspaceFileRoute } from "@/utils/host-routes"; const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message"; const isToolSequenceItem = (item?: StreamItem) => @@ -213,7 +213,7 @@ export const AgentStreamView = forwardRef { it("returns selected agent key when provided", () => { const key = resolveSelectedOrRouteAgentKey({ selectedAgentId: "server-1:agent-1", - pathname: "/h/server-1/workspace/workspace-1/tab/draft_123", + pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123", }); expect(key).toBe("server-1:agent-1"); @@ -18,7 +18,7 @@ describe("keyboard-shortcut-routing", () => { it("maps workspace draft tabs to the draft tab id key", () => { const key = resolveSelectedOrRouteAgentKey({ - pathname: "/h/server-1/workspace/workspace-1/tab/draft_123", + pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123", }); expect(key).toBe("server-1:draft_123"); @@ -31,7 +31,7 @@ describe("keyboard-shortcut-routing", () => { it("allows the shortcut on selected-agent routes", () => { const canToggle = canToggleFileExplorerShortcut({ selectedAgentId: "server-1:agent-1", - pathname: "/h/server-1/workspace/workspace-1/tab/agent_agent-1", + pathname: "/h/server-1/workspace/workspace-1?open=agent%3Aagent-1", toggleFileExplorer, }); @@ -47,18 +47,18 @@ describe("keyboard-shortcut-routing", () => { expect(canToggle).toBe(true); }); - it("allows the shortcut on workspace terminal routes", () => { + it("allows the shortcut on workspace routes with intent query", () => { const canToggle = canToggleFileExplorerShortcut({ - pathname: "/h/server-1/workspace/workspace-1/tab/terminal_terminal-1", + pathname: "/h/server-1/workspace/workspace-1?open=terminal%3Aterminal-1", toggleFileExplorer, }); expect(canToggle).toBe(true); }); - it("allows the shortcut on workspace draft-tab routes", () => { + it("allows the shortcut on workspace draft-intent routes", () => { const canToggle = canToggleFileExplorerShortcut({ - pathname: "/h/server-1/workspace/workspace-1/tab/draft_123", + pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123", toggleFileExplorer, }); @@ -67,7 +67,7 @@ describe("keyboard-shortcut-routing", () => { it("blocks the shortcut when no toggle handler exists", () => { const canToggle = canToggleFileExplorerShortcut({ - pathname: "/h/server-1/workspace/workspace-1/tab/draft_123", + pathname: "/h/server-1/workspace/workspace-1?open=draft%3Adraft_123", }); expect(canToggle).toBe(false); diff --git a/packages/app/src/keyboard/keyboard-shortcut-routing.ts b/packages/app/src/keyboard/keyboard-shortcut-routing.ts index 7cf2ad691..f18943fe2 100644 --- a/packages/app/src/keyboard/keyboard-shortcut-routing.ts +++ b/packages/app/src/keyboard/keyboard-shortcut-routing.ts @@ -1,7 +1,7 @@ import { parseHostAgentRouteFromPathname, + parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, - parseHostWorkspaceTabRouteFromPathname, } from "@/utils/host-routes"; export function resolveSelectedOrRouteAgentKey(input: { @@ -12,15 +12,16 @@ export function resolveSelectedOrRouteAgentKey(input: { return input.selectedAgentId; } - const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname); - if (workspaceTabRoute?.tabId) { - const tabId = workspaceTabRoute.tabId; - if (tabId.startsWith("agent_")) { - const agentId = tabId.slice("agent_".length).trim(); - return agentId ? `${workspaceTabRoute.serverId}:${agentId}` : null; + const workspaceRoute = parseHostWorkspaceRouteFromPathname(input.pathname); + const workspaceOpenIntent = parseHostWorkspaceOpenIntentFromPathname(input.pathname); + if (workspaceRoute && workspaceOpenIntent) { + if (workspaceOpenIntent.kind === "agent") { + const agentId = workspaceOpenIntent.agentId.trim(); + return agentId ? `${workspaceRoute.serverId}:${agentId}` : null; } - if (tabId.startsWith("draft_")) { - return `${workspaceTabRoute.serverId}:${tabId}`; + if (workspaceOpenIntent.kind === "draft") { + const draftId = workspaceOpenIntent.draftId.trim(); + return draftId ? `${workspaceRoute.serverId}:${draftId}` : null; } } diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index b59e43fe6..2fd9c64a4 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -48,7 +48,7 @@ import type { AgentSessionConfig, } from '@server/server/agent/agent-sdk-types' import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest' -import { buildHostWorkspaceAgentTabRoute } from '@/utils/host-routes' +import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes' import { useTauriDragHandlers } from '@/utils/tauri-window' import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' import { normalizeAgentSnapshot } from '@/utils/agent-snapshots' @@ -1055,7 +1055,7 @@ function DraftAgentScreenContent({ const configuredWorkingDir = config.cwd.trim() const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir - const route: Href = buildHostWorkspaceAgentTabRoute( + const route: Href = buildHostWorkspaceAgentRoute( selectedServerId, workspaceId, agentId diff --git a/packages/app/src/screens/workspace/workspace-route-canonicalization.test.ts b/packages/app/src/screens/workspace/workspace-route-canonicalization.test.ts deleted file mode 100644 index 1d5b0f237..000000000 --- a/packages/app/src/screens/workspace/workspace-route-canonicalization.test.ts +++ /dev/null @@ -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/ 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(); - }); -}); diff --git a/packages/app/src/screens/workspace/workspace-route-canonicalization.ts b/packages/app/src/screens/workspace/workspace-route-canonicalization.ts deleted file mode 100644 index 9d12975d9..000000000 --- a/packages/app/src/screens/workspace/workspace-route-canonicalization.ts +++ /dev/null @@ -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; -} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 93aa782b1..b1aa8c5e9 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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 ( ); @@ -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(null); + const consumedOpenIntentsRef = useRef(new Set()); 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(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( @@ -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(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, ]); diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index 01d2abb69..01224ee8c 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -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"); diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index 6e29ff861..c3f9a7edf 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -108,7 +108,6 @@ export function deriveWorkspaceTabModel(input: { uiTabs: WorkspaceTab[]; tabOrder: string[]; focusedTabId?: string | null; - routeTabId?: string | null; }): WorkspaceTabModel { const tabsById = new Map(); @@ -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 diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 6e75b1527..fad43fd36 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -1,18 +1,21 @@ import { describe, expect, it } from "vitest"; import { + buildHostAgentDetailRoute, buildHostNewAgentRoute, buildHostRootRoute, - buildHostWorkspaceAgentTabRoute, - buildHostWorkspaceFileTabRoute, + buildHostWorkspaceAgentRoute, + buildHostWorkspaceFileRoute, buildHostWorkspaceRoute, - buildHostWorkspaceTerminalTabRoute, + buildHostWorkspaceRouteWithOpenIntent, + buildHostWorkspaceTerminalRoute, decodeFilePathFromPathSegment, decodeWorkspaceIdFromPathSegment, encodeFilePathForPathSegment, encodeWorkspaceIdForPathSegment, parseHostAgentRouteFromPathname, - parseHostWorkspaceTabRouteFromPathname, + parseHostWorkspaceOpenIntentFromPathname, parseHostWorkspaceRouteFromPathname, + parseWorkspaceOpenIntent, } from "./host-routes"; describe("parseHostAgentRouteFromPathname", () => { @@ -49,35 +52,10 @@ describe("workspace route parsing", () => { }); }); - it("parses workspace route for /tab targets", () => { + it("does not treat /tab routes as valid workspace routes", () => { expect( parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123") - ).toEqual({ - serverId: "local", - workspaceId: "/tmp/repo", - }); - }); - - it("parses workspace tab route", () => { - expect( - parseHostWorkspaceTabRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123") - ).toEqual({ - serverId: "local", - workspaceId: "/tmp/repo", - tabId: "draft_abc123", - }); - }); - - it("prefers path tab segment and ignores query params for workspace tab routes", () => { - expect( - parseHostWorkspaceTabRouteFromPathname( - "/h/local/workspace/L3RtcC9yZXBv/tab/draft_from_path?tabId=draft_from_query&workspaceId=wrong" - ) - ).toEqual({ - serverId: "local", - workspaceId: "/tmp/repo", - tabId: "draft_from_path", - }); + ).toBeNull(); }); it("builds base64url workspace routes", () => { @@ -92,21 +70,50 @@ describe("workspace route parsing", () => { expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent"); }); - it("builds workspace agent tab routes", () => { - expect(buildHostWorkspaceAgentTabRoute("local", "/tmp/repo", "agent-1")).toBe( - "/h/local/workspace/L3RtcC9yZXBv/tab/agent_agent-1" + it("builds workspace routes with open intent query", () => { + expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe( + "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1" ); + expect(buildHostWorkspaceTerminalRoute("local", "/tmp/repo", "term-1")).toBe( + "/h/local/workspace/L3RtcC9yZXBv?open=terminal%3Aterm-1" + ); + expect(buildHostWorkspaceFileRoute("local", "/tmp/repo", "src/index.ts")).toBe( + "/h/local/workspace/L3RtcC9yZXBv?open=file%3Ac3JjL2luZGV4LnRz" + ); + expect( + buildHostWorkspaceRouteWithOpenIntent("local", "/tmp/repo", { + kind: "draft", + draftId: "new", + }) + ).toBe("/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew"); }); - it("builds workspace terminal tab routes", () => { - expect(buildHostWorkspaceTerminalTabRoute("local", "/tmp/repo", "term-1")).toBe( - "/h/local/workspace/L3RtcC9yZXBv/tab/terminal_term-1" - ); + it("parses workspace open intent from pathname query", () => { + expect( + parseHostWorkspaceOpenIntentFromPathname( + "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1" + ) + ).toEqual({ + kind: "agent", + agentId: "agent-1", + }); + expect(parseWorkspaceOpenIntent("terminal:term-1")).toEqual({ + kind: "terminal", + terminalId: "term-1", + }); + expect(parseWorkspaceOpenIntent("draft:new")).toEqual({ + kind: "draft", + draftId: "new", + }); + expect(parseWorkspaceOpenIntent("file:c3JjL2luZGV4LnRz")).toEqual({ + kind: "file", + path: "src/index.ts", + }); }); - it("builds workspace file tab routes", () => { - expect(buildHostWorkspaceFileTabRoute("local", "/tmp/repo", "src/index.ts")).toBe( - "/h/local/workspace/L3RtcC9yZXBv/tab/file_src%2Findex.ts" + it("keeps agent detail workspace routing on workspace path with open intent", () => { + expect(buildHostAgentDetailRoute("local", "agent-1", "/tmp/repo")).toBe( + "/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1" ); }); }); diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 2d91e0be3..40d09d296 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -11,6 +11,17 @@ function stripSearchAndHash(pathname: string): string { return pathname.slice(0, end); } +function extractSearch(pathname: string): string { + const queryIndex = pathname.indexOf("?"); + if (queryIndex < 0) { + return ""; + } + const hashIndex = pathname.indexOf("#", queryIndex); + return hashIndex >= 0 + ? pathname.slice(queryIndex + 1, hashIndex) + : pathname.slice(queryIndex + 1); +} + function trimNonEmpty(value: NullableString): string | null { if (typeof value !== "string") { return null; @@ -88,19 +99,82 @@ function normalizeWorkspaceId(value: string): string { return value.trim().replace(/\\/g, "/").replace(/\/+$/, ""); } -function buildWorkspaceAgentTabId(agentId: string): string | null { - const normalized = trimNonEmpty(agentId); - return normalized ? `agent_${normalized}` : null; +export type WorkspaceOpenIntent = + | { kind: "agent"; agentId: string } + | { kind: "terminal"; terminalId: string } + | { kind: "file"; path: string } + | { kind: "draft"; draftId: string }; + +export function parseWorkspaceOpenIntent( + value: string | null | undefined +): WorkspaceOpenIntent | null { + const normalized = trimNonEmpty(value); + if (!normalized) { + return null; + } + + const separator = normalized.indexOf(":"); + if (separator <= 0 || separator >= normalized.length - 1) { + return null; + } + + const kind = normalized.slice(0, separator); + const payload = trimNonEmpty(normalized.slice(separator + 1)); + if (!payload) { + return null; + } + + if (kind === "agent") { + return { kind: "agent", agentId: payload }; + } + if (kind === "terminal") { + return { kind: "terminal", terminalId: payload }; + } + if (kind === "draft") { + return { kind: "draft", draftId: payload }; + } + if (kind === "file") { + const decodedPath = decodeFilePathFromPathSegment(payload); + if (!decodedPath) { + return null; + } + return { kind: "file", path: decodedPath }; + } + + return null; } -function buildWorkspaceTerminalTabId(terminalId: string): string | null { - const normalized = trimNonEmpty(terminalId); - return normalized ? `terminal_${normalized}` : null; +export function buildWorkspaceOpenIntentParam( + intent: WorkspaceOpenIntent +): string | null { + if (intent.kind === "agent") { + const agentId = trimNonEmpty(intent.agentId); + return agentId ? `agent:${agentId}` : null; + } + if (intent.kind === "terminal") { + const terminalId = trimNonEmpty(intent.terminalId); + return terminalId ? `terminal:${terminalId}` : null; + } + if (intent.kind === "draft") { + const draftId = trimNonEmpty(intent.draftId); + return draftId ? `draft:${draftId}` : null; + } + const normalizedPath = trimNonEmpty(intent.path); + if (!normalizedPath) { + return null; + } + const encodedPath = encodeFilePathForPathSegment(normalizedPath); + return encodedPath ? `file:${encodedPath}` : null; } -function buildWorkspaceFileTabId(filePath: string): string | null { - const normalized = trimNonEmpty(filePath); - return normalized ? `file_${normalized.replace(/\\/g, "/")}` : null; +export function parseHostWorkspaceOpenIntentFromPathname( + pathname: string +): WorkspaceOpenIntent | null { + const search = extractSearch(pathname); + if (!search) { + return null; + } + return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open")); } export function encodeWorkspaceIdForPathSegment(workspaceId: string): string { @@ -203,35 +277,17 @@ export function parseHostWorkspaceRouteFromPathname( pathname: string ): { serverId: string; workspaceId: string } | null { const pathOnly = stripSearchAndHash(pathname); - const prefix = "/h/"; - if (!pathOnly.startsWith(prefix)) { + const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/); + if (!match) { return null; } - const serverIdStart = prefix.length; - const serverIdEnd = pathOnly.indexOf("/", serverIdStart); - if (serverIdEnd < 0) { - return null; - } - const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd); - const serverId = trimNonEmpty(decodeSegment(rawServerId)); + const serverId = trimNonEmpty(decodeSegment(match[1])); if (!serverId) { return null; } - const workspacePrefix = "/workspace/"; - if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) { - return null; - } - - const workspaceIdStart = serverIdEnd + workspacePrefix.length; - let workspaceIdEnd = pathOnly.length; - const tabIdx = pathOnly.lastIndexOf("/tab/"); - if (tabIdx >= 0 && tabIdx > workspaceIdStart) { - workspaceIdEnd = tabIdx; - } - - const rawWorkspaceId = pathOnly.slice(workspaceIdStart, workspaceIdEnd).replace(/\/+$/, ""); + const rawWorkspaceId = match[2]; const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId); if (!workspaceId) { return null; @@ -239,56 +295,6 @@ export function parseHostWorkspaceRouteFromPathname( return { serverId, workspaceId }; } -export function parseHostWorkspaceTabRouteFromPathname( - pathname: string -): { serverId: string; workspaceId: string; tabId: string } | null { - const pathOnly = stripSearchAndHash(pathname); - const prefix = "/h/"; - if (!pathOnly.startsWith(prefix)) { - return null; - } - - const serverIdStart = prefix.length; - const serverIdEnd = pathOnly.indexOf("/", serverIdStart); - if (serverIdEnd < 0) { - return null; - } - const rawServerId = pathOnly.slice(serverIdStart, serverIdEnd); - const serverId = trimNonEmpty(decodeSegment(rawServerId)); - if (!serverId) { - return null; - } - - const workspacePrefix = "/workspace/"; - if (!pathOnly.startsWith(workspacePrefix, serverIdEnd)) { - return null; - } - - const workspaceIdStart = serverIdEnd + workspacePrefix.length; - const tabMarker = "/tab/"; - const tabIdx = pathOnly.lastIndexOf(tabMarker); - if (tabIdx < 0 || tabIdx <= workspaceIdStart) { - return null; - } - - const rawWorkspaceId = pathOnly.slice(workspaceIdStart, tabIdx).replace(/\/+$/, ""); - const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId); - if (!workspaceId) { - return null; - } - - const tabIdStart = tabIdx + tabMarker.length; - const tabIdEnd = pathOnly.indexOf("/", tabIdStart); - const rawTabId = - tabIdEnd < 0 ? pathOnly.slice(tabIdStart) : pathOnly.slice(tabIdStart, tabIdEnd); - const tabId = trimNonEmpty(decodeSegment(rawTabId)); - if (!tabId) { - return null; - } - - return { serverId, workspaceId, tabId }; -} - export function buildHostWorkspaceRoute( serverId: string, workspaceId: string @@ -305,53 +311,53 @@ export function buildHostWorkspaceRoute( return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`; } -export function buildHostWorkspaceAgentTabRoute( +export function buildHostWorkspaceRouteWithOpenIntent( + serverId: string, + workspaceId: string, + intent: WorkspaceOpenIntent +): string { + const base = buildHostWorkspaceRoute(serverId, workspaceId); + if (base === "/") { + return "/"; + } + const open = buildWorkspaceOpenIntentParam(intent); + if (!open) { + return "/"; + } + return `${base}?open=${encodeURIComponent(open)}`; +} + +export function buildHostWorkspaceAgentRoute( serverId: string, workspaceId: string, agentId: string ): string { - const tabId = buildWorkspaceAgentTabId(agentId); - if (!tabId) { - return "/"; - } - return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId); + return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, { + kind: "agent", + agentId, + }); } -export function buildHostWorkspaceTerminalTabRoute( +export function buildHostWorkspaceTerminalRoute( serverId: string, workspaceId: string, terminalId: string ): string { - const tabId = buildWorkspaceTerminalTabId(terminalId); - if (!tabId) { - return "/"; - } - return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId); + return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, { + kind: "terminal", + terminalId, + }); } -export function buildHostWorkspaceTabRoute( - serverId: string, - workspaceId: string, - tabId: string -): string { - const base = buildHostWorkspaceRoute(serverId, workspaceId); - const normalizedTabId = trimNonEmpty(tabId); - if (base === "/" || !normalizedTabId) { - return "/"; - } - return `${base}/tab/${encodeSegment(normalizedTabId)}`; -} - -export function buildHostWorkspaceFileTabRoute( +export function buildHostWorkspaceFileRoute( serverId: string, workspaceId: string, filePath: string ): string { - const tabId = buildWorkspaceFileTabId(filePath); - if (!tabId) { - return "/"; - } - return buildHostWorkspaceTabRoute(serverId, workspaceId, tabId); + return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, { + kind: "file", + path: filePath, + }); } export function buildHostAgentDetailRoute( @@ -361,7 +367,7 @@ export function buildHostAgentDetailRoute( ): string { const normalizedWorkspaceId = trimNonEmpty(workspaceId); if (normalizedWorkspaceId) { - return buildHostWorkspaceAgentTabRoute( + return buildHostWorkspaceAgentRoute( serverId, normalizedWorkspaceId, agentId @@ -429,8 +435,21 @@ export function mapPathnameToServer( if (suffix.startsWith("new-agent")) { return `${base}/new-agent`; } - if (suffix.startsWith("workspace/")) { - return `${base}/${suffix}`; + const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); + if (workspaceRoute) { + const workspacePath = buildHostWorkspaceRoute( + normalized, + workspaceRoute.workspaceId + ); + const openIntent = parseHostWorkspaceOpenIntentFromPathname(pathname); + if (!openIntent) { + return workspacePath; + } + return buildHostWorkspaceRouteWithOpenIntent( + normalized, + workspaceRoute.workspaceId, + openIntent + ); } if (suffix.startsWith("agent/")) { return `${base}/${suffix}`; diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index 90e90520c..2372b63e8 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -79,7 +79,7 @@ describe("resolveSelectedAgentForNewAgent", () => { it("prefers the agent in the current route", () => { expect( resolveSelectedAgentForNewAgent({ - pathname: "/h/srv-1/workspace/%2Frepo/tab/agent_agent-2", + pathname: "/h/srv-1/workspace/L3JlcG8?open=agent%3Aagent-2", selectedAgentId: "srv-9:agent-9", }) ).toEqual({ diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts index 6383a068b..931daf001 100644 --- a/packages/app/src/utils/new-agent-routing.ts +++ b/packages/app/src/utils/new-agent-routing.ts @@ -1,8 +1,9 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; import { + parseHostWorkspaceOpenIntentFromPathname, buildHostWorkspaceRoute, parseHostAgentRouteFromPathname, - parseHostWorkspaceTabRouteFromPathname, + parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; export function parseAgentKey( @@ -27,11 +28,12 @@ export function resolveSelectedAgentForNewAgent(input: { pathname: string; selectedAgentId?: string; }): { serverId: string; agentId: string } | null { - const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname); - if (workspaceTabRoute?.tabId?.startsWith("agent_")) { - const agentId = workspaceTabRoute.tabId.slice("agent_".length).trim(); + const workspaceRoute = parseHostWorkspaceRouteFromPathname(input.pathname); + const openIntent = parseHostWorkspaceOpenIntentFromPathname(input.pathname); + if (workspaceRoute && openIntent?.kind === "agent") { + const agentId = openIntent.agentId.trim(); if (agentId) { - return { serverId: workspaceTabRoute.serverId, agentId }; + return { serverId: workspaceRoute.serverId, agentId }; } } return ( diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index ee85ab628..033bee811 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -34,14 +34,14 @@ describe("resolveNotificationTarget", () => { }); describe("buildNotificationRoute", () => { - it("routes directly to workspace tab when workspace id is present", () => { + it("routes to workspace with one-shot open intent when workspace id is present", () => { expect( buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1", workspaceId: "/tmp/repo", }) - ).toBe("/h/srv-1/workspace/L3RtcC9yZXBv/tab/agent_agent-1"); + ).toBe("/h/srv-1/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"); }); it("routes directly to server-scoped agent path when both ids are present", () => { diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index 11befbfb9..f8dd1244b 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,7 +1,7 @@ import { buildHostAgentDetailRoute, buildHostRootRoute, - buildHostWorkspaceAgentTabRoute, + buildHostWorkspaceAgentRoute, } from "@/utils/host-routes"; type NotificationData = Record | null | undefined; @@ -36,7 +36,7 @@ export function buildNotificationRoute(data: NotificationData): string { const { serverId, agentId, workspaceId } = resolveNotificationTarget(data); if (serverId && agentId) { if (workspaceId) { - return buildHostWorkspaceAgentTabRoute(serverId, workspaceId, agentId); + return buildHostWorkspaceAgentRoute(serverId, workspaceId, agentId); } return buildHostAgentDetailRoute(serverId, agentId); }