diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index dc1b919b1..0ae68e115 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -43,6 +43,10 @@ import { type WebNotificationClickDetail, } from "@/utils/os-notifications"; import { buildNotificationRoute } from "@/utils/notification-routing"; +import { + buildHostAgentDraftRoute, + parseHostAgentRouteFromPathname, +} from "@/utils/host-routes"; polyfillCrypto(); @@ -268,53 +272,8 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { function ProvidersWrapper({ children }: { children: ReactNode }) { const { settings, isLoading: settingsLoading } = useAppSettings(); const { daemons, isLoading: registryLoading, upsertDaemonFromOfferUrl } = useDaemonRegistry(); - const { connectionStates } = useDaemonConnections(); const isLoading = settingsLoading || registryLoading; - const isInitialConnectionPending = useMemo(() => { - if (daemons.length === 0) return false; - return daemons.some((daemon) => { - const record = connectionStates.get(daemon.serverId); - if (!record) return true; - if (record.hasEverReceivedAgentList) return false; - return ( - record.status === "idle" || - record.status === "connecting" || - (record.status === "online" && !record.agentListReady) - ); - }); - }, [daemons, connectionStates]); - - const [connectionTimedOut, setConnectionTimedOut] = useState(false); - - useEffect(() => { - if (!isInitialConnectionPending) { - setConnectionTimedOut(false); - return; - } - const timer = setTimeout(() => setConnectionTimedOut(true), 5000); - return () => clearTimeout(timer); - }, [isInitialConnectionPending]); - - const connectingMessage = useMemo(() => { - if (!isInitialConnectionPending) return null; - const pending = daemons.filter((daemon) => { - const record = connectionStates.get(daemon.serverId); - if (!record) return true; - if (record.hasEverReceivedAgentList) return false; - return ( - record.status === "idle" || - record.status === "connecting" || - (record.status === "online" && !record.agentListReady) - ); - }); - if (pending.length === 1) { - const label = pending[0].label?.trim(); - return label ? `Connecting to ${label}...` : "Connecting..."; - } - return "Connecting..."; - }, [isInitialConnectionPending, daemons, connectionStates]); - // Apply theme setting on mount and when it changes useEffect(() => { if (isLoading) return; @@ -330,10 +289,6 @@ function ProvidersWrapper({ children }: { children: ReactNode }) { return ; } - if (connectingMessage && !connectionTimedOut) { - return ; - } - return ( @@ -359,7 +314,7 @@ function OfferLinkListener({ if (cancelled) return; const serverId = (profile as any)?.serverId; if (typeof serverId !== "string" || !serverId) return; - router.replace({ pathname: "/", params: { serverId } }); + router.replace(buildHostAgentDraftRoute(serverId) as any); }) .catch((error) => { if (cancelled) return; @@ -389,13 +344,8 @@ function AppWithSidebar({ children }: { children: ReactNode }) { // Parse selectedAgentKey directly from pathname // useLocalSearchParams doesn't update when navigating between same-pattern routes const selectedAgentKey = useMemo(() => { - // Match /agent/[serverId]/[agentId] pattern - const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)\/?$/); - if (match) { - const [, serverId, agentId] = match; - return `${serverId}:${agentId}`; - } - return undefined; + const match = parseHostAgentRouteFromPathname(pathname); + return match ? `${match.serverId}:${match.agentId}` : undefined; }, [pathname]); return ( @@ -478,14 +428,14 @@ export default function RootLayout() { }} > - - - - + + + + diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx deleted file mode 100644 index bbd32ceb4..000000000 --- a/packages/app/src/app/agent/[id].tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; -import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen"; - -export default function LegacyAgentRoute() { - const { id } = useLocalSearchParams<{ id?: string }>(); - return ; -} - diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx deleted file mode 100644 index 993cba09b..000000000 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { useLocalSearchParams } from "expo-router"; -import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; - -export default function AgentReadyRoute() { - const { serverId, agentId } = useLocalSearchParams<{ - serverId?: string; - agentId?: string; - }>(); - - return ( - - ); -} diff --git a/packages/app/src/app/agent/index.tsx b/packages/app/src/app/agent/index.tsx deleted file mode 100644 index 4a1a5c7d7..000000000 --- a/packages/app/src/app/agent/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; - -export default function AgentDraftRoute() { - return ; -} - diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx new file mode 100644 index 000000000..8e908de4a --- /dev/null +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -0,0 +1,17 @@ +import { useLocalSearchParams } from "expo-router"; +import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; + +export default function HostAgentReadyRoute() { + const params = useLocalSearchParams<{ + serverId?: string; + agentId?: string; + }>(); + + return ( + + ); +} + diff --git a/packages/app/src/app/h/[serverId]/agent/index.tsx b/packages/app/src/app/h/[serverId]/agent/index.tsx new file mode 100644 index 000000000..b1aaa55ac --- /dev/null +++ b/packages/app/src/app/h/[serverId]/agent/index.tsx @@ -0,0 +1,10 @@ +import { useLocalSearchParams } from "expo-router"; +import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; + +export default function HostDraftAgentRoute() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + + return ; +} + diff --git a/packages/app/src/app/h/[serverId]/agents.tsx b/packages/app/src/app/h/[serverId]/agents.tsx new file mode 100644 index 000000000..7772cfa71 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/agents.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; +import { AgentsScreen } from "@/screens/agents-screen"; + +export default function HostAgentsRoute() { + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + + return ; +} diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx new file mode 100644 index 000000000..1de679d5e --- /dev/null +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { buildHostAgentDraftRoute } from "@/utils/host-routes"; + +export default function HostIndexRoute() { + const router = useRouter(); + const params = useLocalSearchParams<{ serverId?: string }>(); + const serverId = typeof params.serverId === "string" ? params.serverId : ""; + + useEffect(() => { + if (!serverId) { + return; + } + router.replace(buildHostAgentDraftRoute(serverId) as any); + }, [router, serverId]); + + return null; +} + diff --git a/packages/app/src/app/h/[serverId]/settings.tsx b/packages/app/src/app/h/[serverId]/settings.tsx new file mode 100644 index 000000000..38d368f98 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/settings.tsx @@ -0,0 +1,3 @@ +import SettingsScreen from "@/screens/settings-screen"; + +export default SettingsScreen; diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 2455b9f99..c6f37eccd 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,5 +1,59 @@ +import { useEffect, useMemo } from "react"; +import { ActivityIndicator, View } from "react-native"; +import { useRouter } from "expo-router"; +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 { buildHostAgentDraftRoute } from "@/utils/host-routes"; export default function Index() { - return ; + const router = useRouter(); + const { theme } = useUnistyles(); + const { daemons, isLoading: registryLoading } = useDaemonRegistry(); + const { preferences, isLoading: preferencesLoading } = useFormPreferences(); + + const targetServerId = useMemo(() => { + if (daemons.length === 0) { + return null; + } + if (preferences.serverId) { + const match = daemons.find((daemon) => daemon.serverId === preferences.serverId); + if (match) { + return match.serverId; + } + } + return daemons[0]?.serverId ?? null; + }, [daemons, preferences.serverId]); + + useEffect(() => { + if (registryLoading || preferencesLoading) { + return; + } + if (!targetServerId) { + return; + } + router.replace(buildHostAgentDraftRoute(targetServerId) as any); + }, [preferencesLoading, registryLoading, router, targetServerId]); + + if (registryLoading || preferencesLoading) { + return ( + + + + ); + } + + if (!targetServerId) { + return ; + } + + return null; } diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index 78845bea3..ad4397fa5 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -11,6 +11,10 @@ import { NameHostModal } from "@/components/name-host-modal"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { probeConnection } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; +import { + buildHostAgentDraftRoute, + buildHostSettingsRoute, +} from "@/utils/host-routes"; const styles = StyleSheet.create((theme) => ({ container: { @@ -138,8 +142,14 @@ export default function PairScanScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const router = useRouter(); - const params = useLocalSearchParams<{ source?: string; targetServerId?: string }>(); + const params = useLocalSearchParams<{ + source?: string; + sourceServerId?: string; + targetServerId?: string; + }>(); const source = typeof params.source === "string" ? params.source : "settings"; + const sourceServerId = + typeof params.sourceServerId === "string" ? params.sourceServerId : null; const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; const { daemons, upsertDaemonFromOfferUrl, updateHost } = useDaemonRegistry(); @@ -160,34 +170,47 @@ export default function PairScanScreen() { const returnToSource = useCallback( (serverId: string) => { if (source === "onboarding") { - router.replace({ pathname: "/", params: { serverId } }); + router.replace(buildHostAgentDraftRoute(serverId) as any); return; } if (source === "editHost" && targetServerId) { - router.replace({ pathname: "/settings", params: { editHost: targetServerId } }); + const settingsServerId = sourceServerId ?? targetServerId; + router.replace({ + pathname: buildHostSettingsRoute(settingsServerId), + params: { editHost: targetServerId }, + } as any); return; } // settings (default): return to previous screen try { router.back(); } catch { - router.replace("/settings"); + const settingsServerId = sourceServerId ?? serverId; + router.replace(buildHostSettingsRoute(settingsServerId) as any); } }, - [router, source, targetServerId] + [router, source, sourceServerId, targetServerId] ); const closeToSource = useCallback(() => { if (source === "editHost" && targetServerId) { - router.replace({ pathname: "/settings", params: { editHost: targetServerId } }); + const settingsServerId = sourceServerId ?? targetServerId; + router.replace({ + pathname: buildHostSettingsRoute(settingsServerId), + params: { editHost: targetServerId }, + } as any); return; } try { router.back(); } catch { - router.replace("/settings"); + if (sourceServerId) { + router.replace(buildHostSettingsRoute(sourceServerId) as any); + return; + } + router.replace("/" as any); } - }, [router, source, targetServerId]); + }, [router, source, sourceServerId, targetServerId]); useEffect(() => { if (Platform.OS === "web") return; diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 963c28fa4..7a8757910 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -28,6 +28,10 @@ import { buildAgentNavigationKey, startNavigationTiming, } from "@/utils/navigation-timing"; +import { + buildHostAgentDetailRoute, + parseHostAgentRouteFromPathname, +} from "@/utils/host-routes"; interface AgentListProps { agents: AggregatedAgent[]; @@ -114,12 +118,12 @@ export function AgentList({ params: { serverId, agentId }, }); - const shouldReplace = pathname.startsWith("/agent/"); + const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; onAgentSelect?.(); - navigate(`/agent/${serverId}/${agentId}` as any); + navigate(buildHostAgentDetailRoute(serverId, agentId) as any); }, [isActionSheetVisible, pathname, onAgentSelect] ); diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index 1c50b8159..eeb60f512 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -1,6 +1,6 @@ import { View, Text, Pressable, ActivityIndicator } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { X, ArrowUp, RefreshCcw, Check, Mic } from "lucide-react-native"; +import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native"; import { VolumeMeter } from "./volume-meter"; import { FOOTER_HEIGHT } from "@/constants/layout"; import type { DictationStatus } from "@/hooks/use-dictation"; @@ -233,7 +233,7 @@ export function DictationOverlay({ { backgroundColor: "rgba(255, 255, 255, 0.25)" }, ]} > - { - router.replace("/agent" as any); + router.replace(buildHostAgentDraftRoute(serverId) as any); }) .catch((err) => { const message = err instanceof Error ? err.message : "Failed to archive worktree"; diff --git a/packages/app/src/components/multi-daemon-session-host.tsx b/packages/app/src/components/multi-daemon-session-host.tsx index eb55b08a1..2201c8fc2 100644 --- a/packages/app/src/components/multi-daemon-session-host.tsx +++ b/packages/app/src/components/multi-daemon-session-host.tsx @@ -3,13 +3,11 @@ import { useDaemonRegistry, type HostProfile, } from "@/contexts/daemon-registry-context"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, } from "@/utils/daemon-endpoints"; -import { probeConnection } from "@/utils/test-daemon-connection"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; import type { ActiveConnection } from "@/contexts/daemon-connections-context"; type Candidate = { @@ -67,120 +65,17 @@ function buildCandidates(host: HostProfile): Candidate[] { } function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) { - const { connectionStates } = useDaemonConnections(); - const { updateHost } = useDaemonRegistry(); - const candidates = useMemo(() => buildCandidates(daemon), [daemon]); - const [activeIndex, setActiveIndex] = useState(0); - const active = candidates[activeIndex] ?? candidates[0] ?? null; + const active = + candidates.find((candidate) => candidate.connectionId === daemon.preferredConnectionId) ?? + candidates[0] ?? + null; const activeUrl = active?.url ?? null; - const lastAttemptedUrlRef = useRef(null); - const pendingPreferenceWriteRef = useRef(false); - const upgradeProbeInFlightRef = useRef(false); - - useEffect(() => { - if (!activeUrl) { - return; - } - // If the active URL fell out of the candidate set (e.g. endpoints updated), snap back. - const idx = candidates.findIndex((c) => c.url === activeUrl); - if (idx === -1) { - setActiveIndex(0); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [candidates.map((c) => c.url).join("|")]); - if (!activeUrl) { return null; } - const connection = connectionStates.get(daemon.serverId); - const status = connection?.status ?? "idle"; - const lastError = connection?.lastError ?? null; - - useEffect(() => { - if (!connection) return; - - if (status === "online") { - if (!active) return; - if (pendingPreferenceWriteRef.current) return; - if (daemon.preferredConnectionId === active.connectionId) return; - - pendingPreferenceWriteRef.current = true; - void updateHost(daemon.serverId, { - preferredConnectionId: active.connectionId, - }).finally(() => { - pendingPreferenceWriteRef.current = false; - }); - return; - } - - if ((status === "error" || (status === "offline" && lastError)) && candidates.length > 1) { - if (lastAttemptedUrlRef.current === activeUrl) { - return; - } - lastAttemptedUrlRef.current = activeUrl; - if (activeIndex < candidates.length - 1) { - setActiveIndex((idx) => Math.min(idx + 1, candidates.length - 1)); - } - } - }, [ - active, - activeIndex, - activeUrl, - candidates.length, - daemon.preferredConnectionId, - daemon.serverId, - lastError, - status, - updateHost, - connection, - ]); - - useEffect(() => { - if (!connection) return; - if (status !== "online") return; - if (activeIndex === 0) return; - - const best = candidates[0]; - if (!best) return; - if (best.activeConnection.type !== "direct") return; - - const intervalMs = 15_000; - let cancelled = false; - - const attemptUpgrade = async () => { - if (cancelled) return; - if (upgradeProbeInFlightRef.current) return; - if (activeIndex === 0) return; - - upgradeProbeInFlightRef.current = true; - try { - const { serverId } = await probeConnection( - { id: "probe", type: "direct", endpoint: best.activeConnection.endpoint }, - { timeoutMs: 2000 }, - ); - if (cancelled) return; - if (serverId !== daemon.serverId) return; - - lastAttemptedUrlRef.current = null; - setActiveIndex(0); - } catch { - // ignore - we'll retry periodically - } finally { - upgradeProbeInFlightRef.current = false; - } - }; - - void attemptUpgrade(); - const interval = setInterval(() => void attemptUpgrade(), intervalMs); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [activeIndex, candidates, connection, daemon.serverId, status]); - return ( void; - onCreateAgent: (workingDir: string) => void; + onCreateAgent: (serverId: string, workingDir: string) => void; onDrag: () => void; isDragging: boolean; } @@ -109,11 +114,12 @@ function SectionHeader({ const handleCreatePress = useCallback( (e: { stopPropagation: () => void }) => { e.stopPropagation(); - if (createAgentWorkingDir) { - onCreateAgent(createAgentWorkingDir); + const serverId = section.firstAgentServerId; + if (createAgentWorkingDir && serverId) { + onCreateAgent(serverId, createAgentWorkingDir); } }, - [onCreateAgent, createAgentWorkingDir] + [onCreateAgent, createAgentWorkingDir, section.firstAgentServerId] ); return ( @@ -405,12 +411,12 @@ export function SidebarAgentList({ params: { serverId, agentId }, }); - const shouldReplace = pathname.startsWith("/agent/"); + const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; onAgentSelect?.(); - navigate(`/agent/${serverId}/${agentId}` as any); + navigate(buildHostAgentDetailRoute(serverId, agentId) as any); }, [isActionSheetVisible, pathname, onAgentSelect] ); @@ -432,9 +438,9 @@ export function SidebarAgentList({ }, [actionAgent, actionClient]); const handleCreateAgentInProject = useCallback( - (workingDir: string) => { + (serverId: string, workingDir: string) => { onAgentSelect?.(); - router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any); + router.push(buildNewAgentRoute(serverId, workingDir) as any); }, [onAgentSelect] ); diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index f060a6179..0bce11f1e 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState, useEffect } from "react"; +import { useCallback, useMemo, useState, useEffect, useRef } from "react"; import { View, Pressable, Text, Platform } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { @@ -10,7 +10,7 @@ import Animated, { import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Plus, Settings, Users } from "lucide-react-native"; -import { router } from "expo-router"; +import { router, usePathname } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; import { SidebarAgentList } from "./sidebar-agent-list"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; @@ -20,6 +20,17 @@ import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-wind import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts"; +import { Combobox } from "@/components/ui/combobox"; +import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { + buildHostAgentDraftRoute, + buildHostAgentsRoute, + buildHostSettingsRoute, + mapPathnameToServer, + parseServerIdFromPathname, +} from "@/utils/host-routes"; const DESKTOP_SIDEBAR_WIDTH = 320; @@ -35,6 +46,42 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); + const pathname = usePathname(); + const { daemons } = useDaemonRegistry(); + const { connectionStates } = useDaemonConnections(); + const activeServerIdFromPath = useMemo( + () => parseServerIdFromPathname(pathname), + [pathname] + ); + const activeServerId = activeServerIdFromPath ?? daemons[0]?.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]); + const activeHostStatus = activeServerId + ? (connectionStates.get(activeServerId)?.status ?? "idle") + : "idle"; + const activeHostStatusColor = + activeHostStatus === "online" + ? theme.colors.palette.green[400] + : activeHostStatus === "connecting" + ? theme.colors.palette.amber[500] + : theme.colors.palette.red[500]; + const hostOptions = useMemo( + () => + daemons.map((daemon) => ({ + id: daemon.serverId, + label: daemon.label?.trim() || daemon.serverId, + description: formatConnectionStatus( + connectionStates.get(daemon.serverId)?.status ?? "idle" + ), + })), + [connectionStates, daemons] + ); + const hostTriggerRef = useRef(null); + const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; @@ -45,7 +92,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { isInitialLoad, isRevalidating, refreshAll, - } = useSidebarAgentsGrouped({ isOpen }); + } = useSidebarAgentsGrouped({ isOpen, serverId: activeServerId }); const { translateX, backdropOpacity, @@ -88,8 +135,11 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { }, [closeToAgent]); const handleCreateAgentClean = useCallback(() => { - router.push("/agent" as any); - }, []); + if (!activeServerId) { + return; + } + router.push(buildHostAgentDraftRoute(activeServerId) as any); + }, [activeServerId]); // Mobile: close sidebar and navigate const handleCreateAgentCleanMobile = useCallback(() => { @@ -104,14 +154,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { // Mobile: close sidebar and navigate const handleSettingsMobile = useCallback(() => { + if (!activeServerId) { + return; + } closeToAgent(); - router.push("/settings"); - }, [closeToAgent]); + router.push(buildHostSettingsRoute(activeServerId) as any); + }, [activeServerId, closeToAgent]); // Desktop: just navigate, don't close const handleSettingsDesktop = useCallback(() => { - router.push("/settings"); - }, []); + if (!activeServerId) { + return; + } + router.push(buildHostSettingsRoute(activeServerId) as any); + }, [activeServerId]); // Mobile: close sidebar when agent is selected // Snap immediately since navigation interrupts animations @@ -122,13 +178,35 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { }, [closeToAgent, translateX, backdropOpacity, windowWidth]); const handleViewMore = useCallback(() => { + if (!activeServerId) { + return; + } if (isMobile) { translateX.value = -windowWidth; backdropOpacity.value = 0; closeToAgent(); } - router.push("/agents"); - }, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]); + router.push(buildHostAgentsRoute(activeServerId) as any); + }, [ + activeServerId, + backdropOpacity, + closeToAgent, + isMobile, + translateX, + windowWidth, + ]); + + const handleHostSelect = useCallback( + (nextServerId: string) => { + if (!nextServerId) { + return; + } + const nextPath = mapPathnameToServer(pathname, nextServerId); + setIsHostPickerOpen(false); + router.push(nextPath as any); + }, + [pathname] + ); // Close gesture (swipe left to close when sidebar is open) // Only activates on leftward swipe, fails on rightward or vertical movement @@ -206,18 +284,46 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { {/* Header */} - - {({ hovered }) => ( - <> - - New agent - - )} - + + + {({ hovered }) => ( + <> + + New agent + + )} + + setIsHostPickerOpen(true)} + disabled={hostOptions.length === 0} + > + + + {activeHostLabel} + + + + {/* Middle: scrollable agent list */} @@ -286,18 +392,46 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { style={[styles.sidebarHeader, { paddingTop: trafficLightPadding.top || styles.sidebarHeader.paddingTop }]} {...dragHandlers} > - - {({ hovered }) => ( - <> - - New agent - - )} - + + + {({ hovered }) => ( + <> + + New agent + + )} + + setIsHostPickerOpen(true)} + disabled={hostOptions.length === 0} + > + + + {activeHostLabel} + + + + {/* Middle: scrollable agent list */} @@ -383,12 +517,19 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.border, userSelect: "none", }, + sidebarHeaderRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, newAgentButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], + flexShrink: 0, }, newAgentButtonHovered: {}, newAgentButtonText: { @@ -399,6 +540,25 @@ const styles = StyleSheet.create((theme) => ({ newAgentButtonTextHovered: { color: theme.colors.foreground, }, + hostTrigger: { + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing[2], + minWidth: 0, + maxWidth: "55%", + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[1], + }, + hostStatusDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + }, + hostTriggerText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, sidebarFooter: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index d40ef5194..db82cf4ca 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -9,6 +9,7 @@ import { useSessionStore } from "@/stores/session-store"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; import { NameHostModal } from "./name-host-modal"; +import { buildHostAgentDraftRoute } from "@/utils/host-routes"; const styles = StyleSheet.create((theme) => ({ container: { @@ -90,7 +91,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const finishOnboarding = useCallback( (serverId: string) => { - router.replace({ pathname: "/", params: { serverId } }); + router.replace(buildHostAgentDraftRoute(serverId) as any); }, [router] ); diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 35efdd68a..14c695f6e 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -1,6 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useQueries, useQueryClient } from "@tanstack/react-query"; -import { useShallow } from "zustand/shallow"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore, type Agent } from "@/stores/session-store"; import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents"; @@ -31,106 +30,69 @@ function toAggregatedAgent(params: { }; } -export function useAllAgentsList(): AggregatedAgentsResult { +export function useAllAgentsList(options?: { + serverId?: string | null; +}): AggregatedAgentsResult { const { connectionStates } = useDaemonConnections(); const queryClient = useQueryClient(); + const serverId = useMemo(() => { + const value = options?.serverId; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; + }, [options?.serverId]); - const sessionClients = useSessionStore( - useShallow((state) => { - const result: Record< - string, - NonNullable | null - > = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.client ?? null; + const session = useSessionStore((state) => + serverId ? state.sessions[serverId] : undefined + ); + const client = session?.client ?? null; + const isConnected = session?.connection.isConnected ?? false; + const liveAgents = session?.agents ?? null; + const canFetch = Boolean(serverId && client && isConnected); + + const agentsQuery = useQuery({ + queryKey: ["allAgents", serverId] as const, + queryFn: async () => { + if (!client) { + throw new Error("Daemon client not available"); } - return result; - }) - ); - - const sessionConnections = useSessionStore( - useShallow((state) => { - const result: Record = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.connection.isConnected; - } - return result; - }) - ); - - const liveAgents = useSessionStore( - useShallow((state) => { - const result: Record | undefined> = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.agents; - } - return result; - }) - ); - - const serverEntries = useMemo( - () => - Object.keys(sessionClients).map((serverId) => ({ - serverId, - client: sessionClients[serverId] ?? null, - isConnected: sessionConnections[serverId] ?? false, - })), - [sessionClients, sessionConnections] - ); - - const queries = useQueries({ - queries: serverEntries.map(({ serverId, client, isConnected }) => ({ - queryKey: ["allAgents", serverId] as const, - queryFn: async () => { - if (!client) { - throw new Error("Daemon client not available"); - } - return await client.fetchAgents(); - }, - enabled: Boolean(client) && isConnected, - staleTime: ALL_AGENTS_STALE_TIME, - refetchOnMount: "always" as const, - })), + return await client.fetchAgents(); + }, + enabled: canFetch, + staleTime: ALL_AGENTS_STALE_TIME, + refetchOnMount: "always" as const, }); const refreshAll = useCallback(() => { - for (const { serverId } of serverEntries) { - void queryClient.invalidateQueries({ - queryKey: ["allAgents", serverId], - }); + if (!serverId) { + return; } - }, [queryClient, serverEntries]); + void queryClient.invalidateQueries({ + queryKey: ["allAgents", serverId], + }); + }, [queryClient, serverId]); const agents = useMemo(() => { - const all: AggregatedAgent[] = []; + if (!serverId) { + return []; + } + const data = agentsQuery.data ?? []; + const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; + const list: AggregatedAgent[] = []; - for (let idx = 0; idx < serverEntries.length; idx++) { - const entry = serverEntries[idx]; - if (!entry) { - continue; - } - const data = queries[idx]?.data; - if (!data) { - continue; - } - const serverLabel = - connectionStates.get(entry.serverId)?.daemon.label ?? entry.serverId; - const liveById = liveAgents[entry.serverId]; - - for (const snapshot of data) { - const normalized = normalizeAgentSnapshot(snapshot, entry.serverId); - const live = liveById?.get(snapshot.id); - all.push( - toAggregatedAgent({ - source: live ?? normalized, - serverId: entry.serverId, - serverLabel, - }) - ); - } + for (const snapshot of data) { + const normalized = normalizeAgentSnapshot(snapshot, serverId); + const live = liveAgents?.get(snapshot.id); + list.push( + toAggregatedAgent({ + source: live ?? normalized, + serverId, + serverLabel, + }) + ); } - all.sort((left, right) => { + list.sort((left, right) => { const leftRunning = left.status === "running"; const rightRunning = right.status === "running"; if (leftRunning && !rightRunning) { @@ -142,10 +104,11 @@ export function useAllAgentsList(): AggregatedAgentsResult { return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); }); - return all; - }, [serverEntries, queries, connectionStates, liveAgents]); + return list; + }, [agentsQuery.data, connectionStates, liveAgents, serverId]); - const isFetching = queries.some((query) => query.isPending || query.isFetching); + const isFetching = + canFetch && (agentsQuery.isPending || agentsQuery.isFetching); const isInitialLoad = isFetching && agents.length === 0; const isRevalidating = isFetching && agents.length > 0; diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index fefeaa974..14470b883 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -17,6 +17,12 @@ import { buildNewAgentRoute, resolveNewAgentWorkingDir, } from "@/utils/new-agent-routing"; +import { + buildHostAgentDetailRoute, + buildHostSettingsRoute, + parseHostAgentRouteFromPathname, + parseServerIdFromPathname, +} from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { focusWithRetries } from "@/utils/web-focus"; @@ -46,19 +52,17 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { } function parseAgentKeyFromPathname(pathname: string): string | null { - const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/); - if (!match) return null; - return `${match[1]}:${match[2]}`; + const match = parseHostAgentRouteFromPathname(pathname); + if (!match) { + return null; + } + return `${match.serverId}:${match.agentId}`; } function parseAgentRouteFromPathname( pathname: string ): { serverId: string; agentId: string } | null { - const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/); - if (!match) return null; - const [, serverId, agentId] = match; - if (!serverId || !agentId) return null; - return { serverId, agentId }; + return parseHostAgentRouteFromPathname(pathname); } type CommandCenterActionDefinition = { @@ -67,7 +71,7 @@ type CommandCenterActionDefinition = { icon?: "plus" | "settings"; shortcutKeys?: ShortcutKey[]; keywords: string[]; - buildRoute: (params: { newAgentRoute: string }) => string; + buildRoute: (params: { newAgentRoute: string; settingsRoute: string }) => string; }; const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ @@ -84,7 +88,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ title: "Settings", icon: "settings", keywords: ["settings", "preferences", "config", "configuration"], - buildRoute: () => "/settings", + buildRoute: ({ settingsRoute }) => settingsRoute, }, ]; @@ -137,22 +141,26 @@ export function useCommandCenter() { return filtered; }, [agents, query]); + const fallbackServerId = agents[0]?.serverId ?? null; + const agentKeyFromPathname = useMemo( () => parseAgentKeyFromPathname(pathname), [pathname] ); const newAgentRoute = useMemo(() => { + const serverIdFromPath = + parseServerIdFromPathname(pathname) ?? fallbackServerId; const routeAgent = parseAgentRouteFromPathname(pathname); if (!routeAgent) { - return "/agent"; + return serverIdFromPath ? buildNewAgentRoute(serverIdFromPath) : "/"; } const { serverId, agentId } = routeAgent; const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); const cwd = currentAgent?.cwd?.trim(); if (!cwd) { - return "/agent"; + return buildNewAgentRoute(serverId); } const checkout = @@ -160,8 +168,14 @@ export function useCommandCenter() { checkoutStatusQueryKey(serverId, cwd) ) ?? null; const workingDir = resolveNewAgentWorkingDir(cwd, checkout); - return buildNewAgentRoute(workingDir); - }, [pathname]); + return buildNewAgentRoute(serverId, workingDir); + }, [fallbackServerId, pathname]); + + const settingsRoute = useMemo(() => { + const serverIdFromPath = + parseServerIdFromPathname(pathname) ?? fallbackServerId; + return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/"; + }, [fallbackServerId, pathname]); const actionItems = useMemo(() => { return COMMAND_CENTER_ACTIONS.filter((action) => @@ -171,10 +185,10 @@ export function useCommandCenter() { id: action.id, title: action.title, icon: action.icon, - route: action.buildRoute({ newAgentRoute }), + route: action.buildRoute({ newAgentRoute, settingsRoute }), shortcutKeys: action.shortcutKeys, })); - }, [newAgentRoute, query]); + }, [newAgentRoute, query, settingsRoute]); const items = useMemo(() => { const next: CommandCenterItem[] = []; @@ -203,14 +217,14 @@ export function useCommandCenter() { const session = useSessionStore.getState().sessions[agent.serverId]; session?.client?.clearAgentAttention(agent.id); - const shouldReplace = pathname.startsWith("/agent/"); + const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; requestFocusChatInput(agentKey(agent)); // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); setOpen(false); - navigate(`/agent/${agent.serverId}/${agent.id}` as any); + navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any); }, [pathname, requestFocusChatInput, setOpen] ); diff --git a/packages/app/src/hooks/use-global-keyboard-nav.ts b/packages/app/src/hooks/use-global-keyboard-nav.ts index a0235cedb..fc0e7d478 100644 --- a/packages/app/src/hooks/use-global-keyboard-nav.ts +++ b/packages/app/src/hooks/use-global-keyboard-nav.ts @@ -15,6 +15,11 @@ import { buildNewAgentRoute, resolveNewAgentWorkingDir, } from "@/utils/new-agent-routing"; +import { + buildHostAgentDetailRoute, + parseHostAgentRouteFromPathname, + parseServerIdFromPathname, +} from "@/utils/host-routes"; export function useGlobalKeyboardNav({ enabled, @@ -86,18 +91,20 @@ export function useGlobalKeyboardNav({ } const { serverId, agentId } = parsed; - const shouldReplace = pathname.startsWith("/agent/"); + const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; - navigate(`/agent/${serverId}/${agentId}` as any); + navigate(buildHostAgentDetailRoute(serverId, agentId) as any); }; const navigateToNewAgent = () => { - let target = "/agent"; + let targetServerId = parseServerIdFromPathname(pathname); + let targetWorkingDir: string | null = null; if (selectedAgentId) { const separatorIndex = selectedAgentId.indexOf(":"); if (separatorIndex > 0) { const serverId = selectedAgentId.slice(0, separatorIndex); const agentId = selectedAgentId.slice(separatorIndex + 1); + targetServerId = serverId; const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId); const cwd = agent?.cwd?.trim(); if (cwd) { @@ -105,13 +112,21 @@ export function useGlobalKeyboardNav({ queryClient.getQueryData( checkoutStatusQueryKey(serverId, cwd) ) ?? null; - const workingDir = resolveNewAgentWorkingDir(cwd, checkout); - target = buildNewAgentRoute(workingDir); + targetWorkingDir = resolveNewAgentWorkingDir(cwd, checkout); } } } - router.push(target as any); + if (!targetServerId) { + const sessionServerIds = Object.keys(useSessionStore.getState().sessions); + targetServerId = sessionServerIds[0] ?? null; + } + + if (!targetServerId) { + return; + } + + router.push(buildNewAgentRoute(targetServerId, targetWorkingDir) as any); }; const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/app/src/hooks/use-sidebar-agents-grouped.ts b/packages/app/src/hooks/use-sidebar-agents-grouped.ts index 2522b173c..928e54af8 100644 --- a/packages/app/src/hooks/use-sidebar-agents-grouped.ts +++ b/packages/app/src/hooks/use-sidebar-agents-grouped.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; -import { useQueries, useQueryClient } from "@tanstack/react-query"; -import { useShallow } from "zustand/shallow"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore, type Agent } from "@/stores/session-store"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; @@ -15,7 +14,8 @@ const SIDEBAR_GROUPS_STALE_TIME = 15_000; const SIDEBAR_GROUPS_REFETCH_INTERVAL = 10_000; const MAX_AGENTS_PER_PROJECT = 5; -type SidebarGroupsPayload = FetchAgentsGroupedByProjectResponseMessage["payload"]; +type SidebarGroupsPayload = + FetchAgentsGroupedByProjectResponseMessage["payload"]; export type SidebarCheckoutLite = SidebarGroupsPayload["groups"][number]["agents"][number]["checkout"]; type MutableSidebarGroup = { @@ -68,95 +68,63 @@ function toAggregatedAgent(params: { export function useSidebarAgentsGrouped(options?: { isOpen?: boolean; + serverId?: string | null; }): SidebarAgentsGroupedResult { const { connectionStates } = useDaemonConnections(); const queryClient = useQueryClient(); const isOpen = options?.isOpen ?? true; + const serverId = useMemo(() => { + const value = options?.serverId; + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; + }, [options?.serverId]); - const sessionClients = useSessionStore( - useShallow((state) => { - const result: Record< - string, - NonNullable | null - > = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.client ?? null; + const session = useSessionStore((state) => + serverId ? state.sessions[serverId] : undefined + ); + const client = session?.client ?? null; + const liveAgents = session?.agents ?? null; + const isConnected = session?.connection.isConnected ?? false; + const canFetch = Boolean(serverId && client && isConnected); + + const groupedQuery = useQuery({ + queryKey: ["sidebarAgentsGrouped", serverId] as const, + queryFn: async () => { + if (!client) { + throw new Error("Daemon client not available"); } - return result; - }) - ); - - const sessionAgents = useSessionStore( - useShallow((state) => { - const result: Record | undefined> = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.agents; - } - return result; - }) - ); - - const sessionConnections = useSessionStore( - useShallow((state) => { - const result: Record = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.connection.isConnected; - } - return result; - }) - ); - - const serverEntries = useMemo( - () => - Object.keys(sessionClients).map((serverId) => ({ - serverId, - client: sessionClients[serverId] ?? null, - isConnected: sessionConnections[serverId] ?? false, - })), - [sessionClients, sessionConnections] - ); - - const groupedQueries = useQueries({ - queries: serverEntries.map(({ serverId, client, isConnected }) => ({ - queryKey: ["sidebarAgentsGrouped", serverId] as const, - queryFn: async () => { - if (!client) { - throw new Error("Daemon client not available"); - } - return await client.fetchAgentsGroupedByProject({ - filter: { labels: { ui: "true" } }, - }); - }, - enabled: Boolean(client) && isConnected, - staleTime: SIDEBAR_GROUPS_STALE_TIME, - refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false, - refetchIntervalInBackground: isOpen, - refetchOnMount: "always" as const, - })), + return await client.fetchAgentsGroupedByProject({ + filter: { labels: { ui: "true" } }, + }); + }, + enabled: canFetch, + staleTime: SIDEBAR_GROUPS_STALE_TIME, + refetchInterval: isOpen ? SIDEBAR_GROUPS_REFETCH_INTERVAL : false, + refetchIntervalInBackground: isOpen, + refetchOnMount: "always" as const, }); const projectOrder = useSectionOrderStore((state) => state.projectOrder); const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); const { sections, checkoutByAgentKey, hasAnyData } = useMemo(() => { + if (!serverId) { + return { + sections: [] as SidebarSectionData[], + checkoutByAgentKey: new Map(), + hasAnyData: false, + }; + } + const groupsByKey = new Map(); const checkoutLookup = new Map(); const seenAgentKeys = new Set(); - const groupedFetchReadyByServer = new Map(); - - for (let idx = 0; idx < serverEntries.length; idx++) { - const { serverId } = serverEntries[idx] ?? {}; - if (!serverId) { - continue; - } - groupedFetchReadyByServer.set(serverId, groupedQueries[idx]?.isFetched ?? false); - const payload = groupedQueries[idx]?.data as SidebarGroupsPayload | undefined; - if (!payload) { - continue; - } - const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; - const liveAgents = sessionAgents[serverId]; + const payload = groupedQuery.data as SidebarGroupsPayload | undefined; + const groupedFetchReady = groupedQuery.isFetched; + const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; + if (payload) { for (const group of payload.groups) { const existing: MutableSidebarGroup = groupsByKey.get(group.projectKey) ?? @@ -180,7 +148,10 @@ export function useSidebarAgentsGrouped(options?: { const agentKey = `${serverId}:${entry.agent.id}`; seenAgentKeys.add(agentKey); - checkoutLookup.set(agentKey, live?.projectPlacement?.checkout ?? entry.checkout); + checkoutLookup.set( + agentKey, + live?.projectPlacement?.checkout ?? entry.checkout + ); existing.agents.push(nextAgent); } @@ -188,16 +159,7 @@ export function useSidebarAgentsGrouped(options?: { } } - for (const { serverId } of serverEntries) { - if (!groupedFetchReadyByServer.get(serverId)) { - continue; - } - const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; - const liveAgents = sessionAgents[serverId]; - if (!liveAgents) { - continue; - } - + if (groupedFetchReady && liveAgents) { for (const live of liveAgents.values()) { if (live.archivedAt || live.labels.ui !== "true") { continue; @@ -270,7 +232,14 @@ export function useSidebarAgentsGrouped(options?: { checkoutByAgentKey: checkoutLookup, hasAnyData: nextSections.length > 0, }; - }, [serverEntries, groupedQueries, connectionStates, sessionAgents, projectOrder]); + }, [ + connectionStates, + groupedQuery.data, + groupedQuery.isFetched, + liveAgents, + projectOrder, + serverId, + ]); useEffect(() => { const currentKeys = sections.map((section) => section.projectKey); @@ -282,16 +251,16 @@ export function useSidebarAgentsGrouped(options?: { }, [sections, projectOrder, setProjectOrder]); const refreshAll = useCallback(() => { - for (const { serverId } of serverEntries) { - void queryClient.invalidateQueries({ - queryKey: ["sidebarAgentsGrouped", serverId], - }); + if (!serverId) { + return; } - }, [queryClient, serverEntries]); + void queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsGrouped", serverId], + }); + }, [queryClient, serverId]); - const isFetching = groupedQueries.some( - (query) => query.isPending || query.isFetching - ); + const isFetching = + canFetch && (groupedQuery.isPending || groupedQuery.isFetching); const isInitialLoad = isFetching && !hasAnyData; const isRevalidating = isFetching && hasAnyData; @@ -304,3 +273,4 @@ export function useSidebarAgentsGrouped(options?: { refreshAll, }; } + diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 291945e82..d722d4e55 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -70,6 +70,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { buildHostAgentDraftRoute } from "@/utils/host-routes"; const DROPDOWN_WIDTH = 220; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -124,7 +125,11 @@ export function AgentReadyScreen({ targetMs: 300, }); } - router.replace("/agent" as any); + if (targetServerId) { + router.replace(buildHostAgentDraftRoute(targetServerId) as any); + return; + } + router.replace("/" as any); }, [resolvedAgentId, resolvedServerId, router]); const focusServerId = resolvedServerId; diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 201234156..6892d37ee 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -14,7 +14,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { Folder, GitBranch, Menu, Monitor, PanelLeft } from "lucide-react-native"; +import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { AgentInputArea } from "@/components/agent-input-area"; import { AgentStreamView } from "@/components/agent-stream-view"; @@ -29,7 +29,6 @@ import { } from "@/hooks/use-checkout-status-query"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { formatConnectionStatus } from "@/utils/daemons"; import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; import { shortenPath } from "@/utils/shorten-path"; import { usePanelStore } from "@/stores/panel-store"; @@ -47,6 +46,7 @@ import type { AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; const DRAFT_AGENT_ID = "__new_agent__"; const EMPTY_PENDING_PERMISSIONS = new Map(); @@ -112,11 +112,13 @@ type DraftAgentParams = { type DraftAgentScreenProps = { isVisible?: boolean; onCreateFlowActiveChange?: (active: boolean) => void; + forcedServerId?: string; }; export function DraftAgentScreen({ isVisible = true, onCreateFlowActiveChange, + forcedServerId, }: DraftAgentScreenProps = {}) { const { theme } = useUnistyles(); const router = useRouter(); @@ -144,7 +146,11 @@ export function DraftAgentScreen({ }; }); - const resolvedServerId = getParamValue(params.serverId); + const forcedServerIdParam = forcedServerId?.trim(); + const resolvedServerId = + forcedServerIdParam && forcedServerIdParam.length > 0 + ? forcedServerIdParam + : getParamValue(params.serverId); const resolvedProvider = getValidProvider(getParamValue(params.provider)); const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); const resolvedModel = getParamValue(params.model); @@ -215,25 +221,9 @@ export function DraftAgentScreen({ isCreateFlow: true, onlineServerIds, }); - const daemonLabelByServerId = useMemo(() => { - const map = new Map(); - for (const daemon of daemons) { - const label = daemon.label?.trim(); - map.set(daemon.serverId, label && label.length > 0 ? label : daemon.serverId); - } - return map; - }, [daemons]); const hostEntry = selectedServerId ? connectionStates.get(selectedServerId) : undefined; - const selectedDaemonLabel = selectedServerId - ? daemonLabelByServerId.get(selectedServerId) - : null; - const hostLabel = - selectedDaemonLabel ?? - hostEntry?.daemon.label ?? - selectedServerId ?? - "Select host"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; @@ -242,7 +232,6 @@ export function DraftAgentScreen({ ? theme.colors.foreground : theme.colors.foregroundMuted; - const [isHostOpen, setIsHostOpen] = useState(false); const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); const [baseBranch, setBaseBranch] = useState(""); const [worktreeSlug, setWorktreeSlug] = useState(""); @@ -252,7 +241,6 @@ export function DraftAgentScreen({ const [isBranchOpen, setIsBranchOpen] = useState(false); const [branchSearchQuery, setBranchSearchQuery] = useState(""); const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState(""); - const hostAnchorRef = useRef(null); const workingDirAnchorRef = useRef(null); const worktreeAnchorRef = useRef(null); const branchAnchorRef = useRef(null); @@ -670,17 +658,6 @@ export function DraftAgentScreen({ worktreeMode === "create" ? "Create new worktree" : selectedWorktreeLabel || "Select worktree"; - const hostOptions = useMemo( - () => - daemons.map((daemon) => ({ - id: daemon.serverId, - label: daemonLabelByServerId.get(daemon.serverId) ?? daemon.serverId, - description: formatConnectionStatus( - connectionStates.get(daemon.serverId)?.status ?? "idle" - ), - })), - [connectionStates, daemonLabelByServerId, daemons] - ); const worktreeComboOptions = useMemo( () => [ { @@ -930,7 +907,9 @@ export function DraftAgentScreen({ const agentId = (result as { id?: string })?.id; if (agentId && selectedServerId) { updatePendingAgentId(agentId); - router.replace(`/agent/${selectedServerId}/${agentId}` as any); + router.replace( + buildHostAgentDetailRoute(selectedServerId, agentId) as any + ); return; } @@ -1031,7 +1010,7 @@ export function DraftAgentScreen({ - setIsHostOpen(true)} - icon={} - showLabel={false} - /> {isDirectoryNotExists && ( @@ -1117,17 +1086,6 @@ export function DraftAgentScreen({ {attachWorktreeError ? {attachWorktreeError} : null} {worktreeOptionsError ? {worktreeOptionsError} : null} - setSelectedServerIdFromUser(serverId)} - title="Host" - searchPlaceholder="Search hosts..." - open={isHostOpen} - onOpenChange={setIsHostOpen} - anchorRef={hostAnchorRef} - /> - { - router.replace("/agent" as any); - }, [router]); + const firstMatchServerId = matches[0]?.serverId ?? null; + if (firstMatchServerId) { + router.replace(buildHostAgentDraftRoute(firstMatchServerId) as any); + return; + } + router.replace("/" as any); + }, [matches, router]); const handleSelectMatch = useCallback( (match: AgentMatch) => { - router.replace(`/agent/${match.serverId}/${match.agent.id}` as any); + router.replace(buildHostAgentDetailRoute(match.serverId, match.agent.id) as any); }, [router] ); diff --git a/packages/app/src/app/agents.tsx b/packages/app/src/screens/agents-screen.tsx similarity index 82% rename from packages/app/src/app/agents.tsx rename to packages/app/src/screens/agents-screen.tsx index 4b803c9fe..90d779be4 100644 --- a/packages/app/src/app/agents.tsx +++ b/packages/app/src/screens/agents-screen.tsx @@ -4,9 +4,13 @@ import { StyleSheet } from "react-native-unistyles"; import { BackHeader } from "@/components/headers/back-header"; import { AgentList } from "@/components/agent-list"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; +import { buildHostAgentDraftRoute } from "@/utils/host-routes"; +import { router } from "expo-router"; -export default function AgentsScreen() { - const { agents, isRevalidating, refreshAll } = useAllAgentsList(); +export function AgentsScreen({ serverId }: { serverId: string }) { + const { agents, isRevalidating, refreshAll } = useAllAgentsList({ + serverId, + }); // Track user-initiated refresh to avoid showing spinner on background revalidation const [isManualRefresh, setIsManualRefresh] = useState(false); @@ -33,7 +37,10 @@ export default function AgentsScreen() { return ( - + router.replace(buildHostAgentDraftRoute(serverId) as any)} + /> ({ export default function SettingsScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const params = useLocalSearchParams<{ editHost?: string }>(); + const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); + const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const { settings, isLoading: settingsLoading, updateSettings, resetSettings } = useAppSettings(); const { daemons, @@ -674,16 +675,19 @@ export default function SettingsScreen() { setIsAddHostMethodVisible(false); setIsPasteLinkVisible(true); }} - onScanQr={() => { - const targetServerId = addConnectionTargetServerId; - const source = targetServerId ? "editHost" : "settings"; - closeAddConnectionFlow(); - router.push({ - pathname: "/pair-scan", - params: targetServerId ? { source, targetServerId } : { source }, - }); - }} - /> + onScanQr={() => { + const targetServerId = addConnectionTargetServerId; + const source = targetServerId ? "editHost" : "settings"; + const sourceServerId = routeServerId || targetServerId || undefined; + closeAddConnectionFlow(); + router.push({ + pathname: "/pair-scan", + params: targetServerId + ? { source, targetServerId, sourceServerId } + : { source, sourceServerId }, + }); + }} + /> 0 ? trimmed : null; +} + +function encodeSegment(value: string): string { + return encodeURIComponent(value); +} + +export function parseServerIdFromPathname(pathname: string): string | null { + const match = pathname.match(/^\/h\/([^/]+)(?:\/|$)/); + if (!match) { + return null; + } + const raw = match[1]; + if (!raw) { + return null; + } + try { + return trimNonEmpty(decodeURIComponent(raw)); + } catch { + return trimNonEmpty(raw); + } +} + +export function parseHostAgentRouteFromPathname( + pathname: string +): { serverId: string; agentId: string } | null { + const match = pathname.match(/^\/h\/([^/]+)\/agent\/([^/]+)(?:\/|$)/); + if (!match) { + return null; + } + + const [, encodedServerId, encodedAgentId] = match; + if (!encodedServerId || !encodedAgentId) { + return null; + } + + const decode = (value: string) => { + try { + return decodeURIComponent(value); + } catch { + return value; + } + }; + + const serverId = trimNonEmpty(decode(encodedServerId)); + const agentId = trimNonEmpty(decode(encodedAgentId)); + if (!serverId || !agentId) { + return null; + } + + return { serverId, agentId }; +} + +export function buildHostAgentDraftRoute(serverId: string): string { + const normalized = trimNonEmpty(serverId); + if (!normalized) { + return "/"; + } + return `/h/${encodeSegment(normalized)}/agent`; +} + +export function buildHostAgentDetailRoute( + serverId: string, + agentId: string +): string { + const normalizedServerId = trimNonEmpty(serverId); + const normalizedAgentId = trimNonEmpty(agentId); + if (!normalizedServerId || !normalizedAgentId) { + return "/"; + } + return `/h/${encodeSegment(normalizedServerId)}/agent/${encodeSegment( + normalizedAgentId + )}`; +} + +export function buildHostAgentsRoute(serverId: string): string { + const normalized = trimNonEmpty(serverId); + if (!normalized) { + return "/"; + } + return `/h/${encodeSegment(normalized)}/agents`; +} + +export function buildHostSettingsRoute(serverId: string): string { + const normalized = trimNonEmpty(serverId); + if (!normalized) { + return "/"; + } + return `/h/${encodeSegment(normalized)}/settings`; +} + +export function mapPathnameToServer( + pathname: string, + nextServerId: string +): string { + const normalized = trimNonEmpty(nextServerId); + if (!normalized) { + return "/"; + } + + const suffix = pathname.replace(/^\/h\/[^/]+\/?/, ""); + const base = `/h/${encodeSegment(normalized)}`; + if (suffix.startsWith("settings")) { + return `${base}/settings`; + } + if (suffix.startsWith("agents")) { + return `${base}/agents`; + } + return `${base}/agent`; +} + diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index 19ba51606..a9486afea 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -7,14 +7,14 @@ import { } from "./new-agent-routing"; describe("buildNewAgentRoute", () => { - it("falls back to /agent when no working directory is provided", () => { - expect(buildNewAgentRoute(undefined)).toBe("/agent"); - expect(buildNewAgentRoute(" ")).toBe("/agent"); + it("falls back to host-scoped draft route when no working directory is provided", () => { + expect(buildNewAgentRoute("srv-1", undefined)).toBe("/h/srv-1/agent"); + expect(buildNewAgentRoute("srv-1", " ")).toBe("/h/srv-1/agent"); }); it("encodes the working directory query parameter", () => { - expect(buildNewAgentRoute("/Users/me/dev/paseo")).toBe( - "/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo" + expect(buildNewAgentRoute("srv-1", "/Users/me/dev/paseo")).toBe( + "/h/srv-1/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo" ); }); }); diff --git a/packages/app/src/utils/new-agent-routing.ts b/packages/app/src/utils/new-agent-routing.ts index ac3caf3ee..7d097ed8a 100644 --- a/packages/app/src/utils/new-agent-routing.ts +++ b/packages/app/src/utils/new-agent-routing.ts @@ -1,4 +1,5 @@ import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query"; +import { buildHostAgentDraftRoute } from "@/utils/host-routes"; export function resolveNewAgentWorkingDir( cwd: string, @@ -7,10 +8,17 @@ export function resolveNewAgentWorkingDir( return (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? cwd; } -export function buildNewAgentRoute(workingDir?: string | null): string { +export function buildNewAgentRoute( + serverId: string, + workingDir?: string | null +): string { + const baseRoute = buildHostAgentDraftRoute(serverId); + if (baseRoute === "/") { + return baseRoute; + } const trimmedWorkingDir = workingDir?.trim(); if (!trimmedWorkingDir) { - return "/agent"; + return baseRoute; } - return `/agent?workingDir=${encodeURIComponent(trimmedWorkingDir)}`; + return `${baseRoute}?workingDir=${encodeURIComponent(trimmedWorkingDir)}`; } diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index df3256bb4..b03ce00df 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -33,16 +33,17 @@ describe("resolveNotificationTarget", () => { describe("buildNotificationRoute", () => { it("routes directly to server-scoped agent path when both ids are present", () => { expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe( - "/agent/srv-1/agent-1" + "/h/srv-1/agent/agent-1" ); }); - it("falls back to legacy agent route when serverId is absent", () => { - expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/agent/agent-legacy"); + it("falls back to host-scoped draft route when only serverId is present", () => { + expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only/agent"); }); - it("falls back to agents list when no agent id is present", () => { - expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/agents"); + it("falls back to root when no server id is present", () => { + expect(buildNotificationRoute({ agentId: "agent-legacy" })).toBe("/"); + expect(buildNotificationRoute(undefined)).toBe("/"); }); it("encodes path segments", () => { @@ -51,6 +52,6 @@ describe("buildNotificationRoute", () => { serverId: "srv/with/slash", agentId: "agent with space", }) - ).toBe("/agent/srv%2Fwith%2Fslash/agent%20with%20space"); + ).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space"); }); }); diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index 7b1e5eff7..e70699b8a 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,3 +1,8 @@ +import { + buildHostAgentDetailRoute, + buildHostAgentDraftRoute, +} from "@/utils/host-routes"; + type NotificationData = Record | null | undefined; function readNonEmptyString( @@ -25,10 +30,10 @@ export function resolveNotificationTarget(data: NotificationData): { export function buildNotificationRoute(data: NotificationData): string { const { serverId, agentId } = resolveNotificationTarget(data); if (serverId && agentId) { - return `/agent/${encodeURIComponent(serverId)}/${encodeURIComponent(agentId)}`; + return buildHostAgentDetailRoute(serverId, agentId); } - if (agentId) { - return `/agent/${encodeURIComponent(agentId)}`; + if (serverId) { + return buildHostAgentDraftRoute(serverId); } - return "/agents"; + return "/"; } diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts index 1fa5a7f5c..2d3ed3f03 100644 --- a/packages/app/src/utils/os-notifications.test.ts +++ b/packages/app/src/utils/os-notifications.test.ts @@ -161,6 +161,6 @@ describe("sendOsNotification", () => { expect(clicked.onclick).toBeTypeOf("function"); clicked.onclick?.({} as Event); - expect(assign).toHaveBeenCalledWith("/agent/srv%20with%20space/agent%2F1"); + expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); }); });