From f47ea7afe5db7ee4146e746deced377983370797 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Mar 2026 15:09:06 +0700 Subject: [PATCH] refactor: centralize host route builders and add dedicated new-agent route --- packages/app/src/app/_layout.tsx | 7 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 7 +- .../app/src/app/h/[serverId]/new-agent.tsx | 9 ++ .../workspace/[workspaceId]/_layout.tsx | 34 +++-- packages/app/src/app/index.tsx | 3 +- packages/app/src/app/pair-scan.tsx | 3 +- packages/app/src/components/left-sidebar.tsx | 65 ++++------ .../app/src/components/welcome-screen.tsx | 3 +- packages/app/src/hooks/use-command-center.ts | 36 +----- .../app/src/hooks/use-keyboard-shortcuts.ts | 33 +---- .../src/screens/agent/agent-ready-screen.tsx | 3 +- packages/app/src/screens/agents-screen.tsx | 3 +- .../workspace/workspace-draft-agent-tab.tsx | 122 ++++++++++-------- packages/app/src/utils/host-routes.test.ts | 10 ++ packages/app/src/utils/host-routes.ts | 33 ++++- .../app/src/utils/notification-routing.ts | 3 +- .../server/src/server/worktree-bootstrap.ts | 50 ++++++- 17 files changed, 231 insertions(+), 193 deletions(-) create mode 100644 packages/app/src/app/h/[serverId]/new-agent.tsx diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 92e3561b3..294943631 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -45,6 +45,7 @@ import { } from "@/utils/os-notifications"; import { buildNotificationRoute } from "@/utils/notification-routing"; import { + buildHostRootRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceTabRouteFromPathname, } from "@/utils/host-routes"; @@ -350,7 +351,7 @@ function OfferLinkListener({ if (cancelled) return; const serverId = (profile as any)?.serverId; if (typeof serverId !== "string" || !serverId) return; - router.replace(`/h/${encodeURIComponent(serverId)}` as any); + router.replace(buildHostRootRoute(serverId) as any); }) .catch((error) => { if (cancelled) return; @@ -471,14 +472,14 @@ export default function RootLayout() { > - - + + diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 277529c9f..7413bae15 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { + buildHostRootRoute, buildHostWorkspaceAgentTabRoute, } from "@/utils/host-routes"; @@ -54,7 +55,7 @@ export default function HostAgentReadyRoute() { } if (!client || !isConnected) { redirectedRef.current = true; - router.replace(`/h/${encodeURIComponent(serverId)}` as any); + router.replace(buildHostRootRoute(serverId) as any); } }, [agentCwd, agentId, client, isConnected, router, serverId]); @@ -79,14 +80,14 @@ export default function HostAgentReadyRoute() { router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any); return; } - router.replace(`/h/${encodeURIComponent(serverId)}` as any); + router.replace(buildHostRootRoute(serverId) as any); }) .catch(() => { if (cancelled || redirectedRef.current) { return; } redirectedRef.current = true; - router.replace(`/h/${encodeURIComponent(serverId)}` as any); + router.replace(buildHostRootRoute(serverId) as any); }); return () => { diff --git a/packages/app/src/app/h/[serverId]/new-agent.tsx b/packages/app/src/app/h/[serverId]/new-agent.tsx new file mode 100644 index 000000000..64bcc2833 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/new-agent.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; +import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; + +export default function HostNewAgentRoute() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + + 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 45d3c56ee..3d0dff7a3 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,20 +1,32 @@ -import { useLocalSearchParams } from "expo-router"; +import { useLocalSearchParams, usePathname } from "expo-router"; import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; +import { + parseHostWorkspaceRouteFromPathname, + parseHostWorkspaceTabRouteFromPathname, +} from "@/utils/host-routes"; + +function readNonEmptyParam(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} export default function HostWorkspaceLayout() { - const params = useLocalSearchParams<{ - serverId?: string; - workspaceId?: string; - tabId?: string; - }>(); - - const tabId = typeof params.tabId === "string" ? params.tabId : ""; + const pathname = usePathname(); + const params = useLocalSearchParams<{ tabId?: string }>(); + const tabRoute = parseHostWorkspaceTabRouteFromPathname(pathname); + const activeRoute = tabRoute ?? parseHostWorkspaceRouteFromPathname(pathname); + const serverId = activeRoute?.serverId ?? ""; + const workspaceId = activeRoute?.workspaceId ?? ""; + const routeTabId = tabRoute?.tabId ?? readNonEmptyParam(params.tabId); return ( ); } diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 71049b6cc..7b86312aa 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -5,6 +5,7 @@ import { useUnistyles } from "react-native-unistyles"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useFormPreferences } from "@/hooks/use-form-preferences"; +import { buildHostRootRoute } from "@/utils/host-routes"; export default function Index() { const router = useRouter(); @@ -44,7 +45,7 @@ export default function Index() { if (!targetServerId) { return; } - router.replace(`/h/${encodeURIComponent(targetServerId)}` as any); + router.replace(buildHostRootRoute(targetServerId) as any); }, [preferencesLoading, registryLoading, router, targetServerId]); if (registryLoading || preferencesLoading) { diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index ca15c11fd..60cd9989e 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -12,6 +12,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en import { probeConnection } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { + buildHostRootRoute, buildHostSettingsRoute, } from "@/utils/host-routes"; @@ -169,7 +170,7 @@ export default function PairScanScreen() { const returnToSource = useCallback( (serverId: string) => { if (source === "onboarding") { - router.replace(`/h/${encodeURIComponent(serverId)}` as any); + router.replace(buildHostRootRoute(serverId) as any); return; } if (source === "editHost" && targetServerId) { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index cf4eb428c..0b3f171b9 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -21,21 +21,11 @@ import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-wind import { Combobox } from '@/components/ui/combobox' import { useDaemonRegistry } from '@/contexts/daemon-registry-context' import { getHostRuntimeStore } from '@/runtime/host-runtime' -import { useSessionStore } from '@/stores/session-store' import { formatConnectionStatus } from '@/utils/daemons' import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' -import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, -} from '@/hooks/use-checkout-status-query' -import { queryClient } from '@/query/query-client' -import { - buildNewAgentRoute, - resolveNewAgentWorkingDir, - resolveSelectedAgentForNewAgent, -} from '@/utils/new-agent-routing' import { buildHostAgentsRoute, + buildHostNewAgentRoute, buildHostSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, @@ -47,7 +37,7 @@ interface LeftSidebarProps { selectedAgentId?: string } -export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { +export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { const { theme } = useUnistyles() const insets = useSafeAreaInsets() const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' @@ -79,13 +69,24 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { .join('|') ) const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]) - const activeServerId = activeServerIdFromPath ?? daemons[0]?.serverId ?? null + const activeDaemon = useMemo(() => { + if (daemons.length === 0) { + return null + } + if (activeServerIdFromPath) { + const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath) + if (routeMatch) { + return routeMatch + } + } + return daemons[0] ?? null + }, [activeServerIdFromPath, daemons]) + const activeServerId = activeDaemon?.serverId ?? null const activeHostLabel = useMemo(() => { - if (!activeServerId) return 'No host' - const daemon = daemons.find((entry) => entry.serverId === activeServerId) - const trimmed = daemon?.label?.trim() - return trimmed && trimmed.length > 0 ? trimmed : activeServerId - }, [activeServerId, daemons]) + if (!activeDaemon) return 'No host' + const trimmed = activeDaemon.label?.trim() + return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId + }, [activeDaemon]) const activeHostStatus = activeServerId ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting') : 'idle' @@ -150,33 +151,11 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { }, [closeToAgent]) const handleCreateAgentClean = useCallback(() => { - let targetServerId = activeServerId - let targetWorkingDir: string | null = null - - const selectedAgent = resolveSelectedAgentForNewAgent({ - pathname, - selectedAgentId, - }) - if (selectedAgent) { - targetServerId = selectedAgent.serverId - const agent = useSessionStore - .getState() - .sessions[selectedAgent.serverId]?.agents?.get(selectedAgent.agentId) - const cwd = agent?.cwd?.trim() - if (cwd) { - const checkout = - queryClient.getQueryData( - checkoutStatusQueryKey(selectedAgent.serverId, cwd) - ) ?? null - targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout) - } - } - - if (!targetServerId) { + if (!activeServerId) { return } - router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any) - }, [activeServerId, pathname, selectedAgentId]) + router.push(buildHostNewAgentRoute(activeServerId) as any) + }, [activeServerId]) // Mobile: close sidebar and navigate const handleCreateAgentCleanMobile = useCallback(() => { diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 607523e49..49cdd87fb 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -11,6 +11,7 @@ import { PairLinkModal } from "./pair-link-modal"; import { NameHostModal } from "./name-host-modal"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; +import { buildHostRootRoute } from "@/utils/host-routes"; const styles = StyleSheet.create((theme) => ({ container: { @@ -105,7 +106,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const finishOnboarding = useCallback( (serverId: string) => { - router.replace(`/h/${encodeURIComponent(serverId)}` as any); + router.replace(buildHostRootRoute(serverId) as any); }, [router] ); diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 8c80a80f6..054d55cd6 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -3,21 +3,12 @@ import type { TextInput } from "react-native"; import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useSessionStore } from "@/stores/session-store"; -import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, -} from "@/hooks/use-checkout-status-query"; -import { queryClient } from "@/query/query-client"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; import { - buildNewAgentRoute, - resolveNewAgentWorkingDir, -} from "@/utils/new-agent-routing"; -import { + buildHostNewAgentRoute, buildHostWorkspaceAgentTabRoute, buildHostSettingsRoute, parseHostAgentRouteFromPathname, @@ -63,12 +54,6 @@ function parseAgentKeyFromPathname(pathname: string): string | null { return `${match.serverId}:${match.agentId}`; } -function parseAgentRouteFromPathname( - pathname: string -): { serverId: string; agentId: string } | null { - return parseHostAgentRouteFromPathname(pathname); -} - type CommandCenterActionDefinition = { id: string; title: string; @@ -157,24 +142,7 @@ export function useCommandCenter() { const newAgentRoute = useMemo(() => { const serverIdFromPath = parseServerIdFromPathname(pathname) ?? fallbackServerId; - const routeAgent = parseAgentRouteFromPathname(pathname); - if (!routeAgent) { - return serverIdFromPath ? (buildNewAgentRoute(serverIdFromPath) as Href) : "/"; - } - - const { serverId, agentId } = routeAgent; - const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); - const cwd = currentAgent?.cwd?.trim(); - if (!cwd) { - return buildNewAgentRoute(serverId) as Href; - } - - const checkout = - queryClient.getQueryData( - checkoutStatusQueryKey(serverId, cwd) - ) ?? null; - const workingDir = resolveNewAgentWorkingDir(cwd, checkout); - return buildNewAgentRoute(serverId, workingDir) as Href; + return serverIdFromPath ? (buildHostNewAgentRoute(serverIdFromPath) as Href) : "/"; }, [fallbackServerId, pathname]); const settingsRoute = useMemo(() => { diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 4de792dba..846f56564 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -6,16 +6,7 @@ import { useSessionStore } from "@/stores/session-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { - checkoutStatusQueryKey, - type CheckoutStatusPayload, -} from "@/hooks/use-checkout-status-query"; -import { queryClient } from "@/query/query-client"; -import { - buildNewAgentRoute, - resolveSelectedAgentForNewAgent, - resolveNewAgentWorkingDir, -} from "@/utils/new-agent-routing"; -import { + buildHostNewAgentRoute, buildHostWorkspaceRoute, parseHostAgentRouteFromPathname, parseHostWorkspaceRouteFromPathname, @@ -85,26 +76,6 @@ export function useKeyboardShortcuts({ const navigateToNewAgent = (): boolean => { let targetServerId = parseServerIdFromPathname(pathname); - let targetWorkingDir: string | null = null; - const selectedAgent = resolveSelectedAgentForNewAgent({ - pathname, - selectedAgentId, - }); - if (selectedAgent) { - targetServerId = selectedAgent.serverId; - const agent = useSessionStore - .getState() - .sessions[selectedAgent.serverId] - ?.agents?.get(selectedAgent.agentId); - const cwd = agent?.cwd?.trim(); - if (cwd) { - const checkout = - queryClient.getQueryData( - checkoutStatusQueryKey(selectedAgent.serverId, cwd) - ) ?? null; - targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); - } - } if (!targetServerId) { const sessionServerIds = Object.keys(useSessionStore.getState().sessions); @@ -115,7 +86,7 @@ export function useKeyboardShortcuts({ return false; } - router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); + router.push(buildHostNewAgentRoute(targetServerId) as any); return true; }; diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 5a16ee9e6..066676457 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -60,6 +60,7 @@ import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import type { DaemonClient } from "@server/client/daemon-client"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import type { ExplorerCheckoutContext } from "@/stores/panel-store"; +import { buildHostRootRoute } from "@/utils/host-routes"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); @@ -528,7 +529,7 @@ function AgentScreenContent({ return; } hasRedirectedArchivedAgentRef.current = true; - const route: Href = `/h/${encodeURIComponent(serverId)}` as Href; + const route: Href = buildHostRootRoute(serverId) as Href; router.replace(route); }, [agent?.archivedAt, resolvedAgentId, router, serverId]); diff --git a/packages/app/src/screens/agents-screen.tsx b/packages/app/src/screens/agents-screen.tsx index a63d1be5c..415d4752f 100644 --- a/packages/app/src/screens/agents-screen.tsx +++ b/packages/app/src/screens/agents-screen.tsx @@ -5,6 +5,7 @@ import { BackHeader } from "@/components/headers/back-header"; import { AgentList } from "@/components/agent-list"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { router } from "expo-router"; +import { buildHostRootRoute } from "@/utils/host-routes"; export function AgentsScreen({ serverId }: { serverId: string }) { const { agents, isRevalidating, refreshAll } = useAllAgentsList({ @@ -43,7 +44,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) { router.replace(`/h/${encodeURIComponent(serverId)}` as any)} + onBack={() => router.replace(buildHostRootRoute(serverId) as any)} /> void) | null>(null); const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); @@ -322,64 +325,75 @@ export function WorkspaceDraftAgentTab({ workspaceId, ]); + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + + const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, []); + return ( - - - {machine.tag === "creating" && draftAgent ? ( - - - - ) : ( - - - + + + {machine.tag === "creating" && draftAgent ? ( + + - - {formErrorMessage ? ( - - {formErrorMessage} - - ) : null} - - )} - + ) : ( + + + - - dispatch({ type: "DRAFT_SET_PROMPT", text: next })} - autoFocus={machine.tag === "draft"} - commandDraftConfig={draftCommandConfig} - draftId={draftId} - /> + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + + )} + + + + dispatch({ type: "DRAFT_SET_PROMPT", text: next })} + autoFocus={machine.tag === "draft"} + onAddImages={handleAddImagesCallback} + commandDraftConfig={draftCommandConfig} + draftId={draftId} + /> + - + ); } diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts index 173489ba5..24a075feb 100644 --- a/packages/app/src/utils/host-routes.test.ts +++ b/packages/app/src/utils/host-routes.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { + buildHostNewAgentRoute, + buildHostRootRoute, buildHostWorkspaceAgentTabRoute, buildHostWorkspaceFileTabRoute, buildHostWorkspaceRoute, @@ -70,6 +72,14 @@ describe("workspace route parsing", () => { expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv"); }); + it("builds host root routes", () => { + expect(buildHostRootRoute("local")).toBe("/h/local"); + }); + + it("builds host new-agent routes", () => { + 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" diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 42bbef691..c873be8a3 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -359,25 +359,41 @@ export function buildHostAgentDetailRoute( if (!normalizedServerId || !normalizedAgentId) { return "/"; } - return `/h/${encodeSegment(normalizedServerId)}/agent/${encodeSegment( + return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment( normalizedAgentId )}`; } -export function buildHostAgentsRoute(serverId: string): string { +export function buildHostRootRoute(serverId: string): string { const normalized = trimNonEmpty(serverId); if (!normalized) { return "/"; } - return `/h/${encodeSegment(normalized)}/agents`; + return `/h/${encodeSegment(normalized)}`; +} + +export function buildHostAgentsRoute(serverId: string): string { + const base = buildHostRootRoute(serverId); + if (base === "/") { + return "/"; + } + return `${base}/agents`; +} + +export function buildHostNewAgentRoute(serverId: string): string { + const base = buildHostRootRoute(serverId); + if (base === "/") { + return "/"; + } + return `${base}/new-agent`; } export function buildHostSettingsRoute(serverId: string): string { - const normalized = trimNonEmpty(serverId); - if (!normalized) { + const base = buildHostRootRoute(serverId); + if (base === "/") { return "/"; } - return `/h/${encodeSegment(normalized)}/settings`; + return `${base}/settings`; } export function mapPathnameToServer( @@ -390,13 +406,16 @@ export function mapPathnameToServer( } const suffix = pathname.replace(/^\/h\/[^/]+\/?/, ""); - const base = `/h/${encodeSegment(normalized)}`; + const base = buildHostRootRoute(normalized); if (suffix.startsWith("settings")) { return `${base}/settings`; } if (suffix.startsWith("agents")) { return `${base}/agents`; } + if (suffix.startsWith("new-agent")) { + return `${base}/new-agent`; + } if (suffix.startsWith("workspace/")) { return `${base}/${suffix}`; } diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index 6f99fe257..11befbfb9 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,5 +1,6 @@ import { buildHostAgentDetailRoute, + buildHostRootRoute, buildHostWorkspaceAgentTabRoute, } from "@/utils/host-routes"; @@ -40,7 +41,7 @@ export function buildNotificationRoute(data: NotificationData): string { return buildHostAgentDetailRoute(serverId, agentId); } if (serverId) { - return `/h/${encodeURIComponent(serverId)}`; + return buildHostRootRoute(serverId); } return "/"; } diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 731955301..8a233a83e 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -1,10 +1,14 @@ import { v4 as uuidv4 } from "uuid"; import type { Logger } from "pino"; +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { sep } from "node:path"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { TerminalSession } from "../terminal/terminal.js"; import { createWorktree, getWorktreeTerminalSpecs, + listPaseoWorktrees, resolveWorktreeRuntimeEnv, runWorktreeSetupCommands, WorktreeSetupError, @@ -42,6 +46,12 @@ export interface CreateAgentWorktreeOptions { const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024; const WORKTREE_SETUP_TRUNCATION_MARKER = "\n......\n"; const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500; +const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { + ...process.env, + GIT_OPTIONAL_LOCKS: "0", +}; +const execAsync = promisify(exec); +const worktreeSetupEligibility = new WeakMap(); type MiddleTruncationAccumulator = { totalBytes: number; @@ -153,7 +163,18 @@ function renderMiddleTruncationAccumulator( export async function createAgentWorktree( options: CreateAgentWorktreeOptions ): Promise { - return createWorktree({ + const existingWorktree = await findExistingPaseoWorktreeBySlug(options); + if (existingWorktree) { + const branchName = await resolveBranchNameForWorktreePath(existingWorktree.path); + const reusedWorktree = { + branchName, + worktreePath: existingWorktree.path, + }; + worktreeSetupEligibility.set(reusedWorktree, false); + return reusedWorktree; + } + + const createdWorktree = await createWorktree({ branchName: options.branchName, cwd: options.cwd, baseBranch: options.baseBranch, @@ -161,6 +182,29 @@ export async function createAgentWorktree( runSetup: false, paseoHome: options.paseoHome, }); + worktreeSetupEligibility.set(createdWorktree, true); + return createdWorktree; +} + +async function findExistingPaseoWorktreeBySlug(options: CreateAgentWorktreeOptions) { + const worktrees = await listPaseoWorktrees({ + cwd: options.cwd, + paseoHome: options.paseoHome, + }); + const slugSuffix = `${sep}${options.worktreeSlug}`; + return worktrees.find((worktree) => worktree.path.endsWith(slugSuffix)); +} + +async function resolveBranchNameForWorktreePath(worktreePath: string): Promise { + const { stdout } = await execAsync("git branch --show-current", { + cwd: worktreePath, + env: READ_ONLY_GIT_ENV, + }); + const branchName = stdout.trim(); + if (!branchName) { + throw new Error(`Unable to resolve branch for existing worktree: ${worktreePath}`); + } + return branchName; } function formatDurationMs(durationMs: number): string { @@ -467,6 +511,10 @@ async function runWorktreeTerminalBootstrap( export async function runAsyncWorktreeBootstrap( options: RunAsyncWorktreeBootstrapOptions ): Promise { + if (worktreeSetupEligibility.get(options.worktree) === false) { + return; + } + const setupCallId = uuidv4(); let setupResults: WorktreeSetupCommandResult[] = []; let runtimeEnv: WorktreeRuntimeEnv | null = null;