Merge branch 'fix/e2e-home-routing'

This commit is contained in:
Mohamed Boudra
2026-01-24 21:25:22 +07:00
21 changed files with 2591 additions and 2037 deletions
+4 -4
View File
@@ -240,7 +240,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
}
await page.getByTestId('sidebar-new-agent').click();
await expect(page).toHaveURL(/\/$/);
await expect(page).toHaveURL(/\/agent\/?$/);
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
@@ -337,7 +337,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
await page.getByTestId('changes-menu-archive').click();
// Archiving a worktree deletes agents and redirects to home
await expect(page).toHaveURL(/\/$/, { timeout: 30000 });
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await page.getByTestId('worktree-attach-toggle').click();
@@ -348,13 +348,13 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
await attachSheetBackdrop.click({ force: true });
}
await page.getByTestId('worktree-attach-toggle').click();
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
await setWorkingDirectory(page, nonGitDir);
const attachPicker = page.getByTestId('worktree-attach-picker');
if (await attachPicker.isVisible()) {
await page.getByTestId('worktree-attach-toggle').click();
await expect(attachPicker).toHaveCount(0);
await expect(attachPicker).toBeHidden({ timeout: 30000 });
}
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
await waitForAssistantText(page, 'NON-GIT');
+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();
await expect(newAgentButton).toBeVisible();
await newAgentButton.click();
await expect(page).toHaveURL(/\/$/);
await expect(page).toHaveURL(/\/agent\/?$/);
await page.waitForTimeout(10_000);
+38 -17
View File
@@ -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,8 +312,13 @@ export const createAgent = async (page: Page, message: string) => {
await input.fill(message);
await input.press('Enter');
await page.waitForURL(/\/agent\//);
await expect(page.getByText(message, { exact: true })).toBeVisible();
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
const userBubble = page
.getByRole('button', { name: 'Copy message' })
.filter({ hasText: message })
.first();
await expect(userBubble).toBeVisible();
};
export interface AgentConfig {
@@ -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();
+5 -1
View File
@@ -347,8 +347,12 @@ export default function RootLayout() {
>
<Stack.Screen name="index" />
<Stack.Screen name="agents" />
<Stack.Screen name="agent" />
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen
name="agent/[serverId]/[agentId]"
options={{ gestureEnabled: false }}
/>
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
<Stack.Screen name="pair-scan" />
+5 -206
View File
@@ -1,209 +1,8 @@
import { useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { useSessionDirectory } from "@/hooks/use-session-directory";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { Agent } from "@/contexts/session-context";
import { useLocalSearchParams } from "expo-router";
import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen";
type AgentMatch = {
serverId: string;
serverLabel: string;
agent: Agent;
};
export default function LegacyAgentRedirectScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const { theme } = useUnistyles();
const { connectionStates } = useDaemonConnections();
const sessionDirectory = useSessionDirectory();
const agentId = typeof id === "string" ? id.trim() : undefined;
const matches = useMemo<AgentMatch[]>(() => {
if (!agentId) {
return [];
}
const results: AgentMatch[] = [];
sessionDirectory.forEach((session, serverId) => {
if (!session) {
return;
}
const agent = session.agents.get(agentId);
if (!agent) {
return;
}
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
results.push({ serverId, serverLabel, agent });
});
return results;
}, [agentId, sessionDirectory, connectionStates]);
const hasSessions = sessionDirectory.size > 0;
const isRedirecting = Boolean(agentId && matches.length === 1);
useEffect(() => {
if (!isRedirecting) {
return;
}
const match = matches[0];
router.replace({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId: match.serverId,
agentId: match.agent.id,
},
});
}, [isRedirecting, matches, router]);
const handleGoHome = useCallback(() => {
router.replace("/");
}, [router]);
const handleSelectMatch = useCallback(
(match: AgentMatch) => {
router.replace({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId: match.serverId,
agentId: match.agent.id,
},
});
},
[router]
);
let body: ReactNode = null;
if (!agentId) {
body = (
<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>
</View>
);
} else if (!hasSessions || isRedirecting) {
body = (
<View style={styles.centerState}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.subtitle}>
{isRedirecting ? "Opening your agent..." : "Looking for your agent..."}
</Text>
</View>
);
} else if (matches.length === 0) {
body = (
<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
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>
</View>
);
} else if (matches.length > 1) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Pick a host</Text>
<Text style={styles.subtitle}>
Multiple hosts have an agent with this id. Choose the one you intended to open.
</Text>
<View style={styles.matchList}>
{matches.map((match) => (
<Pressable
key={`${match.serverId}:${match.agent.id}`}
style={styles.matchButton}
onPress={() => handleSelectMatch(match)}
>
<Text style={styles.matchLabel}>{match.serverLabel}</Text>
<Text style={styles.matchMeta} numberOfLines={1}>
{match.agent.cwd}
</Text>
</Pressable>
))}
</View>
</View>
);
}
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={handleGoHome} />
<View style={styles.content}>{body}</View>
</View>
);
export default function LegacyAgentRoute() {
const { id } = useLocalSearchParams<{ id?: string }>();
return <LegacyAgentIdScreen agentId={typeof id === "string" ? id : ""} />;
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[8],
},
centerState: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[4],
},
title: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
subtitle: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
primaryButton: {
marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.primary,
},
primaryButtonText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
matchList: {
width: "100%",
gap: theme.spacing[3],
},
matchButton: {
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
matchLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
matchMeta: {
marginTop: theme.spacing[1],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}));
@@ -1,860 +1,17 @@
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
ScrollView,
Platform,
BackHandler,
} from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQuery } from "@tanstack/react-query";
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
MoreVertical,
GitBranch,
Folder,
RotateCcw,
PanelRight,
} from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
ExplorerSidebarAnimationProvider,
useExplorerSidebarAnimation,
} from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore } from "@/stores/panel-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import {
buildAgentNavigationKey,
endNavigationTiming,
HOME_NAVIGATION_KEY,
startNavigationTiming,
} from "@/utils/navigation-timing";
import { extractAgentModel } from "@/utils/extract-agent-model";
import { startPerfMonitor } from "@/utils/perf-monitor";
import { shortenPath } from "@/utils/shorten-path";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useLocalSearchParams } from "expo-router";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
type BranchStatus = "idle" | "loading" | "ready" | "error";
export default function AgentScreen() {
const router = useRouter();
export default function AgentReadyRoute() {
const { serverId, agentId } = useLocalSearchParams<{
serverId: string;
agentId: string;
serverId?: string;
agentId?: string;
}>();
const resolvedAgentId = typeof agentId === "string" ? agentId : undefined;
const resolvedServerId = typeof serverId === "string" ? serverId : undefined;
const { connectionStates } = useDaemonConnections();
// Check if session exists for this serverId
const hasSession = useSessionStore((state) =>
resolvedServerId ? !!state.sessions[resolvedServerId] : false
);
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId
? connectionStates.get(connectionServerId)
: null;
const serverLabel =
connection?.daemon.label ?? connectionServerId ?? "Selected host";
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus =
connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastConnectionError = connection?.lastError ?? null;
const handleBackToHome = useCallback(() => {
const targetServerId = resolvedServerId;
const targetAgentId = resolvedAgentId ?? null;
if (targetServerId && targetAgentId) {
startNavigationTiming(HOME_NAVIGATION_KEY, {
from: "agent",
to: "home",
targetMs: 300,
params: {
serverId: targetServerId,
agentId: targetAgentId,
},
});
} else {
startNavigationTiming(HOME_NAVIGATION_KEY, {
from: "agent",
to: "home",
targetMs: 300,
});
}
router.replace("/");
}, [resolvedAgentId, resolvedServerId, router]);
const focusServerId = resolvedServerId;
const navigationStatus = hasSession ? "ready" : "session_unavailable";
useFocusEffect(
useCallback(() => {
if (!resolvedAgentId || !focusServerId) {
return;
}
const navigationKey = buildAgentNavigationKey(
focusServerId,
resolvedAgentId
);
endNavigationTiming(navigationKey, {
screen: "agent",
status: navigationStatus,
});
}, [focusServerId, navigationStatus, resolvedAgentId])
);
if (!hasSession || !resolvedServerId) {
return (
<AgentSessionUnavailableState
onBack={handleBackToHome}
serverLabel={serverLabel}
connectionStatus={connectionStatus}
connectionStatusLabel={connectionStatusLabel}
lastError={lastConnectionError}
isUnknownDaemon={isUnknownDaemon}
/>
);
}
return (
<ExplorerSidebarAnimationProvider>
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
/>
</ExplorerSidebarAnimationProvider>
<AgentReadyScreen
serverId={typeof serverId === "string" ? serverId : ""}
agentId={typeof agentId === "string" ? agentId : ""}
/>
);
}
type AgentScreenContentProps = {
serverId: string;
agentId?: string;
};
function AgentScreenContent({
serverId,
agentId,
}: AgentScreenContentProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
// Derive isExplorerOpen from the unified panel state
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const {
translateX: explorerTranslateX,
backdropOpacity: explorerBackdropOpacity,
windowWidth: explorerWindowWidth,
animateToOpen: animateExplorerToOpen,
animateToClose: animateExplorerToClose,
isGesturing: isExplorerGesturing,
} = useExplorerSidebarAnimation();
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const scope = `agent:${serverId}:${agentId ?? "unknown"}`;
const stop = startPerfMonitor(scope);
return stop;
}, [serverId, agentId]);
// Swipe-left gesture to open explorer sidebar on mobile
const explorerOpenGesture = useMemo(
() =>
Gesture.Pan()
.enabled(isMobile && !isExplorerOpen)
// Only activate after 15px horizontal movement to the left (negative)
.activeOffsetX(-15)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onStart(() => {
isExplorerGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0
// Swiping left means negative translationX
const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX);
explorerTranslateX.value = newTranslateX;
explorerBackdropOpacity.value = interpolate(
newTranslateX,
[explorerWindowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isExplorerGesturing.value = false;
// Open if dragged more than 1/3 of window or fast swipe left
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
if (shouldOpen) {
animateExplorerToOpen();
runOnJS(openFileExplorer)();
} else {
animateExplorerToClose();
}
})
.onFinalize(() => {
isExplorerGesturing.value = false;
}),
[
isMobile,
isExplorerOpen,
explorerWindowWidth,
explorerTranslateX,
explorerBackdropOpacity,
animateExplorerToOpen,
animateExplorerToClose,
openFileExplorer,
isExplorerGesturing,
]
);
// Handle hardware back button - close explorer sidebar first, then navigate back
useEffect(() => {
if (Platform.OS === "web") return;
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
if (isExplorerOpen) {
closeToAgent();
return true; // Prevent default back navigation
}
return false; // Let default back navigation happen
});
return () => handler.remove();
}, [isExplorerOpen, closeToAgent]);
const resolvedAgentId = agentId;
// Select only the specific agent
const agent = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
: undefined
);
// Select only the specific stream tail - use stable empty array to avoid infinite loop
const streamItemsRaw = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId)
: undefined
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
// Select only the specific initializing state
const isInitializingFromMap = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ??
false
: false
);
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
const allPendingPermissions = useSessionStore(
(state) => state.sessions[serverId]?.pendingPermissions
);
const pendingPermissions = useMemo(() => {
if (!allPendingPermissions || !resolvedAgentId) return new Map();
const filtered = new Map();
for (const [key, perm] of allPendingPermissions) {
if (perm.agentId === resolvedAgentId) {
filtered.set(key, perm);
}
}
return filtered;
}, [allPendingPermissions, resolvedAgentId]);
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
// Get methods
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const refreshAgent = methods?.refreshAgent;
const setFocusedAgentId = useCallback(
(agentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
},
[serverId]
);
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [insets.bottom, bottomInset]);
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
transform: [{ translateY: -shift }],
};
});
const agentModel = extractAgentModel(agent);
const modelDisplayValue = agentModel ?? "Unknown";
const repoInfoQuery = useQuery({
queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""],
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getGitRepoInfo({
cwd: agent?.cwd ?? ".",
});
if (payload.error) {
throw new Error(payload.error);
}
return {
cwd: payload.cwd,
currentBranch: payload.currentBranch ?? null,
};
},
enabled: Boolean(client && isConnected && agent?.cwd),
retry: false,
});
const { refetch: refetchRepoInfo } = repoInfoQuery;
const branchStatus: BranchStatus = !agent?.cwd
? "idle"
: repoInfoQuery.isPending || repoInfoQuery.isFetching
? "loading"
: repoInfoQuery.isError
? "error"
: repoInfoQuery.isSuccess
? "ready"
: "idle";
const branchLabel = repoInfoQuery.data?.currentBranch ?? null;
const branchError = repoInfoQuery.error instanceof Error
? repoInfoQuery.error.message
: null;
const branchDisplayValue =
branchStatus === "error"
? branchError ?? "Unavailable"
: branchLabel ?? "Unknown";
useEffect(() => {
if (!resolvedAgentId) {
setFocusedAgentId(null);
return;
}
setFocusedAgentId(resolvedAgentId);
return () => {
setFocusedAgentId(null);
};
}, [resolvedAgentId, setFocusedAgentId]);
const isInitializing = resolvedAgentId
? isInitializingFromMap !== false
: false;
// Get ensureAgentIsInitialized from methods
const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized;
useEffect(() => {
if (!resolvedAgentId || !ensureAgentIsInitialized) {
return;
}
// Skip if not connected - will re-run when connection is established
if (!isConnected) {
return;
}
// ensureAgentIsInitialized handles deduplication via module-level promises map
// If already initialized or in-flight, returns resolved/pending promise immediately
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed", {
agentId: resolvedAgentId,
error,
});
});
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const title = agent?.title || "Agent";
document.title = title;
}, [agent?.title]);
// Track previous agent status to detect completion while viewing
const previousStatusRef = useRef<string | null>(null);
// Clear attention when agent finishes while user is viewing this screen
useEffect(() => {
if (!resolvedAgentId || !agent || !client) {
return;
}
const previousStatus = previousStatusRef.current;
const currentStatus = agent.status;
previousStatusRef.current = currentStatus;
// If agent transitioned from running to idle while we're viewing,
// immediately clear attention since user witnessed the completion
if (previousStatus === "running" && currentStatus === "idle") {
client.clearAgentAttention(resolvedAgentId);
}
}, [resolvedAgentId, agent?.status, client]);
const handleViewChanges = useCallback(() => {
setExplorerTab("changes");
openFileExplorer();
}, [setExplorerTab, openFileExplorer]);
const handleBrowseFiles = useCallback(() => {
setExplorerTab("files");
openFileExplorer();
}, [setExplorerTab, openFileExplorer]);
const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId || !refreshAgent) {
return;
}
refreshAgent({ agentId: resolvedAgentId });
}, [resolvedAgentId, refreshAgent]);
if (!agent) {
return (
<View style={styles.container} testID="agent-not-found">
<MenuHeader title="Agent" />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
const mainContent = (
<View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing}>
<View style={styles.container}>
{/* Header */}
<MenuHeader
title={agent.title || "Agent"}
rightContent={
<View style={styles.headerRightContent}>
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
{isMobile ? (
<Folder
size={16}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<PanelRight
size={16}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {
refetchRepoInfo().catch(() => {});
}
}}
>
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Directory</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={2}
ellipsizeMode="middle"
>
{shortenPath(agent.cwd)}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Model</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{modelDisplayValue}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Branch</Text>
<View style={styles.menuMetaValueRow}>
{branchStatus === "loading" ? (
<>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={styles.menuMetaPendingText}>Fetching…</Text>
</>
) : (
<Text
style={[
styles.menuMetaValue,
branchStatus === "error" ? styles.menuMetaValueError : null,
]}
numberOfLines={1}
ellipsizeMode="middle"
>
{branchDisplayValue}
</Text>
)}
</View>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<GitBranch size={16} color={theme.colors.foreground} />}
onSelect={handleViewChanges}
>
View Changes
</DropdownMenuItem>
<DropdownMenuItem
leading={<Folder size={16} color={theme.colors.foreground} />}
onSelect={handleBrowseFiles}
>
Browse Files
</DropdownMenuItem>
<DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
disabled={isInitializing}
trailing={
isInitializing ? (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
) : null
}
onSelect={handleRefreshAgent}
>
{isInitializing ? "Refreshing..." : "Refresh"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
}
/>
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
{isInitializing ? (
<View style={styles.loadingContainer}>
<ActivityIndicator
size="large"
color={theme.colors.primary}
/>
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<AgentStreamView
agentId={agent.id}
serverId={serverId}
agent={agent}
streamItems={streamItems}
pendingPermissions={pendingPermissions}
/>
)}
</ReanimatedAnimated.View>
</View>
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)}
</View>
</FileDropZone>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
)}
</View>
);
return (
<>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
{mainContent}
</GestureDetector>
) : (
mainContent
)}
{/* Mobile Explorer Sidebar Overlay */}
{isMobile && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
)}
</>
);
}
function AgentSessionUnavailableState({
onBack,
serverLabel,
connectionStatus,
connectionStatusLabel,
lastError,
isUnknownDaemon = false,
}: {
onBack: () => void;
serverLabel: string;
connectionStatus: ConnectionStatus;
connectionStatusLabel: string;
lastError: string | null;
isUnknownDaemon?: boolean;
}) {
if (isUnknownDaemon) {
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={onBack} />
<View style={styles.centerState}>
<Text style={styles.errorText}>
Cannot open this agent because {serverLabel} is not configured on
this device.
</Text>
<Text style={styles.statusText}>
Add the host in Settings or open an agent on a configured server to
continue.
</Text>
</View>
</View>
);
}
const isConnecting = connectionStatus === "connecting";
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={onBack} />
<View style={styles.centerState}>
{isConnecting ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>
Connecting to {serverLabel}...
</Text>
<Text style={styles.statusText}>
We will show this agent once the host is online.
</Text>
</>
) : (
<>
<Text style={styles.offlineTitle}>
{serverLabel} is currently {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.offlineDescription}>
We'll reconnect automatically and show this agent as soon as the
host comes back online.
</Text>
{lastError ? (
<Text style={styles.offlineDetails}>{lastError}</Text>
) : null}
</>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
outerContainer: {
flex: 1,
flexDirection: "row",
backgroundColor: theme.colors.surface0,
},
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
headerRightContent: {
flexDirection: "row",
alignItems: "center",
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
content: {
flex: 1,
},
loadingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
gap: theme.spacing[3],
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
errorText: {
fontSize: theme.fontSize.lg,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
statusText: {
marginTop: theme.spacing[2],
textAlign: "center",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
errorDetails: {
marginTop: theme.spacing[1],
textAlign: "center",
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
offlineTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
offlineDescription: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
offlineDetails: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
menuButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
menuMetaContainer: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
gap: theme.spacing[2],
},
menuMetaRow: {
gap: theme.spacing[1],
},
menuMetaLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
letterSpacing: 0.5,
textTransform: "uppercase",
},
menuMetaValue: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
menuMetaValueRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
menuMetaPendingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
menuMetaValueError: {
color: theme.colors.destructive,
},
menuItemSpinner: {
marginLeft: "auto",
},
}));
+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 { 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}
+1 -7
View File
@@ -84,13 +84,7 @@ export function AgentList({
onAgentSelect?.();
navigate({
pathname: "/agent/[serverId]/[agentId]",
params: {
serverId,
agentId,
},
});
navigate(`/agent/${serverId}/${agentId}` as any);
},
[isActionSheetVisible, pathname, onAgentSelect]
);
@@ -657,7 +657,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
router.replace("/");
router.replace("/agent" as any);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
+2 -2
View File
@@ -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" as any);
}}
style={({ pressed }) => [
styles.footerButton,
+46 -11
View File
@@ -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" as any);
}, []);
// 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]
);
}
@@ -0,0 +1,958 @@
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
ScrollView,
Platform,
BackHandler,
} from "react-native";
import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQuery } from "@tanstack/react-query";
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
runOnJS,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
MoreVertical,
GitBranch,
Folder,
RotateCcw,
PanelRight,
} from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
ExplorerSidebarAnimationProvider,
useExplorerSidebarAnimation,
} from "@/contexts/explorer-sidebar-animation-context";
import { usePanelStore } from "@/stores/panel-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/contexts/session-context";
import type { StreamItem } from "@/types/stream";
import {
buildAgentNavigationKey,
endNavigationTiming,
HOME_NAVIGATION_KEY,
startNavigationTiming,
} from "@/utils/navigation-timing";
import { extractAgentModel } from "@/utils/extract-agent-model";
import { startPerfMonitor } from "@/utils/perf-monitor";
import { shortenPath } from "@/utils/shorten-path";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const DROPDOWN_WIDTH = 220;
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
type BranchStatus = "idle" | "loading" | "ready" | "error";
export function AgentReadyScreen({
serverId,
agentId,
}: {
serverId: string;
agentId: string;
}) {
const router = useRouter();
const resolvedAgentId = agentId?.trim() || undefined;
const resolvedServerId = serverId?.trim() || undefined;
const { connectionStates } = useDaemonConnections();
// Check if session exists for this serverId
const hasSession = useSessionStore((state) =>
resolvedServerId ? !!state.sessions[resolvedServerId] : false
);
const connectionServerId = resolvedServerId ?? null;
const connection = connectionServerId
? connectionStates.get(connectionServerId)
: null;
const serverLabel =
connection?.daemon.label ?? connectionServerId ?? "Selected host";
const isUnknownDaemon = Boolean(connectionServerId && !connection);
const connectionStatus =
connection?.status ?? (isUnknownDaemon ? "offline" : "idle");
const connectionStatusLabel = formatConnectionStatus(connectionStatus);
const lastConnectionError = connection?.lastError ?? null;
const handleBackToHome = useCallback(() => {
const targetServerId = resolvedServerId;
const targetAgentId = resolvedAgentId ?? null;
if (targetServerId && targetAgentId) {
startNavigationTiming(HOME_NAVIGATION_KEY, {
from: "agent",
to: "home",
targetMs: 300,
params: {
serverId: targetServerId,
agentId: targetAgentId,
},
});
} else {
startNavigationTiming(HOME_NAVIGATION_KEY, {
from: "agent",
to: "home",
targetMs: 300,
});
}
router.replace("/agent" as any);
}, [resolvedAgentId, resolvedServerId, router]);
const focusServerId = resolvedServerId;
const navigationStatus = hasSession ? "ready" : "session_unavailable";
useFocusEffect(
useCallback(() => {
if (!resolvedAgentId || !focusServerId) {
return;
}
const navigationKey = buildAgentNavigationKey(
focusServerId,
resolvedAgentId
);
endNavigationTiming(navigationKey, {
screen: "agent",
status: navigationStatus,
});
}, [focusServerId, navigationStatus, resolvedAgentId])
);
if (!hasSession || !resolvedServerId) {
return (
<AgentSessionUnavailableState
onBack={handleBackToHome}
serverLabel={serverLabel}
connectionStatus={connectionStatus}
connectionStatusLabel={connectionStatusLabel}
lastError={lastConnectionError}
isUnknownDaemon={isUnknownDaemon}
/>
);
}
return (
<ExplorerSidebarAnimationProvider>
<AgentScreenContent
serverId={resolvedServerId}
agentId={resolvedAgentId}
/>
</ExplorerSidebarAnimationProvider>
);
}
type AgentScreenContentProps = {
serverId: string;
agentId?: string;
};
function AgentScreenContent({
serverId,
agentId,
}: AgentScreenContentProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
addImagesRef.current?.(files);
}, []);
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
const closeToAgent = usePanelStore((state) => state.closeToAgent);
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
// Derive isExplorerOpen from the unified panel state
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const {
translateX: explorerTranslateX,
backdropOpacity: explorerBackdropOpacity,
windowWidth: explorerWindowWidth,
animateToOpen: animateExplorerToOpen,
animateToClose: animateExplorerToClose,
isGesturing: isExplorerGesturing,
} = useExplorerSidebarAnimation();
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const scope = `agent:${serverId}:${agentId ?? "unknown"}`;
const stop = startPerfMonitor(scope);
return stop;
}, [serverId, agentId]);
// Swipe-left gesture to open explorer sidebar on mobile
const explorerOpenGesture = useMemo(
() =>
Gesture.Pan()
.enabled(isMobile && !isExplorerOpen)
// Only activate after 15px horizontal movement to the left (negative)
.activeOffsetX(-15)
// Fail if 10px vertical movement happens first (allow vertical scroll)
.failOffsetY([-10, 10])
.onStart(() => {
isExplorerGesturing.value = true;
})
.onUpdate((event) => {
// Right sidebar: start from closed position (+windowWidth) and move towards 0
// Swiping left means negative translationX
const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX);
explorerTranslateX.value = newTranslateX;
explorerBackdropOpacity.value = interpolate(
newTranslateX,
[explorerWindowWidth, 0],
[0, 1],
Extrapolation.CLAMP
);
})
.onEnd((event) => {
isExplorerGesturing.value = false;
// Open if dragged more than 1/3 of window or fast swipe left
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
if (shouldOpen) {
animateExplorerToOpen();
runOnJS(openFileExplorer)();
} else {
animateExplorerToClose();
}
})
.onFinalize(() => {
isExplorerGesturing.value = false;
}),
[
isMobile,
isExplorerOpen,
explorerWindowWidth,
explorerTranslateX,
explorerBackdropOpacity,
animateExplorerToOpen,
animateExplorerToClose,
openFileExplorer,
isExplorerGesturing,
]
);
// Handle hardware back button - close explorer sidebar first, then navigate back
useEffect(() => {
if (Platform.OS === "web") return;
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
if (isExplorerOpen) {
closeToAgent();
return true; // Prevent default back navigation
}
return false; // Let default back navigation happen
});
return () => handler.remove();
}, [isExplorerOpen, closeToAgent]);
const resolvedAgentId = agentId;
// Select only the specific agent
const agent = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agents?.get(resolvedAgentId)
: undefined
);
// Select only the specific stream tail - use stable empty array to avoid infinite loop
const streamItemsRaw = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.agentStreamTail?.get(resolvedAgentId)
: undefined
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingCreate = useCreateFlowStore((state) => state.pending);
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
const isPendingCreateForRoute =
Boolean(pendingCreate) &&
pendingCreate?.serverId === serverId &&
pendingCreate?.agentId === resolvedAgentId;
// Select only the specific initializing state
const isInitializingFromMap = useSessionStore((state) =>
resolvedAgentId
? state.sessions[serverId]?.initializingAgents?.get(resolvedAgentId) ?? false
: false
);
// Select raw pending permissions - filter with useMemo to avoid new Map on every render
const allPendingPermissions = useSessionStore(
(state) => state.sessions[serverId]?.pendingPermissions
);
const pendingPermissions = useMemo(() => {
if (!allPendingPermissions || !resolvedAgentId) return new Map();
const filtered = new Map();
for (const [key, perm] of allPendingPermissions) {
if (perm.agentId === resolvedAgentId) {
filtered.set(key, perm);
}
}
return filtered;
}, [allPendingPermissions, resolvedAgentId]);
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
);
// Get methods
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
const refreshAgent = methods?.refreshAgent;
const setFocusedAgentId = useCallback(
(agentId: string | null) => {
useSessionStore.getState().setFocusedAgentId(serverId, agentId);
},
[serverId]
);
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
useEffect(() => {
bottomInset.value = insets.bottom;
}, [insets.bottom, bottomInset]);
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
transform: [{ translateY: -shift }],
};
});
const agentModel = extractAgentModel(agent);
const modelDisplayValue = agentModel ?? "Unknown";
const repoInfoQuery = useQuery({
queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""],
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getGitRepoInfo({
cwd: agent?.cwd ?? ".",
});
if (payload.error) {
throw new Error(payload.error);
}
return {
cwd: payload.cwd,
currentBranch: payload.currentBranch ?? null,
};
},
enabled: Boolean(client && isConnected && agent?.cwd),
retry: false,
});
const { refetch: refetchRepoInfo } = repoInfoQuery;
const branchStatus: BranchStatus = !agent?.cwd
? "idle"
: repoInfoQuery.isPending || repoInfoQuery.isFetching
? "loading"
: repoInfoQuery.isError
? "error"
: repoInfoQuery.isSuccess
? "ready"
: "idle";
const branchLabel = repoInfoQuery.data?.currentBranch ?? null;
const branchError = repoInfoQuery.error instanceof Error
? repoInfoQuery.error.message
: null;
const branchDisplayValue =
branchStatus === "error"
? branchError ?? "Unavailable"
: branchLabel ?? "Unknown";
useEffect(() => {
if (!resolvedAgentId) {
setFocusedAgentId(null);
return;
}
setFocusedAgentId(resolvedAgentId);
return () => {
setFocusedAgentId(null);
};
}, [resolvedAgentId, setFocusedAgentId]);
const isInitializing = resolvedAgentId ? isInitializingFromMap !== false : false;
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
if (!isPendingCreateForRoute || !pendingCreate) {
return EMPTY_STREAM_ITEMS;
}
return [
{
kind: "user_message",
id: pendingCreate.messageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
},
];
}, [isPendingCreateForRoute, pendingCreate]);
const mergedStreamItems = useMemo<StreamItem[]>(() => {
if (optimisticStreamItems.length === 0) {
return streamItems;
}
const optimistic = optimisticStreamItems[0];
if (!optimistic) {
return streamItems;
}
const alreadyHasOptimistic = streamItems.some(
(item) => item.kind === "user_message" && item.id === optimistic.id
);
return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems];
}, [optimisticStreamItems, streamItems]);
const shouldUseOptimisticStream = isPendingCreateForRoute && optimisticStreamItems.length > 0;
const placeholderAgent: Agent | null = useMemo(() => {
if (!shouldUseOptimisticStream || !resolvedAgentId) {
return null;
}
const now = new Date();
return {
serverId,
id: resolvedAgentId,
provider: "claude",
status: "running",
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
lastActivityAt: now,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
runtimeInfo: {
provider: "claude",
sessionId: null,
model: null,
modeId: null,
},
title: "Agent",
cwd: ".",
model: null,
};
}, [resolvedAgentId, serverId, shouldUseOptimisticStream]);
const effectiveAgent = agent ?? placeholderAgent;
useEffect(() => {
if (!isPendingCreateForRoute || !pendingCreate) {
return;
}
const hasUserMessage = streamItems.some(
(item) =>
item.kind === "user_message" &&
(item.id === pendingCreate.messageId || item.text === pendingCreate.text)
);
if (agent && hasUserMessage) {
clearPendingCreate();
}
}, [
agent,
clearPendingCreate,
isPendingCreateForRoute,
pendingCreate,
streamItems,
]);
// Get ensureAgentIsInitialized from methods
const ensureAgentIsInitialized = methods?.ensureAgentIsInitialized;
useEffect(() => {
if (!resolvedAgentId || !ensureAgentIsInitialized) {
return;
}
// Skip if not connected - will re-run when connection is established
if (!isConnected) {
return;
}
// ensureAgentIsInitialized handles deduplication via module-level promises map
// If already initialized or in-flight, returns resolved/pending promise immediately
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed", {
agentId: resolvedAgentId,
error,
});
});
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
useEffect(() => {
if (Platform.OS !== "web") {
return;
}
const title = agent?.title || "Agent";
document.title = title;
}, [agent?.title]);
// Track previous agent status to detect completion while viewing
const previousStatusRef = useRef<string | null>(null);
// Clear attention when agent finishes while user is viewing this screen
useEffect(() => {
if (!resolvedAgentId || !agent || !client) {
return;
}
const previousStatus = previousStatusRef.current;
const currentStatus = agent.status;
previousStatusRef.current = currentStatus;
// If agent transitioned from running to idle while we're viewing,
// immediately clear attention since user witnessed the completion
if (previousStatus === "running" && currentStatus === "idle") {
client.clearAgentAttention(resolvedAgentId);
}
}, [resolvedAgentId, agent?.status, client]);
const handleViewChanges = useCallback(() => {
setExplorerTab("changes");
openFileExplorer();
}, [setExplorerTab, openFileExplorer]);
const handleBrowseFiles = useCallback(() => {
setExplorerTab("files");
openFileExplorer();
}, [setExplorerTab, openFileExplorer]);
const handleRefreshAgent = useCallback(() => {
if (!resolvedAgentId || !refreshAgent) {
return;
}
refreshAgent({ agentId: resolvedAgentId });
}, [resolvedAgentId, refreshAgent]);
if (!effectiveAgent) {
return (
<View style={styles.container} testID="agent-not-found">
<MenuHeader title="Agent" />
<View style={styles.errorContainer}>
<Text style={styles.errorText}>Agent not found</Text>
</View>
</View>
);
}
const mainContent = (
<View style={styles.outerContainer}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isInitializing}>
<View style={styles.container}>
{/* Header */}
<MenuHeader
title={effectiveAgent.title || "Agent"}
rightContent={
<View style={styles.headerRightContent}>
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
{isMobile ? (
<Folder
size={16}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
) : (
<PanelRight
size={16}
color={
isExplorerOpen
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
)}
</Pressable>
<DropdownMenu
onOpenChange={(open) => {
if (open && agent?.cwd) {
refetchRepoInfo().catch(() => {});
}
}}
>
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Directory</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={2}
ellipsizeMode="middle"
>
{shortenPath(effectiveAgent.cwd)}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Model</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{modelDisplayValue}
</Text>
</View>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Branch</Text>
<View style={styles.menuMetaValueRow}>
{branchStatus === "loading" ? (
<>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={styles.menuMetaPendingText}>Fetching…</Text>
</>
) : (
<Text
style={[
styles.menuMetaValue,
branchStatus === "error" ? styles.menuMetaValueError : null,
]}
numberOfLines={1}
ellipsizeMode="middle"
>
{branchDisplayValue}
</Text>
)}
</View>
</View>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<GitBranch size={16} color={theme.colors.foreground} />}
onSelect={handleViewChanges}
>
View Changes
</DropdownMenuItem>
<DropdownMenuItem
leading={<Folder size={16} color={theme.colors.foreground} />}
onSelect={handleBrowseFiles}
>
Browse Files
</DropdownMenuItem>
<DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
disabled={isInitializing}
trailing={
isInitializing ? (
<ActivityIndicator
size="small"
color={theme.colors.primary}
style={styles.menuItemSpinner}
/>
) : null
}
onSelect={handleRefreshAgent}
>
{isInitializing ? "Refreshing..." : "Refresh"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
}
/>
{/* Content Area with Keyboard Animation */}
<View style={styles.contentContainer}>
<ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]}
>
{isInitializing && !shouldUseOptimisticStream ? (
<View style={styles.loadingContainer}>
<ActivityIndicator
size="large"
color={theme.colors.primary}
/>
<Text style={styles.loadingText}>Loading agent...</Text>
</View>
) : (
<AgentStreamView
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
/>
)}
</ReanimatedAnimated.View>
</View>
{/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)}
</View>
</FileDropZone>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
)}
</View>
);
return (
<>
{isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
{mainContent}
</GestureDetector>
) : (
mainContent
)}
{/* Mobile Explorer Sidebar Overlay */}
{isMobile && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} />
)}
</>
);
}
function AgentSessionUnavailableState({
onBack,
serverLabel,
connectionStatus,
connectionStatusLabel,
lastError,
isUnknownDaemon = false,
}: {
onBack: () => void;
serverLabel: string;
connectionStatus: ConnectionStatus;
connectionStatusLabel: string;
lastError: string | null;
isUnknownDaemon?: boolean;
}) {
if (isUnknownDaemon) {
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={onBack} />
<View style={styles.centerState}>
<Text style={styles.errorText}>
Cannot open this agent because {serverLabel} is not configured on
this device.
</Text>
<Text style={styles.statusText}>
Add the host in Settings or open an agent on a configured server to
continue.
</Text>
</View>
</View>
);
}
const isConnecting = connectionStatus === "connecting";
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={onBack} />
<View style={styles.centerState}>
{isConnecting ? (
<>
<ActivityIndicator size="large" />
<Text style={styles.loadingText}>
Connecting to {serverLabel}...
</Text>
<Text style={styles.statusText}>
We will show this agent once the host is online.
</Text>
</>
) : (
<>
<Text style={styles.offlineTitle}>
{serverLabel} is currently {connectionStatusLabel.toLowerCase()}.
</Text>
<Text style={styles.offlineDescription}>
We'll reconnect automatically and show this agent as soon as the
host comes back online.
</Text>
{lastError ? (
<Text style={styles.offlineDetails}>{lastError}</Text>
) : null}
</>
)}
</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
outerContainer: {
flex: 1,
flexDirection: "row",
backgroundColor: theme.colors.surface0,
},
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
headerRightContent: {
flexDirection: "row",
alignItems: "center",
},
contentContainer: {
flex: 1,
overflow: "hidden",
},
content: {
flex: 1,
},
loadingContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 16,
},
loadingText: {
fontSize: theme.fontSize.base,
color: theme.colors.foregroundMuted,
},
centerState: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[6],
gap: theme.spacing[3],
},
errorContainer: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
errorText: {
fontSize: theme.fontSize.lg,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
statusText: {
marginTop: theme.spacing[2],
textAlign: "center",
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
errorDetails: {
marginTop: theme.spacing[1],
textAlign: "center",
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
offlineTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
offlineDescription: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
offlineDetails: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
menuButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
menuMetaContainer: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
gap: theme.spacing[2],
},
menuMetaRow: {
gap: theme.spacing[1],
},
menuMetaLabel: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
letterSpacing: 0.5,
textTransform: "uppercase",
},
menuMetaValue: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
menuMetaValueRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
menuMetaPendingText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
menuMetaValueError: {
color: theme.colors.destructive,
},
menuItemSpinner: {
marginLeft: "auto",
},
}));
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
import { useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header";
import { useSessionDirectory } from "@/hooks/use-session-directory";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { Agent } from "@/contexts/session-context";
type AgentMatch = {
serverId: string;
serverLabel: string;
agent: Agent;
};
export function LegacyAgentIdScreen({ agentId }: { agentId: string }) {
const router = useRouter();
const { theme } = useUnistyles();
const { connectionStates } = useDaemonConnections();
const sessionDirectory = useSessionDirectory();
const resolvedAgentId = typeof agentId === "string" ? agentId.trim() : undefined;
const matches = useMemo<AgentMatch[]>(() => {
if (!resolvedAgentId) {
return [];
}
const results: AgentMatch[] = [];
sessionDirectory.forEach((session, serverId) => {
if (!session) {
return;
}
const agent = session.agents.get(resolvedAgentId);
if (!agent) {
return;
}
const serverLabel = connectionStates.get(serverId)?.daemon.label ?? serverId;
results.push({ serverId, serverLabel, agent });
});
return results;
}, [resolvedAgentId, sessionDirectory, connectionStates]);
const hasSessions = sessionDirectory.size > 0;
const isRedirecting = Boolean(resolvedAgentId && matches.length === 1);
useEffect(() => {
if (!isRedirecting) {
return;
}
const match = matches[0];
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
}, [isRedirecting, matches, router]);
const handleGoDraft = useCallback(() => {
router.replace("/agent" as any);
}, [router]);
const handleSelectMatch = useCallback(
(match: AgentMatch) => {
router.replace(`/agent/${match.serverId}/${match.agent.id}` as any);
},
[router]
);
let body: ReactNode = null;
if (!resolvedAgentId) {
body = (
<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={handleGoDraft}>
<Text style={styles.primaryButtonText}>New Agent</Text>
</Pressable>
</View>
);
} else if (!hasSessions || isRedirecting) {
body = (
<View style={styles.centerState}>
<ActivityIndicator size="large" color={theme.colors.primary} />
<Text style={styles.subtitle}>
{isRedirecting ? "Opening your agent..." : "Looking for your agent..."}
</Text>
</View>
);
} else if (matches.length === 0) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Agent not found</Text>
<Text style={styles.subtitle}>
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={handleGoDraft}>
<Text style={styles.primaryButtonText}>New Agent</Text>
</Pressable>
</View>
);
} else if (matches.length > 1) {
body = (
<View style={styles.centerState}>
<Text style={styles.title}>Pick a host</Text>
<Text style={styles.subtitle}>
Multiple hosts have an agent with this id. Choose the one you intended to open.
</Text>
<View style={styles.matchList}>
{matches.map((match) => (
<Pressable
key={`${match.serverId}:${match.agent.id}`}
style={styles.matchButton}
onPress={() => handleSelectMatch(match)}
>
<Text style={styles.matchLabel}>{match.serverLabel}</Text>
<Text style={styles.matchMeta} numberOfLines={1}>
{match.agent.cwd}
</Text>
</Pressable>
))}
</View>
</View>
);
}
return (
<View style={styles.container}>
<BackHeader title="Agent" onBack={handleGoDraft} />
<View style={styles.content}>{body}</View>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
content: {
flex: 1,
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[8],
},
centerState: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: theme.spacing[4],
},
title: {
fontSize: theme.fontSize.xl,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
subtitle: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
textAlign: "center",
},
primaryButton: {
marginTop: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.primary,
},
primaryButtonText: {
color: theme.colors.primaryForeground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
matchList: {
width: "100%",
gap: theme.spacing[3],
},
matchButton: {
width: "100%",
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
},
matchLabel: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
},
matchMeta: {
marginTop: theme.spacing[1],
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
}));
@@ -0,0 +1,27 @@
import { create } from "zustand";
type PendingCreateAttempt = {
serverId: string;
agentId: string | null;
messageId: string;
text: string;
timestamp: number;
};
type CreateFlowState = {
pending: PendingCreateAttempt | null;
setPending: (pending: PendingCreateAttempt) => void;
updateAgentId: (agentId: string) => void;
clear: () => void;
};
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
pending: null,
setPending: (pending) => set({ pending }),
updateAgentId: (agentId) =>
set((state) =>
state.pending ? { pending: { ...state.pending, agentId } } : state
),
clear: () => set({ pending: null }),
}));