diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index 441874ee9..e70977779 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -2,10 +2,7 @@ import { expect, type Page } from "@playwright/test"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { randomUUID } from "node:crypto"; -import { - buildHostWorkspaceAgentRoute, - buildHostWorkspaceRoute, -} from "../../src/utils/host-routes"; +import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; const NEAR_BOTTOM_THRESHOLD_PX = 72; @@ -162,7 +159,7 @@ export async function seedBottomAnchorAgent(input: { id: created.id, title, expectedTailText, - url: buildHostWorkspaceAgentRoute(getServerId(), input.cwd, created.id), + url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`, workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd), }; } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 54ef75896..91ccb4026 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -4,8 +4,8 @@ import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildHostRootRoute, - buildHostWorkspaceAgentRoute, } from "@/utils/host-routes"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; export default function HostAgentReadyRoute() { const router = useRouter(); @@ -39,7 +39,11 @@ export default function HostAgentReadyRoute() { if (normalizedCwd) { redirectedRef.current = true; router.replace( - buildHostWorkspaceAgentRoute(serverId, normalizedCwd, agentId) as any + prepareWorkspaceTab({ + serverId, + workspaceId: normalizedCwd, + target: { kind: "agent", agentId }, + }) as any ); } }, [agentCwd, agentId, router, serverId]); @@ -78,7 +82,13 @@ export default function HostAgentReadyRoute() { const cwd = result?.agent?.cwd?.trim(); redirectedRef.current = true; if (cwd) { - router.replace(buildHostWorkspaceAgentRoute(serverId, cwd, agentId) as any); + router.replace( + prepareWorkspaceTab({ + serverId, + workspaceId: cwd, + target: { kind: "agent", 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 ae00cb487..57b46f9c5 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -5,9 +5,9 @@ import { useFormPreferences } from "@/hooks/use-form-preferences"; import { buildHostOpenProjectRoute, buildHostRootRoute, - buildHostWorkspaceAgentRoute, buildHostWorkspaceRoute, } from "@/utils/host-routes"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; const HOST_ROOT_REDIRECT_DELAY_MS = 300; @@ -59,11 +59,11 @@ export default function HostIndexRoute() { const primaryAgent = visibleAgents[0]; if (primaryAgent?.cwd?.trim()) { router.replace( - buildHostWorkspaceAgentRoute( + prepareWorkspaceTab({ serverId, - primaryAgent.cwd.trim(), - primaryAgent.id - ) as any + workspaceId: primaryAgent.cwd.trim(), + target: { kind: "agent", agentId: primaryAgent.id }, + }) as any ); return; } 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 a9b66fb62..6c81f9374 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,14 +1,42 @@ -import { useCallback } from 'react' +import { useEffect, useRef } from 'react' import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router' +import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store' import { WorkspaceScreen } from '@/screens/workspace/workspace-screen' import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, parseWorkspaceOpenIntent, + type WorkspaceOpenIntent, } from '@/utils/host-routes' +import { prepareWorkspaceTab } from '@/utils/workspace-navigation' + +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 } + } + return { kind: 'draft', draftId: openIntent.draftId } +} export default function HostWorkspaceLayout() { const router = useRouter() + const consumedIntentRef = useRef(null) const params = useLocalSearchParams<{ serverId?: string | string[] workspaceId?: string | string[] @@ -16,29 +44,44 @@ export default function HostWorkspaceLayout() { const globalParams = useGlobalSearchParams<{ open?: string | string[] }>() - const serverValue = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId - const workspaceValue = Array.isArray(params.workspaceId) - ? params.workspaceId[0] - : params.workspaceId - const serverId = serverValue?.trim() ?? '' + const serverId = getParamValue(params.serverId) + const workspaceValue = getParamValue(params.workspaceId) const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : '' - const openValue = Array.isArray(globalParams.open) ? globalParams.open[0] : globalParams.open - const openIntent = parseWorkspaceOpenIntent(openValue) + const openValue = getParamValue(globalParams.open) - const handleOpenIntentConsumed = useCallback( - function handleOpenIntentConsumed() { - router.replace(buildHostWorkspaceRoute(serverId, workspaceId) as any) - }, - [router, serverId, workspaceId] - ) + useEffect(() => { + if (!openValue) { + return + } + + const consumptionKey = `${serverId}:${workspaceId}:${openValue}` + if (consumedIntentRef.current === consumptionKey) { + return + } + consumedIntentRef.current = consumptionKey + + const openIntent = parseWorkspaceOpenIntent(openValue) + const route = openIntent + ? prepareWorkspaceTab({ + serverId, + workspaceId, + target: getOpenIntentTarget(openIntent), + pin: openIntent.kind === 'agent', + }) + : buildHostWorkspaceRoute(serverId, workspaceId) + + router.replace(route as any) + }, [openValue, router, serverId, workspaceId]) + + if (openValue) { + return null + } return ( ) } diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index bcce7fdef..f5d1d0738 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -9,14 +9,14 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useCallback, useMemo, useState, type ReactElement } from 'react' -import { router, type Href } from 'expo-router' +import { router } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { formatTimeAgo } from '@/utils/time' import { shortenPath } from '@/utils/shorten-path' import { type AggregatedAgent } from '@/hooks/use-aggregated-agents' import { useSessionStore } from '@/stores/session-store' import { AgentStatusDot } from '@/components/agent-status-dot' -import { buildHostWorkspaceAgentRoute } from '@/utils/host-routes' +import { prepareWorkspaceTab } from '@/utils/workspace-navigation' interface AgentListProps { agents: AggregatedAgent[] @@ -272,8 +272,12 @@ export function AgentList({ onAgentSelect?.() - const route: Href = buildHostWorkspaceAgentRoute(serverId, agent.cwd, agentId) as Href - router.navigate(route) + const route = prepareWorkspaceTab({ + serverId, + workspaceId: agent.cwd, + target: { kind: 'agent', agentId }, + }) + router.navigate(route as any) }, [isActionSheetVisible, onAgentSelect] ) diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index a55ac7ec3..8b7fb7992 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -67,8 +67,8 @@ import { import { createMarkdownStyles } from "@/styles/markdown-styles"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { getMarkdownListMarker } from "@/utils/markdown-list"; -import { buildHostWorkspaceFileRoute } from "@/utils/host-routes"; import { normalizeInlinePathTarget } from "@/utils/inline-path"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { getWorkingIndicatorDotStrength, WORKING_INDICATOR_CYCLE_MS, @@ -171,12 +171,12 @@ export const AgentStreamView = forwardRef { - const route: Href = buildHostWorkspaceAgentRoute( - selectedServerId as string, - result.cwd, - result.id - ) as Href - router.replace(route) + const route = prepareWorkspaceTab({ + serverId: selectedServerId as string, + workspaceId: result.cwd, + target: { kind: 'agent', agentId: result.id }, + }) + router.replace(route as any) }, }) useEffect(() => { diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index deb06b4b6..eb77e1fa9 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { - canOpenAgentTabFromRoute, deriveWorkspaceAgentVisibility, shouldPruneWorkspaceAgentTab, workspaceAgentVisibilityEqual, @@ -89,18 +88,6 @@ describe("workspace agent visibility", () => { expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); }); - it("allows explicit route open for archived agent once agents are hydrated", () => { - const knownAgentIds = new Set(["archived-agent"]); - - expect( - canOpenAgentTabFromRoute({ - agentId: "archived-agent", - agentsHydrated: true, - knownAgentIds, - }) - ).toBe(true); - }); - it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => { const knownAgentIds = new Set(["archived-agent"]); const activeAgentIds = new Set(); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index bc4ab9bf2..4aa63d3cf 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -57,24 +57,8 @@ function setsEqual(a: Set, b: Set): boolean { return true; } -export function canOpenAgentTabFromRoute(input: { - agentId: string; - agentsHydrated: boolean; - knownAgentIds: Set; -}): boolean { - if (!input.agentId.trim()) { - return false; - } - if (!input.agentsHydrated) { - return true; - } - return input.knownAgentIds.has(input.agentId); -} - // Prune agent tabs that are unknown (deleted) or archived. // Archived agents get pruned so that archiving on one client closes the tab on all clients. -// Users can still explicitly open archived agents via navigation (openIntent), -// which re-creates the tab in a separate effect. export function shouldPruneWorkspaceAgentTab(input: { agentId: string; agentsHydrated: boolean; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 91843c2cf..d625539c0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -64,8 +64,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { - buildWorkspaceOpenIntentParam, - type WorkspaceOpenIntent, decodeWorkspaceIdFromPathSegment, } from "@/utils/host-routes"; import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity"; @@ -103,7 +101,6 @@ import { } from "@/screens/workspace/workspace-agent-visibility"; import { deriveWorkspacePaneState, - type WorkspaceDerivedTab, } from "@/screens/workspace/workspace-pane-state"; import { buildWorkspacePaneContentModel, @@ -126,8 +123,6 @@ const EMPTY_SET = new Set(); type WorkspaceScreenProps = { serverId: string; workspaceId: string; - openIntent?: WorkspaceOpenIntent | null; - onOpenIntentConsumed?: () => void; }; function trimNonEmpty(value: string | null | undefined): string | null { @@ -146,49 +141,6 @@ function decodeSegment(value: string): string { } } -function buildOpenIntentKey(input: { - serverId: string; - workspaceId: string; - openIntent?: WorkspaceOpenIntent | null; -}): string | null { - if (!input.openIntent) { - return null; - } - const openParam = buildWorkspaceOpenIntentParam(input.openIntent); - if (!openParam) { - return null; - } - return `${input.serverId}:${input.workspaceId}:${openParam}`; -} - -function openIntentMatchesActiveTab(input: { - openIntent?: WorkspaceOpenIntent | null; - activeTab: WorkspaceDerivedTab | null; -}): boolean { - const { openIntent, activeTab } = input; - if (!openIntent || !activeTab) { - return false; - } - - const target = activeTab.descriptor.target; - if (openIntent.kind !== target.kind) { - return false; - } - if (openIntent.kind === "agent" && target.kind === "agent") { - return target.agentId === openIntent.agentId; - } - if (openIntent.kind === "terminal" && target.kind === "terminal") { - return target.terminalId === openIntent.terminalId; - } - if (openIntent.kind === "file" && target.kind === "file") { - return target.path === openIntent.path; - } - if (openIntent.kind === "draft" && target.kind === "draft") { - return openIntent.draftId === "new" || target.draftId === openIntent.draftId; - } - return false; -} - function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; @@ -509,8 +461,6 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ export function WorkspaceScreen({ serverId, workspaceId, - openIntent, - onOpenIntentConsumed, }: WorkspaceScreenProps) { const isFocused = useIsFocused(); @@ -523,8 +473,6 @@ export function WorkspaceScreen({ ); @@ -563,8 +511,6 @@ function useCloseTabs(): UseCloseTabsResult { function WorkspaceScreenContent({ serverId, workspaceId, - openIntent, - onOpenIntentConsumed, }: WorkspaceScreenProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); @@ -846,7 +792,6 @@ function WorkspaceScreenContent({ const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab); const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); - const pinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.pinAgent); const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); @@ -861,33 +806,7 @@ function WorkspaceScreenContent({ : EMPTY_PINNED_AGENT_IDS ); const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); - const consumedOpenIntentsRef = useRef(new Set()); const { closingTabIds, closeTab } = useCloseTabs(); - const [resolvedOpenIntentKey, setResolvedOpenIntentKey] = useState(null); - const currentOpenIntentKey = useMemo( - () => - buildOpenIntentKey({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - openIntent, - }), - [normalizedServerId, normalizedWorkspaceId, openIntent] - ); - const currentOpenIntentPinnedAgentId = useMemo( - function getCurrentOpenIntentPinnedAgentId() { - if (!openIntent || openIntent.kind !== "agent" || !hasHydratedAgents) { - return null; - } - if (!workspaceAgentVisibility.knownAgentIds.has(openIntent.agentId)) { - return null; - } - if (workspaceAgentVisibility.activeAgentIds.has(openIntent.agentId)) { - return null; - } - return openIntent.agentId; - }, - [hasHydratedAgents, openIntent, workspaceAgentVisibility] - ); const closeWorkspaceTabWithCleanup = useCallback( function closeWorkspaceTabWithCleanup(input: { tabId: string; @@ -984,96 +903,6 @@ function WorkspaceScreenContent({ [ensureWorkspaceTab, focusWorkspaceTab, openWorkspaceTab, persistenceKey] ); - useEffect(() => { - if (!currentOpenIntentKey) { - if (resolvedOpenIntentKey !== null) { - setResolvedOpenIntentKey(null); - } - return; - } - - if (resolvedOpenIntentKey === currentOpenIntentKey) { - return; - } - }, [currentOpenIntentKey, resolvedOpenIntentKey]); - - useEffect(() => { - if (!openIntent || !persistenceKey) { - return; - } - - if (!currentOpenIntentKey) { - return; - } - - const intentKey = currentOpenIntentKey; - if (consumedOpenIntentsRef.current.has(intentKey)) { - if (resolvedOpenIntentKey !== intentKey) { - setResolvedOpenIntentKey(intentKey); - } - return; - } - consumedOpenIntentsRef.current.add(intentKey); - - if (currentOpenIntentPinnedAgentId) { - pinWorkspaceAgent(persistenceKey, currentOpenIntentPinnedAgentId); - } - - if (openIntent.kind === "draft") { - const draftId = openIntent.draftId.trim(); - const tabId = openWorkspaceDraftTab({ - ...(draftId === "new" ? {} : { draftId }), - focus: true, - }); - onOpenIntentConsumed?.(); - return; - } - - const target: WorkspaceTabTarget = - openIntent.kind === "agent" - ? { kind: "agent", agentId: openIntent.agentId } - : openIntent.kind === "terminal" - ? { kind: "terminal", terminalId: openIntent.terminalId } - : { kind: "file", path: openIntent.path }; - const tabId = openWorkspaceTab(persistenceKey, target); - if (tabId) { - focusWorkspaceTab(persistenceKey, tabId); - } - onOpenIntentConsumed?.(); - }, [ - currentOpenIntentPinnedAgentId, - currentOpenIntentKey, - focusWorkspaceTab, - onOpenIntentConsumed, - openIntent, - openWorkspaceDraftTab, - openWorkspaceTab, - pinWorkspaceAgent, - persistenceKey, - ]); - - const unresolvedOpenIntent = currentOpenIntentKey && resolvedOpenIntentKey !== currentOpenIntentKey - ? openIntent - : null; - const resolvedPaneTabState = useMemo( - () => - deriveWorkspacePaneState({ - layout: workspaceLayout, - tabs: uiTabs, - preferredTarget: - unresolvedOpenIntent?.kind === "agent" - ? { kind: "agent", agentId: unresolvedOpenIntent.agentId } - : unresolvedOpenIntent?.kind === "terminal" - ? { kind: "terminal", terminalId: unresolvedOpenIntent.terminalId } - : unresolvedOpenIntent?.kind === "draft" - ? { kind: "draft", draftId: unresolvedOpenIntent.draftId } - : unresolvedOpenIntent?.kind === "file" - ? { kind: "file", path: unresolvedOpenIntent.path } - : null, - }), - [uiTabs, unresolvedOpenIntent, workspaceLayout] - ); - useEffect(() => { if (!normalizedServerId || !normalizedWorkspaceId || !persistenceKey) { return; @@ -1115,7 +944,6 @@ function WorkspaceScreenContent({ canPruneAgentTabs && tab.target.kind === "agent" && !pinnedAgentIds.has(tab.target.agentId) && - tab.target.agentId !== currentOpenIntentPinnedAgentId && shouldPruneWorkspaceAgentTab({ agentId: tab.target.agentId, agentsHydrated: hasHydratedAgents, @@ -1135,7 +963,6 @@ function WorkspaceScreenContent({ } }, [ closeWorkspaceTabWithCleanup, - currentOpenIntentPinnedAgentId, ensureWorkspaceTab, hasHydratedAgents, pendingByDraftId, @@ -1147,25 +974,8 @@ function WorkspaceScreenContent({ workspaceAgentVisibility, ]); - const activeTabId = resolvedPaneTabState.activeTabId; - const activeTab = resolvedPaneTabState.activeTab; - - useEffect(() => { - if (!openIntent || !currentOpenIntentKey) { - return; - } - if (resolvedOpenIntentKey === currentOpenIntentKey) { - return; - } - if ( - openIntentMatchesActiveTab({ - openIntent, - activeTab, - }) - ) { - setResolvedOpenIntentKey(currentOpenIntentKey); - } - }, [activeTab, currentOpenIntentKey, openIntent, resolvedOpenIntentKey]); + const activeTabId = focusedPaneTabState.activeTabId; + const activeTab = focusedPaneTabState.activeTab; useEffect(() => { if (!activeTabId || !persistenceKey) { @@ -1175,8 +985,8 @@ function WorkspaceScreenContent({ }, [activeTabId, focusWorkspaceTab, persistenceKey]); const tabs = useMemo( - () => resolvedPaneTabState.tabs.map((tab) => tab.descriptor), - [resolvedPaneTabState.tabs] + () => focusedPaneTabState.tabs.map((tab) => tab.descriptor), + [focusedPaneTabState.tabs] ); const navigateToTabId = useCallback( @@ -1194,10 +1004,6 @@ function WorkspaceScreenContent({ if (!persistenceKey) { return; } - if (openIntent) { - emptyWorkspaceSeedRef.current = null; - return; - } if (workspaceAgentVisibility.activeAgentIds.size > 0 || terminals.length > 0) { emptyWorkspaceSeedRef.current = null; return; @@ -1215,7 +1021,6 @@ function WorkspaceScreenContent({ }, [ normalizedServerId, normalizedWorkspaceId, - openIntent, openWorkspaceDraftTab, persistenceKey, terminals.length, diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 70784fa91..fcfcee944 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildHostAgentDetailRoute, buildHostRootRoute, - buildHostWorkspaceAgentRoute, - buildHostWorkspaceFileRoute, buildHostWorkspaceRoute, - buildHostWorkspaceRouteWithOpenIntent, - buildHostWorkspaceTerminalRoute, decodeFilePathFromPathSegment, decodeWorkspaceIdFromPathSegment, encodeFilePathForPathSegment, @@ -65,24 +61,6 @@ describe("workspace route parsing", () => { expect(buildHostRootRoute("local")).toBe("/h/local"); }); - 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("parses workspace open intent from pathname query", () => { expect( parseHostWorkspaceOpenIntentFromPathname( @@ -106,7 +84,7 @@ describe("workspace route parsing", () => { }); }); - it("keeps agent detail workspace routing on workspace path with open intent", () => { + it("uses the plain workspace route when workspace context is provided", () => { 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 f912c67e5..902f7ac4f 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -144,29 +144,6 @@ export function parseWorkspaceOpenIntent( return 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; -} - export function parseHostWorkspaceOpenIntentFromPathname( pathname: string ): WorkspaceOpenIntent | null { @@ -311,55 +288,6 @@ export function buildHostWorkspaceRoute( return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`; } -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 { - return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, { - kind: "agent", - agentId, - }); -} - -export function buildHostWorkspaceTerminalRoute( - serverId: string, - workspaceId: string, - terminalId: string -): string { - return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, { - kind: "terminal", - terminalId, - }); -} - -export function buildHostWorkspaceFileRoute( - serverId: string, - workspaceId: string, - filePath: string -): string { - return buildHostWorkspaceRouteWithOpenIntent(serverId, workspaceId, { - kind: "file", - path: filePath, - }); -} - export function buildHostAgentDetailRoute( serverId: string, agentId: string, @@ -367,11 +295,15 @@ export function buildHostAgentDetailRoute( ): string { const normalizedWorkspaceId = trimNonEmpty(workspaceId); if (normalizedWorkspaceId) { - return buildHostWorkspaceAgentRoute( - serverId, - normalizedWorkspaceId, - agentId - ); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedAgentId) { + return "/"; + } + const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId); + if (base === "/") { + return "/"; + } + return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`; } const normalizedServerId = trimNonEmpty(serverId); const normalizedAgentId = trimNonEmpty(agentId); @@ -437,19 +369,10 @@ export function mapPathnameToServer( } const workspaceRoute = parseHostWorkspaceRouteFromPathname(pathname); if (workspaceRoute) { - const workspacePath = buildHostWorkspaceRoute( + return 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/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index f8dd1244b..731214080 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,7 +1,7 @@ import { buildHostAgentDetailRoute, buildHostRootRoute, - buildHostWorkspaceAgentRoute, + buildHostWorkspaceRoute, } from "@/utils/host-routes"; type NotificationData = Record | null | undefined; @@ -36,7 +36,8 @@ export function buildNotificationRoute(data: NotificationData): string { const { serverId, agentId, workspaceId } = resolveNotificationTarget(data); if (serverId && agentId) { if (workspaceId) { - return buildHostWorkspaceAgentRoute(serverId, workspaceId, agentId); + const base = buildHostWorkspaceRoute(serverId, workspaceId); + return `${base}?open=${encodeURIComponent(`agent:${agentId}`)}`; } return buildHostAgentDetailRoute(serverId, agentId); } diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts new file mode 100644 index 000000000..6eb564f9d --- /dev/null +++ b/packages/app/src/utils/workspace-navigation.ts @@ -0,0 +1,42 @@ +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { generateDraftId } from "@/stores/draft-keys"; +import { + buildWorkspaceTabPersistenceKey, + type WorkspaceTabTarget, +} from "@/stores/workspace-tabs-store"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +interface PrepareWorkspaceTabInput { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + pin?: boolean; +} + +function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget { + if (target.kind !== "draft" || target.draftId.trim() !== "new") { + return target; + } + return { kind: "draft", draftId: generateDraftId() }; +} + +export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string { + const target = getPreparedTarget(input.target); + const key = + buildWorkspaceTabPersistenceKey({ + serverId: input.serverId, + workspaceId: input.workspaceId, + }) ?? ""; + + const tabId = useWorkspaceLayoutStore.getState().openTab(key, target); + + if (tabId) { + useWorkspaceLayoutStore.getState().focusTab(key, tabId); + } + + if (input.pin && target.kind === "agent") { + useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId); + } + + return buildHostWorkspaceRoute(input.serverId, input.workspaceId); +}