diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index d09638133..77b61dbcc 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -240,7 +240,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { } await page.getByTestId('sidebar-new-agent').click(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/agent\/?$/); await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); @@ -337,7 +337,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { await expect(page.getByTestId('changes-menu-archive')).toBeVisible(); await page.getByTestId('changes-menu-archive').click(); // Archiving a worktree deletes agents and redirects to home - await expect(page).toHaveURL(/\/$/, { timeout: 30000 }); + await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 }); await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); await page.getByTestId('worktree-attach-toggle').click(); @@ -348,13 +348,13 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { await attachSheetBackdrop.click({ force: true }); } await page.getByTestId('worktree-attach-toggle').click(); - await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0); + await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 }); await setWorkingDirectory(page, nonGitDir); const attachPicker = page.getByTestId('worktree-attach-picker'); if (await attachPicker.isVisible()) { await page.getByTestId('worktree-attach-toggle').click(); - await expect(attachPicker).toHaveCount(0); + await expect(attachPicker).toBeHidden({ timeout: 30000 }); } await createAgentAndWait(page, 'Respond with exactly: NON-GIT'); await waitForAssistantText(page, 'NON-GIT'); diff --git a/packages/app/e2e/dictation-web.spec.ts b/packages/app/e2e/dictation-web.spec.ts index 84f240912..2e737620d 100644 --- a/packages/app/e2e/dictation-web.spec.ts +++ b/packages/app/e2e/dictation-web.spec.ts @@ -203,7 +203,7 @@ test('dictation confirm+send does not dispatch after navigating away', async ({ const newAgentButton = page.getByText('New Agent', { exact: true }).first(); await expect(newAgentButton).toBeVisible(); await newAgentButton.click(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/agent\/?$/); await page.waitForTimeout(10_000); diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index e5860bec8..962601fbd 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -151,6 +151,7 @@ export const openSettings = async (page: Page) => { export const setWorkingDirectory = async (page: Page, directory: string) => { const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first(); await expect(workingDirectoryLabel).toBeVisible(); + const workingDirectorySelect = page.getByTestId('working-directory-select').first(); const input = page.getByRole('textbox', { name: '/path/to/project' }); const worktreePicker = page.getByTestId('worktree-attach-picker'); @@ -199,30 +200,45 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { if (!(await input.isVisible())) { await closeBottomSheet(); - await workingDirectoryLabel.click({ force: true }); + await workingDirectorySelect.click({ force: true }); if (!(await input.isVisible())) { await closeBottomSheet(); - await workingDirectoryLabel.click({ force: true }); + await workingDirectorySelect.click({ force: true }); } await expect(input).toBeVisible(); } - await input.fill(directory); + const trimmedDirectory = directory.replace(/\/+$/, ''); + await input.fill(trimmedDirectory); await input.press('Enter'); - const useOption = page.getByText(`Use "${directory}"`); - await expect(useOption).toBeVisible(); - await useOption.click({ force: true }); - // Wait for UI to update after clicking "Use" - await page.waitForTimeout(500); - const normalizedDirectory = directory.startsWith('/var/') - ? `/private${directory}` - : directory; - const workingDirectoryContainer = workingDirectoryLabel.locator('..'); + // Desktop web supports selecting via Enter; mobile may require clicking the explicit option. + const useOption = page.getByTestId('working-directory-custom-option').first(); + if (await useOption.isVisible().catch(() => false)) { + await expect(useOption).toContainText(`Use "${trimmedDirectory}"`); + await useOption.click({ force: true }); + } + + // Wait for the sheet to close after clicking "Use" + await expect(input).not.toBeVisible({ timeout: 10000 }); + + const directoryCandidates = new Set([trimmedDirectory]); + if (trimmedDirectory.startsWith('/var/')) { + directoryCandidates.add(`/private${trimmedDirectory}`); + } + if (trimmedDirectory.startsWith('/private/var/')) { + directoryCandidates.add(trimmedDirectory.replace(/^\/private/, '')); + } + const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory; + await expect.poll(async () => { - const text = await workingDirectoryContainer.innerText(); - return text.includes(directory) || text.includes(normalizedDirectory); - }, { timeout: 15000 }).toBe(true); + const text = await workingDirectorySelect.innerText().catch(() => ''); + if (text.includes(basename)) return true; + for (const candidate of directoryCandidates) { + if (text.includes(candidate)) return true; + } + return false; + }, { timeout: 30000 }).toBe(true); }; export const ensureHostSelected = async (page: Page) => { @@ -296,8 +312,13 @@ export const createAgent = async (page: Page, message: string) => { await input.fill(message); await input.press('Enter'); - await page.waitForURL(/\/agent\//); - await expect(page.getByText(message, { exact: true })).toBeVisible(); + // Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`. + await page.waitForURL(/\/agent\//, { waitUntil: 'commit' }); + const userBubble = page + .getByRole('button', { name: 'Copy message' }) + .filter({ hasText: message }) + .first(); + await expect(userBubble).toBeVisible(); }; export interface AgentConfig { diff --git a/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts b/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts index dd8b1d656..bd637cbb9 100644 --- a/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts +++ b/packages/app/e2e/sidebar-new-agent-clones-selected.spec.ts @@ -15,7 +15,7 @@ test('sidebar New Agent opens a fresh create screen', async ({ page }) => { // Click sidebar New Agent and assert it does not carry over agent settings via URL. await page.getByTestId('sidebar-new-agent').click(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/agent\/?$/); await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path))); } finally { await repoA.cleanup(); diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 7aab73946..78d16b0a4 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -347,8 +347,12 @@ export default function RootLayout() { > + - + diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index 0d2484d6d..bbd32ceb4 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -1,209 +1,8 @@ -import { useCallback, useEffect, useMemo } from "react"; -import type { ReactNode } from "react"; -import { ActivityIndicator, Pressable, Text, View } from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { BackHeader } from "@/components/headers/back-header"; -import { useSessionDirectory } from "@/hooks/use-session-directory"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import type { Agent } from "@/contexts/session-context"; +import { useLocalSearchParams } from "expo-router"; +import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen"; -type AgentMatch = { - serverId: string; - serverLabel: string; - agent: Agent; -}; - -export default function LegacyAgentRedirectScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); - const router = useRouter(); - const { theme } = useUnistyles(); - const { connectionStates } = useDaemonConnections(); - const sessionDirectory = useSessionDirectory(); - const agentId = typeof id === "string" ? id.trim() : undefined; - - const matches = useMemo(() => { - if (!agentId) { - return []; - } - - const results: AgentMatch[] = []; - sessionDirectory.forEach((session, serverId) => { - if (!session) { - return; - } - const agent = session.agents.get(agentId); - if (!agent) { - return; - } - const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; - results.push({ serverId, serverLabel, agent }); - }); - - return results; - }, [agentId, sessionDirectory, connectionStates]); - - const hasSessions = sessionDirectory.size > 0; - const isRedirecting = Boolean(agentId && matches.length === 1); - - useEffect(() => { - if (!isRedirecting) { - return; - } - const match = matches[0]; - router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: match.serverId, - agentId: match.agent.id, - }, - }); - }, [isRedirecting, matches, router]); - - const handleGoHome = useCallback(() => { - router.replace("/"); - }, [router]); - - const handleSelectMatch = useCallback( - (match: AgentMatch) => { - router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: match.serverId, - agentId: match.agent.id, - }, - }); - }, - [router] - ); - - let body: ReactNode = null; - - if (!agentId) { - body = ( - - Missing agent - - This link is missing an agent id. Go back to the Agents screen to pick one. - - - Go Home - - - ); - } else if (!hasSessions || isRedirecting) { - body = ( - - - - {isRedirecting ? "Opening your agent..." : "Looking for your agent..."} - - - ); - } else if (matches.length === 0) { - body = ( - - Agent not found - - We could not find {agentId} on any host right now. Hosts reconnect automatically—open it - again from the Agents screen after it comes back online. - - - Go Home - - - ); - } else if (matches.length > 1) { - body = ( - - Pick a host - - Multiple hosts have an agent with this id. Choose the one you intended to open. - - - {matches.map((match) => ( - handleSelectMatch(match)} - > - {match.serverLabel} - - {match.agent.cwd} - - - ))} - - - ); - } - - return ( - - - {body} - - ); +export default function LegacyAgentRoute() { + const { id } = useLocalSearchParams<{ id?: string }>(); + return ; } -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - content: { - flex: 1, - paddingHorizontal: theme.spacing[6], - paddingVertical: theme.spacing[8], - }, - centerState: { - flex: 1, - justifyContent: "center", - alignItems: "center", - gap: theme.spacing[4], - }, - title: { - fontSize: theme.fontSize.xl, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - subtitle: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - primaryButton: { - marginTop: theme.spacing[2], - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.primary, - }, - primaryButtonText: { - color: theme.colors.primaryForeground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - matchList: { - width: "100%", - gap: theme.spacing[3], - }, - matchButton: { - width: "100%", - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface2, - }, - matchLabel: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - }, - matchMeta: { - marginTop: theme.spacing[1], - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, -})); diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index b1ad5f533..0fe2f7282 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -1,860 +1,17 @@ -import { useEffect, useMemo, useRef, useCallback, useState } from "react"; -import { - View, - Text, - ActivityIndicator, - Pressable, - ScrollView, - Platform, - BackHandler, -} from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { useFocusEffect } from "@react-navigation/native"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import { useQuery } from "@tanstack/react-query"; -import ReanimatedAnimated, { - useAnimatedStyle, - useSharedValue, - runOnJS, - interpolate, - Extrapolation, -} from "react-native-reanimated"; -import { Gesture, GestureDetector } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { - MoreVertical, - GitBranch, - Folder, - RotateCcw, - PanelRight, -} from "lucide-react-native"; -import { MenuHeader } from "@/components/headers/menu-header"; -import { BackHeader } from "@/components/headers/back-header"; -import { AgentStreamView } from "@/components/agent-stream-view"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { ExplorerSidebar } from "@/components/explorer-sidebar"; -import { FileDropZone } from "@/components/file-drop-zone"; -import type { ImageAttachment } from "@/components/message-input"; -import { - ExplorerSidebarAnimationProvider, - useExplorerSidebarAnimation, -} from "@/contexts/explorer-sidebar-animation-context"; -import { usePanelStore } from "@/stores/panel-store"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; -import { formatConnectionStatus } from "@/utils/daemons"; -import { useSessionStore } from "@/stores/session-store"; -import type { StreamItem } from "@/types/stream"; -import { - buildAgentNavigationKey, - endNavigationTiming, - HOME_NAVIGATION_KEY, - startNavigationTiming, -} from "@/utils/navigation-timing"; -import { extractAgentModel } from "@/utils/extract-agent-model"; -import { startPerfMonitor } from "@/utils/perf-monitor"; -import { shortenPath } from "@/utils/shorten-path"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { useLocalSearchParams } from "expo-router"; +import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; -const DROPDOWN_WIDTH = 220; -const EMPTY_STREAM_ITEMS: StreamItem[] = []; - - -type BranchStatus = "idle" | "loading" | "ready" | "error"; - -export default function AgentScreen() { - const router = useRouter(); +export default function AgentReadyRoute() { const { serverId, agentId } = useLocalSearchParams<{ - serverId: string; - agentId: string; + serverId?: string; + agentId?: string; }>(); - const resolvedAgentId = typeof agentId === "string" ? agentId : undefined; - const resolvedServerId = typeof serverId === "string" ? serverId : undefined; - const { connectionStates } = useDaemonConnections(); - - // Check if session exists for this serverId - const hasSession = useSessionStore((state) => - resolvedServerId ? !!state.sessions[resolvedServerId] : false - ); - - const connectionServerId = resolvedServerId ?? null; - const connection = connectionServerId - ? connectionStates.get(connectionServerId) - : null; - const serverLabel = - connection?.daemon.label ?? connectionServerId ?? "Selected host"; - const isUnknownDaemon = Boolean(connectionServerId && !connection); - const connectionStatus = - connection?.status ?? (isUnknownDaemon ? "offline" : "idle"); - const connectionStatusLabel = formatConnectionStatus(connectionStatus); - const lastConnectionError = connection?.lastError ?? null; - - const handleBackToHome = useCallback(() => { - const targetServerId = resolvedServerId; - const targetAgentId = resolvedAgentId ?? null; - if (targetServerId && targetAgentId) { - startNavigationTiming(HOME_NAVIGATION_KEY, { - from: "agent", - to: "home", - targetMs: 300, - params: { - serverId: targetServerId, - agentId: targetAgentId, - }, - }); - } else { - startNavigationTiming(HOME_NAVIGATION_KEY, { - from: "agent", - to: "home", - targetMs: 300, - }); - } - router.replace("/"); - }, [resolvedAgentId, resolvedServerId, router]); - - const focusServerId = resolvedServerId; - const navigationStatus = hasSession ? "ready" : "session_unavailable"; - - useFocusEffect( - useCallback(() => { - if (!resolvedAgentId || !focusServerId) { - return; - } - const navigationKey = buildAgentNavigationKey( - focusServerId, - resolvedAgentId - ); - endNavigationTiming(navigationKey, { - screen: "agent", - status: navigationStatus, - }); - }, [focusServerId, navigationStatus, resolvedAgentId]) - ); - - if (!hasSession || !resolvedServerId) { - return ( - - ); - } return ( - - - + ); } -type AgentScreenContentProps = { - serverId: string; - agentId?: string; -}; - -function AgentScreenContent({ - serverId, - agentId, -}: AgentScreenContentProps) { - const { theme } = useUnistyles(); - const insets = useSafeAreaInsets(); - const router = useRouter(); - - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { - addImagesRef.current?.(files); - }, []); - - const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages; - }, []); - - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - - const mobileView = usePanelStore((state) => state.mobileView); - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); - const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); - const openFileExplorer = usePanelStore((state) => state.openFileExplorer); - const closeToAgent = usePanelStore((state) => state.closeToAgent); - const setExplorerTab = usePanelStore((state) => state.setExplorerTab); - - // Derive isExplorerOpen from the unified panel state - const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - - const { - translateX: explorerTranslateX, - backdropOpacity: explorerBackdropOpacity, - windowWidth: explorerWindowWidth, - animateToOpen: animateExplorerToOpen, - animateToClose: animateExplorerToClose, - isGesturing: isExplorerGesturing, - } = useExplorerSidebarAnimation(); - - useEffect(() => { - if (Platform.OS !== "web") { - return; - } - const scope = `agent:${serverId}:${agentId ?? "unknown"}`; - const stop = startPerfMonitor(scope); - return stop; - }, [serverId, agentId]); - - // Swipe-left gesture to open explorer sidebar on mobile - const explorerOpenGesture = useMemo( - () => - Gesture.Pan() - .enabled(isMobile && !isExplorerOpen) - // Only activate after 15px horizontal movement to the left (negative) - .activeOffsetX(-15) - // Fail if 10px vertical movement happens first (allow vertical scroll) - .failOffsetY([-10, 10]) - .onStart(() => { - isExplorerGesturing.value = true; - }) - .onUpdate((event) => { - // Right sidebar: start from closed position (+windowWidth) and move towards 0 - // Swiping left means negative translationX - const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX); - explorerTranslateX.value = newTranslateX; - explorerBackdropOpacity.value = interpolate( - newTranslateX, - [explorerWindowWidth, 0], - [0, 1], - Extrapolation.CLAMP - ); - }) - .onEnd((event) => { - isExplorerGesturing.value = false; - // Open if dragged more than 1/3 of window or fast swipe left - const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500; - if (shouldOpen) { - animateExplorerToOpen(); - runOnJS(openFileExplorer)(); - } else { - animateExplorerToClose(); - } - }) - .onFinalize(() => { - isExplorerGesturing.value = false; - }), - [ - isMobile, - isExplorerOpen, - explorerWindowWidth, - explorerTranslateX, - explorerBackdropOpacity, - animateExplorerToOpen, - animateExplorerToClose, - openFileExplorer, - isExplorerGesturing, - ] - ); - - // Handle hardware back button - close explorer sidebar first, then navigate back - useEffect(() => { - if (Platform.OS === "web") return; - - const handler = BackHandler.addEventListener("hardwareBackPress", () => { - if (isExplorerOpen) { - closeToAgent(); - return true; // Prevent default back navigation - } - return false; // Let default back navigation happen - }); - - return () => handler.remove(); - }, [isExplorerOpen, closeToAgent]); - - const resolvedAgentId = agentId; - - // Select only the specific agent - const agent = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agents?.get(resolvedAgentId) - : undefined - ); - - // Select only the specific stream tail - use stable empty array to avoid infinite loop - const streamItemsRaw = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId) - : undefined - ); - const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; - - // Select only the specific initializing state - const isInitializingFromMap = useSessionStore((state) => - resolvedAgentId - ? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? - false - : false - ); - - // Select raw pending permissions - filter with useMemo to avoid new Map on every render - const allPendingPermissions = useSessionStore( - (state) => state.sessions[serverId]?.pendingPermissions - ); - const pendingPermissions = useMemo(() => { - if (!allPendingPermissions || !resolvedAgentId) return new Map(); - const filtered = new Map(); - for (const [key, perm] of allPendingPermissions) { - if (perm.agentId === resolvedAgentId) { - filtered.set(key, perm); - } - } - return filtered; - }, [allPendingPermissions, resolvedAgentId]); - - const client = useSessionStore( - (state) => state.sessions[serverId]?.client ?? null - ); - const isConnected = useSessionStore( - (state) => state.sessions[serverId]?.connection.isConnected ?? false - ); - - // Get methods - const methods = useSessionStore((state) => state.sessions[serverId]?.methods); - const refreshAgent = methods?.refreshAgent; - const setFocusedAgentId = useCallback( - (agentId: string | null) => { - useSessionStore.getState().setFocusedAgentId(serverId, agentId); - }, - [serverId] - ); - - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const bottomInset = useSharedValue(insets.bottom); - - useEffect(() => { - bottomInset.value = insets.bottom; - }, [insets.bottom, bottomInset]); - - const animatedKeyboardStyle = useAnimatedStyle(() => { - "worklet"; - const absoluteHeight = Math.abs(keyboardHeight.value); - const shift = Math.max(0, absoluteHeight - bottomInset.value); - return { - transform: [{ translateY: -shift }], - }; - }); - - const agentModel = extractAgentModel(agent); - const modelDisplayValue = agentModel ?? "Unknown"; - - const repoInfoQuery = useQuery({ - queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""], - queryFn: async () => { - if (!client) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.getGitRepoInfo({ - cwd: agent?.cwd ?? ".", - }); - if (payload.error) { - throw new Error(payload.error); - } - return { - cwd: payload.cwd, - currentBranch: payload.currentBranch ?? null, - }; - }, - enabled: Boolean(client && isConnected && agent?.cwd), - retry: false, - }); - const { refetch: refetchRepoInfo } = repoInfoQuery; - const branchStatus: BranchStatus = !agent?.cwd - ? "idle" - : repoInfoQuery.isPending || repoInfoQuery.isFetching - ? "loading" - : repoInfoQuery.isError - ? "error" - : repoInfoQuery.isSuccess - ? "ready" - : "idle"; - const branchLabel = repoInfoQuery.data?.currentBranch ?? null; - const branchError = repoInfoQuery.error instanceof Error - ? repoInfoQuery.error.message - : null; - const branchDisplayValue = - branchStatus === "error" - ? branchError ?? "Unavailable" - : branchLabel ?? "Unknown"; - - useEffect(() => { - if (!resolvedAgentId) { - setFocusedAgentId(null); - return; - } - - setFocusedAgentId(resolvedAgentId); - return () => { - setFocusedAgentId(null); - }; - }, [resolvedAgentId, setFocusedAgentId]); - - const isInitializing = resolvedAgentId - ? isInitializingFromMap !== false - : false; - - // Get ensureAgentIsInitialized from methods - const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized; - - useEffect(() => { - if (!resolvedAgentId || !ensureAgentIsInitialized) { - return; - } - - // Skip if not connected - will re-run when connection is established - if (!isConnected) { - return; - } - - // ensureAgentIsInitialized handles deduplication via module-level promises map - // If already initialized or in-flight, returns resolved/pending promise immediately - ensureAgentIsInitialized(resolvedAgentId).catch((error) => { - console.warn("[AgentScreen] Agent initialization failed", { - agentId: resolvedAgentId, - error, - }); - }); - }, [resolvedAgentId, ensureAgentIsInitialized, isConnected]); - - useEffect(() => { - if (Platform.OS !== "web") { - return; - } - const title = agent?.title || "Agent"; - document.title = title; - }, [agent?.title]); - - // Track previous agent status to detect completion while viewing - const previousStatusRef = useRef(null); - - // Clear attention when agent finishes while user is viewing this screen - useEffect(() => { - if (!resolvedAgentId || !agent || !client) { - return; - } - - const previousStatus = previousStatusRef.current; - const currentStatus = agent.status; - previousStatusRef.current = currentStatus; - - // If agent transitioned from running to idle while we're viewing, - // immediately clear attention since user witnessed the completion - if (previousStatus === "running" && currentStatus === "idle") { - client.clearAgentAttention(resolvedAgentId); - } - }, [resolvedAgentId, agent?.status, client]); - - const handleViewChanges = useCallback(() => { - setExplorerTab("changes"); - openFileExplorer(); - }, [setExplorerTab, openFileExplorer]); - - const handleBrowseFiles = useCallback(() => { - setExplorerTab("files"); - openFileExplorer(); - }, [setExplorerTab, openFileExplorer]); - - const handleRefreshAgent = useCallback(() => { - if (!resolvedAgentId || !refreshAgent) { - return; - } - refreshAgent({ agentId: resolvedAgentId }); - }, [resolvedAgentId, refreshAgent]); - - if (!agent) { - return ( - - - - Agent not found - - - ); - } - - const mainContent = ( - - - - {/* Header */} - - - {isMobile ? ( - - ) : ( - - )} - - { - if (open && agent?.cwd) { - refetchRepoInfo().catch(() => {}); - } - }} - > - - - - - - - Directory - - {shortenPath(agent.cwd)} - - - - - Model - - {modelDisplayValue} - - - - - Branch - - {branchStatus === "loading" ? ( - <> - - Fetching… - - ) : ( - - {branchDisplayValue} - - )} - - - - - - - } - onSelect={handleViewChanges} - > - View Changes - - } - onSelect={handleBrowseFiles} - > - Browse Files - - } - disabled={isInitializing} - trailing={ - isInitializing ? ( - - ) : null - } - onSelect={handleRefreshAgent} - > - {isInitializing ? "Refreshing..." : "Refresh"} - - - - - } - /> - - {/* Content Area with Keyboard Animation */} - - - {isInitializing ? ( - - - Loading agent... - - ) : ( - - )} - - - - {/* Agent Input Area */} - {!isInitializing && agent && resolvedAgentId && ( - - )} - - - - - {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} - {!isMobile && isExplorerOpen && resolvedAgentId && ( - - )} - - ); - - return ( - <> - {isMobile ? ( - - {mainContent} - - ) : ( - mainContent - )} - - {/* Mobile Explorer Sidebar Overlay */} - {isMobile && resolvedAgentId && ( - - )} - - ); -} - -function AgentSessionUnavailableState({ - onBack, - serverLabel, - connectionStatus, - connectionStatusLabel, - lastError, - isUnknownDaemon = false, -}: { - onBack: () => void; - serverLabel: string; - connectionStatus: ConnectionStatus; - connectionStatusLabel: string; - lastError: string | null; - isUnknownDaemon?: boolean; -}) { - if (isUnknownDaemon) { - return ( - - - - - Cannot open this agent because {serverLabel} is not configured on - this device. - - - Add the host in Settings or open an agent on a configured server to - continue. - - - - ); - } - - const isConnecting = connectionStatus === "connecting"; - - return ( - - - - {isConnecting ? ( - <> - - - Connecting to {serverLabel}... - - - We will show this agent once the host is online. - - - ) : ( - <> - - {serverLabel} is currently {connectionStatusLabel.toLowerCase()}. - - - We'll reconnect automatically and show this agent as soon as the - host comes back online. - - {lastError ? ( - {lastError} - ) : null} - - )} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - outerContainer: { - flex: 1, - flexDirection: "row", - backgroundColor: theme.colors.surface0, - }, - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - headerRightContent: { - flexDirection: "row", - alignItems: "center", - }, - contentContainer: { - flex: 1, - overflow: "hidden", - }, - content: { - flex: 1, - }, - loadingContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: 16, - }, - loadingText: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - }, - centerState: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingHorizontal: theme.spacing[6], - gap: theme.spacing[3], - }, - errorContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - }, - errorText: { - fontSize: theme.fontSize.lg, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - statusText: { - marginTop: theme.spacing[2], - textAlign: "center", - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - errorDetails: { - marginTop: theme.spacing[1], - textAlign: "center", - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, - offlineTitle: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - offlineDescription: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - offlineDetails: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - menuButton: { - padding: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - menuMetaContainer: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - gap: theme.spacing[2], - }, - menuMetaRow: { - gap: theme.spacing[1], - }, - menuMetaLabel: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - letterSpacing: 0.5, - textTransform: "uppercase", - }, - menuMetaValue: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - }, - menuMetaValueRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - menuMetaPendingText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - menuMetaValueError: { - color: theme.colors.destructive, - }, - menuItemSpinner: { - marginLeft: "auto", - }, -})); diff --git a/packages/app/src/app/agent/index.tsx b/packages/app/src/app/agent/index.tsx new file mode 100644 index 000000000..4a1a5c7d7 --- /dev/null +++ b/packages/app/src/app/agent/index.tsx @@ -0,0 +1,6 @@ +import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; + +export default function AgentDraftRoute() { + return ; +} + diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 038a63c80..2455b9f99 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,894 +1,5 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { createNameId } from "mnemonic-id"; -import type { ImageAttachment } from "@/components/message-input"; -import { - View, - Text, - Pressable, - ScrollView, - ActivityIndicator, -} from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { Monitor } from "lucide-react-native"; -import { MenuHeader } from "@/components/headers/menu-header"; -import { AgentInputArea } from "@/components/agent-input-area"; -import { - DropdownSheet, - GitOptionsSection, - WorkingDirectoryDropdown, - AgentConfigRow, -} from "@/components/agent-form/agent-form-dropdowns"; -import { FileDropZone } from "@/components/file-drop-zone"; -import { useQuery } from "@tanstack/react-query"; -import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; -import { formatConnectionStatus } from "@/utils/daemons"; -import { useSessionStore } from "@/stores/session-store"; -import { MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { WelcomeScreen } from "@/components/welcome-screen"; -import type { - AgentProvider, - AgentSessionConfig, -} from "@server/server/agent/agent-sdk-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen"; -const DRAFT_AGENT_ID = "__new_agent__"; -const PROVIDER_DEFINITION_MAP = new Map( - AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]) -); - -function getParamValue(value: string | string[] | undefined) { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - if (Array.isArray(value)) { - for (const entry of value) { - const trimmed = entry.trim(); - if (trimmed.length > 0) { - return trimmed; - } - } - } - return undefined; +export default function Index() { + return ; } - -function getValidProvider(value: string | undefined) { - if (!value) { - return undefined; - } - return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) - ? (value as AgentProvider) - : undefined; -} - -function getValidMode( - provider: AgentProvider | undefined, - value: string | undefined -) { - if (!provider || !value) { - return undefined; - } - const definition = PROVIDER_DEFINITION_MAP.get(provider); - const modes = definition?.modes ?? []; - return modes.some((mode) => mode.id === value) ? value : undefined; -} - -type DraftAgentParams = { - serverId?: string; - provider?: string; - modeId?: string; - model?: string; - workingDir?: string; -}; - -export default function HomeScreen() { - const { theme } = useUnistyles(); - const router = useRouter(); - const insets = useSafeAreaInsets(); - const { connectionStates } = useDaemonConnections(); - const { daemons } = useDaemonRegistry(); - const params = useLocalSearchParams(); - - const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); - const bottomInset = useSharedValue(insets.bottom); - - useEffect(() => { - bottomInset.value = insets.bottom; - }, [insets.bottom, bottomInset]); - - const animatedKeyboardStyle = useAnimatedStyle(() => { - "worklet"; - const absoluteHeight = Math.abs(keyboardHeight.value); - const shift = Math.max(0, absoluteHeight - bottomInset.value); - return { - transform: [{ translateY: -shift }], - }; - }); - - const resolvedServerId = getParamValue(params.serverId); - const resolvedProvider = getValidProvider(getParamValue(params.provider)); - const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); - const resolvedModel = getParamValue(params.model); - const resolvedWorkingDir = getParamValue(params.workingDir); - - const initialValues = useMemo((): CreateAgentInitialValues => { - const values: CreateAgentInitialValues = {}; - if (resolvedWorkingDir) { - values.workingDir = resolvedWorkingDir; - } - if (resolvedProvider) { - values.provider = resolvedProvider; - } - if (resolvedMode) { - values.modeId = resolvedMode; - } - if (resolvedModel) { - values.model = resolvedModel; - } - return values; - }, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]); - - const { - selectedServerId, - setSelectedServerIdFromUser, - selectedProvider, - setProviderFromUser, - selectedMode, - setModeFromUser, - selectedModel, - setModelFromUser, - workingDir, - setWorkingDirFromUser, - providerDefinitions, - modeOptions, - availableModels, - isModelLoading, - modelError, - refreshProviderModels, - persistFormPreferences, - } = useAgentFormState({ - initialServerId: resolvedServerId ?? null, - initialValues, - isVisible: true, - isCreateFlow: true, - }); - const hostEntry = selectedServerId - ? connectionStates.get(selectedServerId) - : undefined; - const hostLabel = - hostEntry?.daemon.label ?? selectedServerId ?? "Select host"; - const hostStatus = hostEntry?.status - ? formatConnectionStatus(hostEntry.status) - : undefined; - - const [openDropdown, setOpenDropdown] = useState<"host" | null>(null); - const [errorMessage, setErrorMessage] = useState(""); - const [isLoading, setIsLoading] = useState(false); - const [promptText, setPromptText] = useState(""); - const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); - const [baseBranch, setBaseBranch] = useState(""); - const [worktreeSlug, setWorktreeSlug] = useState(""); - const [selectedWorktreePath, setSelectedWorktreePath] = useState(""); - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); - - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { - addImagesRef.current?.(files); - }, []); - - const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages; - }, []); - const openDropdownSheet = useCallback((key: "host") => { - setOpenDropdown(key); - }, []); - const closeDropdown = useCallback(() => { - setOpenDropdown(null); - }, []); - const sessionAgents = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.agents : undefined - ); - const agentWorkingDirSuggestions = useMemo(() => { - if (!selectedServerId || !sessionAgents) { - return []; - } - const uniquePaths = new Set(); - sessionAgents.forEach((agent) => { - if (agent.cwd) { - uniquePaths.add(agent.cwd); - } - }); - return Array.from(uniquePaths).sort(); - }, [selectedServerId, sessionAgents]); - - const sessionClient = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null - ); - const isConnected = useSessionStore((state) => - selectedServerId - ? state.sessions[selectedServerId]?.connection.isConnected ?? false - : false - ); - const trimmedWorkingDir = workingDir.trim(); - const shouldInspectRepo = trimmedWorkingDir.length > 0; - const daemonAvailabilityError = - !selectedServerId || hostEntry?.status !== "online" - ? "Host is offline" - : null; - const repoAvailabilityError = - shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isConnected) - ? daemonAvailabilityError ?? - "Repository details will load automatically once the selected host is back online." - : null; - - type RepoInfoState = { - cwd: string; - repoRoot: string; - branches: Array<{ name: string; isCurrent: boolean }>; - currentBranch: string | null; - isDirty: boolean; - }; - const repoInfoQuery = useQuery({ - queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir], - queryFn: async () => { - const client = sessionClient; - if (!client) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.getGitRepoInfo({ - cwd: trimmedWorkingDir || ".", - }); - if (payload.error) { - throw new Error(payload.error); - } - return { - cwd: payload.cwd, - repoRoot: payload.repoRoot, - branches: payload.branches ?? [], - currentBranch: payload.currentBranch ?? null, - isDirty: Boolean(payload.isDirty), - }; - }, - enabled: - Boolean(trimmedWorkingDir) && - !repoAvailabilityError && - Boolean(sessionClient) && - isConnected, - retry: false, - }); - const repoInfo = repoInfoQuery.data ?? null; - const refetchRepoInfo = repoInfoQuery.refetch; - const repoRequestError = repoInfoQuery.error as Error | null; - const repoRequestStatus: "idle" | "loading" | "success" | "error" = - !shouldInspectRepo || repoAvailabilityError - ? "idle" - : repoInfoQuery.isPending || repoInfoQuery.isFetching - ? "loading" - : repoInfoQuery.isError - ? "error" - : repoInfoQuery.isSuccess - ? "success" - : "idle"; - const isNonGitDirectory = - repoRequestStatus === "error" && - /not in a git repository/i.test(repoRequestError?.message ?? ""); - const isDirectoryNotExists = - repoRequestStatus === "error" && - /does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? ""); - const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo - ? "idle" - : repoAvailabilityError - ? "error" - : repoRequestStatus === "loading" - ? "loading" - : repoRequestStatus === "error" - ? isNonGitDirectory - ? "idle" - : "error" - : repoRequestStatus === "success" - ? "ready" - : "idle"; - const repoInfoError = - repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); - const gitHelperText = isNonGitDirectory - ? "No git repository detected. Git options are disabled for this directory." - : null; - const isCreateWorktree = worktreeMode === "create"; - const isAttachWorktree = worktreeMode === "attach"; - - const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir; - const worktreeListQuery = useQuery({ - queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot], - queryFn: async () => { - const client = sessionClient; - if (!client) { - throw new Error("Daemon client unavailable"); - } - const payload = await client.getPaseoWorktreeList({ - repoRoot: worktreeListRoot || undefined, - cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined, - }); - if (payload.error) { - throw new Error(payload.error.message); - } - return payload.worktrees ?? []; - }, - enabled: - isAttachWorktree && - Boolean(worktreeListRoot || trimmedWorkingDir) && - !repoAvailabilityError && - Boolean(sessionClient) && - isConnected && - !isNonGitDirectory, - retry: false, - staleTime: 0, - refetchOnMount: "always", - }); - const worktreeOptions = useMemo(() => { - return (worktreeListQuery.data ?? []).map((worktree) => ({ - path: worktree.worktreePath, - label: worktree.branchName ?? worktree.head ?? "Unknown branch", - })); - }, [worktreeListQuery.data]); - const worktreeOptionsError = - worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null; - const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" = - !isAttachWorktree - ? "idle" - : worktreeListQuery.isPending || worktreeListQuery.isFetching - ? "loading" - : worktreeListQuery.isError - ? "error" - : "ready"; - const attachWorktreeError = - isAttachWorktree && - worktreeOptionsStatus === "ready" && - worktreeOptions.length > 0 && - !selectedWorktreePath - ? "Select a worktree to attach" - : null; - - const handleWorktreeModeChange = useCallback( - (mode: "none" | "create" | "attach") => { - setWorktreeMode(mode); - if (mode === "create" && !worktreeSlug) { - setWorktreeSlug(createNameId()); - } - if (mode !== "attach") { - setSelectedWorktreePath(""); - } - if (mode !== "none") { - refetchRepoInfo(); - } - }, - [worktreeSlug, refetchRepoInfo] - ); - - const validateWorktreeName = useCallback( - (name: string): { valid: boolean; error?: string } => { - if (!name) { - return { valid: true }; - } - if (name.length > 100) { - return { - valid: false, - error: "Worktree name too long (max 100 characters)", - }; - } - if (!/^[a-z0-9-/]+$/.test(name)) { - return { - valid: false, - error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", - }; - } - if (name.startsWith("-") || name.endsWith("-")) { - return { valid: false, error: "Cannot start or end with a hyphen" }; - } - if (name.includes("--")) { - return { valid: false, error: "Cannot have consecutive hyphens" }; - } - return { valid: true }; - }, - [] - ); - - const gitBlockingError = useMemo(() => { - if (!isCreateWorktree || isNonGitDirectory) { - return null; - } - if (!worktreeSlug) { - return null; - } - const validation = validateWorktreeName(worktreeSlug); - if (!validation.valid) { - return `Invalid worktree name: ${ - validation.error ?? "Must use lowercase letters, numbers, or hyphens" - }`; - } - return null; - }, [ - isCreateWorktree, - isNonGitDirectory, - worktreeSlug, - validateWorktreeName, - ]); - - const baseBranchError = useMemo(() => { - if (!isCreateWorktree || isNonGitDirectory) { - return null; - } - if (!baseBranch) { - return "Base branch is required"; - } - const branches = repoInfo?.branches ?? []; - if (branches.length === 0) { - return null; - } - const branchExists = branches.some((b) => b.name === baseBranch); - if (!branchExists) { - return `Branch "${baseBranch}" not found in repository`; - } - return null; - }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); - - const handleBaseBranchChange = useCallback((value: string) => { - setBaseBranch(value); - }, []); - - const handleSelectWorktreePath = useCallback( - (path: string) => { - setSelectedWorktreePath(path); - setWorkingDirFromUser(path); - }, - [setWorkingDirFromUser] - ); - - useEffect(() => { - if (!isCreateWorktree || isNonGitDirectory) { - return; - } - if (baseBranch) { - return; - } - const current = repoInfo?.currentBranch?.trim(); - if (current) { - setBaseBranch(current); - } - }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]); - - useEffect(() => { - if (isNonGitDirectory && worktreeMode !== "none") { - setWorktreeMode("none"); - setSelectedWorktreePath(""); - } - }, [isNonGitDirectory, worktreeMode]); - - const sessionMethods = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.methods : undefined - ); - - const handleCreateFromInput = useCallback( - async ({ - text, - images, - }: { - text: string; - images?: Array<{ uri: string; mimeType: string }>; - }) => { - setErrorMessage(""); - const trimmedPath = workingDir.trim(); - const trimmedPrompt = text.trim(); - const resolvedWorkingDir = - isAttachWorktree && selectedWorktreePath - ? selectedWorktreePath - : trimmedPath; - if (!trimmedPath) { - setErrorMessage("Working directory is required"); - throw new Error("Working directory is required"); - } - if (isDirectoryNotExists) { - setErrorMessage("Working directory does not exist on the selected host"); - throw new Error("Working directory does not exist on the selected host"); - } - if (!trimmedPrompt) { - setErrorMessage("Initial prompt is required"); - throw new Error("Initial prompt is required"); - } - if (!selectedServerId) { - setErrorMessage("No host selected"); - throw new Error("No host selected"); - } - if (gitBlockingError) { - setErrorMessage(gitBlockingError); - throw new Error(gitBlockingError); - } - if (isAttachWorktree && !selectedWorktreePath) { - const message = "Select a worktree to attach"; - setErrorMessage(message); - throw new Error(message); - } - if (baseBranchError) { - setErrorMessage(baseBranchError); - throw new Error(baseBranchError); - } - if (isLoading) { - throw new Error("Already loading"); - } - const createAgent = sessionMethods?.createAgent; - if (!createAgent) { - setErrorMessage("Host is not connected"); - throw new Error("Host is not connected"); - } - const modeId = - modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; - const trimmedModel = selectedModel.trim(); - const config: AgentSessionConfig = { - provider: selectedProvider, - cwd: resolvedWorkingDir, - ...(modeId ? { modeId } : {}), - ...(trimmedModel ? { model: trimmedModel } : {}), - }; - const effectiveBaseBranch = baseBranch.trim(); - const effectiveWorktreeSlug = - isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug; - if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) { - setWorktreeSlug(effectiveWorktreeSlug); - } - const gitOptions = - isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug - ? { - createWorktree: true, - createNewBranch: true, - newBranchName: effectiveWorktreeSlug, - worktreeSlug: effectiveWorktreeSlug, - baseBranch: effectiveBaseBranch, - } - : undefined; - - void persistFormPreferences(); - setIsLoading(true); - - try { - const result = await createAgent({ - config, - initialPrompt: trimmedPrompt, - images, - git: gitOptions, - }); - - // Success - clear prompt and navigate to agent - setPromptText(""); - const agentId = (result as { id?: string })?.id; - if (agentId && selectedServerId) { - router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { serverId: selectedServerId, agentId }, - }); - } - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create agent"; - setErrorMessage(message); - throw error; // Re-throw so AgentInputArea knows it failed - } finally { - setIsLoading(false); - } - }, - [ - worktreeMode, - baseBranch, - worktreeSlug, - selectedWorktreePath, - repoInfo?.currentBranch, - gitBlockingError, - baseBranchError, - isDirectoryNotExists, - isLoading, - isNonGitDirectory, - modeOptions, - persistFormPreferences, - router, - selectedMode, - selectedModel, - selectedProvider, - selectedServerId, - sessionMethods, - workingDir, - ] - ); - - if (daemons.length === 0) { - return ( - { - setSelectedServerIdFromUser(profile.id); - }} - /> - ); - } - - return ( - - - - openDropdownSheet("host")} - > - - {hostLabel} - - {hostStatus ? ( - {hostStatus} - ) : null} - - } - /> - - - - - - {isDirectoryNotExists && ( - - - Directory does not exist on the selected host - - - )} - - {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( - - ) : null} - - - {connectionStates.size === 0 ? ( - - No hosts available yet. - - ) : ( - - {Array.from(connectionStates.values()).map(({ daemon, status }) => { - const isSelected = daemon.id === selectedServerId; - const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id; - return ( - { - setSelectedServerIdFromUser(daemon.id); - closeDropdown(); - }} - > - - {label} - - - {formatConnectionStatus(status)} - - - ); - })} - - )} - - - {errorMessage ? ( - - {errorMessage} - - ) : null} - - - - - - - - {isLoading ? ( - - - - Creating agent... - - - ) : null} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - agentPanel: { - flex: 1, - }, - contentContainer: { - flex: 1, - overflow: "hidden", - }, - scrollView: { - flex: 1, - }, - inputAreaWrapper: { - backgroundColor: theme.colors.surface0, - }, - configScrollContent: { - flexGrow: 1, - justifyContent: "flex-end", - }, - configSection: { - paddingHorizontal: theme.spacing[4], - paddingTop: theme.spacing[3], - paddingBottom: theme.spacing[4], - gap: theme.spacing[2], - maxWidth: MAX_CONTENT_WIDTH, - alignSelf: "center", - width: "100%", - }, - dropdownHelper: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - }, - dropdownSheetList: { - marginTop: theme.spacing[3], - }, - dropdownSheetOption: { - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface0, - marginBottom: theme.spacing[2], - }, - dropdownSheetOptionSelected: { - borderColor: theme.colors.palette.blue[400], - backgroundColor: "rgba(59, 130, 246, 0.18)", - }, - dropdownSheetOptionLabel: { - color: theme.colors.foreground, - fontWeight: theme.fontWeight.semibold, - }, - dropdownSheetOptionDescription: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - marginTop: theme.spacing[1], - }, - errorContainer: { - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - marginHorizontal: theme.spacing[4], - marginBottom: theme.spacing[2], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.destructive, - }, - errorText: { - color: theme.colors.destructiveForeground, - fontSize: theme.fontSize.sm, - }, - warningContainer: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.palette.yellow[400], - }, - warningText: { - color: "#000000", - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - hostBadge: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.full, - }, - hostBadgeLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, - }, - hostBadgeStatus: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - }, - hostStatusDot: { - width: 6, - height: 6, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.foregroundMuted, - }, - hostStatusDotOnline: { - backgroundColor: theme.colors.palette.green[500], - }, - loadingOverlay: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: "rgba(0, 0, 0, 0.7)", - justifyContent: "center", - alignItems: "center", - }, - loadingContent: { - alignItems: "center", - gap: theme.spacing[4], - }, - loadingText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - }, -})); diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 2ab40a508..38e82a886 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -127,6 +127,7 @@ interface SelectFieldProps { warningMessage?: string | null; helperText?: string | null; controlRef?: React.RefObject; + valueEllipsizeMode?: "head" | "middle" | "tail" | "clip"; testID?: string; } @@ -140,6 +141,7 @@ export function SelectField({ warningMessage, helperText, controlRef, + valueEllipsizeMode, testID, }: SelectFieldProps): ReactElement { return ( @@ -156,6 +158,7 @@ export function SelectField({ {value || placeholder || "Select..."} @@ -1045,6 +1048,13 @@ export function WorkingDirectoryDropdown({ [handleClose, onSelectPath] ); + const handleSubmitSearch = useCallback(() => { + if (!showCustomOption) { + return; + } + handleSelect(sanitizedSearchValue); + }, [handleSelect, sanitizedSearchValue, showCustomOption]); + return ( <> ) : ( )} {!hasSuggestedPaths && !showCustomOption ? ( @@ -1094,6 +1108,7 @@ export function WorkingDirectoryDropdown({ handleSelect(sanitizedSearchValue)} > diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 993ebac96..aa5533462 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -46,6 +46,8 @@ interface AgentInputAreaProps { onSubmitMessage?: (payload: MessagePayload) => Promise; /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean; + /** When true, blurs the input immediately when submitting. */ + blurOnSubmit?: boolean; value?: string; onChangeText?: (text: string) => void; /** When true, auto-focuses the text input on web. */ @@ -70,6 +72,7 @@ export function AgentInputArea({ serverId, onSubmitMessage, isSubmitLoading = false, + blurOnSubmit = false, value, onChangeText, autoFocus = false, @@ -219,10 +222,13 @@ export function AgentInputArea({ forceSend?: boolean ) { const socketConnected = isConnected; - if (!message.trim() || !socketConnected) return; - if (!sendAgentMessage && !onSubmitMessageRef.current) return; - const trimmedMessage = message.trim(); + if (!trimmedMessage) return; + // When the parent controls submission (e.g. draft agent creation), let it + // decide what to do even if the socket is currently disconnected (so we + // don't no-op and lose deterministic error handling in the UI/tests). + if (!onSubmitMessageRef.current && !socketConnected) return; + if (!sendAgentMessage && !onSubmitMessageRef.current) return; if (agent?.status === "running" && !forceSend) { queueMessage(trimmedMessage, imageAttachments); @@ -257,6 +263,9 @@ export function AgentInputArea({ } function handleSubmit(payload: MessagePayload) { + if (blurOnSubmit) { + messageInputRef.current?.blur(); + } void sendMessageWithContent( payload.text, payload.images, @@ -569,7 +578,7 @@ export function AgentInputArea({ client={client} placeholder="Message agent..." autoFocus={autoFocus} - disabled={isRealtimeMode} + disabled={isRealtimeMode || isSubmitLoading} isScreenFocused={isScreenFocused} leftContent={leftContent} rightContent={rightContent} diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index ca71842cb..a444976d2 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -84,13 +84,7 @@ export function AgentList({ onAgentSelect?.(); - navigate({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId, - agentId, - }, - }); + navigate(`/agent/${serverId}/${agentId}` as any); }, [isActionSheetVisible, pathname, onAgentSelect] ); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 3facfd176..4c3fad20e 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -657,7 +657,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", }); - router.replace("/"); + router.replace("/agent" as any); }, onError: (err) => { const message = err instanceof Error ? err.message : "Failed to archive worktree"; diff --git a/packages/app/src/components/home-footer.tsx b/packages/app/src/components/home-footer.tsx index 141e15edb..7759dc8be 100644 --- a/packages/app/src/components/home-footer.tsx +++ b/packages/app/src/components/home-footer.tsx @@ -123,7 +123,7 @@ export function HomeFooter() { {/* Action menu */} router.push("/")} + onPress={() => router.push("/agents")} style={({ pressed }) => [ styles.footerButton, pressed && styles.buttonPressed, @@ -142,7 +142,7 @@ export function HomeFooter() { { console.log("[HomeFooter] New Agent button pressed"); - router.push("/"); + router.push("/agent" as any); }} style={({ pressed }) => [ styles.footerButton, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 4f31afb6a..e9fce0891 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -70,6 +70,7 @@ export interface MessageInputProps { export interface MessageInputRef { focus: () => void; + blur: () => void; } const MIN_INPUT_HEIGHT = 30; @@ -118,15 +119,19 @@ export const MessageInput = forwardRef( ) { const { theme } = useUnistyles(); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); - const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); - const textInputRef = useRef< - TextInput | (TextInput & { getNativeRef?: () => unknown }) | null - >(null); + const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const textInputRef = useRef< + TextInput | (TextInput & { getNativeRef?: () => unknown }) | null + >(null); + const isInputFocusedRef = useRef(false); useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); }, + blur: () => { + textInputRef.current?.blur?.(); + }, })); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const baselineInputHeightRef = useRef(null); @@ -210,6 +215,11 @@ export const MessageInput = forwardRef( enableDuration: true, }); + const isDictatingRef = useRef(isDictating); + useEffect(() => { + isDictatingRef.current = isDictating; + }, [isDictating]); + useEffect(() => { if (isDictating || isDictationProcessing) { return; @@ -219,12 +229,29 @@ export const MessageInput = forwardRef( // Cmd+D to start/submit dictation, Escape to cancel useEffect(() => { - if (!IS_WEB || !isScreenFocused) return; + if (!IS_WEB) return; + const resolveNativeInput = (): unknown => { + const current = textInputRef.current as any; + if (!current) return null; + if (typeof current.getNativeRef === "function") { + return current.getNativeRef(); + } + return current; + }; function handleKeyDown(event: KeyboardEvent) { + const nativeInput = resolveNativeInput(); + const isFromInput = Boolean(nativeInput && event.target === nativeInput); + if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) { + return; + } + const dictating = isDictatingRef.current; // Cmd+D: start dictation or submit if already dictating - if ((event.metaKey || event.ctrlKey) && event.key === "d") { + if ( + (event.metaKey || event.ctrlKey) && + (event.code === "KeyD" || event.key.toLowerCase() === "d") + ) { event.preventDefault(); - if (isDictating) { + if (dictating) { sendAfterTranscriptRef.current = true; confirmDictation(); } else { @@ -233,14 +260,14 @@ export const MessageInput = forwardRef( return; } // Escape: cancel dictation - if (event.key === "Escape" && isDictating) { + if (event.key === "Escape" && dictating) { event.preventDefault(); cancelDictation(); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isDictating, cancelDictation, confirmDictation, startDictation]); + }, [cancelDictation, confirmDictation, isScreenFocused, startDictation]); // Animate overlay useEffect(() => { @@ -400,15 +427,17 @@ export const MessageInput = forwardRef( const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; + const key = event.nativeEvent.key.toLowerCase(); + // Cmd+B or Ctrl+B: toggle sidebar - if ((metaKey || ctrlKey) && event.nativeEvent.key === "b") { + if ((metaKey || ctrlKey) && key === "b") { event.preventDefault(); toggleAgentList(); return; } // Cmd+D or Ctrl+D: start dictation or submit if already dictating - if ((metaKey || ctrlKey) && event.nativeEvent.key === "d") { + if ((metaKey || ctrlKey) && key === "d") { event.preventDefault(); if (isDictating) { sendAfterTranscriptRef.current = true; @@ -502,6 +531,12 @@ export const MessageInput = forwardRef( onChangeText={onChangeText} placeholder={placeholder} placeholderTextColor={theme.colors.mutedForeground} + onFocus={() => { + isInputFocusedRef.current = true; + }} + onBlur={() => { + isInputFocusedRef.current = false; + }} style={[ styles.textInput, IS_WEB diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 4cd07f95a..856021905 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -82,7 +82,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { }, [closeToAgent]); const handleCreateAgentClean = useCallback(() => { - router.push("/"); + router.push("/agent" as any); }, []); // Mobile: close sidebar and navigate diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index cd6cb9ff4..46fdd34da 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -65,6 +65,14 @@ const int16ToBase64 = (pcm: Int16Array): string => { return btoa(binary); }; +type RecorderRefs = { + recorder: MediaRecorder | null; + audioChunks: Blob[]; + stoppedPromise: Promise | null; + stoppedResolve: ((blob: Blob) => void) | null; + stoppedReject: ((error: unknown) => void) | null; +}; + export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource { const [volume, setVolume] = useState(0); @@ -84,7 +92,49 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic gain: GainNode | null; pending: Int16Array; started: boolean; - }>({ stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false }); + mode: "pcm" | "recorder"; + recorder: RecorderRefs; + }>({ + stream: null, + context: null, + source: null, + processor: null, + gain: null, + pending: new Int16Array(0), + started: false, + mode: "pcm", + recorder: { + recorder: null, + audioChunks: [], + stoppedPromise: null, + stoppedResolve: null, + stoppedReject: null, + }, + }); + + const decodeAudioData = useCallback(async (context: AudioContext, buffer: ArrayBuffer): Promise => { + const maybePromise = context.decodeAudioData(buffer); + if (maybePromise && typeof (maybePromise as Promise).then === "function") { + return maybePromise as Promise; + } + return await new Promise((resolve, reject) => { + context.decodeAudioData(buffer, resolve, reject); + }); + }, []); + + const emitPcmSegments = useCallback((pcm: Int16Array) => { + const outputRate = 16000; + const chunkSamples = outputRate; // ~1s + let pending = pcm; + while (pending.length >= chunkSamples) { + const chunk = pending.slice(0, chunkSamples); + pending = pending.slice(chunkSamples); + onPcmSegmentRef.current(int16ToBase64(chunk)); + } + if (pending.length > 0) { + onPcmSegmentRef.current(int16ToBase64(pending)); + } + }, []); const start = useCallback(async () => { const missingNavigator = @@ -111,7 +161,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic throw new Error("AudioContext unavailable"); } - const stream = await navigator.mediaDevices.getUserMedia({ + const rawStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, noiseSuppression: true, @@ -120,54 +170,131 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic }, }); + const stream = rawStream as MediaStream; + const context = new AudioContextCtor(); - const source = context.createMediaStreamSource(stream); - const processor = context.createScriptProcessor(4096, 1, 1); - const gain = context.createGain(); - gain.gain.value = 0; - const outputRate = 16000; - const chunkSamples = outputRate; // ~1s + try { + const source = context.createMediaStreamSource(stream); + const processor = context.createScriptProcessor(4096, 1, 1); + const gain = context.createGain(); + gain.gain.value = 0; - refs.current.started = true; + const outputRate = 16000; + const chunkSamples = outputRate; // ~1s - processor.onaudioprocess = (event) => { - if (!refs.current.started) { - return; - } - const input = event.inputBuffer.getChannelData(0); + refs.current.started = true; + refs.current.mode = "pcm"; - let sumSquares = 0; - for (let i = 0; i < input.length; i++) { - const sample = input[i]; - sumSquares += sample * sample; - } - const rms = Math.sqrt(sumSquares / Math.max(1, input.length)); - const normalized = Math.min(1, Math.max(0, rms * 2)); - setVolume(normalized); + processor.onaudioprocess = (event) => { + if (!refs.current.started) { + return; + } + const input = event.inputBuffer.getChannelData(0); - const next = resampleToPcm16(input, context.sampleRate, outputRate); - refs.current.pending = concatInt16(refs.current.pending, next); + let sumSquares = 0; + for (let i = 0; i < input.length; i++) { + const sample = input[i]; + sumSquares += sample * sample; + } + const rms = Math.sqrt(sumSquares / Math.max(1, input.length)); + const normalized = Math.min(1, Math.max(0, rms * 2)); + setVolume(normalized); - while (refs.current.pending.length >= chunkSamples) { - const chunk = refs.current.pending.slice(0, chunkSamples); - refs.current.pending = refs.current.pending.slice(chunkSamples); - onPcmSegmentRef.current(int16ToBase64(chunk)); - } + const next = resampleToPcm16(input, context.sampleRate, outputRate); + refs.current.pending = concatInt16(refs.current.pending, next); + + while (refs.current.pending.length >= chunkSamples) { + const chunk = refs.current.pending.slice(0, chunkSamples); + refs.current.pending = refs.current.pending.slice(chunkSamples); + onPcmSegmentRef.current(int16ToBase64(chunk)); + } + }; + + source.connect(processor); + processor.connect(gain); + gain.connect(context.destination); + + refs.current = { + ...refs.current, + stream, + context, + source, + processor, + gain, + pending: new Int16Array(0), + started: true, + }; + return; + } catch { + // Fall back to MediaRecorder for environments where MediaStreamAudioSourceNode + // isn't available (e.g., Playwright tests with a stubbed getUserMedia). + } + + const RecorderCtor = typeof window !== "undefined" ? (window as any).MediaRecorder : undefined; + if (!RecorderCtor) { + throw new Error("MediaRecorder unavailable"); + } + + const recorder = new RecorderCtor(stream, { mimeType: "audio/webm;codecs=opus" } as MediaRecorderOptions) as MediaRecorder; + + const recorderRefs: RecorderRefs = { + recorder, + audioChunks: [], + stoppedPromise: null, + stoppedResolve: null, + stoppedReject: null, }; - source.connect(processor); - processor.connect(gain); - gain.connect(context.destination); + recorder.ondataavailable = (event: any) => { + const data: Blob | undefined = event?.data; + if (data) { + recorderRefs.audioChunks.push(data); + } + }; + recorder.onerror = (event: any) => { + recorderRefs.stoppedReject?.(event); + }; + recorder.addEventListener("stop", () => { + try { + const blob = recorderRefs.audioChunks.length > 0 ? new Blob(recorderRefs.audioChunks, { type: recorder.mimeType }) : new Blob([], { type: recorder.mimeType }); + recorderRefs.stoppedResolve?.(blob); + } catch (err) { + recorderRefs.stoppedReject?.(err); + } + }); - refs.current = { stream, context, source, processor, gain, pending: new Int16Array(0), started: true }; + recorderRefs.stoppedPromise = new Promise((resolve, reject) => { + recorderRefs.stoppedResolve = resolve; + recorderRefs.stoppedReject = reject; + }); + + try { + recorder.start(); + } catch (err) { + throw err instanceof Error ? err : new Error(String(err)); + } + + refs.current = { + ...refs.current, + stream, + context, + source: null, + processor: null, + gain: null, + pending: new Int16Array(0), + started: true, + mode: "recorder", + recorder: recorderRefs, + }; }, []); const stop = useCallback(async () => { refs.current.started = false; setVolume(0); - const { processor, source, gain, context, stream, pending } = refs.current; + const { processor, source, gain, context, stream, pending, mode, recorder } = + refs.current; if (processor) { try { @@ -204,6 +331,32 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic } }); } + + if (mode === "recorder" && recorder.recorder) { + try { + if (recorder.recorder.state === "recording") { + recorder.recorder.stop(); + } + } catch { + // ignore stop errors; we still need to stop tracks + } + } + + if (mode === "recorder" && recorder.stoppedPromise && context) { + try { + const blob = await recorder.stoppedPromise; + const arrayBuffer = await blob.arrayBuffer(); + if (arrayBuffer.byteLength > 0) { + const decoded = await decodeAudioData(context, arrayBuffer); + const floatPcm = decoded.getChannelData(0); + const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000); + emitPcmSegments(pcm16); + } + } catch (err) { + onErrorRef.current?.(err instanceof Error ? err : new Error(String(err))); + } + } + if (context) { try { await context.close(); @@ -216,8 +369,24 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic onPcmSegmentRef.current(int16ToBase64(pending)); } - refs.current = { stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false }; - }, []); + refs.current = { + stream: null, + context: null, + source: null, + processor: null, + gain: null, + pending: new Int16Array(0), + started: false, + mode: "pcm", + recorder: { + recorder: null, + audioChunks: [], + stoppedPromise: null, + stoppedResolve: null, + stoppedReject: null, + }, + }; + }, [decodeAudioData, emitPcmSegments]); useEffect(() => { return () => { @@ -244,4 +413,3 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic [start, stop, volume] ); } - diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx new file mode 100644 index 000000000..3c7b2148e --- /dev/null +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -0,0 +1,958 @@ +import { useEffect, useMemo, useRef, useCallback, useState } from "react"; +import { + View, + Text, + ActivityIndicator, + Pressable, + ScrollView, + Platform, + BackHandler, +} from "react-native"; +import { useRouter } from "expo-router"; +import { useFocusEffect } from "@react-navigation/native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import { useQuery } from "@tanstack/react-query"; +import ReanimatedAnimated, { + useAnimatedStyle, + useSharedValue, + runOnJS, + interpolate, + Extrapolation, +} from "react-native-reanimated"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { + MoreVertical, + GitBranch, + Folder, + RotateCcw, + PanelRight, +} from "lucide-react-native"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { BackHeader } from "@/components/headers/back-header"; +import { AgentStreamView } from "@/components/agent-stream-view"; +import { AgentInputArea } from "@/components/agent-input-area"; +import { ExplorerSidebar } from "@/components/explorer-sidebar"; +import { FileDropZone } from "@/components/file-drop-zone"; +import type { ImageAttachment } from "@/components/message-input"; +import { + ExplorerSidebarAnimationProvider, + useExplorerSidebarAnimation, +} from "@/contexts/explorer-sidebar-animation-context"; +import { usePanelStore } from "@/stores/panel-store"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { useSessionStore } from "@/stores/session-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import type { Agent } from "@/contexts/session-context"; +import type { StreamItem } from "@/types/stream"; +import { + buildAgentNavigationKey, + endNavigationTiming, + HOME_NAVIGATION_KEY, + startNavigationTiming, +} from "@/utils/navigation-timing"; +import { extractAgentModel } from "@/utils/extract-agent-model"; +import { startPerfMonitor } from "@/utils/perf-monitor"; +import { shortenPath } from "@/utils/shorten-path"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +const DROPDOWN_WIDTH = 220; +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + + +type BranchStatus = "idle" | "loading" | "ready" | "error"; + +export function AgentReadyScreen({ + serverId, + agentId, +}: { + serverId: string; + agentId: string; +}) { + const router = useRouter(); + const resolvedAgentId = agentId?.trim() || undefined; + const resolvedServerId = serverId?.trim() || undefined; + const { connectionStates } = useDaemonConnections(); + + // Check if session exists for this serverId + const hasSession = useSessionStore((state) => + resolvedServerId ? !!state.sessions[resolvedServerId] : false + ); + + const connectionServerId = resolvedServerId ?? null; + const connection = connectionServerId + ? connectionStates.get(connectionServerId) + : null; + const serverLabel = + connection?.daemon.label ?? connectionServerId ?? "Selected host"; + const isUnknownDaemon = Boolean(connectionServerId && !connection); + const connectionStatus = + connection?.status ?? (isUnknownDaemon ? "offline" : "idle"); + const connectionStatusLabel = formatConnectionStatus(connectionStatus); + const lastConnectionError = connection?.lastError ?? null; + + const handleBackToHome = useCallback(() => { + const targetServerId = resolvedServerId; + const targetAgentId = resolvedAgentId ?? null; + if (targetServerId && targetAgentId) { + startNavigationTiming(HOME_NAVIGATION_KEY, { + from: "agent", + to: "home", + targetMs: 300, + params: { + serverId: targetServerId, + agentId: targetAgentId, + }, + }); + } else { + startNavigationTiming(HOME_NAVIGATION_KEY, { + from: "agent", + to: "home", + targetMs: 300, + }); + } + router.replace("/agent" as any); + }, [resolvedAgentId, resolvedServerId, router]); + + const focusServerId = resolvedServerId; + const navigationStatus = hasSession ? "ready" : "session_unavailable"; + + useFocusEffect( + useCallback(() => { + if (!resolvedAgentId || !focusServerId) { + return; + } + const navigationKey = buildAgentNavigationKey( + focusServerId, + resolvedAgentId + ); + endNavigationTiming(navigationKey, { + screen: "agent", + status: navigationStatus, + }); + }, [focusServerId, navigationStatus, resolvedAgentId]) + ); + + if (!hasSession || !resolvedServerId) { + return ( + + ); + } + + return ( + + + + ); +} + +type AgentScreenContentProps = { + serverId: string; + agentId?: string; +}; + +function AgentScreenContent({ + serverId, + agentId, +}: AgentScreenContentProps) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const router = useRouter(); + + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); + + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + + const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, []); + + const isMobile = + UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const closeToAgent = usePanelStore((state) => state.closeToAgent); + const setExplorerTab = usePanelStore((state) => state.setExplorerTab); + + // Derive isExplorerOpen from the unified panel state + const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; + + const { + translateX: explorerTranslateX, + backdropOpacity: explorerBackdropOpacity, + windowWidth: explorerWindowWidth, + animateToOpen: animateExplorerToOpen, + animateToClose: animateExplorerToClose, + isGesturing: isExplorerGesturing, + } = useExplorerSidebarAnimation(); + + useEffect(() => { + if (Platform.OS !== "web") { + return; + } + const scope = `agent:${serverId}:${agentId ?? "unknown"}`; + const stop = startPerfMonitor(scope); + return stop; + }, [serverId, agentId]); + + // Swipe-left gesture to open explorer sidebar on mobile + const explorerOpenGesture = useMemo( + () => + Gesture.Pan() + .enabled(isMobile && !isExplorerOpen) + // Only activate after 15px horizontal movement to the left (negative) + .activeOffsetX(-15) + // Fail if 10px vertical movement happens first (allow vertical scroll) + .failOffsetY([-10, 10]) + .onStart(() => { + isExplorerGesturing.value = true; + }) + .onUpdate((event) => { + // Right sidebar: start from closed position (+windowWidth) and move towards 0 + // Swiping left means negative translationX + const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX); + explorerTranslateX.value = newTranslateX; + explorerBackdropOpacity.value = interpolate( + newTranslateX, + [explorerWindowWidth, 0], + [0, 1], + Extrapolation.CLAMP + ); + }) + .onEnd((event) => { + isExplorerGesturing.value = false; + // Open if dragged more than 1/3 of window or fast swipe left + const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500; + if (shouldOpen) { + animateExplorerToOpen(); + runOnJS(openFileExplorer)(); + } else { + animateExplorerToClose(); + } + }) + .onFinalize(() => { + isExplorerGesturing.value = false; + }), + [ + isMobile, + isExplorerOpen, + explorerWindowWidth, + explorerTranslateX, + explorerBackdropOpacity, + animateExplorerToOpen, + animateExplorerToClose, + openFileExplorer, + isExplorerGesturing, + ] + ); + + // Handle hardware back button - close explorer sidebar first, then navigate back + useEffect(() => { + if (Platform.OS === "web") return; + + const handler = BackHandler.addEventListener("hardwareBackPress", () => { + if (isExplorerOpen) { + closeToAgent(); + return true; // Prevent default back navigation + } + return false; // Let default back navigation happen + }); + + return () => handler.remove(); + }, [isExplorerOpen, closeToAgent]); + + const resolvedAgentId = agentId; + + // Select only the specific agent + const agent = useSessionStore((state) => + resolvedAgentId + ? state.sessions[serverId]?.agents?.get(resolvedAgentId) + : undefined + ); + + // Select only the specific stream tail - use stable empty array to avoid infinite loop + const streamItemsRaw = useSessionStore((state) => + resolvedAgentId + ? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId) + : undefined + ); + const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; + + const pendingCreate = useCreateFlowStore((state) => state.pending); + const clearPendingCreate = useCreateFlowStore((state) => state.clear); + const isPendingCreateForRoute = + Boolean(pendingCreate) && + pendingCreate?.serverId === serverId && + pendingCreate?.agentId === resolvedAgentId; + + // Select only the specific initializing state + const isInitializingFromMap = useSessionStore((state) => + resolvedAgentId + ? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false + : false + ); + + // Select raw pending permissions - filter with useMemo to avoid new Map on every render + const allPendingPermissions = useSessionStore( + (state) => state.sessions[serverId]?.pendingPermissions + ); + const pendingPermissions = useMemo(() => { + if (!allPendingPermissions || !resolvedAgentId) return new Map(); + const filtered = new Map(); + for (const [key, perm] of allPendingPermissions) { + if (perm.agentId === resolvedAgentId) { + filtered.set(key, perm); + } + } + return filtered; + }, [allPendingPermissions, resolvedAgentId]); + + const client = useSessionStore( + (state) => state.sessions[serverId]?.client ?? null + ); + const isConnected = useSessionStore( + (state) => state.sessions[serverId]?.connection.isConnected ?? false + ); + + // Get methods + const methods = useSessionStore((state) => state.sessions[serverId]?.methods); + const refreshAgent = methods?.refreshAgent; + const setFocusedAgentId = useCallback( + (agentId: string | null) => { + useSessionStore.getState().setFocusedAgentId(serverId, agentId); + }, + [serverId] + ); + + const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); + const bottomInset = useSharedValue(insets.bottom); + + useEffect(() => { + bottomInset.value = insets.bottom; + }, [insets.bottom, bottomInset]); + + const animatedKeyboardStyle = useAnimatedStyle(() => { + "worklet"; + const absoluteHeight = Math.abs(keyboardHeight.value); + const shift = Math.max(0, absoluteHeight - bottomInset.value); + return { + transform: [{ translateY: -shift }], + }; + }); + + const agentModel = extractAgentModel(agent); + const modelDisplayValue = agentModel ?? "Unknown"; + + const repoInfoQuery = useQuery({ + queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""], + queryFn: async () => { + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getGitRepoInfo({ + cwd: agent?.cwd ?? ".", + }); + if (payload.error) { + throw new Error(payload.error); + } + return { + cwd: payload.cwd, + currentBranch: payload.currentBranch ?? null, + }; + }, + enabled: Boolean(client && isConnected && agent?.cwd), + retry: false, + }); + const { refetch: refetchRepoInfo } = repoInfoQuery; + const branchStatus: BranchStatus = !agent?.cwd + ? "idle" + : repoInfoQuery.isPending || repoInfoQuery.isFetching + ? "loading" + : repoInfoQuery.isError + ? "error" + : repoInfoQuery.isSuccess + ? "ready" + : "idle"; + const branchLabel = repoInfoQuery.data?.currentBranch ?? null; + const branchError = repoInfoQuery.error instanceof Error + ? repoInfoQuery.error.message + : null; + const branchDisplayValue = + branchStatus === "error" + ? branchError ?? "Unavailable" + : branchLabel ?? "Unknown"; + + useEffect(() => { + if (!resolvedAgentId) { + setFocusedAgentId(null); + return; + } + + setFocusedAgentId(resolvedAgentId); + return () => { + setFocusedAgentId(null); + }; + }, [resolvedAgentId, setFocusedAgentId]); + + const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false; + + const optimisticStreamItems = useMemo(() => { + if (!isPendingCreateForRoute || !pendingCreate) { + return EMPTY_STREAM_ITEMS; + } + return [ + { + kind: "user_message", + id: pendingCreate.messageId, + text: pendingCreate.text, + timestamp: new Date(pendingCreate.timestamp), + }, + ]; + }, [isPendingCreateForRoute, pendingCreate]); + + const mergedStreamItems = useMemo(() => { + if (optimisticStreamItems.length === 0) { + return streamItems; + } + const optimistic = optimisticStreamItems[0]; + if (!optimistic) { + return streamItems; + } + const alreadyHasOptimistic = streamItems.some( + (item) => item.kind === "user_message" && item.id === optimistic.id + ); + return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; + }, [optimisticStreamItems, streamItems]); + + const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0; + + const placeholderAgent: Agent | null = useMemo(() => { + if (!shouldUseOptimisticStream || !resolvedAgentId) { + return null; + } + const now = new Date(); + return { + serverId, + id: resolvedAgentId, + provider: "claude", + status: "running", + createdAt: now, + updatedAt: now, + lastUserMessageAt: now, + lastActivityAt: now, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "claude", + sessionId: null, + model: null, + modeId: null, + }, + title: "Agent", + cwd: ".", + model: null, + }; + }, [resolvedAgentId, serverId, shouldUseOptimisticStream]); + + const effectiveAgent = agent ?? placeholderAgent; + + useEffect(() => { + if (!isPendingCreateForRoute || !pendingCreate) { + return; + } + const hasUserMessage = streamItems.some( + (item) => + item.kind === "user_message" && + (item.id === pendingCreate.messageId || item.text === pendingCreate.text) + ); + if (agent && hasUserMessage) { + clearPendingCreate(); + } + }, [ + agent, + clearPendingCreate, + isPendingCreateForRoute, + pendingCreate, + streamItems, + ]); + + // Get ensureAgentIsInitialized from methods + const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized; + + useEffect(() => { + if (!resolvedAgentId || !ensureAgentIsInitialized) { + return; + } + + // Skip if not connected - will re-run when connection is established + if (!isConnected) { + return; + } + + // ensureAgentIsInitialized handles deduplication via module-level promises map + // If already initialized or in-flight, returns resolved/pending promise immediately + ensureAgentIsInitialized(resolvedAgentId).catch((error) => { + console.warn("[AgentScreen] Agent initialization failed", { + agentId: resolvedAgentId, + error, + }); + }); + }, [resolvedAgentId, ensureAgentIsInitialized, isConnected]); + + useEffect(() => { + if (Platform.OS !== "web") { + return; + } + const title = agent?.title || "Agent"; + document.title = title; + }, [agent?.title]); + + // Track previous agent status to detect completion while viewing + const previousStatusRef = useRef(null); + + // Clear attention when agent finishes while user is viewing this screen + useEffect(() => { + if (!resolvedAgentId || !agent || !client) { + return; + } + + const previousStatus = previousStatusRef.current; + const currentStatus = agent.status; + previousStatusRef.current = currentStatus; + + // If agent transitioned from running to idle while we're viewing, + // immediately clear attention since user witnessed the completion + if (previousStatus === "running" && currentStatus === "idle") { + client.clearAgentAttention(resolvedAgentId); + } + }, [resolvedAgentId, agent?.status, client]); + + const handleViewChanges = useCallback(() => { + setExplorerTab("changes"); + openFileExplorer(); + }, [setExplorerTab, openFileExplorer]); + + const handleBrowseFiles = useCallback(() => { + setExplorerTab("files"); + openFileExplorer(); + }, [setExplorerTab, openFileExplorer]); + + const handleRefreshAgent = useCallback(() => { + if (!resolvedAgentId || !refreshAgent) { + return; + } + refreshAgent({ agentId: resolvedAgentId }); + }, [resolvedAgentId, refreshAgent]); + + if (!effectiveAgent) { + return ( + + + + Agent not found + + + ); + } + + const mainContent = ( + + + + {/* Header */} + + + {isMobile ? ( + + ) : ( + + )} + + { + if (open && agent?.cwd) { + refetchRepoInfo().catch(() => {}); + } + }} + > + + + + + + + Directory + + {shortenPath(effectiveAgent.cwd)} + + + + + Model + + {modelDisplayValue} + + + + + Branch + + {branchStatus === "loading" ? ( + <> + + Fetching… + + ) : ( + + {branchDisplayValue} + + )} + + + + + + + } + onSelect={handleViewChanges} + > + View Changes + + } + onSelect={handleBrowseFiles} + > + Browse Files + + } + disabled={isInitializing} + trailing={ + isInitializing ? ( + + ) : null + } + onSelect={handleRefreshAgent} + > + {isInitializing ? "Refreshing..." : "Refresh"} + + + + + } + /> + + {/* Content Area with Keyboard Animation */} + + + {isInitializing && !shouldUseOptimisticStream ? ( + + + Loading agent... + + ) : ( + + )} + + + + {/* Agent Input Area */} + {!isInitializing && agent && resolvedAgentId && ( + + )} + + + + + {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} + {!isMobile && isExplorerOpen && resolvedAgentId && ( + + )} + + ); + + return ( + <> + {isMobile ? ( + + {mainContent} + + ) : ( + mainContent + )} + + {/* Mobile Explorer Sidebar Overlay */} + {isMobile && resolvedAgentId && ( + + )} + + ); +} + +function AgentSessionUnavailableState({ + onBack, + serverLabel, + connectionStatus, + connectionStatusLabel, + lastError, + isUnknownDaemon = false, +}: { + onBack: () => void; + serverLabel: string; + connectionStatus: ConnectionStatus; + connectionStatusLabel: string; + lastError: string | null; + isUnknownDaemon?: boolean; +}) { + if (isUnknownDaemon) { + return ( + + + + + Cannot open this agent because {serverLabel} is not configured on + this device. + + + Add the host in Settings or open an agent on a configured server to + continue. + + + + ); + } + + const isConnecting = connectionStatus === "connecting"; + + return ( + + + + {isConnecting ? ( + <> + + + Connecting to {serverLabel}... + + + We will show this agent once the host is online. + + + ) : ( + <> + + {serverLabel} is currently {connectionStatusLabel.toLowerCase()}. + + + We'll reconnect automatically and show this agent as soon as the + host comes back online. + + {lastError ? ( + {lastError} + ) : null} + + )} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + outerContainer: { + flex: 1, + flexDirection: "row", + backgroundColor: theme.colors.surface0, + }, + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + headerRightContent: { + flexDirection: "row", + alignItems: "center", + }, + contentContainer: { + flex: 1, + overflow: "hidden", + }, + content: { + flex: 1, + }, + loadingContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: 16, + }, + loadingText: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + }, + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[6], + gap: theme.spacing[3], + }, + errorContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + errorText: { + fontSize: theme.fontSize.lg, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + statusText: { + marginTop: theme.spacing[2], + textAlign: "center", + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + errorDetails: { + marginTop: theme.spacing[1], + textAlign: "center", + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + offlineTitle: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + offlineDescription: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + offlineDetails: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + menuButton: { + padding: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, + menuMetaContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + gap: theme.spacing[2], + }, + menuMetaRow: { + gap: theme.spacing[1], + }, + menuMetaLabel: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + letterSpacing: 0.5, + textTransform: "uppercase", + }, + menuMetaValue: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + menuMetaValueRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + menuMetaPendingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + menuMetaValueError: { + color: theme.colors.destructive, + }, + menuItemSpinner: { + marginLeft: "auto", + }, +})); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx new file mode 100644 index 000000000..2c023e1fa --- /dev/null +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -0,0 +1,1054 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { createNameId } from "mnemonic-id"; +import type { ImageAttachment } from "@/components/message-input"; +import { + View, + Text, + Pressable, + ScrollView, + Keyboard, + Platform, +} from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; +import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { Monitor } from "lucide-react-native"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { AgentInputArea } from "@/components/agent-input-area"; +import { AgentStreamView } from "@/components/agent-stream-view"; +import { + DropdownSheet, + GitOptionsSection, + WorkingDirectoryDropdown, + AgentConfigRow, +} from "@/components/agent-form/agent-form-dropdowns"; +import { FileDropZone } from "@/components/file-drop-zone"; +import { useQuery } from "@tanstack/react-query"; +import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { useSessionStore } from "@/stores/session-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { WelcomeScreen } from "@/components/welcome-screen"; +import type { Agent } from "@/contexts/session-context"; +import type { StreamItem } from "@/types/stream"; +import { generateMessageId } from "@/types/stream"; +import type { + AgentProvider, + AgentCapabilityFlags, + AgentSessionConfig, +} from "@server/server/agent/agent-sdk-types"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; + +const DRAFT_AGENT_ID = "__new_agent__"; +const EMPTY_PENDING_PERMISSIONS = new Map(); +const EMPTY_STREAM_ITEMS: StreamItem[] = []; +const DRAFT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, +}; +const PROVIDER_DEFINITION_MAP = new Map( + AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]) +); + +function getParamValue(value: string | string[] | undefined) { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (Array.isArray(value)) { + for (const entry of value) { + const trimmed = entry.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + } + return undefined; +} + +function getValidProvider(value: string | undefined) { + if (!value) { + return undefined; + } + return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) + ? (value as AgentProvider) + : undefined; +} + +function getValidMode( + provider: AgentProvider | undefined, + value: string | undefined +) { + if (!provider || !value) { + return undefined; + } + const definition = PROVIDER_DEFINITION_MAP.get(provider); + const modes = definition?.modes ?? []; + return modes.some((mode) => mode.id === value) ? value : undefined; +} + +type DraftAgentParams = { + serverId?: string; + provider?: string; + modeId?: string; + model?: string; + workingDir?: string; +}; + +type DraftAgentScreenProps = { + isVisible?: boolean; + onCreateFlowActiveChange?: (active: boolean) => void; +}; + +export function DraftAgentScreen({ + isVisible = true, + onCreateFlowActiveChange, +}: DraftAgentScreenProps = {}) { + const { theme } = useUnistyles(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const { connectionStates } = useDaemonConnections(); + const { daemons } = useDaemonRegistry(); + const params = useLocalSearchParams(); + + const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); + const bottomInset = useSharedValue(insets.bottom); + + useEffect(() => { + bottomInset.value = insets.bottom; + }, [insets.bottom, bottomInset]); + + const animatedKeyboardStyle = useAnimatedStyle(() => { + "worklet"; + const absoluteHeight = Math.abs(keyboardHeight.value); + const shift = Math.max(0, absoluteHeight - bottomInset.value); + return { + transform: [{ translateY: -shift }], + }; + }); + + const resolvedServerId = getParamValue(params.serverId); + const resolvedProvider = getValidProvider(getParamValue(params.provider)); + const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); + const resolvedModel = getParamValue(params.model); + const resolvedWorkingDir = getParamValue(params.workingDir); + + const initialValues = useMemo((): CreateAgentInitialValues => { + const values: CreateAgentInitialValues = {}; + if (resolvedWorkingDir) { + values.workingDir = resolvedWorkingDir; + } + if (resolvedProvider) { + values.provider = resolvedProvider; + } + if (resolvedMode) { + values.modeId = resolvedMode; + } + if (resolvedModel) { + values.model = resolvedModel; + } + return values; + }, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]); + + const { + selectedServerId, + setSelectedServerIdFromUser, + selectedProvider, + setProviderFromUser, + selectedMode, + setModeFromUser, + selectedModel, + setModelFromUser, + workingDir, + setWorkingDirFromUser, + providerDefinitions, + modeOptions, + availableModels, + isModelLoading, + modelError, + refreshProviderModels, + persistFormPreferences, + } = useAgentFormState({ + initialServerId: resolvedServerId ?? null, + initialValues, + isVisible, + isCreateFlow: true, + }); + const hostEntry = selectedServerId + ? connectionStates.get(selectedServerId) + : undefined; + const hostLabel = + hostEntry?.daemon.label ?? selectedServerId ?? "Select host"; + const hostStatus = hostEntry?.status + ? formatConnectionStatus(hostEntry.status) + : undefined; + + const [openDropdown, setOpenDropdown] = useState<"host" | null>(null); + const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); + const [baseBranch, setBaseBranch] = useState(""); + const [worktreeSlug, setWorktreeSlug] = useState(""); + const [selectedWorktreePath, setSelectedWorktreePath] = useState(""); + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); + const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); + const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); + + type CreateAttempt = { + messageId: string; + text: string; + timestamp: Date; + }; + + type DraftAgentMachineState = + | { tag: "draft"; promptText: string; errorMessage: string } + | { tag: "creating"; attempt: CreateAttempt }; + + type DraftAgentMachineEvent = + | { type: "DRAFT_SET_PROMPT"; text: string } + | { type: "DRAFT_SET_ERROR"; message: string } + | { type: "SUBMIT"; attempt: CreateAttempt } + | { type: "CREATE_FAILED"; message: string }; + + function assertNever(value: never): never { + throw new Error(`Unhandled state: ${JSON.stringify(value)}`); + } + + const [machine, dispatch] = useReducer( + (state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => { + switch (event.type) { + case "DRAFT_SET_PROMPT": { + if (state.tag !== "draft") { + return state; + } + return { ...state, promptText: event.text }; + } + case "DRAFT_SET_ERROR": { + if (state.tag !== "draft") { + return state; + } + return { ...state, errorMessage: event.message }; + } + case "SUBMIT": { + return { tag: "creating", attempt: event.attempt }; + } + case "CREATE_FAILED": { + if (state.tag !== "creating") { + return state; + } + return { tag: "draft", promptText: state.attempt.text, errorMessage: event.message }; + } + default: + return assertNever(event); + } + }, + { tag: "draft", promptText: "", errorMessage: "" } + ); + + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + + const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, []); + const openDropdownSheet = useCallback((key: "host") => { + setOpenDropdown(key); + }, []); + const closeDropdown = useCallback(() => { + setOpenDropdown(null); + }, []); + const sessionAgents = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.agents : undefined + ); + const agentWorkingDirSuggestions = useMemo(() => { + if (!selectedServerId || !sessionAgents) { + return []; + } + const uniquePaths = new Set(); + sessionAgents.forEach((agent) => { + if (agent.cwd) { + uniquePaths.add(agent.cwd); + } + }); + return Array.from(uniquePaths).sort(); + }, [selectedServerId, sessionAgents]); + + const sessionClient = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null + ); + const isConnected = useSessionStore((state) => + selectedServerId + ? state.sessions[selectedServerId]?.connection.isConnected ?? false + : false + ); + const trimmedWorkingDir = workingDir.trim(); + const shouldInspectRepo = trimmedWorkingDir.length > 0; + const daemonAvailabilityError = + !selectedServerId || hostEntry?.status !== "online" + ? "Host is offline" + : null; + const repoAvailabilityError = + shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isConnected) + ? daemonAvailabilityError ?? + "Repository details will load automatically once the selected host is back online." + : null; + + type RepoInfoState = { + cwd: string; + repoRoot: string; + branches: Array<{ name: string; isCurrent: boolean }>; + currentBranch: string | null; + isDirty: boolean; + }; + const repoInfoQuery = useQuery({ + queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir], + queryFn: async () => { + const client = sessionClient; + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getGitRepoInfo({ + cwd: trimmedWorkingDir || ".", + }); + if (payload.error) { + throw new Error(payload.error); + } + return { + cwd: payload.cwd, + repoRoot: payload.repoRoot, + branches: payload.branches ?? [], + currentBranch: payload.currentBranch ?? null, + isDirty: Boolean(payload.isDirty), + }; + }, + enabled: + Boolean(trimmedWorkingDir) && + !repoAvailabilityError && + Boolean(sessionClient) && + isConnected, + retry: false, + }); + const repoInfo = repoInfoQuery.data ?? null; + const refetchRepoInfo = repoInfoQuery.refetch; + const repoRequestError = repoInfoQuery.error as Error | null; + const repoRequestStatus: "idle" | "loading" | "success" | "error" = + !shouldInspectRepo || repoAvailabilityError + ? "idle" + : repoInfoQuery.isPending || repoInfoQuery.isFetching + ? "loading" + : repoInfoQuery.isError + ? "error" + : repoInfoQuery.isSuccess + ? "success" + : "idle"; + const isNonGitDirectory = + repoRequestStatus === "error" && + /not in a git repository|not a git repository/i.test(repoRequestError?.message ?? ""); + const isDirectoryNotExists = + repoRequestStatus === "error" && + /does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? ""); + const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo + ? "idle" + : repoAvailabilityError + ? "error" + : repoRequestStatus === "loading" + ? "loading" + : repoRequestStatus === "error" + ? isNonGitDirectory + ? "idle" + : "error" + : repoRequestStatus === "success" + ? "ready" + : "idle"; + const repoInfoError = + repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null); + const gitHelperText = isNonGitDirectory + ? "No git repository detected. Git options are disabled for this directory." + : null; + const isCreateWorktree = worktreeMode === "create"; + const isAttachWorktree = worktreeMode === "attach"; + + const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir; + const worktreeListQuery = useQuery({ + queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot], + queryFn: async () => { + const client = sessionClient; + if (!client) { + throw new Error("Daemon client unavailable"); + } + const payload = await client.getPaseoWorktreeList({ + repoRoot: worktreeListRoot || undefined, + cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined, + }); + if (payload.error) { + throw new Error(payload.error.message); + } + return payload.worktrees ?? []; + }, + enabled: + isAttachWorktree && + Boolean(worktreeListRoot || trimmedWorkingDir) && + !repoAvailabilityError && + Boolean(sessionClient) && + isConnected && + !isNonGitDirectory, + retry: false, + staleTime: 0, + refetchOnMount: "always", + }); + const worktreeOptions = useMemo(() => { + return (worktreeListQuery.data ?? []).map((worktree) => ({ + path: worktree.worktreePath, + label: worktree.branchName ?? worktree.head ?? "Unknown branch", + })); + }, [worktreeListQuery.data]); + const worktreeOptionsError = + worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null; + const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" = + !isAttachWorktree + ? "idle" + : worktreeListQuery.isPending || worktreeListQuery.isFetching + ? "loading" + : worktreeListQuery.isError + ? "error" + : "ready"; + const attachWorktreeError = + isAttachWorktree && + worktreeOptionsStatus === "ready" && + worktreeOptions.length > 0 && + !selectedWorktreePath + ? "Select a worktree to attach" + : null; + + const handleWorktreeModeChange = useCallback( + (mode: "none" | "create" | "attach") => { + setWorktreeMode(mode); + if (mode === "create" && !worktreeSlug) { + setWorktreeSlug(createNameId()); + } + if (mode !== "attach") { + setSelectedWorktreePath(""); + } + if (mode !== "none") { + refetchRepoInfo(); + } + }, + [worktreeSlug, refetchRepoInfo] + ); + + const validateWorktreeName = useCallback( + (name: string): { valid: boolean; error?: string } => { + if (!name) { + return { valid: true }; + } + if (name.length > 100) { + return { + valid: false, + error: "Worktree name too long (max 100 characters)", + }; + } + if (!/^[a-z0-9-/]+$/.test(name)) { + return { + valid: false, + error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", + }; + } + if (name.startsWith("-") || name.endsWith("-")) { + return { valid: false, error: "Cannot start or end with a hyphen" }; + } + if (name.includes("--")) { + return { valid: false, error: "Cannot have consecutive hyphens" }; + } + return { valid: true }; + }, + [] + ); + + const gitBlockingError = useMemo(() => { + if (!isCreateWorktree || isNonGitDirectory) { + return null; + } + if (!worktreeSlug) { + return null; + } + const validation = validateWorktreeName(worktreeSlug); + if (!validation.valid) { + return `Invalid worktree name: ${ + validation.error ?? "Must use lowercase letters, numbers, or hyphens" + }`; + } + return null; + }, [ + isCreateWorktree, + isNonGitDirectory, + worktreeSlug, + validateWorktreeName, + ]); + + const baseBranchError = useMemo(() => { + if (!isCreateWorktree || isNonGitDirectory) { + return null; + } + if (!baseBranch) { + return "Base branch is required"; + } + const branches = repoInfo?.branches ?? []; + if (branches.length === 0) { + return null; + } + const branchExists = branches.some((b) => b.name === baseBranch); + if (!branchExists) { + return `Branch "${baseBranch}" not found in repository`; + } + return null; + }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]); + + const handleBaseBranchChange = useCallback((value: string) => { + setBaseBranch(value); + }, []); + + const handleSelectWorktreePath = useCallback( + (path: string) => { + setSelectedWorktreePath(path); + setWorkingDirFromUser(path); + }, + [setWorkingDirFromUser] + ); + + useEffect(() => { + if (!isCreateWorktree || isNonGitDirectory) { + return; + } + if (baseBranch) { + return; + } + const current = repoInfo?.currentBranch?.trim(); + if (current) { + setBaseBranch(current); + } + }, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]); + + useEffect(() => { + if (isNonGitDirectory && worktreeMode !== "none") { + setWorktreeMode("none"); + setSelectedWorktreePath(""); + } + }, [isNonGitDirectory, worktreeMode]); + + const sessionMethods = useSessionStore((state) => + selectedServerId ? state.sessions[selectedServerId]?.methods : undefined + ); + + const promptValue = machine.tag === "draft" ? machine.promptText : ""; + const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; + const isSubmitting = machine.tag === "creating"; + + const optimisticStreamItems = useMemo(() => { + if (machine.tag !== "creating") { + return EMPTY_STREAM_ITEMS; + } + return [ + { + kind: "user_message", + id: machine.attempt.messageId, + text: machine.attempt.text, + timestamp: machine.attempt.timestamp, + }, + ]; + }, [machine]); + + const draftAgent = useMemo(() => { + if (machine.tag !== "creating") { + return null; + } + const serverId = selectedServerId ?? ""; + const now = machine.attempt.timestamp; + const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || "."; + const provider = selectedProvider; + const model = selectedModel.trim() || null; + const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; + + return { + serverId, + id: DRAFT_AGENT_ID, + provider, + status: "running", + createdAt: now, + updatedAt: now, + lastUserMessageAt: now, + lastActivityAt: now, + capabilities: DRAFT_CAPABILITIES, + currentModeId: modeId, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider, + sessionId: null, + model, + modeId, + }, + title: "New Agent", + cwd, + model, + }; + }, [ + machine.tag, + machine.tag === "creating" ? machine.attempt.timestamp : null, + isAttachWorktree, + modeOptions.length, + selectedMode, + selectedModel, + selectedProvider, + selectedServerId, + selectedWorktreePath, + workingDir, + ]); + + const handleCreateFromInput = useCallback( + async ({ + text, + images, + }: { + text: string; + images?: Array<{ uri: string; mimeType: string }>; + }) => { + if (isSubmitting) { + throw new Error("Already loading"); + } + dispatch({ type: "DRAFT_SET_ERROR", message: "" }); + const trimmedPath = workingDir.trim(); + const trimmedPrompt = text.trim(); + const resolvedWorkingDir = + isAttachWorktree && selectedWorktreePath + ? selectedWorktreePath + : trimmedPath; + if (!trimmedPath) { + dispatch({ type: "DRAFT_SET_ERROR", message: "Working directory is required" }); + throw new Error("Working directory is required"); + } + if (isDirectoryNotExists) { + dispatch({ + type: "DRAFT_SET_ERROR", + message: "Working directory does not exist on the selected host", + }); + throw new Error("Working directory does not exist on the selected host"); + } + if (!trimmedPrompt) { + dispatch({ type: "DRAFT_SET_ERROR", message: "Initial prompt is required" }); + throw new Error("Initial prompt is required"); + } + if (!selectedServerId) { + dispatch({ type: "DRAFT_SET_ERROR", message: "No host selected" }); + throw new Error("No host selected"); + } + if (gitBlockingError) { + dispatch({ type: "DRAFT_SET_ERROR", message: gitBlockingError }); + throw new Error(gitBlockingError); + } + if (isAttachWorktree && !selectedWorktreePath) { + const message = "Select a worktree to attach"; + dispatch({ type: "DRAFT_SET_ERROR", message }); + throw new Error(message); + } + if (baseBranchError) { + dispatch({ type: "DRAFT_SET_ERROR", message: baseBranchError }); + throw new Error(baseBranchError); + } + const createAgent = sessionMethods?.createAgent; + if (!createAgent) { + dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" }); + throw new Error("Host is not connected"); + } + const attempt = { + messageId: generateMessageId(), + text: trimmedPrompt, + timestamp: new Date(), + }; + setPendingCreateAttempt({ + serverId: selectedServerId, + agentId: null, + messageId: attempt.messageId, + text: attempt.text, + timestamp: attempt.timestamp.getTime(), + }); + const modeId = + modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + const trimmedModel = selectedModel.trim(); + const config: AgentSessionConfig = { + provider: selectedProvider, + cwd: resolvedWorkingDir, + ...(modeId ? { modeId } : {}), + ...(trimmedModel ? { model: trimmedModel } : {}), + }; + const effectiveBaseBranch = baseBranch.trim(); + const effectiveWorktreeSlug = + isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug; + if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) { + setWorktreeSlug(effectiveWorktreeSlug); + } + const gitOptions = + isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug + ? { + createWorktree: true, + createNewBranch: true, + newBranchName: effectiveWorktreeSlug, + worktreeSlug: effectiveWorktreeSlug, + baseBranch: effectiveBaseBranch, + } + : undefined; + + void persistFormPreferences(); + if (Platform.OS === "web") { + (document.activeElement as HTMLElement | null)?.blur?.(); + } + Keyboard.dismiss(); + dispatch({ type: "SUBMIT", attempt }); + onCreateFlowActiveChange?.(true); + + try { + const result = await createAgent({ + config, + initialPrompt: trimmedPrompt, + images, + git: gitOptions, + }); + + const agentId = (result as { id?: string })?.id; + if (agentId && selectedServerId) { + updatePendingAgentId(agentId); + router.replace(`/agent/${selectedServerId}/${agentId}` as any); + return; + } + + dispatch({ + type: "CREATE_FAILED", + message: "Failed to create agent", + }); + onCreateFlowActiveChange?.(false); + clearPendingCreateAttempt(); + throw new Error("Failed to create agent"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create agent"; + dispatch({ type: "CREATE_FAILED", message }); + onCreateFlowActiveChange?.(false); + clearPendingCreateAttempt(); + throw error; // Re-throw so AgentInputArea knows it failed + } + }, + [ + worktreeMode, + baseBranch, + worktreeSlug, + selectedWorktreePath, + repoInfo?.currentBranch, + gitBlockingError, + baseBranchError, + isDirectoryNotExists, + isNonGitDirectory, + modeOptions, + persistFormPreferences, + router, + selectedMode, + selectedModel, + selectedProvider, + selectedServerId, + sessionMethods, + setPendingCreateAttempt, + updatePendingAgentId, + clearPendingCreateAttempt, + workingDir, + isAttachWorktree, + isSubmitting, + onCreateFlowActiveChange, + ] + ); + + if (daemons.length === 0) { + return ( + { + setSelectedServerIdFromUser(profile.id); + }} + /> + ); + } + + return ( + + + + openDropdownSheet("host")} + > + + {hostLabel} + + {hostStatus ? ( + {hostStatus} + ) : null} + + } + /> + + + {machine.tag === "creating" && draftAgent && selectedServerId ? ( + + + + ) : ( + + + + {isDirectoryNotExists && ( + + + Directory does not exist on the selected host + + + )} + + {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( + + ) : null} + + + {connectionStates.size === 0 ? ( + + No hosts available yet. + + ) : ( + + {Array.from(connectionStates.values()).map(({ daemon, status }) => { + const isSelected = daemon.id === selectedServerId; + const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id; + return ( + { + setSelectedServerIdFromUser(daemon.id); + closeDropdown(); + }} + > + + {label} + + + {formatConnectionStatus(status)} + + + ); + })} + + )} + + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + )} + + + dispatch({ type: "DRAFT_SET_PROMPT", text: next })} + autoFocus={machine.tag === "draft"} + onAddImages={handleAddImagesCallback} + /> + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + agentPanel: { + flex: 1, + }, + contentContainer: { + flex: 1, + overflow: "hidden", + }, + scrollView: { + flex: 1, + }, + inputAreaWrapper: { + backgroundColor: theme.colors.surface0, + }, + streamContainer: { + flex: 1, + }, + configScrollContent: { + flexGrow: 1, + justifyContent: "flex-end", + }, + configSection: { + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[3], + paddingBottom: theme.spacing[4], + gap: theme.spacing[2], + maxWidth: MAX_CONTENT_WIDTH, + alignSelf: "center", + width: "100%", + }, + dropdownHelper: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + dropdownSheetList: { + marginTop: theme.spacing[3], + }, + dropdownSheetOption: { + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + borderRadius: theme.borderRadius.lg, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface0, + marginBottom: theme.spacing[2], + }, + dropdownSheetOptionSelected: { + borderColor: theme.colors.palette.blue[400], + backgroundColor: "rgba(59, 130, 246, 0.18)", + }, + dropdownSheetOptionLabel: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.semibold, + }, + dropdownSheetOptionDescription: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + marginTop: theme.spacing[1], + }, + errorContainer: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + marginHorizontal: theme.spacing[4], + marginBottom: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.destructive, + }, + errorText: { + color: theme.colors.destructiveForeground, + fontSize: theme.fontSize.sm, + }, + warningContainer: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderRadius: theme.borderRadius.md, + backgroundColor: theme.colors.palette.yellow[400], + }, + warningText: { + color: "#000000", + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + hostBadge: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.full, + }, + hostBadgeLabel: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + }, + hostBadgeStatus: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, + hostStatusDot: { + width: 6, + height: 6, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.foregroundMuted, + }, + hostStatusDotOnline: { + backgroundColor: theme.colors.palette.green[500], + }, +})); diff --git a/packages/app/src/screens/agent/legacy-agent-id-screen.tsx b/packages/app/src/screens/agent/legacy-agent-id-screen.tsx new file mode 100644 index 000000000..e2ec21202 --- /dev/null +++ b/packages/app/src/screens/agent/legacy-agent-id-screen.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useMemo } from "react"; +import type { ReactNode } from "react"; +import { ActivityIndicator, Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { BackHeader } from "@/components/headers/back-header"; +import { useSessionDirectory } from "@/hooks/use-session-directory"; +import { useDaemonConnections } from "@/contexts/daemon-connections-context"; +import type { Agent } from "@/contexts/session-context"; + +type AgentMatch = { + serverId: string; + serverLabel: string; + agent: Agent; +}; + +export function LegacyAgentIdScreen({ agentId }: { agentId: string }) { + const router = useRouter(); + const { theme } = useUnistyles(); + const { connectionStates } = useDaemonConnections(); + const sessionDirectory = useSessionDirectory(); + const resolvedAgentId = typeof agentId === "string" ? agentId.trim() : undefined; + + const matches = useMemo(() => { + if (!resolvedAgentId) { + return []; + } + + const results: AgentMatch[] = []; + sessionDirectory.forEach((session, serverId) => { + if (!session) { + return; + } + const agent = session.agents.get(resolvedAgentId); + if (!agent) { + return; + } + const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId; + results.push({ serverId, serverLabel, agent }); + }); + + return results; + }, [resolvedAgentId, sessionDirectory, connectionStates]); + + const hasSessions = sessionDirectory.size > 0; + const isRedirecting = Boolean(resolvedAgentId && matches.length === 1); + + useEffect(() => { + if (!isRedirecting) { + return; + } + const match = matches[0]; + router.replace(`/agent/${match.serverId}/${match.agent.id}` as any); + }, [isRedirecting, matches, router]); + + const handleGoDraft = useCallback(() => { + router.replace("/agent" as any); + }, [router]); + + const handleSelectMatch = useCallback( + (match: AgentMatch) => { + router.replace(`/agent/${match.serverId}/${match.agent.id}` as any); + }, + [router] + ); + + let body: ReactNode = null; + + if (!resolvedAgentId) { + body = ( + + Missing agent + + This link is missing an agent id. Go back to the Agents screen to pick one. + + + New Agent + + + ); + } else if (!hasSessions || isRedirecting) { + body = ( + + + + {isRedirecting ? "Opening your agent..." : "Looking for your agent..."} + + + ); + } else if (matches.length === 0) { + body = ( + + Agent not found + + We could not find {resolvedAgentId} on any host right now. Hosts reconnect automatically—open it + again from the Agents screen after it comes back online. + + + New Agent + + + ); + } else if (matches.length > 1) { + body = ( + + Pick a host + + Multiple hosts have an agent with this id. Choose the one you intended to open. + + + {matches.map((match) => ( + handleSelectMatch(match)} + > + {match.serverLabel} + + {match.agent.cwd} + + + ))} + + + ); + } + + return ( + + + {body} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + content: { + flex: 1, + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[8], + }, + centerState: { + flex: 1, + justifyContent: "center", + alignItems: "center", + gap: theme.spacing[4], + }, + title: { + fontSize: theme.fontSize.xl, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + textAlign: "center", + }, + subtitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + primaryButton: { + marginTop: theme.spacing[2], + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.primary, + }, + primaryButtonText: { + color: theme.colors.primaryForeground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + matchList: { + width: "100%", + gap: theme.spacing[3], + }, + matchButton: { + width: "100%", + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface2, + }, + matchLabel: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + }, + matchMeta: { + marginTop: theme.spacing[1], + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts new file mode 100644 index 000000000..832eeedac --- /dev/null +++ b/packages/app/src/stores/create-flow-store.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +type PendingCreateAttempt = { + serverId: string; + agentId: string | null; + messageId: string; + text: string; + timestamp: number; +}; + +type CreateFlowState = { + pending: PendingCreateAttempt | null; + setPending: (pending: PendingCreateAttempt) => void; + updateAgentId: (agentId: string) => void; + clear: () => void; +}; + +export const useCreateFlowStore = create((set) => ({ + pending: null, + setPending: (pending) => set({ pending }), + updateAgentId: (agentId) => + set((state) => + state.pending ? { pending: { ...state.pending, agentId } } : state + ), + clear: () => set({ pending: null }), +})); +