refactor agent route for draft→chat transition

This commit is contained in:
Mohamed Boudra
2026-01-24 19:18:07 +07:00
parent 56399bcb42
commit 6ecf88b502
18 changed files with 1425 additions and 1010 deletions
+4 -4
View File
@@ -242,7 +242,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
).toBeTruthy(); ).toBeTruthy();
await page.getByTestId('sidebar-new-agent').click(); await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/agent\/?$/);
await setWorkingDirectory(page, repo.path); await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page); 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 expect(page.getByTestId('changes-menu-archive')).toBeVisible();
await page.getByTestId('changes-menu-archive').click(); await page.getByTestId('changes-menu-archive').click();
// Archiving a worktree deletes agents and redirects to home // 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 setWorkingDirectory(page, repo.path);
await ensureHostSelected(page); await ensureHostSelected(page);
await page.getByTestId('worktree-attach-toggle').click(); 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 attachSheetBackdrop.click({ force: true });
} }
await page.getByTestId('worktree-attach-toggle').click(); 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); await setWorkingDirectory(page, nonGitDir);
const attachPicker = page.getByTestId('worktree-attach-picker'); const attachPicker = page.getByTestId('worktree-attach-picker');
if (await attachPicker.isVisible()) { if (await attachPicker.isVisible()) {
await page.getByTestId('worktree-attach-toggle').click(); 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 createAgentAndWait(page, 'Respond with exactly: NON-GIT');
await waitForAssistantText(page, 'NON-GIT'); await waitForAssistantText(page, 'NON-GIT');
+1 -1
View File
@@ -203,7 +203,7 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
const newAgentButton = page.getByText('New Agent', { exact: true }).first(); const newAgentButton = page.getByText('New Agent', { exact: true }).first();
await expect(newAgentButton).toBeVisible(); await expect(newAgentButton).toBeVisible();
await newAgentButton.click(); await newAgentButton.click();
await expect(page).toHaveURL(/\/$/); await expect(page).toHaveURL(/\/agent\/?$/);
await page.waitForTimeout(10_000); await page.waitForTimeout(10_000);
+34 -17
View File
@@ -135,7 +135,7 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
} }
export const gotoHome = async (page: Page) => { export const gotoHome = async (page: Page) => {
await page.goto('/'); await page.goto('/agent');
await ensureE2EStorageSeeded(page); await ensureE2EStorageSeeded(page);
await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible(); await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).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) => { export const setWorkingDirectory = async (page: Page, directory: string) => {
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first(); const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
await expect(workingDirectoryLabel).toBeVisible(); await expect(workingDirectoryLabel).toBeVisible();
const workingDirectorySelect = page.getByTestId('working-directory-select').first();
const input = page.getByRole('textbox', { name: '/path/to/project' }); const input = page.getByRole('textbox', { name: '/path/to/project' });
const worktreePicker = page.getByTestId('worktree-attach-picker'); const worktreePicker = page.getByTestId('worktree-attach-picker');
@@ -199,30 +200,45 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (!(await input.isVisible())) { if (!(await input.isVisible())) {
await closeBottomSheet(); await closeBottomSheet();
await workingDirectoryLabel.click({ force: true }); await workingDirectorySelect.click({ force: true });
if (!(await input.isVisible())) { if (!(await input.isVisible())) {
await closeBottomSheet(); await closeBottomSheet();
await workingDirectoryLabel.click({ force: true }); await workingDirectorySelect.click({ force: true });
} }
await expect(input).toBeVisible(); await expect(input).toBeVisible();
} }
await input.fill(directory); const trimmedDirectory = directory.replace(/\/+$/, '');
await input.fill(trimmedDirectory);
await input.press('Enter'); await input.press('Enter');
const useOption = page.getByText(`Use "${directory}"`); // Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
await expect(useOption).toBeVisible(); const useOption = page.getByTestId('working-directory-custom-option').first();
await useOption.click({ force: true }); if (await useOption.isVisible().catch(() => false)) {
// Wait for UI to update after clicking "Use" await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
await page.waitForTimeout(500); await useOption.click({ force: true });
const normalizedDirectory = directory.startsWith('/var/') }
? `/private${directory}`
: directory; // Wait for the sheet to close after clicking "Use"
const workingDirectoryContainer = workingDirectoryLabel.locator('..'); await expect(input).not.toBeVisible({ timeout: 10000 });
const directoryCandidates = new Set<string>([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 () => { await expect.poll(async () => {
const text = await workingDirectoryContainer.innerText(); const text = await workingDirectorySelect.innerText().catch(() => '');
return text.includes(directory) || text.includes(normalizedDirectory); if (text.includes(basename)) return true;
}, { timeout: 15000 }).toBe(true); for (const candidate of directoryCandidates) {
if (text.includes(candidate)) return true;
}
return false;
}, { timeout: 30000 }).toBe(true);
}; };
export const ensureHostSelected = async (page: Page) => { export const ensureHostSelected = async (page: Page) => {
@@ -296,7 +312,8 @@ export const createAgent = async (page: Page, message: string) => {
await input.fill(message); await input.fill(message);
await input.press('Enter'); 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(); await expect(page.getByText(message, { exact: true })).toBeVisible();
}; };
@@ -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. // Click sidebar New Agent and assert it does not carry over agent settings via URL.
await page.getByTestId('sidebar-new-agent').click(); 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))); await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
} finally { } finally {
await repoA.cleanup(); await repoA.cleanup();
+31
View File
@@ -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<AgentRouteParams>();
const segments = normalizeSegments(params.route);
if (segments.length === 1) {
return <LegacyAgentIdScreen agentId={segments[0]} />;
}
if (segments.length >= 2) {
const [serverId, agentId] = segments;
return <AgentReadyScreen serverId={serverId} agentId={agentId} />;
}
return <LegacyAgentIdScreen agentId="" />;
}
+6
View File
@@ -0,0 +1,6 @@
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
export default function AgentDraftRoute() {
return <DraftAgentScreen />;
}
+3 -892
View File
@@ -1,894 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
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";
const DRAFT_AGENT_ID = "__new_agent__"; export default function Index() {
const PROVIDER_DEFINITION_MAP = new Map( return <DraftAgentScreen />;
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 default function HomeScreen() {
const { theme } = useUnistyles();
const router = useRouter();
const insets = useSafeAreaInsets();
const { connectionStates } = useDaemonConnections();
const { daemons } = useDaemonRegistry();
const params = useLocalSearchParams<DraftAgentParams>();
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<string>();
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 (
<WelcomeScreen
onHostAdded={(profile) => {
setSelectedServerIdFromUser(profile.id);
}}
/>
);
}
return (
<FileDropZone onFilesDropped={handleFilesDropped}>
<View style={styles.container}>
<View style={styles.agentPanel}>
<MenuHeader
title="New Agent"
rightContent={
<Pressable
style={styles.hostBadge}
onPress={() => openDropdownSheet("host")}
>
<Monitor size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.hostBadgeLabel}>{hostLabel}</Text>
<View
style={[
styles.hostStatusDot,
hostEntry?.status === "online" && styles.hostStatusDotOnline,
]}
/>
{hostStatus ? (
<Text style={styles.hostBadgeStatus}>{hostStatus}</Text>
) : null}
</Pressable>
}
/>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
<View style={styles.configSection}>
<WorkingDirectoryDropdown
workingDir={workingDir}
errorMessage=""
disabled={false}
suggestedPaths={agentWorkingDirSuggestions}
onSelectPath={setWorkingDirFromUser}
/>
{isDirectoryNotExists && (
<View style={styles.warningContainer}>
<Text style={styles.warningText}>
Directory does not exist on the selected host
</Text>
</View>
)}
<AgentConfigRow
providerDefinitions={providerDefinitions}
selectedProvider={selectedProvider}
onSelectProvider={setProviderFromUser}
modeOptions={modeOptions}
selectedMode={selectedMode}
onSelectMode={setModeFromUser}
models={availableModels}
selectedModel={selectedModel}
isModelLoading={isModelLoading}
onSelectModel={setModelFromUser}
/>
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<GitOptionsSection
worktreeMode={worktreeMode}
onWorktreeModeChange={handleWorktreeModeChange}
worktreeSlug={worktreeSlug}
currentBranch={repoInfo?.currentBranch ?? null}
baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange}
branches={repoInfo?.branches ?? []}
status={repoInfoStatus}
repoError={repoInfoError}
gitValidationError={gitBlockingError}
baseBranchError={baseBranchError}
worktreeOptions={worktreeOptions}
selectedWorktreePath={selectedWorktreePath}
worktreeOptionsStatus={worktreeOptionsStatus}
worktreeOptionsError={worktreeOptionsError}
attachWorktreeError={attachWorktreeError}
onSelectWorktreePath={handleSelectWorktreePath}
/>
) : null}
</View>
<DropdownSheet
title="Host"
visible={openDropdown === "host"}
onClose={closeDropdown}
>
{connectionStates.size === 0 ? (
<Text style={styles.dropdownHelper}>
No hosts available yet.
</Text>
) : (
<View style={styles.dropdownSheetList}>
{Array.from(connectionStates.values()).map(({ daemon, status }) => {
const isSelected = daemon.id === selectedServerId;
const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id;
return (
<Pressable
key={daemon.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
setSelectedServerIdFromUser(daemon.id);
closeDropdown();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>
{label}
</Text>
<Text style={styles.dropdownSheetOptionDescription}>
{formatConnectionStatus(status)}
</Text>
</Pressable>
);
})}
</View>
)}
</DropdownSheet>
{errorMessage ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{errorMessage}</Text>
</View>
) : null}
</ScrollView>
</Animated.View>
<View style={styles.inputAreaWrapper}>
<AgentInputArea
agentId={DRAFT_AGENT_ID}
serverId={selectedServerId ?? ""}
onSubmitMessage={handleCreateFromInput}
isSubmitLoading={isLoading}
value={promptText}
onChangeText={setPromptText}
autoFocus
onAddImages={handleAddImagesCallback}
/>
</View>
</View>
{isLoading ? (
<View style={styles.loadingOverlay}>
<View style={styles.loadingContent}>
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.loadingText}>Creating agent...</Text>
</View>
</View>
) : null}
</View>
</FileDropZone>
);
}
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,
},
}));
@@ -127,6 +127,7 @@ interface SelectFieldProps {
warningMessage?: string | null; warningMessage?: string | null;
helperText?: string | null; helperText?: string | null;
controlRef?: React.RefObject<View | null>; controlRef?: React.RefObject<View | null>;
valueEllipsizeMode?: "head" | "middle" | "tail" | "clip";
testID?: string; testID?: string;
} }
@@ -140,6 +141,7 @@ export function SelectField({
warningMessage, warningMessage,
helperText, helperText,
controlRef, controlRef,
valueEllipsizeMode,
testID, testID,
}: SelectFieldProps): ReactElement { }: SelectFieldProps): ReactElement {
return ( return (
@@ -156,6 +158,7 @@ export function SelectField({
<Text <Text
style={value ? styles.selectFieldValue : styles.selectFieldPlaceholder} style={value ? styles.selectFieldValue : styles.selectFieldPlaceholder}
numberOfLines={1} numberOfLines={1}
ellipsizeMode={valueEllipsizeMode ?? "tail"}
> >
{value || placeholder || "Select..."} {value || placeholder || "Select..."}
</Text> </Text>
@@ -1045,6 +1048,13 @@ export function WorkingDirectoryDropdown({
[handleClose, onSelectPath] [handleClose, onSelectPath]
); );
const handleSubmitSearch = useCallback(() => {
if (!showCustomOption) {
return;
}
handleSelect(sanitizedSearchValue);
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
return ( return (
<> <>
<SelectField <SelectField
@@ -1054,7 +1064,9 @@ export function WorkingDirectoryDropdown({
onPress={handleOpen} onPress={handleOpen}
disabled={disabled} disabled={disabled}
errorMessage={errorMessage || undefined} errorMessage={errorMessage || undefined}
valueEllipsizeMode="middle"
controlRef={anchorRef} controlRef={anchorRef}
testID="working-directory-select"
/> />
<AdaptiveSelect <AdaptiveSelect
title="Working Directory" title="Working Directory"
@@ -1072,6 +1084,7 @@ export function WorkingDirectoryDropdown({
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
autoFocus autoFocus
onSubmitEditing={handleSubmitSearch}
/> />
) : ( ) : (
<BottomSheetTextInput <BottomSheetTextInput
@@ -1083,6 +1096,7 @@ export function WorkingDirectoryDropdown({
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
autoFocus autoFocus
onSubmitEditing={handleSubmitSearch}
/> />
)} )}
{!hasSuggestedPaths && !showCustomOption ? ( {!hasSuggestedPaths && !showCustomOption ? (
@@ -1094,6 +1108,7 @@ export function WorkingDirectoryDropdown({
<View style={styles.dropdownSheetList}> <View style={styles.dropdownSheetList}>
<Pressable <Pressable
key="working-dir-custom-option" key="working-dir-custom-option"
testID="working-directory-custom-option"
style={styles.dropdownSheetOption} style={styles.dropdownSheetOption}
onPress={() => handleSelect(sanitizedSearchValue)} onPress={() => handleSelect(sanitizedSearchValue)}
> >
@@ -46,6 +46,8 @@ interface AgentInputAreaProps {
onSubmitMessage?: (payload: MessagePayload) => Promise<void>; onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** Externally controlled loading state. When true, disables the submit button. */ /** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean; isSubmitLoading?: boolean;
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
value?: string; value?: string;
onChangeText?: (text: string) => void; onChangeText?: (text: string) => void;
/** When true, auto-focuses the text input on web. */ /** When true, auto-focuses the text input on web. */
@@ -70,6 +72,7 @@ export function AgentInputArea({
serverId, serverId,
onSubmitMessage, onSubmitMessage,
isSubmitLoading = false, isSubmitLoading = false,
blurOnSubmit = false,
value, value,
onChangeText, onChangeText,
autoFocus = false, autoFocus = false,
@@ -219,10 +222,13 @@ export function AgentInputArea({
forceSend?: boolean forceSend?: boolean
) { ) {
const socketConnected = isConnected; const socketConnected = isConnected;
if (!message.trim() || !socketConnected) return;
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
const trimmedMessage = message.trim(); 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) { if (agent?.status === "running" && !forceSend) {
queueMessage(trimmedMessage, imageAttachments); queueMessage(trimmedMessage, imageAttachments);
@@ -257,6 +263,9 @@ export function AgentInputArea({
} }
function handleSubmit(payload: MessagePayload) { function handleSubmit(payload: MessagePayload) {
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent( void sendMessageWithContent(
payload.text, payload.text,
payload.images, payload.images,
@@ -569,7 +578,7 @@ export function AgentInputArea({
client={client} client={client}
placeholder="Message agent..." placeholder="Message agent..."
autoFocus={autoFocus} autoFocus={autoFocus}
disabled={isRealtimeMode} disabled={isRealtimeMode || isSubmitLoading}
isScreenFocused={isScreenFocused} isScreenFocused={isScreenFocused}
leftContent={leftContent} leftContent={leftContent}
rightContent={rightContent} rightContent={rightContent}
+2 -3
View File
@@ -85,10 +85,9 @@ export function AgentList({
onAgentSelect?.(); onAgentSelect?.();
navigate({ navigate({
pathname: "/agent/[serverId]/[agentId]", pathname: "/agent/[...route]",
params: { params: {
serverId, route: [serverId, agentId],
agentId,
}, },
}); });
}, },
@@ -607,7 +607,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
predicate: (query) => predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList", Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
}); });
router.replace("/"); router.replace("/agent");
}, },
onError: (err) => { onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree"; const message = err instanceof Error ? err.message : "Failed to archive worktree";
+2 -2
View File
@@ -123,7 +123,7 @@ export function HomeFooter() {
{/* Action menu */} {/* Action menu */}
<View style={styles.actionButtonContainer}> <View style={styles.actionButtonContainer}>
<Pressable <Pressable
onPress={() => router.push("/")} onPress={() => router.push("/agents")}
style={({ pressed }) => [ style={({ pressed }) => [
styles.footerButton, styles.footerButton,
pressed && styles.buttonPressed, pressed && styles.buttonPressed,
@@ -142,7 +142,7 @@ export function HomeFooter() {
<Pressable <Pressable
onPress={() => { onPress={() => {
console.log("[HomeFooter] New Agent button pressed"); console.log("[HomeFooter] New Agent button pressed");
router.push("/"); router.push("/agent");
}} }}
style={({ pressed }) => [ style={({ pressed }) => [
styles.footerButton, styles.footerButton,
+46 -11
View File
@@ -70,6 +70,7 @@ export interface MessageInputProps {
export interface MessageInputRef { export interface MessageInputRef {
focus: () => void; focus: () => void;
blur: () => void;
} }
const MIN_INPUT_HEIGHT = 30; const MIN_INPUT_HEIGHT = 30;
@@ -118,15 +119,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
) { ) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const textInputRef = useRef< const textInputRef = useRef<
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
>(null); >(null);
const isInputFocusedRef = useRef(false);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focus: () => { focus: () => {
textInputRef.current?.focus(); textInputRef.current?.focus();
}, },
blur: () => {
textInputRef.current?.blur?.();
},
})); }));
const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
const baselineInputHeightRef = useRef<number | null>(null); const baselineInputHeightRef = useRef<number | null>(null);
@@ -210,6 +215,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
enableDuration: true, enableDuration: true,
}); });
const isDictatingRef = useRef(isDictating);
useEffect(() => {
isDictatingRef.current = isDictating;
}, [isDictating]);
useEffect(() => { useEffect(() => {
if (isDictating || isDictationProcessing) { if (isDictating || isDictationProcessing) {
return; return;
@@ -219,12 +229,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// Cmd+D to start/submit dictation, Escape to cancel // Cmd+D to start/submit dictation, Escape to cancel
useEffect(() => { 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) { 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 // 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(); event.preventDefault();
if (isDictating) { if (dictating) {
sendAfterTranscriptRef.current = true; sendAfterTranscriptRef.current = true;
confirmDictation(); confirmDictation();
} else { } else {
@@ -233,14 +260,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
return; return;
} }
// Escape: cancel dictation // Escape: cancel dictation
if (event.key === "Escape" && isDictating) { if (event.key === "Escape" && dictating) {
event.preventDefault(); event.preventDefault();
cancelDictation(); cancelDictation();
} }
} }
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [isDictating, cancelDictation, confirmDictation, startDictation]); }, [cancelDictation, confirmDictation, isScreenFocused, startDictation]);
// Animate overlay // Animate overlay
useEffect(() => { useEffect(() => {
@@ -400,15 +427,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
const key = event.nativeEvent.key.toLowerCase();
// Cmd+B or Ctrl+B: toggle sidebar // Cmd+B or Ctrl+B: toggle sidebar
if ((metaKey || ctrlKey) && event.nativeEvent.key === "b") { if ((metaKey || ctrlKey) && key === "b") {
event.preventDefault(); event.preventDefault();
toggleAgentList(); toggleAgentList();
return; return;
} }
// Cmd+D or Ctrl+D: start dictation or submit if already dictating // 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(); event.preventDefault();
if (isDictating) { if (isDictating) {
sendAfterTranscriptRef.current = true; sendAfterTranscriptRef.current = true;
@@ -502,6 +531,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onChangeText={onChangeText} onChangeText={onChangeText}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.mutedForeground} placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => {
isInputFocusedRef.current = true;
}}
onBlur={() => {
isInputFocusedRef.current = false;
}}
style={[ style={[
styles.textInput, styles.textInput,
IS_WEB IS_WEB
@@ -82,7 +82,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
}, [closeToAgent]); }, [closeToAgent]);
const handleCreateAgentClean = useCallback(() => { const handleCreateAgentClean = useCallback(() => {
router.push("/"); router.push("/agent");
}, []); }, []);
// Mobile: close sidebar and navigate // Mobile: close sidebar and navigate
@@ -65,6 +65,14 @@ const int16ToBase64 = (pcm: Int16Array): string => {
return btoa(binary); return btoa(binary);
}; };
type RecorderRefs = {
recorder: MediaRecorder | null;
audioChunks: Blob[];
stoppedPromise: Promise<Blob> | null;
stoppedResolve: ((blob: Blob) => void) | null;
stoppedReject: ((error: unknown) => void) | null;
};
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource { export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
const [volume, setVolume] = useState(0); const [volume, setVolume] = useState(0);
@@ -84,7 +92,49 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
gain: GainNode | null; gain: GainNode | null;
pending: Int16Array; pending: Int16Array;
started: boolean; 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<AudioBuffer> => {
const maybePromise = context.decodeAudioData(buffer);
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
return maybePromise as Promise<AudioBuffer>;
}
return await new Promise<AudioBuffer>((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 start = useCallback(async () => {
const missingNavigator = const missingNavigator =
@@ -111,7 +161,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
throw new Error("AudioContext unavailable"); throw new Error("AudioContext unavailable");
} }
const stream = await navigator.mediaDevices.getUserMedia({ const rawStream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
channelCount: 1, channelCount: 1,
noiseSuppression: true, noiseSuppression: true,
@@ -120,54 +170,131 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
}, },
}); });
const stream = rawStream as MediaStream;
const context = new AudioContextCtor(); 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; try {
const chunkSamples = outputRate; // ~1s 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) => { refs.current.started = true;
if (!refs.current.started) { refs.current.mode = "pcm";
return;
}
const input = event.inputBuffer.getChannelData(0);
let sumSquares = 0; processor.onaudioprocess = (event) => {
for (let i = 0; i < input.length; i++) { if (!refs.current.started) {
const sample = input[i]; return;
sumSquares += sample * sample; }
} const input = event.inputBuffer.getChannelData(0);
const rms = Math.sqrt(sumSquares / Math.max(1, input.length));
const normalized = Math.min(1, Math.max(0, rms * 2));
setVolume(normalized);
const next = resampleToPcm16(input, context.sampleRate, outputRate); let sumSquares = 0;
refs.current.pending = concatInt16(refs.current.pending, next); 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 next = resampleToPcm16(input, context.sampleRate, outputRate);
const chunk = refs.current.pending.slice(0, chunkSamples); refs.current.pending = concatInt16(refs.current.pending, next);
refs.current.pending = refs.current.pending.slice(chunkSamples);
onPcmSegmentRef.current(int16ToBase64(chunk)); 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); recorder.ondataavailable = (event: any) => {
processor.connect(gain); const data: Blob | undefined = event?.data;
gain.connect(context.destination); 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<Blob>((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 () => { const stop = useCallback(async () => {
refs.current.started = false; refs.current.started = false;
setVolume(0); setVolume(0);
const { processor, source, gain, context, stream, pending } = refs.current; const { processor, source, gain, context, stream, pending, mode, recorder } =
refs.current;
if (processor) { if (processor) {
try { 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) { if (context) {
try { try {
await context.close(); await context.close();
@@ -216,8 +369,24 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
onPcmSegmentRef.current(int16ToBase64(pending)); 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(() => { useEffect(() => {
return () => { return () => {
@@ -244,4 +413,3 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
[start, stop, volume] [start, stop, volume]
); );
} }
@@ -8,7 +8,7 @@ import {
Platform, Platform,
BackHandler, BackHandler,
} from "react-native"; } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
@@ -69,14 +69,16 @@ const EMPTY_STREAM_ITEMS: StreamItem[] = [];
type BranchStatus = "idle" | "loading" | "ready" | "error"; type BranchStatus = "idle" | "loading" | "ready" | "error";
export default function AgentScreen() { export function AgentReadyScreen({
serverId,
agentId,
}: {
serverId: string;
agentId: string;
}) {
const router = useRouter(); const router = useRouter();
const { serverId, agentId } = useLocalSearchParams<{ const resolvedAgentId = agentId?.trim() || undefined;
serverId: string; const resolvedServerId = serverId?.trim() || undefined;
agentId: string;
}>();
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
// Check if session exists for this serverId // Check if session exists for this serverId
@@ -116,7 +118,7 @@ export default function AgentScreen() {
targetMs: 300, targetMs: 300,
}); });
} }
router.replace("/"); router.replace("/agent");
}, [resolvedAgentId, resolvedServerId, router]); }, [resolvedAgentId, resolvedServerId, router]);
const focusServerId = resolvedServerId; const focusServerId = resolvedServerId;
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native"; 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { useSessionDirectory } from "@/hooks/use-session-directory"; import { useSessionDirectory } from "@/hooks/use-session-directory";
@@ -14,16 +14,15 @@ type AgentMatch = {
agent: Agent; agent: Agent;
}; };
export default function LegacyAgentRedirectScreen() { export function LegacyAgentIdScreen({ agentId }: { agentId: string }) {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter(); const router = useRouter();
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { connectionStates } = useDaemonConnections(); const { connectionStates } = useDaemonConnections();
const sessionDirectory = useSessionDirectory(); const sessionDirectory = useSessionDirectory();
const agentId = typeof id === "string" ? id.trim() : undefined; const resolvedAgentId = typeof agentId === "string" ? agentId.trim() : undefined;
const matches = useMemo<AgentMatch[]>(() => { const matches = useMemo<AgentMatch[]>(() => {
if (!agentId) { if (!resolvedAgentId) {
return []; return [];
} }
@@ -32,7 +31,7 @@ export default function LegacyAgentRedirectScreen() {
if (!session) { if (!session) {
return; return;
} }
const agent = session.agents.get(agentId); const agent = session.agents.get(resolvedAgentId);
if (!agent) { if (!agent) {
return; return;
} }
@@ -41,10 +40,10 @@ export default function LegacyAgentRedirectScreen() {
}); });
return results; return results;
}, [agentId, sessionDirectory, connectionStates]); }, [resolvedAgentId, sessionDirectory, connectionStates]);
const hasSessions = sessionDirectory.size > 0; const hasSessions = sessionDirectory.size > 0;
const isRedirecting = Boolean(agentId && matches.length === 1); const isRedirecting = Boolean(resolvedAgentId && matches.length === 1);
useEffect(() => { useEffect(() => {
if (!isRedirecting) { if (!isRedirecting) {
@@ -52,26 +51,20 @@ export default function LegacyAgentRedirectScreen() {
} }
const match = matches[0]; const match = matches[0];
router.replace({ router.replace({
pathname: "/agent/[serverId]/[agentId]", pathname: "/agent/[...route]",
params: { params: { route: [match.serverId, match.agent.id] },
serverId: match.serverId,
agentId: match.agent.id,
},
}); });
}, [isRedirecting, matches, router]); }, [isRedirecting, matches, router]);
const handleGoHome = useCallback(() => { const handleGoDraft = useCallback(() => {
router.replace("/"); router.replace("/agent");
}, [router]); }, [router]);
const handleSelectMatch = useCallback( const handleSelectMatch = useCallback(
(match: AgentMatch) => { (match: AgentMatch) => {
router.replace({ router.replace({
pathname: "/agent/[serverId]/[agentId]", pathname: "/agent/[...route]",
params: { params: { route: [match.serverId, match.agent.id] },
serverId: match.serverId,
agentId: match.agent.id,
},
}); });
}, },
[router] [router]
@@ -79,15 +72,15 @@ export default function LegacyAgentRedirectScreen() {
let body: ReactNode = null; let body: ReactNode = null;
if (!agentId) { if (!resolvedAgentId) {
body = ( body = (
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.title}>Missing agent</Text> <Text style={styles.title}>Missing agent</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
This link is missing an agent id. Go back to the Agents screen to pick one. This link is missing an agent id. Go back to the Agents screen to pick one.
</Text> </Text>
<Pressable style={styles.primaryButton} onPress={handleGoHome}> <Pressable style={styles.primaryButton} onPress={handleGoDraft}>
<Text style={styles.primaryButtonText}>Go Home</Text> <Text style={styles.primaryButtonText}>New Agent</Text>
</Pressable> </Pressable>
</View> </View>
); );
@@ -105,11 +98,11 @@ export default function LegacyAgentRedirectScreen() {
<View style={styles.centerState}> <View style={styles.centerState}>
<Text style={styles.title}>Agent not found</Text> <Text style={styles.title}>Agent not found</Text>
<Text style={styles.subtitle}> <Text style={styles.subtitle}>
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. again from the Agents screen after it comes back online.
</Text> </Text>
<Pressable style={styles.primaryButton} onPress={handleGoHome}> <Pressable style={styles.primaryButton} onPress={handleGoDraft}>
<Text style={styles.primaryButtonText}>Go Home</Text> <Text style={styles.primaryButtonText}>New Agent</Text>
</Pressable> </Pressable>
</View> </View>
); );
@@ -140,7 +133,7 @@ export default function LegacyAgentRedirectScreen() {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<BackHeader title="Agent" onBack={handleGoHome} /> <BackHeader title="Agent" onBack={handleGoDraft} />
<View style={styles.content}>{body}</View> <View style={styles.content}>{body}</View>
</View> </View>
); );