refactor: centralize host route builders and add dedicated new-agent route

This commit is contained in:
Mohamed Boudra
2026-03-04 15:09:06 +07:00
parent 92991ffdb8
commit f47ea7afe5
17 changed files with 231 additions and 193 deletions

View File

@@ -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() {
>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/index" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/tab/[tabId]" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen
name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/new-agent" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" />
</Stack>

View File

@@ -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 () => {

View File

@@ -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 <DraftAgentScreen forcedServerId={serverId} />;
}

View File

@@ -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 (
<WorkspaceScreen
serverId={typeof params.serverId === "string" ? params.serverId : ""}
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
routeTabId={tabId || null}
serverId={serverId}
workspaceId={workspaceId}
routeTabId={routeTabId}
/>
);
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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<CheckoutStatusPayload>(
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(() => {

View File

@@ -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]
);

View File

@@ -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<Href>(() => {
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<CheckoutStatusPayload>(
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<Href>(() => {

View File

@@ -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<CheckoutStatusPayload>(
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;
};

View File

@@ -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]);

View File

@@ -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 }) {
<View style={styles.container}>
<BackHeader
title="All agents"
onBack={() => router.replace(`/h/${encodeURIComponent(serverId)}` as any)}
onBack={() => router.replace(buildHostRootRoute(serverId) as any)}
/>
<AgentList
agents={sortedAgents}

View File

@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useReducer } from "react";
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentConfigRow } from "@/components/agent-form/agent-form-dropdowns";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/components/agent-stream-view";
import type { ImageAttachment } from "@/components/message-input";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useAgentFormState } from "@/hooks/use-agent-form-state";
import { useHostRuntimeSession } from "@/runtime/host-runtime";
@@ -63,6 +65,7 @@ export function WorkspaceDraftAgentTab({
onCreated,
}: WorkspaceDraftAgentTabProps) {
const { client, isConnected } = useHostRuntimeSession(serverId);
const addImagesRef = useRef<((images: ImageAttachment[]) => 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 (
<View style={styles.container}>
<View style={styles.contentContainer}>
{machine.tag === "creating" && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
/>
</View>
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
<AgentConfigRow
providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider}
onSelectProvider={setProviderFromUser}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelectMode={setModeFromUser}
models={availableModels}
selectedModel={selectedModel}
onSelectModel={setModelFromUser}
isModelLoading={isModelLoading}
thinkingOptions={availableThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
onSelectThinkingOption={setThinkingOptionFromUser}
disabled={isSubmitting}
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<View style={styles.contentContainer}>
{machine.tag === "creating" && draftAgent ? (
<View style={styles.streamContainer}>
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
/>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</ScrollView>
)}
</View>
) : (
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
<AgentConfigRow
providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider}
onSelectProvider={setProviderFromUser}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelectMode={setModeFromUser}
models={availableModels}
selectedModel={selectedModel}
onSelectModel={setModelFromUser}
isModelLoading={isModelLoading}
thinkingOptions={availableThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
onSelectThinkingOption={setThinkingOptionFromUser}
disabled={isSubmitting}
/>
<View style={styles.inputAreaWrapper}>
<AgentInputArea
agentId={tabId}
serverId={serverId}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={promptValue}
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
autoFocus={machine.tag === "draft"}
commandDraftConfig={draftCommandConfig}
draftId={draftId}
/>
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View>
</ScrollView>
)}
</View>
<View style={styles.inputAreaWrapper}>
<AgentInputArea
agentId={tabId}
serverId={serverId}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isSubmitting}
blurOnSubmit={true}
value={promptValue}
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
autoFocus={machine.tag === "draft"}
onAddImages={handleAddImagesCallback}
commandDraftConfig={draftCommandConfig}
draftId={draftId}
/>
</View>
</View>
</View>
</FileDropZone>
);
}

View File

@@ -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"

View File

@@ -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}`;
}

View File

@@ -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 "/";
}

View File

@@ -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...<output truncated in the middle>...\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<WorktreeConfig, boolean>();
type MiddleTruncationAccumulator = {
totalBytes: number;
@@ -153,7 +163,18 @@ function renderMiddleTruncationAccumulator(
export async function createAgentWorktree(
options: CreateAgentWorktreeOptions
): Promise<WorktreeConfig> {
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<string> {
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<void> {
if (worktreeSetupEligibility.get(options.worktree) === false) {
return;
}
const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = [];
let runtimeEnv: WorktreeRuntimeEnv | null = null;