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"; } from "@/utils/os-notifications";
import { buildNotificationRoute } from "@/utils/notification-routing"; import { buildNotificationRoute } from "@/utils/notification-routing";
import { import {
buildHostRootRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceTabRouteFromPathname, parseHostWorkspaceTabRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
@@ -350,7 +351,7 @@ function OfferLinkListener({
if (cancelled) return; if (cancelled) return;
const serverId = (profile as any)?.serverId; const serverId = (profile as any)?.serverId;
if (typeof serverId !== "string" || !serverId) return; if (typeof serverId !== "string" || !serverId) return;
router.replace(`/h/${encodeURIComponent(serverId)}` as any); router.replace(buildHostRootRoute(serverId) as any);
}) })
.catch((error) => { .catch((error) => {
if (cancelled) return; if (cancelled) return;
@@ -471,14 +472,14 @@ export default function RootLayout() {
> >
<Stack.Screen name="index" /> <Stack.Screen name="index" />
<Stack.Screen name="settings" /> <Stack.Screen name="settings" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/index" /> <Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/tab/[tabId]" />
<Stack.Screen <Stack.Screen
name="h/[serverId]/agent/[agentId]" name="h/[serverId]/agent/[agentId]"
options={{ gestureEnabled: false }} options={{ gestureEnabled: false }}
/> />
<Stack.Screen name="h/[serverId]/index" /> <Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/agents" /> <Stack.Screen name="h/[serverId]/agents" />
<Stack.Screen name="h/[serverId]/new-agent" />
<Stack.Screen name="h/[serverId]/settings" /> <Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="pair-scan" /> <Stack.Screen name="pair-scan" />
</Stack> </Stack>

View File

@@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { import {
buildHostRootRoute,
buildHostWorkspaceAgentTabRoute, buildHostWorkspaceAgentTabRoute,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
@@ -54,7 +55,7 @@ export default function HostAgentReadyRoute() {
} }
if (!client || !isConnected) { if (!client || !isConnected) {
redirectedRef.current = true; redirectedRef.current = true;
router.replace(`/h/${encodeURIComponent(serverId)}` as any); router.replace(buildHostRootRoute(serverId) as any);
} }
}, [agentCwd, agentId, client, isConnected, router, serverId]); }, [agentCwd, agentId, client, isConnected, router, serverId]);
@@ -79,14 +80,14 @@ export default function HostAgentReadyRoute() {
router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any); router.replace(buildHostWorkspaceAgentTabRoute(serverId, cwd, agentId) as any);
return; return;
} }
router.replace(`/h/${encodeURIComponent(serverId)}` as any); router.replace(buildHostRootRoute(serverId) as any);
}) })
.catch(() => { .catch(() => {
if (cancelled || redirectedRef.current) { if (cancelled || redirectedRef.current) {
return; return;
} }
redirectedRef.current = true; redirectedRef.current = true;
router.replace(`/h/${encodeURIComponent(serverId)}` as any); router.replace(buildHostRootRoute(serverId) as any);
}); });
return () => { 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 { 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() { export default function HostWorkspaceLayout() {
const params = useLocalSearchParams<{ const pathname = usePathname();
serverId?: string; const params = useLocalSearchParams<{ tabId?: string }>();
workspaceId?: string; const tabRoute = parseHostWorkspaceTabRouteFromPathname(pathname);
tabId?: string; const activeRoute = tabRoute ?? parseHostWorkspaceRouteFromPathname(pathname);
}>(); const serverId = activeRoute?.serverId ?? "";
const workspaceId = activeRoute?.workspaceId ?? "";
const tabId = typeof params.tabId === "string" ? params.tabId : ""; const routeTabId = tabRoute?.tabId ?? readNonEmptyParam(params.tabId);
return ( return (
<WorkspaceScreen <WorkspaceScreen
serverId={typeof params.serverId === "string" ? params.serverId : ""} serverId={serverId}
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""} workspaceId={workspaceId}
routeTabId={tabId || null} routeTabId={routeTabId}
/> />
); );
} }

View File

@@ -5,6 +5,7 @@ import { useUnistyles } from "react-native-unistyles";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences";
import { buildHostRootRoute } from "@/utils/host-routes";
export default function Index() { export default function Index() {
const router = useRouter(); const router = useRouter();
@@ -44,7 +45,7 @@ export default function Index() {
if (!targetServerId) { if (!targetServerId) {
return; return;
} }
router.replace(`/h/${encodeURIComponent(targetServerId)}` as any); router.replace(buildHostRootRoute(targetServerId) as any);
}, [preferencesLoading, registryLoading, router, targetServerId]); }, [preferencesLoading, registryLoading, router, targetServerId]);
if (registryLoading || preferencesLoading) { if (registryLoading || preferencesLoading) {

View File

@@ -12,6 +12,7 @@ import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-en
import { probeConnection } from "@/utils/test-daemon-connection"; import { probeConnection } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { import {
buildHostRootRoute,
buildHostSettingsRoute, buildHostSettingsRoute,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
@@ -169,7 +170,7 @@ export default function PairScanScreen() {
const returnToSource = useCallback( const returnToSource = useCallback(
(serverId: string) => { (serverId: string) => {
if (source === "onboarding") { if (source === "onboarding") {
router.replace(`/h/${encodeURIComponent(serverId)}` as any); router.replace(buildHostRootRoute(serverId) as any);
return; return;
} }
if (source === "editHost" && targetServerId) { if (source === "editHost" && targetServerId) {

View File

@@ -21,21 +21,11 @@ import { useTauriDragHandlers, useTrafficLightPadding } from '@/utils/tauri-wind
import { Combobox } from '@/components/ui/combobox' import { Combobox } from '@/components/ui/combobox'
import { useDaemonRegistry } from '@/contexts/daemon-registry-context' import { useDaemonRegistry } from '@/contexts/daemon-registry-context'
import { getHostRuntimeStore } from '@/runtime/host-runtime' import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { useSessionStore } from '@/stores/session-store'
import { formatConnectionStatus } from '@/utils/daemons' import { formatConnectionStatus } from '@/utils/daemons'
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' 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 { import {
buildHostAgentsRoute, buildHostAgentsRoute,
buildHostNewAgentRoute,
buildHostSettingsRoute, buildHostSettingsRoute,
mapPathnameToServer, mapPathnameToServer,
parseServerIdFromPathname, parseServerIdFromPathname,
@@ -47,7 +37,7 @@ interface LeftSidebarProps {
selectedAgentId?: string selectedAgentId?: string
} }
export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
@@ -79,13 +69,24 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
.join('|') .join('|')
) )
const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]) 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(() => { const activeHostLabel = useMemo(() => {
if (!activeServerId) return 'No host' if (!activeDaemon) return 'No host'
const daemon = daemons.find((entry) => entry.serverId === activeServerId) const trimmed = activeDaemon.label?.trim()
const trimmed = daemon?.label?.trim() return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId
return trimmed && trimmed.length > 0 ? trimmed : activeServerId }, [activeDaemon])
}, [activeServerId, daemons])
const activeHostStatus = activeServerId const activeHostStatus = activeServerId
? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting') ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting')
: 'idle' : 'idle'
@@ -150,33 +151,11 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
}, [closeToAgent]) }, [closeToAgent])
const handleCreateAgentClean = useCallback(() => { const handleCreateAgentClean = useCallback(() => {
let targetServerId = activeServerId if (!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) {
return return
} }
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any) router.push(buildHostNewAgentRoute(activeServerId) as any)
}, [activeServerId, pathname, selectedAgentId]) }, [activeServerId])
// Mobile: close sidebar and navigate // Mobile: close sidebar and navigate
const handleCreateAgentCleanMobile = useCallback(() => { const handleCreateAgentCleanMobile = useCallback(() => {

View File

@@ -11,6 +11,7 @@ import { PairLinkModal } from "./pair-link-modal";
import { NameHostModal } from "./name-host-modal"; import { NameHostModal } from "./name-host-modal";
import { resolveAppVersion } from "@/utils/app-version"; import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
container: { container: {
@@ -105,7 +106,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const finishOnboarding = useCallback( const finishOnboarding = useCallback(
(serverId: string) => { (serverId: string) => {
router.replace(`/h/${encodeURIComponent(serverId)}` as any); router.replace(buildHostRootRoute(serverId) as any);
}, },
[router] [router]
); );

View File

@@ -3,21 +3,12 @@ import type { TextInput } from "react-native";
import { router, usePathname, type Href } from "expo-router"; import { router, usePathname, type Href } from "expo-router";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; 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 { import {
clearCommandCenterFocusRestoreElement, clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore"; } from "@/utils/command-center-focus-restore";
import { import {
buildNewAgentRoute, buildHostNewAgentRoute,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostWorkspaceAgentTabRoute, buildHostWorkspaceAgentTabRoute,
buildHostSettingsRoute, buildHostSettingsRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
@@ -63,12 +54,6 @@ function parseAgentKeyFromPathname(pathname: string): string | null {
return `${match.serverId}:${match.agentId}`; return `${match.serverId}:${match.agentId}`;
} }
function parseAgentRouteFromPathname(
pathname: string
): { serverId: string; agentId: string } | null {
return parseHostAgentRouteFromPathname(pathname);
}
type CommandCenterActionDefinition = { type CommandCenterActionDefinition = {
id: string; id: string;
title: string; title: string;
@@ -157,24 +142,7 @@ export function useCommandCenter() {
const newAgentRoute = useMemo<Href>(() => { const newAgentRoute = useMemo<Href>(() => {
const serverIdFromPath = const serverIdFromPath =
parseServerIdFromPathname(pathname) ?? fallbackServerId; parseServerIdFromPathname(pathname) ?? fallbackServerId;
const routeAgent = parseAgentRouteFromPathname(pathname); return serverIdFromPath ? (buildHostNewAgentRoute(serverIdFromPath) as Href) : "/";
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;
}, [fallbackServerId, pathname]); }, [fallbackServerId, pathname]);
const settingsRoute = useMemo<Href>(() => { const settingsRoute = useMemo<Href>(() => {

View File

@@ -6,16 +6,7 @@ import { useSessionStore } from "@/stores/session-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import { import {
checkoutStatusQueryKey, buildHostNewAgentRoute,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { queryClient } from "@/query/query-client";
import {
buildNewAgentRoute,
resolveSelectedAgentForNewAgent,
resolveNewAgentWorkingDir,
} from "@/utils/new-agent-routing";
import {
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
parseHostAgentRouteFromPathname, parseHostAgentRouteFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
@@ -85,26 +76,6 @@ export function useKeyboardShortcuts({
const navigateToNewAgent = (): boolean => { const navigateToNewAgent = (): boolean => {
let targetServerId = parseServerIdFromPathname(pathname); 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) { if (!targetServerId) {
const sessionServerIds = Object.keys(useSessionStore.getState().sessions); const sessionServerIds = Object.keys(useSessionStore.getState().sessions);
@@ -115,7 +86,7 @@ export function useKeyboardShortcuts({
return false; return false;
} }
router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); router.push(buildHostNewAgentRoute(targetServerId) as any);
return true; return true;
}; };

View File

@@ -60,6 +60,7 @@ import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import type { ExplorerCheckoutContext } from "@/stores/panel-store"; import type { ExplorerCheckoutContext } from "@/stores/panel-store";
import { buildHostRootRoute } from "@/utils/host-routes";
const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -528,7 +529,7 @@ function AgentScreenContent({
return; return;
} }
hasRedirectedArchivedAgentRef.current = true; hasRedirectedArchivedAgentRef.current = true;
const route: Href = `/h/${encodeURIComponent(serverId)}` as Href; const route: Href = buildHostRootRoute(serverId) as Href;
router.replace(route); router.replace(route);
}, [agent?.archivedAt, resolvedAgentId, router, serverId]); }, [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 { AgentList } from "@/components/agent-list";
import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useAllAgentsList } from "@/hooks/use-all-agents-list";
import { router } from "expo-router"; import { router } from "expo-router";
import { buildHostRootRoute } from "@/utils/host-routes";
export function AgentsScreen({ serverId }: { serverId: string }) { export function AgentsScreen({ serverId }: { serverId: string }) {
const { agents, isRevalidating, refreshAll } = useAllAgentsList({ const { agents, isRevalidating, refreshAll } = useAllAgentsList({
@@ -43,7 +44,7 @@ export function AgentsScreen({ serverId }: { serverId: string }) {
<View style={styles.container}> <View style={styles.container}>
<BackHeader <BackHeader
title="All agents" title="All agents"
onBack={() => router.replace(`/h/${encodeURIComponent(serverId)}` as any)} onBack={() => router.replace(buildHostRootRoute(serverId) as any)}
/> />
<AgentList <AgentList
agents={sortedAgents} 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 { Keyboard, Platform, ScrollView, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { AgentInputArea } from "@/components/agent-input-area"; import { AgentInputArea } from "@/components/agent-input-area";
import { AgentConfigRow } from "@/components/agent-form/agent-form-dropdowns"; import { AgentConfigRow } from "@/components/agent-form/agent-form-dropdowns";
import { FileDropZone } from "@/components/file-drop-zone";
import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentStreamView } from "@/components/agent-stream-view";
import type { ImageAttachment } from "@/components/message-input";
import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useAgentFormState } from "@/hooks/use-agent-form-state";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
@@ -63,6 +65,7 @@ export function WorkspaceDraftAgentTab({
onCreated, onCreated,
}: WorkspaceDraftAgentTabProps) { }: WorkspaceDraftAgentTabProps) {
const { client, isConnected } = useHostRuntimeSession(serverId); const { client, isConnected } = useHostRuntimeSession(serverId);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
@@ -322,64 +325,75 @@ export function WorkspaceDraftAgentTab({
workspaceId, workspaceId,
]); ]);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
return ( return (
<View style={styles.container}> <FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.contentContainer}> <View style={styles.container}>
{machine.tag === "creating" && draftAgent ? ( <View style={styles.contentContainer}>
<View style={styles.streamContainer}> {machine.tag === "creating" && draftAgent ? (
<AgentStreamView <View style={styles.streamContainer}>
agentId={tabId} <AgentStreamView
serverId={serverId} agentId={tabId}
agent={draftAgent} serverId={serverId}
streamItems={optimisticStreamItems} agent={draftAgent}
pendingPermissions={EMPTY_PENDING_PERMISSIONS} 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}
/> />
{formErrorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{formErrorMessage}</Text>
</View>
) : null}
</View> </View>
</ScrollView> ) : (
)} <ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
</View> <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}> {formErrorMessage ? (
<AgentInputArea <View style={styles.errorContainer}>
agentId={tabId} <Text style={styles.errorText}>{formErrorMessage}</Text>
serverId={serverId} </View>
onSubmitMessage={handleCreateFromInput} ) : null}
isSubmitLoading={isSubmitting} </View>
blurOnSubmit={true} </ScrollView>
value={promptValue} )}
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })} </View>
autoFocus={machine.tag === "draft"}
commandDraftConfig={draftCommandConfig} <View style={styles.inputAreaWrapper}>
draftId={draftId} <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>
</View> </FileDropZone>
); );
} }

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildHostNewAgentRoute,
buildHostRootRoute,
buildHostWorkspaceAgentTabRoute, buildHostWorkspaceAgentTabRoute,
buildHostWorkspaceFileTabRoute, buildHostWorkspaceFileTabRoute,
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
@@ -70,6 +72,14 @@ describe("workspace route parsing", () => {
expect(buildHostWorkspaceRoute("local", "/tmp/repo")).toBe("/h/local/workspace/L3RtcC9yZXBv"); 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", () => { it("builds workspace agent tab routes", () => {
expect(buildHostWorkspaceAgentTabRoute("local", "/tmp/repo", "agent-1")).toBe( expect(buildHostWorkspaceAgentTabRoute("local", "/tmp/repo", "agent-1")).toBe(
"/h/local/workspace/L3RtcC9yZXBv/tab/agent_agent-1" "/h/local/workspace/L3RtcC9yZXBv/tab/agent_agent-1"

View File

@@ -359,25 +359,41 @@ export function buildHostAgentDetailRoute(
if (!normalizedServerId || !normalizedAgentId) { if (!normalizedServerId || !normalizedAgentId) {
return "/"; return "/";
} }
return `/h/${encodeSegment(normalizedServerId)}/agent/${encodeSegment( return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(
normalizedAgentId normalizedAgentId
)}`; )}`;
} }
export function buildHostAgentsRoute(serverId: string): string { export function buildHostRootRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId); const normalized = trimNonEmpty(serverId);
if (!normalized) { if (!normalized) {
return "/"; 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 { export function buildHostSettingsRoute(serverId: string): string {
const normalized = trimNonEmpty(serverId); const base = buildHostRootRoute(serverId);
if (!normalized) { if (base === "/") {
return "/"; return "/";
} }
return `/h/${encodeSegment(normalized)}/settings`; return `${base}/settings`;
} }
export function mapPathnameToServer( export function mapPathnameToServer(
@@ -390,13 +406,16 @@ export function mapPathnameToServer(
} }
const suffix = pathname.replace(/^\/h\/[^/]+\/?/, ""); const suffix = pathname.replace(/^\/h\/[^/]+\/?/, "");
const base = `/h/${encodeSegment(normalized)}`; const base = buildHostRootRoute(normalized);
if (suffix.startsWith("settings")) { if (suffix.startsWith("settings")) {
return `${base}/settings`; return `${base}/settings`;
} }
if (suffix.startsWith("agents")) { if (suffix.startsWith("agents")) {
return `${base}/agents`; return `${base}/agents`;
} }
if (suffix.startsWith("new-agent")) {
return `${base}/new-agent`;
}
if (suffix.startsWith("workspace/")) { if (suffix.startsWith("workspace/")) {
return `${base}/${suffix}`; return `${base}/${suffix}`;
} }

View File

@@ -1,5 +1,6 @@
import { import {
buildHostAgentDetailRoute, buildHostAgentDetailRoute,
buildHostRootRoute,
buildHostWorkspaceAgentTabRoute, buildHostWorkspaceAgentTabRoute,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
@@ -40,7 +41,7 @@ export function buildNotificationRoute(data: NotificationData): string {
return buildHostAgentDetailRoute(serverId, agentId); return buildHostAgentDetailRoute(serverId, agentId);
} }
if (serverId) { if (serverId) {
return `/h/${encodeURIComponent(serverId)}`; return buildHostRootRoute(serverId);
} }
return "/"; return "/";
} }

View File

@@ -1,10 +1,14 @@
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import type { Logger } from "pino"; 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 { TerminalManager } from "../terminal/terminal-manager.js";
import type { TerminalSession } from "../terminal/terminal.js"; import type { TerminalSession } from "../terminal/terminal.js";
import { import {
createWorktree, createWorktree,
getWorktreeTerminalSpecs, getWorktreeTerminalSpecs,
listPaseoWorktrees,
resolveWorktreeRuntimeEnv, resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands, runWorktreeSetupCommands,
WorktreeSetupError, WorktreeSetupError,
@@ -42,6 +46,12 @@ export interface CreateAgentWorktreeOptions {
const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024; const MAX_WORKTREE_SETUP_COMMAND_OUTPUT_BYTES = 64 * 1024;
const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n"; const WORKTREE_SETUP_TRUNCATION_MARKER = "\n...<output truncated in the middle>...\n";
const WORKTREE_BOOTSTRAP_TERMINAL_READY_TIMEOUT_MS = 1_500; 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 = { type MiddleTruncationAccumulator = {
totalBytes: number; totalBytes: number;
@@ -153,7 +163,18 @@ function renderMiddleTruncationAccumulator(
export async function createAgentWorktree( export async function createAgentWorktree(
options: CreateAgentWorktreeOptions options: CreateAgentWorktreeOptions
): Promise<WorktreeConfig> { ): 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, branchName: options.branchName,
cwd: options.cwd, cwd: options.cwd,
baseBranch: options.baseBranch, baseBranch: options.baseBranch,
@@ -161,6 +182,29 @@ export async function createAgentWorktree(
runSetup: false, runSetup: false,
paseoHome: options.paseoHome, 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 { function formatDurationMs(durationMs: number): string {
@@ -467,6 +511,10 @@ async function runWorktreeTerminalBootstrap(
export async function runAsyncWorktreeBootstrap( export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions options: RunAsyncWorktreeBootstrapOptions
): Promise<void> { ): Promise<void> {
if (worktreeSetupEligibility.get(options.worktree) === false) {
return;
}
const setupCallId = uuidv4(); const setupCallId = uuidv4();
let setupResults: WorktreeSetupCommandResult[] = []; let setupResults: WorktreeSetupCommandResult[] = [];
let runtimeEnv: WorktreeRuntimeEnv | null = null; let runtimeEnv: WorktreeRuntimeEnv | null = null;