refactor agent route for draft→chat transition
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
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<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 () => {
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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="" />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function AgentDraftRoute() {
|
||||
return <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
@@ -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 <DraftAgentScreen />;
|
||||
}
|
||||
|
||||
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;
|
||||
helperText?: string | null;
|
||||
controlRef?: React.RefObject<View | null>;
|
||||
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({
|
||||
<Text
|
||||
style={value ? styles.selectFieldValue : styles.selectFieldPlaceholder}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode={valueEllipsizeMode ?? "tail"}
|
||||
>
|
||||
{value || placeholder || "Select..."}
|
||||
</Text>
|
||||
@@ -1045,6 +1048,13 @@ export function WorkingDirectoryDropdown({
|
||||
[handleClose, onSelectPath]
|
||||
);
|
||||
|
||||
const handleSubmitSearch = useCallback(() => {
|
||||
if (!showCustomOption) {
|
||||
return;
|
||||
}
|
||||
handleSelect(sanitizedSearchValue);
|
||||
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectField
|
||||
@@ -1054,7 +1064,9 @@ export function WorkingDirectoryDropdown({
|
||||
onPress={handleOpen}
|
||||
disabled={disabled}
|
||||
errorMessage={errorMessage || undefined}
|
||||
valueEllipsizeMode="middle"
|
||||
controlRef={anchorRef}
|
||||
testID="working-directory-select"
|
||||
/>
|
||||
<AdaptiveSelect
|
||||
title="Working Directory"
|
||||
@@ -1072,6 +1084,7 @@ export function WorkingDirectoryDropdown({
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
/>
|
||||
) : (
|
||||
<BottomSheetTextInput
|
||||
@@ -1083,6 +1096,7 @@ export function WorkingDirectoryDropdown({
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
onSubmitEditing={handleSubmitSearch}
|
||||
/>
|
||||
)}
|
||||
{!hasSuggestedPaths && !showCustomOption ? (
|
||||
@@ -1094,6 +1108,7 @@ export function WorkingDirectoryDropdown({
|
||||
<View style={styles.dropdownSheetList}>
|
||||
<Pressable
|
||||
key="working-dir-custom-option"
|
||||
testID="working-directory-custom-option"
|
||||
style={styles.dropdownSheetOption}
|
||||
onPress={() => handleSelect(sanitizedSearchValue)}
|
||||
>
|
||||
|
||||
@@ -46,6 +46,8 @@ interface AgentInputAreaProps {
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
/** 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}
|
||||
|
||||
@@ -85,10 +85,9 @@ export function AgentList({
|
||||
onAgentSelect?.();
|
||||
|
||||
navigate({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
pathname: "/agent/[...route]",
|
||||
params: {
|
||||
serverId,
|
||||
agentId,
|
||||
route: [serverId, agentId],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -123,7 +123,7 @@ export function HomeFooter() {
|
||||
{/* Action menu */}
|
||||
<View style={styles.actionButtonContainer}>
|
||||
<Pressable
|
||||
onPress={() => router.push("/")}
|
||||
onPress={() => router.push("/agents")}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButton,
|
||||
pressed && styles.buttonPressed,
|
||||
@@ -142,7 +142,7 @@ export function HomeFooter() {
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
console.log("[HomeFooter] New Agent button pressed");
|
||||
router.push("/");
|
||||
router.push("/agent");
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.footerButton,
|
||||
|
||||
@@ -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<MessageInputRef, MessageInputProps>(
|
||||
) {
|
||||
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<number | null>(null);
|
||||
@@ -210,6 +215,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
const isDictatingRef = useRef(isDictating);
|
||||
useEffect(() => {
|
||||
isDictatingRef.current = isDictating;
|
||||
}, [isDictating]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDictating || isDictationProcessing) {
|
||||
return;
|
||||
@@ -219,12 +229,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
// 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<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.mutedForeground}
|
||||
onFocus={() => {
|
||||
isInputFocusedRef.current = true;
|
||||
}}
|
||||
onBlur={() => {
|
||||
isInputFocusedRef.current = false;
|
||||
}}
|
||||
style={[
|
||||
styles.textInput,
|
||||
IS_WEB
|
||||
|
||||
@@ -82,7 +82,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}, [closeToAgent]);
|
||||
|
||||
const handleCreateAgentClean = useCallback(() => {
|
||||
router.push("/");
|
||||
router.push("/agent");
|
||||
}, []);
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
|
||||
@@ -65,6 +65,14 @@ const int16ToBase64 = (pcm: Int16Array): string => {
|
||||
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 {
|
||||
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<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 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<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 () => {
|
||||
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]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-9
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
+20
-27
@@ -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<AgentMatch[]>(() => {
|
||||
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 = (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.title}>Missing agent</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
This link is missing an agent id. Go back to the Agents screen to pick one.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
|
||||
<Text style={styles.primaryButtonText}>Go Home</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoDraft}>
|
||||
<Text style={styles.primaryButtonText}>New Agent</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -105,11 +98,11 @@ export default function LegacyAgentRedirectScreen() {
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.title}>Agent not found</Text>
|
||||
<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.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoHome}>
|
||||
<Text style={styles.primaryButtonText}>Go Home</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={handleGoDraft}>
|
||||
<Text style={styles.primaryButtonText}>New Agent</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -140,7 +133,7 @@ export default function LegacyAgentRedirectScreen() {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Agent" onBack={handleGoHome} />
|
||||
<BackHeader title="Agent" onBack={handleGoDraft} />
|
||||
<View style={styles.content}>{body}</View>
|
||||
</View>
|
||||
);
|
||||
Reference in New Issue
Block a user