diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index dc9539a75..47924373a 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -242,7 +242,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { ).toBeTruthy(); await page.getByTestId('sidebar-new-agent').click(); - await expect(page).toHaveURL(/\/$/); + await expect(page).toHaveURL(/\/agent\/?$/); await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); @@ -339,7 +339,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(); @@ -350,13 +350,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..d7417ae4f 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -135,7 +135,7 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise { } export const gotoHome = async (page: Page) => { - await page.goto('/'); + await page.goto('/agent'); await ensureE2EStorageSeeded(page); await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible(); await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible(); @@ -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,7 +312,8 @@ export const createAgent = async (page: Page, message: string) => { await input.fill(message); await input.press('Enter'); - await page.waitForURL(/\/agent\//); + // Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`. + await page.waitForURL(/\/agent\//, { waitUntil: 'commit' }); await expect(page.getByText(message, { exact: true })).toBeVisible(); }; 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/agent/[...route].tsx b/packages/app/src/app/agent/[...route].tsx new file mode 100644 index 000000000..eccbac009 --- /dev/null +++ b/packages/app/src/app/agent/[...route].tsx @@ -0,0 +1,31 @@ +import { useLocalSearchParams } from "expo-router"; +import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen"; +import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen"; + +type AgentRouteParams = { + route?: string[] | string; +}; + +function normalizeSegments(raw: AgentRouteParams["route"]): string[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw.filter((s) => typeof s === "string" && s.length > 0); + if (typeof raw === "string") return raw.length > 0 ? [raw] : []; + return []; +} + +export default function AgentCatchAllRoute() { + const params = useLocalSearchParams(); + const segments = normalizeSegments(params.route); + + if (segments.length === 1) { + return ; + } + + if (segments.length >= 2) { + const [serverId, agentId] = segments; + return ; + } + + return ; +} + 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 ecfe50b45..664bd6809 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -85,10 +85,9 @@ export function AgentList({ onAgentSelect?.(); navigate({ - pathname: "/agent/[serverId]/[agentId]", + pathname: "/agent/[...route]", params: { - serverId, - agentId, + route: [serverId, agentId], }, }); }, diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index d62f42bb4..89d3a5dd2 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -607,7 +607,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", }); - router.replace("/"); + router.replace("/agent"); }, 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..1b446a19e 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"); }} 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..e21175512 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"); }, []); // 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/app/agent/[serverId]/[agentId].tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx similarity index 98% rename from packages/app/src/app/agent/[serverId]/[agentId].tsx rename to packages/app/src/screens/agent/agent-ready-screen.tsx index b1ad5f533..33da601bf 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -8,7 +8,7 @@ import { Platform, BackHandler, } from "react-native"; -import { useLocalSearchParams, useRouter } from "expo-router"; +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"; @@ -69,14 +69,16 @@ const EMPTY_STREAM_ITEMS: StreamItem[] = []; type BranchStatus = "idle" | "loading" | "ready" | "error"; -export default function AgentScreen() { +export function AgentReadyScreen({ + serverId, + agentId, +}: { + serverId: string; + agentId: string; +}) { const router = useRouter(); - const { serverId, agentId } = useLocalSearchParams<{ - serverId: string; - agentId: string; - }>(); - const resolvedAgentId = typeof agentId === "string" ? agentId : undefined; - const resolvedServerId = typeof serverId === "string" ? serverId : undefined; + const resolvedAgentId = agentId?.trim() || undefined; + const resolvedServerId = serverId?.trim() || undefined; const { connectionStates } = useDaemonConnections(); // Check if session exists for this serverId @@ -116,7 +118,7 @@ export default function AgentScreen() { targetMs: 300, }); } - router.replace("/"); + router.replace("/agent"); }, [resolvedAgentId, resolvedServerId, router]); const focusServerId = resolvedServerId; diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx new file mode 100644 index 000000000..f16837ad0 --- /dev/null +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -0,0 +1,1029 @@ +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 { 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; +}; + +export function DraftAgentScreen() { + 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 [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); + + 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 : machine.attempt.text; + 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(), + }; + 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 }); + + try { + const result = await createAgent({ + config, + initialPrompt: trimmedPrompt, + images, + git: gitOptions, + }); + + const agentId = (result as { id?: string })?.id; + if (agentId && selectedServerId) { + router.replace({ + pathname: "/agent/[...route]", + params: { route: [selectedServerId, agentId] }, + }); + return; + } + + dispatch({ + type: "CREATE_FAILED", + message: "Failed to create agent", + }); + 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 }); + 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, + workingDir, + isAttachWorktree, + isSubmitting, + ] + ); + + 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/app/agent/[id].tsx b/packages/app/src/screens/agent/legacy-agent-id-screen.tsx similarity index 82% rename from packages/app/src/app/agent/[id].tsx rename to packages/app/src/screens/agent/legacy-agent-id-screen.tsx index 0d2484d6d..208ae10f8 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/screens/agent/legacy-agent-id-screen.tsx @@ -1,7 +1,7 @@ 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 { 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"; @@ -14,16 +14,15 @@ type AgentMatch = { agent: Agent; }; -export default function LegacyAgentRedirectScreen() { - const { id } = useLocalSearchParams<{ id: string }>(); +export function LegacyAgentIdScreen({ agentId }: { agentId: string }) { const router = useRouter(); const { theme } = useUnistyles(); const { connectionStates } = useDaemonConnections(); const sessionDirectory = useSessionDirectory(); - const agentId = typeof id === "string" ? id.trim() : undefined; + const resolvedAgentId = typeof agentId === "string" ? agentId.trim() : undefined; const matches = useMemo(() => { - if (!agentId) { + if (!resolvedAgentId) { return []; } @@ -32,7 +31,7 @@ export default function LegacyAgentRedirectScreen() { if (!session) { return; } - const agent = session.agents.get(agentId); + const agent = session.agents.get(resolvedAgentId); if (!agent) { return; } @@ -41,10 +40,10 @@ export default function LegacyAgentRedirectScreen() { }); return results; - }, [agentId, sessionDirectory, connectionStates]); + }, [resolvedAgentId, sessionDirectory, connectionStates]); const hasSessions = sessionDirectory.size > 0; - const isRedirecting = Boolean(agentId && matches.length === 1); + const isRedirecting = Boolean(resolvedAgentId && matches.length === 1); useEffect(() => { if (!isRedirecting) { @@ -52,26 +51,20 @@ export default function LegacyAgentRedirectScreen() { } const match = matches[0]; router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: match.serverId, - agentId: match.agent.id, - }, + pathname: "/agent/[...route]", + params: { route: [match.serverId, match.agent.id] }, }); }, [isRedirecting, matches, router]); - const handleGoHome = useCallback(() => { - router.replace("/"); + const handleGoDraft = useCallback(() => { + router.replace("/agent"); }, [router]); const handleSelectMatch = useCallback( (match: AgentMatch) => { router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { - serverId: match.serverId, - agentId: match.agent.id, - }, + pathname: "/agent/[...route]", + params: { route: [match.serverId, match.agent.id] }, }); }, [router] @@ -79,15 +72,15 @@ export default function LegacyAgentRedirectScreen() { let body: ReactNode = null; - if (!agentId) { + if (!resolvedAgentId) { body = ( Missing agent This link is missing an agent id. Go back to the Agents screen to pick one. - - Go Home + + New Agent ); @@ -105,11 +98,11 @@ export default function LegacyAgentRedirectScreen() { Agent not found - We could not find {agentId} on any host right now. Hosts reconnect automatically—open it + 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. - - Go Home + + New Agent ); @@ -140,7 +133,7 @@ export default function LegacyAgentRedirectScreen() { return ( - + {body} );