Merge branch 'fix/e2e-home-routing'
This commit is contained in:
@@ -240,7 +240,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
await page.getByTestId('sidebar-new-agent').click();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||||
|
|
||||||
await setWorkingDirectory(page, repo.path);
|
await setWorkingDirectory(page, repo.path);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
@@ -337,7 +337,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
|
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
|
||||||
await page.getByTestId('changes-menu-archive').click();
|
await page.getByTestId('changes-menu-archive').click();
|
||||||
// Archiving a worktree deletes agents and redirects to home
|
// Archiving a worktree deletes agents and redirects to home
|
||||||
await expect(page).toHaveURL(/\/$/, { timeout: 30000 });
|
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||||
await setWorkingDirectory(page, repo.path);
|
await setWorkingDirectory(page, repo.path);
|
||||||
await ensureHostSelected(page);
|
await ensureHostSelected(page);
|
||||||
await page.getByTestId('worktree-attach-toggle').click();
|
await page.getByTestId('worktree-attach-toggle').click();
|
||||||
@@ -348,13 +348,13 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
|||||||
await attachSheetBackdrop.click({ force: true });
|
await attachSheetBackdrop.click({ force: true });
|
||||||
}
|
}
|
||||||
await page.getByTestId('worktree-attach-toggle').click();
|
await page.getByTestId('worktree-attach-toggle').click();
|
||||||
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
|
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
|
||||||
|
|
||||||
await setWorkingDirectory(page, nonGitDir);
|
await setWorkingDirectory(page, nonGitDir);
|
||||||
const attachPicker = page.getByTestId('worktree-attach-picker');
|
const attachPicker = page.getByTestId('worktree-attach-picker');
|
||||||
if (await attachPicker.isVisible()) {
|
if (await attachPicker.isVisible()) {
|
||||||
await page.getByTestId('worktree-attach-toggle').click();
|
await page.getByTestId('worktree-attach-toggle').click();
|
||||||
await expect(attachPicker).toHaveCount(0);
|
await expect(attachPicker).toBeHidden({ timeout: 30000 });
|
||||||
}
|
}
|
||||||
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
||||||
await waitForAssistantText(page, 'NON-GIT');
|
await waitForAssistantText(page, 'NON-GIT');
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ test('dictation confirm+send does not dispatch after navigating away', async ({
|
|||||||
const newAgentButton = page.getByText('New Agent', { exact: true }).first();
|
const newAgentButton = page.getByText('New Agent', { exact: true }).first();
|
||||||
await expect(newAgentButton).toBeVisible();
|
await expect(newAgentButton).toBeVisible();
|
||||||
await newAgentButton.click();
|
await newAgentButton.click();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||||
|
|
||||||
await page.waitForTimeout(10_000);
|
await page.waitForTimeout(10_000);
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export const openSettings = async (page: Page) => {
|
|||||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||||
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
|
const workingDirectoryLabel = page.getByText('WORKING DIRECTORY', { exact: true }).first();
|
||||||
await expect(workingDirectoryLabel).toBeVisible();
|
await expect(workingDirectoryLabel).toBeVisible();
|
||||||
|
const workingDirectorySelect = page.getByTestId('working-directory-select').first();
|
||||||
|
|
||||||
const input = page.getByRole('textbox', { name: '/path/to/project' });
|
const input = page.getByRole('textbox', { name: '/path/to/project' });
|
||||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||||
@@ -199,30 +200,45 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
|||||||
|
|
||||||
if (!(await input.isVisible())) {
|
if (!(await input.isVisible())) {
|
||||||
await closeBottomSheet();
|
await closeBottomSheet();
|
||||||
await workingDirectoryLabel.click({ force: true });
|
await workingDirectorySelect.click({ force: true });
|
||||||
if (!(await input.isVisible())) {
|
if (!(await input.isVisible())) {
|
||||||
await closeBottomSheet();
|
await closeBottomSheet();
|
||||||
await workingDirectoryLabel.click({ force: true });
|
await workingDirectorySelect.click({ force: true });
|
||||||
}
|
}
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
await input.fill(directory);
|
const trimmedDirectory = directory.replace(/\/+$/, '');
|
||||||
|
await input.fill(trimmedDirectory);
|
||||||
await input.press('Enter');
|
await input.press('Enter');
|
||||||
|
|
||||||
const useOption = page.getByText(`Use "${directory}"`);
|
// Desktop web supports selecting via Enter; mobile may require clicking the explicit option.
|
||||||
await expect(useOption).toBeVisible();
|
const useOption = page.getByTestId('working-directory-custom-option').first();
|
||||||
await useOption.click({ force: true });
|
if (await useOption.isVisible().catch(() => false)) {
|
||||||
// Wait for UI to update after clicking "Use"
|
await expect(useOption).toContainText(`Use "${trimmedDirectory}"`);
|
||||||
await page.waitForTimeout(500);
|
await useOption.click({ force: true });
|
||||||
const normalizedDirectory = directory.startsWith('/var/')
|
}
|
||||||
? `/private${directory}`
|
|
||||||
: directory;
|
// Wait for the sheet to close after clicking "Use"
|
||||||
const workingDirectoryContainer = workingDirectoryLabel.locator('..');
|
await expect(input).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||||
|
if (trimmedDirectory.startsWith('/var/')) {
|
||||||
|
directoryCandidates.add(`/private${trimmedDirectory}`);
|
||||||
|
}
|
||||||
|
if (trimmedDirectory.startsWith('/private/var/')) {
|
||||||
|
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
|
||||||
|
}
|
||||||
|
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
|
||||||
|
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
const text = await workingDirectoryContainer.innerText();
|
const text = await workingDirectorySelect.innerText().catch(() => '');
|
||||||
return text.includes(directory) || text.includes(normalizedDirectory);
|
if (text.includes(basename)) return true;
|
||||||
}, { timeout: 15000 }).toBe(true);
|
for (const candidate of directoryCandidates) {
|
||||||
|
if (text.includes(candidate)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, { timeout: 30000 }).toBe(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ensureHostSelected = async (page: Page) => {
|
export const ensureHostSelected = async (page: Page) => {
|
||||||
@@ -296,8 +312,13 @@ export const createAgent = async (page: Page, message: string) => {
|
|||||||
await input.fill(message);
|
await input.fill(message);
|
||||||
await input.press('Enter');
|
await input.press('Enter');
|
||||||
|
|
||||||
await page.waitForURL(/\/agent\//);
|
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
|
||||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
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 {
|
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.
|
// Click sidebar New Agent and assert it does not carry over agent settings via URL.
|
||||||
await page.getByTestId('sidebar-new-agent').click();
|
await page.getByTestId('sidebar-new-agent').click();
|
||||||
await expect(page).toHaveURL(/\/$/);
|
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||||
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
|
await expect(page).not.toHaveURL(new RegExp(encodeURIComponent(repoA.path)));
|
||||||
} finally {
|
} finally {
|
||||||
await repoA.cleanup();
|
await repoA.cleanup();
|
||||||
|
|||||||
@@ -347,8 +347,12 @@ export default function RootLayout() {
|
|||||||
>
|
>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="agents" />
|
<Stack.Screen name="agents" />
|
||||||
|
<Stack.Screen name="agent" />
|
||||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
<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="settings" />
|
||||||
<Stack.Screen name="audio-test" />
|
<Stack.Screen name="audio-test" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="pair-scan" />
|
||||||
|
|||||||
@@ -1,209 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import type { ReactNode } from "react";
|
import { LegacyAgentIdScreen } from "@/screens/agent/legacy-agent-id-screen";
|
||||||
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";
|
|
||||||
|
|
||||||
type AgentMatch = {
|
export default function LegacyAgentRoute() {
|
||||||
serverId: string;
|
const { id } = useLocalSearchParams<{ id?: string }>();
|
||||||
serverLabel: string;
|
return <LegacyAgentIdScreen agentId={typeof id === "string" ? id : ""} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { useLocalSearchParams } from "expo-router";
|
||||||
import {
|
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
||||||
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";
|
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
export default function AgentReadyRoute() {
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
|
||||||
|
|
||||||
|
|
||||||
type BranchStatus = "idle" | "loading" | "ready" | "error";
|
|
||||||
|
|
||||||
export default function AgentScreen() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { serverId, agentId } = useLocalSearchParams<{
|
const { serverId, agentId } = useLocalSearchParams<{
|
||||||
serverId: string;
|
serverId?: string;
|
||||||
agentId: 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 (
|
return (
|
||||||
<ExplorerSidebarAnimationProvider>
|
<AgentReadyScreen
|
||||||
<AgentScreenContent
|
serverId={typeof serverId === "string" ? serverId : ""}
|
||||||
serverId={resolvedServerId}
|
agentId={typeof agentId === "string" ? agentId : ""}
|
||||||
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;
|
|
||||||
|
|
||||||
// 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",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||||
|
|
||||||
|
export default function AgentDraftRoute() {
|
||||||
|
return <DraftAgentScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,894 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||||
import { createNameId } from "mnemonic-id";
|
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from "react-native";
|
|
||||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
|
||||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
|
||||||
import { Monitor } from "lucide-react-native";
|
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
|
||||||
import {
|
|
||||||
DropdownSheet,
|
|
||||||
GitOptionsSection,
|
|
||||||
WorkingDirectoryDropdown,
|
|
||||||
AgentConfigRow,
|
|
||||||
} from "@/components/agent-form/agent-form-dropdowns";
|
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
|
||||||
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
|
||||||
import { formatConnectionStatus } from "@/utils/daemons";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
|
||||||
import { WelcomeScreen } from "@/components/welcome-screen";
|
|
||||||
import type {
|
|
||||||
AgentProvider,
|
|
||||||
AgentSessionConfig,
|
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
|
||||||
|
|
||||||
const DRAFT_AGENT_ID = "__new_agent__";
|
export default function Index() {
|
||||||
const PROVIDER_DEFINITION_MAP = new Map(
|
return <DraftAgentScreen />;
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition])
|
|
||||||
);
|
|
||||||
|
|
||||||
function getParamValue(value: string | string[] | undefined) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const entry of value) {
|
|
||||||
const trimmed = entry.trim();
|
|
||||||
if (trimmed.length > 0) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getValidProvider(value: string | undefined) {
|
|
||||||
if (!value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return PROVIDER_DEFINITION_MAP.has(value as AgentProvider)
|
|
||||||
? (value as AgentProvider)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValidMode(
|
|
||||||
provider: AgentProvider | undefined,
|
|
||||||
value: string | undefined
|
|
||||||
) {
|
|
||||||
if (!provider || !value) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const definition = PROVIDER_DEFINITION_MAP.get(provider);
|
|
||||||
const modes = definition?.modes ?? [];
|
|
||||||
return modes.some((mode) => mode.id === value) ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DraftAgentParams = {
|
|
||||||
serverId?: string;
|
|
||||||
provider?: string;
|
|
||||||
modeId?: string;
|
|
||||||
model?: string;
|
|
||||||
workingDir?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HomeScreen() {
|
|
||||||
const { theme } = useUnistyles();
|
|
||||||
const router = useRouter();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { connectionStates } = useDaemonConnections();
|
|
||||||
const { daemons } = useDaemonRegistry();
|
|
||||||
const params = useLocalSearchParams<DraftAgentParams>();
|
|
||||||
|
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
|
||||||
const bottomInset = useSharedValue(insets.bottom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
bottomInset.value = insets.bottom;
|
|
||||||
}, [insets.bottom, bottomInset]);
|
|
||||||
|
|
||||||
const animatedKeyboardStyle = useAnimatedStyle(() => {
|
|
||||||
"worklet";
|
|
||||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
|
||||||
const shift = Math.max(0, absoluteHeight - bottomInset.value);
|
|
||||||
return {
|
|
||||||
transform: [{ translateY: -shift }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const resolvedServerId = getParamValue(params.serverId);
|
|
||||||
const resolvedProvider = getValidProvider(getParamValue(params.provider));
|
|
||||||
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
|
|
||||||
const resolvedModel = getParamValue(params.model);
|
|
||||||
const resolvedWorkingDir = getParamValue(params.workingDir);
|
|
||||||
|
|
||||||
const initialValues = useMemo((): CreateAgentInitialValues => {
|
|
||||||
const values: CreateAgentInitialValues = {};
|
|
||||||
if (resolvedWorkingDir) {
|
|
||||||
values.workingDir = resolvedWorkingDir;
|
|
||||||
}
|
|
||||||
if (resolvedProvider) {
|
|
||||||
values.provider = resolvedProvider;
|
|
||||||
}
|
|
||||||
if (resolvedMode) {
|
|
||||||
values.modeId = resolvedMode;
|
|
||||||
}
|
|
||||||
if (resolvedModel) {
|
|
||||||
values.model = resolvedModel;
|
|
||||||
}
|
|
||||||
return values;
|
|
||||||
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
selectedServerId,
|
|
||||||
setSelectedServerIdFromUser,
|
|
||||||
selectedProvider,
|
|
||||||
setProviderFromUser,
|
|
||||||
selectedMode,
|
|
||||||
setModeFromUser,
|
|
||||||
selectedModel,
|
|
||||||
setModelFromUser,
|
|
||||||
workingDir,
|
|
||||||
setWorkingDirFromUser,
|
|
||||||
providerDefinitions,
|
|
||||||
modeOptions,
|
|
||||||
availableModels,
|
|
||||||
isModelLoading,
|
|
||||||
modelError,
|
|
||||||
refreshProviderModels,
|
|
||||||
persistFormPreferences,
|
|
||||||
} = useAgentFormState({
|
|
||||||
initialServerId: resolvedServerId ?? null,
|
|
||||||
initialValues,
|
|
||||||
isVisible: true,
|
|
||||||
isCreateFlow: true,
|
|
||||||
});
|
|
||||||
const hostEntry = selectedServerId
|
|
||||||
? connectionStates.get(selectedServerId)
|
|
||||||
: undefined;
|
|
||||||
const hostLabel =
|
|
||||||
hostEntry?.daemon.label ?? selectedServerId ?? "Select host";
|
|
||||||
const hostStatus = hostEntry?.status
|
|
||||||
? formatConnectionStatus(hostEntry.status)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<"host" | null>(null);
|
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [promptText, setPromptText] = useState("");
|
|
||||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
|
|
||||||
const [baseBranch, setBaseBranch] = useState("");
|
|
||||||
const [worktreeSlug, setWorktreeSlug] = useState("");
|
|
||||||
const [selectedWorktreePath, setSelectedWorktreePath] = useState("");
|
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
|
||||||
|
|
||||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
|
||||||
addImagesRef.current?.(files);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
|
||||||
addImagesRef.current = addImages;
|
|
||||||
}, []);
|
|
||||||
const openDropdownSheet = useCallback((key: "host") => {
|
|
||||||
setOpenDropdown(key);
|
|
||||||
}, []);
|
|
||||||
const closeDropdown = useCallback(() => {
|
|
||||||
setOpenDropdown(null);
|
|
||||||
}, []);
|
|
||||||
const sessionAgents = useSessionStore((state) =>
|
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.agents : undefined
|
|
||||||
);
|
|
||||||
const agentWorkingDirSuggestions = useMemo(() => {
|
|
||||||
if (!selectedServerId || !sessionAgents) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const uniquePaths = new Set<string>();
|
|
||||||
sessionAgents.forEach((agent) => {
|
|
||||||
if (agent.cwd) {
|
|
||||||
uniquePaths.add(agent.cwd);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Array.from(uniquePaths).sort();
|
|
||||||
}, [selectedServerId, sessionAgents]);
|
|
||||||
|
|
||||||
const sessionClient = useSessionStore((state) =>
|
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
|
||||||
);
|
|
||||||
const isConnected = useSessionStore((state) =>
|
|
||||||
selectedServerId
|
|
||||||
? state.sessions[selectedServerId]?.connection.isConnected ?? false
|
|
||||||
: false
|
|
||||||
);
|
|
||||||
const trimmedWorkingDir = workingDir.trim();
|
|
||||||
const shouldInspectRepo = trimmedWorkingDir.length > 0;
|
|
||||||
const daemonAvailabilityError =
|
|
||||||
!selectedServerId || hostEntry?.status !== "online"
|
|
||||||
? "Host is offline"
|
|
||||||
: null;
|
|
||||||
const repoAvailabilityError =
|
|
||||||
shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isConnected)
|
|
||||||
? daemonAvailabilityError ??
|
|
||||||
"Repository details will load automatically once the selected host is back online."
|
|
||||||
: null;
|
|
||||||
|
|
||||||
type RepoInfoState = {
|
|
||||||
cwd: string;
|
|
||||||
repoRoot: string;
|
|
||||||
branches: Array<{ name: string; isCurrent: boolean }>;
|
|
||||||
currentBranch: string | null;
|
|
||||||
isDirty: boolean;
|
|
||||||
};
|
|
||||||
const repoInfoQuery = useQuery({
|
|
||||||
queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir],
|
|
||||||
queryFn: async () => {
|
|
||||||
const client = sessionClient;
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("Daemon client unavailable");
|
|
||||||
}
|
|
||||||
const payload = await client.getGitRepoInfo({
|
|
||||||
cwd: trimmedWorkingDir || ".",
|
|
||||||
});
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
cwd: payload.cwd,
|
|
||||||
repoRoot: payload.repoRoot,
|
|
||||||
branches: payload.branches ?? [],
|
|
||||||
currentBranch: payload.currentBranch ?? null,
|
|
||||||
isDirty: Boolean(payload.isDirty),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled:
|
|
||||||
Boolean(trimmedWorkingDir) &&
|
|
||||||
!repoAvailabilityError &&
|
|
||||||
Boolean(sessionClient) &&
|
|
||||||
isConnected,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
const repoInfo = repoInfoQuery.data ?? null;
|
|
||||||
const refetchRepoInfo = repoInfoQuery.refetch;
|
|
||||||
const repoRequestError = repoInfoQuery.error as Error | null;
|
|
||||||
const repoRequestStatus: "idle" | "loading" | "success" | "error" =
|
|
||||||
!shouldInspectRepo || repoAvailabilityError
|
|
||||||
? "idle"
|
|
||||||
: repoInfoQuery.isPending || repoInfoQuery.isFetching
|
|
||||||
? "loading"
|
|
||||||
: repoInfoQuery.isError
|
|
||||||
? "error"
|
|
||||||
: repoInfoQuery.isSuccess
|
|
||||||
? "success"
|
|
||||||
: "idle";
|
|
||||||
const isNonGitDirectory =
|
|
||||||
repoRequestStatus === "error" &&
|
|
||||||
/not in a git repository/i.test(repoRequestError?.message ?? "");
|
|
||||||
const isDirectoryNotExists =
|
|
||||||
repoRequestStatus === "error" &&
|
|
||||||
/does not exist|no such file or directory|ENOENT/i.test(repoRequestError?.message ?? "");
|
|
||||||
const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo
|
|
||||||
? "idle"
|
|
||||||
: repoAvailabilityError
|
|
||||||
? "error"
|
|
||||||
: repoRequestStatus === "loading"
|
|
||||||
? "loading"
|
|
||||||
: repoRequestStatus === "error"
|
|
||||||
? isNonGitDirectory
|
|
||||||
? "idle"
|
|
||||||
: "error"
|
|
||||||
: repoRequestStatus === "success"
|
|
||||||
? "ready"
|
|
||||||
: "idle";
|
|
||||||
const repoInfoError =
|
|
||||||
repoAvailabilityError ?? (isNonGitDirectory ? null : repoRequestError?.message ?? null);
|
|
||||||
const gitHelperText = isNonGitDirectory
|
|
||||||
? "No git repository detected. Git options are disabled for this directory."
|
|
||||||
: null;
|
|
||||||
const isCreateWorktree = worktreeMode === "create";
|
|
||||||
const isAttachWorktree = worktreeMode === "attach";
|
|
||||||
|
|
||||||
const worktreeListRoot = repoInfo?.repoRoot ?? trimmedWorkingDir;
|
|
||||||
const worktreeListQuery = useQuery({
|
|
||||||
queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot],
|
|
||||||
queryFn: async () => {
|
|
||||||
const client = sessionClient;
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("Daemon client unavailable");
|
|
||||||
}
|
|
||||||
const payload = await client.getPaseoWorktreeList({
|
|
||||||
repoRoot: worktreeListRoot || undefined,
|
|
||||||
cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined,
|
|
||||||
});
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error.message);
|
|
||||||
}
|
|
||||||
return payload.worktrees ?? [];
|
|
||||||
},
|
|
||||||
enabled:
|
|
||||||
isAttachWorktree &&
|
|
||||||
Boolean(worktreeListRoot || trimmedWorkingDir) &&
|
|
||||||
!repoAvailabilityError &&
|
|
||||||
Boolean(sessionClient) &&
|
|
||||||
isConnected &&
|
|
||||||
!isNonGitDirectory,
|
|
||||||
retry: false,
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: "always",
|
|
||||||
});
|
|
||||||
const worktreeOptions = useMemo(() => {
|
|
||||||
return (worktreeListQuery.data ?? []).map((worktree) => ({
|
|
||||||
path: worktree.worktreePath,
|
|
||||||
label: worktree.branchName ?? worktree.head ?? "Unknown branch",
|
|
||||||
}));
|
|
||||||
}, [worktreeListQuery.data]);
|
|
||||||
const worktreeOptionsError =
|
|
||||||
worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null;
|
|
||||||
const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" =
|
|
||||||
!isAttachWorktree
|
|
||||||
? "idle"
|
|
||||||
: worktreeListQuery.isPending || worktreeListQuery.isFetching
|
|
||||||
? "loading"
|
|
||||||
: worktreeListQuery.isError
|
|
||||||
? "error"
|
|
||||||
: "ready";
|
|
||||||
const attachWorktreeError =
|
|
||||||
isAttachWorktree &&
|
|
||||||
worktreeOptionsStatus === "ready" &&
|
|
||||||
worktreeOptions.length > 0 &&
|
|
||||||
!selectedWorktreePath
|
|
||||||
? "Select a worktree to attach"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const handleWorktreeModeChange = useCallback(
|
|
||||||
(mode: "none" | "create" | "attach") => {
|
|
||||||
setWorktreeMode(mode);
|
|
||||||
if (mode === "create" && !worktreeSlug) {
|
|
||||||
setWorktreeSlug(createNameId());
|
|
||||||
}
|
|
||||||
if (mode !== "attach") {
|
|
||||||
setSelectedWorktreePath("");
|
|
||||||
}
|
|
||||||
if (mode !== "none") {
|
|
||||||
refetchRepoInfo();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[worktreeSlug, refetchRepoInfo]
|
|
||||||
);
|
|
||||||
|
|
||||||
const validateWorktreeName = useCallback(
|
|
||||||
(name: string): { valid: boolean; error?: string } => {
|
|
||||||
if (!name) {
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
if (name.length > 100) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Worktree name too long (max 100 characters)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!/^[a-z0-9-/]+$/.test(name)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (name.startsWith("-") || name.endsWith("-")) {
|
|
||||||
return { valid: false, error: "Cannot start or end with a hyphen" };
|
|
||||||
}
|
|
||||||
if (name.includes("--")) {
|
|
||||||
return { valid: false, error: "Cannot have consecutive hyphens" };
|
|
||||||
}
|
|
||||||
return { valid: true };
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const gitBlockingError = useMemo(() => {
|
|
||||||
if (!isCreateWorktree || isNonGitDirectory) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!worktreeSlug) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const validation = validateWorktreeName(worktreeSlug);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return `Invalid worktree name: ${
|
|
||||||
validation.error ?? "Must use lowercase letters, numbers, or hyphens"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [
|
|
||||||
isCreateWorktree,
|
|
||||||
isNonGitDirectory,
|
|
||||||
worktreeSlug,
|
|
||||||
validateWorktreeName,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const baseBranchError = useMemo(() => {
|
|
||||||
if (!isCreateWorktree || isNonGitDirectory) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!baseBranch) {
|
|
||||||
return "Base branch is required";
|
|
||||||
}
|
|
||||||
const branches = repoInfo?.branches ?? [];
|
|
||||||
if (branches.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const branchExists = branches.some((b) => b.name === baseBranch);
|
|
||||||
if (!branchExists) {
|
|
||||||
return `Branch "${baseBranch}" not found in repository`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
|
|
||||||
|
|
||||||
const handleBaseBranchChange = useCallback((value: string) => {
|
|
||||||
setBaseBranch(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSelectWorktreePath = useCallback(
|
|
||||||
(path: string) => {
|
|
||||||
setSelectedWorktreePath(path);
|
|
||||||
setWorkingDirFromUser(path);
|
|
||||||
},
|
|
||||||
[setWorkingDirFromUser]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isCreateWorktree || isNonGitDirectory) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (baseBranch) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = repoInfo?.currentBranch?.trim();
|
|
||||||
if (current) {
|
|
||||||
setBaseBranch(current);
|
|
||||||
}
|
|
||||||
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.currentBranch]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isNonGitDirectory && worktreeMode !== "none") {
|
|
||||||
setWorktreeMode("none");
|
|
||||||
setSelectedWorktreePath("");
|
|
||||||
}
|
|
||||||
}, [isNonGitDirectory, worktreeMode]);
|
|
||||||
|
|
||||||
const sessionMethods = useSessionStore((state) =>
|
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCreateFromInput = useCallback(
|
|
||||||
async ({
|
|
||||||
text,
|
|
||||||
images,
|
|
||||||
}: {
|
|
||||||
text: string;
|
|
||||||
images?: Array<{ uri: string; mimeType: string }>;
|
|
||||||
}) => {
|
|
||||||
setErrorMessage("");
|
|
||||||
const trimmedPath = workingDir.trim();
|
|
||||||
const trimmedPrompt = text.trim();
|
|
||||||
const resolvedWorkingDir =
|
|
||||||
isAttachWorktree && selectedWorktreePath
|
|
||||||
? selectedWorktreePath
|
|
||||||
: trimmedPath;
|
|
||||||
if (!trimmedPath) {
|
|
||||||
setErrorMessage("Working directory is required");
|
|
||||||
throw new Error("Working directory is required");
|
|
||||||
}
|
|
||||||
if (isDirectoryNotExists) {
|
|
||||||
setErrorMessage("Working directory does not exist on the selected host");
|
|
||||||
throw new Error("Working directory does not exist on the selected host");
|
|
||||||
}
|
|
||||||
if (!trimmedPrompt) {
|
|
||||||
setErrorMessage("Initial prompt is required");
|
|
||||||
throw new Error("Initial prompt is required");
|
|
||||||
}
|
|
||||||
if (!selectedServerId) {
|
|
||||||
setErrorMessage("No host selected");
|
|
||||||
throw new Error("No host selected");
|
|
||||||
}
|
|
||||||
if (gitBlockingError) {
|
|
||||||
setErrorMessage(gitBlockingError);
|
|
||||||
throw new Error(gitBlockingError);
|
|
||||||
}
|
|
||||||
if (isAttachWorktree && !selectedWorktreePath) {
|
|
||||||
const message = "Select a worktree to attach";
|
|
||||||
setErrorMessage(message);
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
if (baseBranchError) {
|
|
||||||
setErrorMessage(baseBranchError);
|
|
||||||
throw new Error(baseBranchError);
|
|
||||||
}
|
|
||||||
if (isLoading) {
|
|
||||||
throw new Error("Already loading");
|
|
||||||
}
|
|
||||||
const createAgent = sessionMethods?.createAgent;
|
|
||||||
if (!createAgent) {
|
|
||||||
setErrorMessage("Host is not connected");
|
|
||||||
throw new Error("Host is not connected");
|
|
||||||
}
|
|
||||||
const modeId =
|
|
||||||
modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
|
||||||
const trimmedModel = selectedModel.trim();
|
|
||||||
const config: AgentSessionConfig = {
|
|
||||||
provider: selectedProvider,
|
|
||||||
cwd: resolvedWorkingDir,
|
|
||||||
...(modeId ? { modeId } : {}),
|
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
|
||||||
};
|
|
||||||
const effectiveBaseBranch = baseBranch.trim();
|
|
||||||
const effectiveWorktreeSlug =
|
|
||||||
isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug;
|
|
||||||
if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) {
|
|
||||||
setWorktreeSlug(effectiveWorktreeSlug);
|
|
||||||
}
|
|
||||||
const gitOptions =
|
|
||||||
isCreateWorktree && !isNonGitDirectory && effectiveWorktreeSlug
|
|
||||||
? {
|
|
||||||
createWorktree: true,
|
|
||||||
createNewBranch: true,
|
|
||||||
newBranchName: effectiveWorktreeSlug,
|
|
||||||
worktreeSlug: effectiveWorktreeSlug,
|
|
||||||
baseBranch: effectiveBaseBranch,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
void persistFormPreferences();
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await createAgent({
|
|
||||||
config,
|
|
||||||
initialPrompt: trimmedPrompt,
|
|
||||||
images,
|
|
||||||
git: gitOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Success - clear prompt and navigate to agent
|
|
||||||
setPromptText("");
|
|
||||||
const agentId = (result as { id?: string })?.id;
|
|
||||||
if (agentId && selectedServerId) {
|
|
||||||
router.replace({
|
|
||||||
pathname: "/agent/[serverId]/[agentId]",
|
|
||||||
params: { serverId: selectedServerId, agentId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : "Failed to create agent";
|
|
||||||
setErrorMessage(message);
|
|
||||||
throw error; // Re-throw so AgentInputArea knows it failed
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
worktreeMode,
|
|
||||||
baseBranch,
|
|
||||||
worktreeSlug,
|
|
||||||
selectedWorktreePath,
|
|
||||||
repoInfo?.currentBranch,
|
|
||||||
gitBlockingError,
|
|
||||||
baseBranchError,
|
|
||||||
isDirectoryNotExists,
|
|
||||||
isLoading,
|
|
||||||
isNonGitDirectory,
|
|
||||||
modeOptions,
|
|
||||||
persistFormPreferences,
|
|
||||||
router,
|
|
||||||
selectedMode,
|
|
||||||
selectedModel,
|
|
||||||
selectedProvider,
|
|
||||||
selectedServerId,
|
|
||||||
sessionMethods,
|
|
||||||
workingDir,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (daemons.length === 0) {
|
|
||||||
return (
|
|
||||||
<WelcomeScreen
|
|
||||||
onHostAdded={(profile) => {
|
|
||||||
setSelectedServerIdFromUser(profile.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.agentPanel}>
|
|
||||||
<MenuHeader
|
|
||||||
title="New Agent"
|
|
||||||
rightContent={
|
|
||||||
<Pressable
|
|
||||||
style={styles.hostBadge}
|
|
||||||
onPress={() => openDropdownSheet("host")}
|
|
||||||
>
|
|
||||||
<Monitor size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.hostBadgeLabel}>{hostLabel}</Text>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.hostStatusDot,
|
|
||||||
hostEntry?.status === "online" && styles.hostStatusDotOnline,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{hostStatus ? (
|
|
||||||
<Text style={styles.hostBadgeStatus}>{hostStatus}</Text>
|
|
||||||
) : null}
|
|
||||||
</Pressable>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
|
|
||||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
|
||||||
<View style={styles.configSection}>
|
|
||||||
<WorkingDirectoryDropdown
|
|
||||||
workingDir={workingDir}
|
|
||||||
errorMessage=""
|
|
||||||
disabled={false}
|
|
||||||
suggestedPaths={agentWorkingDirSuggestions}
|
|
||||||
onSelectPath={setWorkingDirFromUser}
|
|
||||||
/>
|
|
||||||
{isDirectoryNotExists && (
|
|
||||||
<View style={styles.warningContainer}>
|
|
||||||
<Text style={styles.warningText}>
|
|
||||||
Directory does not exist on the selected host
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<AgentConfigRow
|
|
||||||
providerDefinitions={providerDefinitions}
|
|
||||||
selectedProvider={selectedProvider}
|
|
||||||
onSelectProvider={setProviderFromUser}
|
|
||||||
modeOptions={modeOptions}
|
|
||||||
selectedMode={selectedMode}
|
|
||||||
onSelectMode={setModeFromUser}
|
|
||||||
models={availableModels}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
isModelLoading={isModelLoading}
|
|
||||||
onSelectModel={setModelFromUser}
|
|
||||||
/>
|
|
||||||
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
|
|
||||||
<GitOptionsSection
|
|
||||||
worktreeMode={worktreeMode}
|
|
||||||
onWorktreeModeChange={handleWorktreeModeChange}
|
|
||||||
worktreeSlug={worktreeSlug}
|
|
||||||
currentBranch={repoInfo?.currentBranch ?? null}
|
|
||||||
baseBranch={baseBranch}
|
|
||||||
onBaseBranchChange={handleBaseBranchChange}
|
|
||||||
branches={repoInfo?.branches ?? []}
|
|
||||||
status={repoInfoStatus}
|
|
||||||
repoError={repoInfoError}
|
|
||||||
gitValidationError={gitBlockingError}
|
|
||||||
baseBranchError={baseBranchError}
|
|
||||||
worktreeOptions={worktreeOptions}
|
|
||||||
selectedWorktreePath={selectedWorktreePath}
|
|
||||||
worktreeOptionsStatus={worktreeOptionsStatus}
|
|
||||||
worktreeOptionsError={worktreeOptionsError}
|
|
||||||
attachWorktreeError={attachWorktreeError}
|
|
||||||
onSelectWorktreePath={handleSelectWorktreePath}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<DropdownSheet
|
|
||||||
title="Host"
|
|
||||||
visible={openDropdown === "host"}
|
|
||||||
onClose={closeDropdown}
|
|
||||||
>
|
|
||||||
{connectionStates.size === 0 ? (
|
|
||||||
<Text style={styles.dropdownHelper}>
|
|
||||||
No hosts available yet.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<View style={styles.dropdownSheetList}>
|
|
||||||
{Array.from(connectionStates.values()).map(({ daemon, status }) => {
|
|
||||||
const isSelected = daemon.id === selectedServerId;
|
|
||||||
const label = daemon.label ?? daemon.endpoints?.[0] ?? daemon.id;
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={daemon.id}
|
|
||||||
style={[
|
|
||||||
styles.dropdownSheetOption,
|
|
||||||
isSelected && styles.dropdownSheetOptionSelected,
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
setSelectedServerIdFromUser(daemon.id);
|
|
||||||
closeDropdown();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={styles.dropdownSheetOptionLabel}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.dropdownSheetOptionDescription}>
|
|
||||||
{formatConnectionStatus(status)}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</DropdownSheet>
|
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</ScrollView>
|
|
||||||
</Animated.View>
|
|
||||||
<View style={styles.inputAreaWrapper}>
|
|
||||||
<AgentInputArea
|
|
||||||
agentId={DRAFT_AGENT_ID}
|
|
||||||
serverId={selectedServerId ?? ""}
|
|
||||||
onSubmitMessage={handleCreateFromInput}
|
|
||||||
isSubmitLoading={isLoading}
|
|
||||||
value={promptText}
|
|
||||||
onChangeText={setPromptText}
|
|
||||||
autoFocus
|
|
||||||
onAddImages={handleAddImagesCallback}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<View style={styles.loadingOverlay}>
|
|
||||||
<View style={styles.loadingContent}>
|
|
||||||
<ActivityIndicator size="large" color={theme.colors.foreground} />
|
|
||||||
<Text style={styles.loadingText}>Creating agent...</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</FileDropZone>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
agentPanel: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
contentContainer: {
|
|
||||||
flex: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
inputAreaWrapper: {
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
},
|
|
||||||
configScrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
},
|
|
||||||
configSection: {
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingTop: theme.spacing[3],
|
|
||||||
paddingBottom: theme.spacing[4],
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
|
||||||
alignSelf: "center",
|
|
||||||
width: "100%",
|
|
||||||
},
|
|
||||||
dropdownHelper: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
dropdownSheetList: {
|
|
||||||
marginTop: theme.spacing[3],
|
|
||||||
},
|
|
||||||
dropdownSheetOption: {
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
backgroundColor: theme.colors.surface0,
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
|
||||||
dropdownSheetOptionSelected: {
|
|
||||||
borderColor: theme.colors.palette.blue[400],
|
|
||||||
backgroundColor: "rgba(59, 130, 246, 0.18)",
|
|
||||||
},
|
|
||||||
dropdownSheetOptionLabel: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
dropdownSheetOptionDescription: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
marginTop: theme.spacing[1],
|
|
||||||
},
|
|
||||||
errorContainer: {
|
|
||||||
paddingHorizontal: theme.spacing[4],
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
marginHorizontal: theme.spacing[4],
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
backgroundColor: theme.colors.destructive,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: theme.colors.destructiveForeground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
warningContainer: {
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
backgroundColor: theme.colors.palette.yellow[400],
|
|
||||||
},
|
|
||||||
warningText: {
|
|
||||||
color: "#000000",
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
hostBadge: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[2],
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
},
|
|
||||||
hostBadgeLabel: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.semibold,
|
|
||||||
},
|
|
||||||
hostBadgeStatus: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
hostStatusDot: {
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
backgroundColor: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
hostStatusDotOnline: {
|
|
||||||
backgroundColor: theme.colors.palette.green[500],
|
|
||||||
},
|
|
||||||
loadingOverlay: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
loadingContent: {
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[4],
|
|
||||||
},
|
|
||||||
loadingText: {
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
fontWeight: theme.fontWeight.medium,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ interface SelectFieldProps {
|
|||||||
warningMessage?: string | null;
|
warningMessage?: string | null;
|
||||||
helperText?: string | null;
|
helperText?: string | null;
|
||||||
controlRef?: React.RefObject<View | null>;
|
controlRef?: React.RefObject<View | null>;
|
||||||
|
valueEllipsizeMode?: "head" | "middle" | "tail" | "clip";
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ export function SelectField({
|
|||||||
warningMessage,
|
warningMessage,
|
||||||
helperText,
|
helperText,
|
||||||
controlRef,
|
controlRef,
|
||||||
|
valueEllipsizeMode,
|
||||||
testID,
|
testID,
|
||||||
}: SelectFieldProps): ReactElement {
|
}: SelectFieldProps): ReactElement {
|
||||||
return (
|
return (
|
||||||
@@ -156,6 +158,7 @@ export function SelectField({
|
|||||||
<Text
|
<Text
|
||||||
style={value ? styles.selectFieldValue : styles.selectFieldPlaceholder}
|
style={value ? styles.selectFieldValue : styles.selectFieldPlaceholder}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
|
ellipsizeMode={valueEllipsizeMode ?? "tail"}
|
||||||
>
|
>
|
||||||
{value || placeholder || "Select..."}
|
{value || placeholder || "Select..."}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1045,6 +1048,13 @@ export function WorkingDirectoryDropdown({
|
|||||||
[handleClose, onSelectPath]
|
[handleClose, onSelectPath]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSubmitSearch = useCallback(() => {
|
||||||
|
if (!showCustomOption) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSelect(sanitizedSearchValue);
|
||||||
|
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SelectField
|
<SelectField
|
||||||
@@ -1054,7 +1064,9 @@ export function WorkingDirectoryDropdown({
|
|||||||
onPress={handleOpen}
|
onPress={handleOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
errorMessage={errorMessage || undefined}
|
errorMessage={errorMessage || undefined}
|
||||||
|
valueEllipsizeMode="middle"
|
||||||
controlRef={anchorRef}
|
controlRef={anchorRef}
|
||||||
|
testID="working-directory-select"
|
||||||
/>
|
/>
|
||||||
<AdaptiveSelect
|
<AdaptiveSelect
|
||||||
title="Working Directory"
|
title="Working Directory"
|
||||||
@@ -1072,6 +1084,7 @@ export function WorkingDirectoryDropdown({
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onSubmitEditing={handleSubmitSearch}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BottomSheetTextInput
|
<BottomSheetTextInput
|
||||||
@@ -1083,6 +1096,7 @@ export function WorkingDirectoryDropdown({
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onSubmitEditing={handleSubmitSearch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!hasSuggestedPaths && !showCustomOption ? (
|
{!hasSuggestedPaths && !showCustomOption ? (
|
||||||
@@ -1094,6 +1108,7 @@ export function WorkingDirectoryDropdown({
|
|||||||
<View style={styles.dropdownSheetList}>
|
<View style={styles.dropdownSheetList}>
|
||||||
<Pressable
|
<Pressable
|
||||||
key="working-dir-custom-option"
|
key="working-dir-custom-option"
|
||||||
|
testID="working-directory-custom-option"
|
||||||
style={styles.dropdownSheetOption}
|
style={styles.dropdownSheetOption}
|
||||||
onPress={() => handleSelect(sanitizedSearchValue)}
|
onPress={() => handleSelect(sanitizedSearchValue)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface AgentInputAreaProps {
|
|||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
|
/** When true, blurs the input immediately when submitting. */
|
||||||
|
blurOnSubmit?: boolean;
|
||||||
value?: string;
|
value?: string;
|
||||||
onChangeText?: (text: string) => void;
|
onChangeText?: (text: string) => void;
|
||||||
/** When true, auto-focuses the text input on web. */
|
/** When true, auto-focuses the text input on web. */
|
||||||
@@ -70,6 +72,7 @@ export function AgentInputArea({
|
|||||||
serverId,
|
serverId,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
onChangeText,
|
onChangeText,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
@@ -219,10 +222,13 @@ export function AgentInputArea({
|
|||||||
forceSend?: boolean
|
forceSend?: boolean
|
||||||
) {
|
) {
|
||||||
const socketConnected = isConnected;
|
const socketConnected = isConnected;
|
||||||
if (!message.trim() || !socketConnected) return;
|
|
||||||
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
|
|
||||||
|
|
||||||
const trimmedMessage = message.trim();
|
const trimmedMessage = message.trim();
|
||||||
|
if (!trimmedMessage) return;
|
||||||
|
// When the parent controls submission (e.g. draft agent creation), let it
|
||||||
|
// decide what to do even if the socket is currently disconnected (so we
|
||||||
|
// don't no-op and lose deterministic error handling in the UI/tests).
|
||||||
|
if (!onSubmitMessageRef.current && !socketConnected) return;
|
||||||
|
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
|
||||||
|
|
||||||
if (agent?.status === "running" && !forceSend) {
|
if (agent?.status === "running" && !forceSend) {
|
||||||
queueMessage(trimmedMessage, imageAttachments);
|
queueMessage(trimmedMessage, imageAttachments);
|
||||||
@@ -257,6 +263,9 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(payload: MessagePayload) {
|
function handleSubmit(payload: MessagePayload) {
|
||||||
|
if (blurOnSubmit) {
|
||||||
|
messageInputRef.current?.blur();
|
||||||
|
}
|
||||||
void sendMessageWithContent(
|
void sendMessageWithContent(
|
||||||
payload.text,
|
payload.text,
|
||||||
payload.images,
|
payload.images,
|
||||||
@@ -569,7 +578,7 @@ export function AgentInputArea({
|
|||||||
client={client}
|
client={client}
|
||||||
placeholder="Message agent..."
|
placeholder="Message agent..."
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
disabled={isRealtimeMode}
|
disabled={isRealtimeMode || isSubmitLoading}
|
||||||
isScreenFocused={isScreenFocused}
|
isScreenFocused={isScreenFocused}
|
||||||
leftContent={leftContent}
|
leftContent={leftContent}
|
||||||
rightContent={rightContent}
|
rightContent={rightContent}
|
||||||
|
|||||||
@@ -84,13 +84,7 @@ export function AgentList({
|
|||||||
|
|
||||||
onAgentSelect?.();
|
onAgentSelect?.();
|
||||||
|
|
||||||
navigate({
|
navigate(`/agent/${serverId}/${agentId}` as any);
|
||||||
pathname: "/agent/[serverId]/[agentId]",
|
|
||||||
params: {
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[isActionSheetVisible, pathname, onAgentSelect]
|
[isActionSheetVisible, pathname, onAgentSelect]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -657,7 +657,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
predicate: (query) =>
|
predicate: (query) =>
|
||||||
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
||||||
});
|
});
|
||||||
router.replace("/");
|
router.replace("/agent" as any);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
const message = err instanceof Error ? err.message : "Failed to archive worktree";
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function HomeFooter() {
|
|||||||
{/* Action menu */}
|
{/* Action menu */}
|
||||||
<View style={styles.actionButtonContainer}>
|
<View style={styles.actionButtonContainer}>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push("/")}
|
onPress={() => router.push("/agents")}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.footerButton,
|
styles.footerButton,
|
||||||
pressed && styles.buttonPressed,
|
pressed && styles.buttonPressed,
|
||||||
@@ -142,7 +142,7 @@ export function HomeFooter() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
console.log("[HomeFooter] New Agent button pressed");
|
console.log("[HomeFooter] New Agent button pressed");
|
||||||
router.push("/");
|
router.push("/agent" as any);
|
||||||
}}
|
}}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.footerButton,
|
styles.footerButton,
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface MessageInputProps {
|
|||||||
|
|
||||||
export interface MessageInputRef {
|
export interface MessageInputRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_INPUT_HEIGHT = 30;
|
const MIN_INPUT_HEIGHT = 30;
|
||||||
@@ -118,15 +119,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
) {
|
) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const textInputRef = useRef<
|
const textInputRef = useRef<
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const isInputFocusedRef = useRef(false);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
},
|
},
|
||||||
|
blur: () => {
|
||||||
|
textInputRef.current?.blur?.();
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
const inputHeightRef = useRef(MIN_INPUT_HEIGHT);
|
||||||
const baselineInputHeightRef = useRef<number | null>(null);
|
const baselineInputHeightRef = useRef<number | null>(null);
|
||||||
@@ -210,6 +215,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
enableDuration: true,
|
enableDuration: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isDictatingRef = useRef(isDictating);
|
||||||
|
useEffect(() => {
|
||||||
|
isDictatingRef.current = isDictating;
|
||||||
|
}, [isDictating]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDictating || isDictationProcessing) {
|
if (isDictating || isDictationProcessing) {
|
||||||
return;
|
return;
|
||||||
@@ -219,12 +229,29 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
|
|
||||||
// Cmd+D to start/submit dictation, Escape to cancel
|
// Cmd+D to start/submit dictation, Escape to cancel
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!IS_WEB || !isScreenFocused) return;
|
if (!IS_WEB) return;
|
||||||
|
const resolveNativeInput = (): unknown => {
|
||||||
|
const current = textInputRef.current as any;
|
||||||
|
if (!current) return null;
|
||||||
|
if (typeof current.getNativeRef === "function") {
|
||||||
|
return current.getNativeRef();
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
const nativeInput = resolveNativeInput();
|
||||||
|
const isFromInput = Boolean(nativeInput && event.target === nativeInput);
|
||||||
|
if (!isScreenFocused && !isInputFocusedRef.current && !isFromInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dictating = isDictatingRef.current;
|
||||||
// Cmd+D: start dictation or submit if already dictating
|
// Cmd+D: start dictation or submit if already dictating
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "d") {
|
if (
|
||||||
|
(event.metaKey || event.ctrlKey) &&
|
||||||
|
(event.code === "KeyD" || event.key.toLowerCase() === "d")
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isDictating) {
|
if (dictating) {
|
||||||
sendAfterTranscriptRef.current = true;
|
sendAfterTranscriptRef.current = true;
|
||||||
confirmDictation();
|
confirmDictation();
|
||||||
} else {
|
} else {
|
||||||
@@ -233,14 +260,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Escape: cancel dictation
|
// Escape: cancel dictation
|
||||||
if (event.key === "Escape" && isDictating) {
|
if (event.key === "Escape" && dictating) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
cancelDictation();
|
cancelDictation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [isDictating, cancelDictation, confirmDictation, startDictation]);
|
}, [cancelDictation, confirmDictation, isScreenFocused, startDictation]);
|
||||||
|
|
||||||
// Animate overlay
|
// Animate overlay
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -400,15 +427,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
|
|
||||||
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent;
|
||||||
|
|
||||||
|
const key = event.nativeEvent.key.toLowerCase();
|
||||||
|
|
||||||
// Cmd+B or Ctrl+B: toggle sidebar
|
// Cmd+B or Ctrl+B: toggle sidebar
|
||||||
if ((metaKey || ctrlKey) && event.nativeEvent.key === "b") {
|
if ((metaKey || ctrlKey) && key === "b") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
toggleAgentList();
|
toggleAgentList();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
|
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
|
||||||
if ((metaKey || ctrlKey) && event.nativeEvent.key === "d") {
|
if ((metaKey || ctrlKey) && key === "d") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isDictating) {
|
if (isDictating) {
|
||||||
sendAfterTranscriptRef.current = true;
|
sendAfterTranscriptRef.current = true;
|
||||||
@@ -502,6 +531,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
onChangeText={onChangeText}
|
onChangeText={onChangeText}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={theme.colors.mutedForeground}
|
placeholderTextColor={theme.colors.mutedForeground}
|
||||||
|
onFocus={() => {
|
||||||
|
isInputFocusedRef.current = true;
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
isInputFocusedRef.current = false;
|
||||||
|
}}
|
||||||
style={[
|
style={[
|
||||||
styles.textInput,
|
styles.textInput,
|
||||||
IS_WEB
|
IS_WEB
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
}, [closeToAgent]);
|
}, [closeToAgent]);
|
||||||
|
|
||||||
const handleCreateAgentClean = useCallback(() => {
|
const handleCreateAgentClean = useCallback(() => {
|
||||||
router.push("/");
|
router.push("/agent" as any);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Mobile: close sidebar and navigate
|
// Mobile: close sidebar and navigate
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ const int16ToBase64 = (pcm: Int16Array): string => {
|
|||||||
return btoa(binary);
|
return btoa(binary);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RecorderRefs = {
|
||||||
|
recorder: MediaRecorder | null;
|
||||||
|
audioChunks: Blob[];
|
||||||
|
stoppedPromise: Promise<Blob> | null;
|
||||||
|
stoppedResolve: ((blob: Blob) => void) | null;
|
||||||
|
stoppedReject: ((error: unknown) => void) | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
|
export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource {
|
||||||
const [volume, setVolume] = useState(0);
|
const [volume, setVolume] = useState(0);
|
||||||
|
|
||||||
@@ -84,7 +92,49 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
gain: GainNode | null;
|
gain: GainNode | null;
|
||||||
pending: Int16Array;
|
pending: Int16Array;
|
||||||
started: boolean;
|
started: boolean;
|
||||||
}>({ stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false });
|
mode: "pcm" | "recorder";
|
||||||
|
recorder: RecorderRefs;
|
||||||
|
}>({
|
||||||
|
stream: null,
|
||||||
|
context: null,
|
||||||
|
source: null,
|
||||||
|
processor: null,
|
||||||
|
gain: null,
|
||||||
|
pending: new Int16Array(0),
|
||||||
|
started: false,
|
||||||
|
mode: "pcm",
|
||||||
|
recorder: {
|
||||||
|
recorder: null,
|
||||||
|
audioChunks: [],
|
||||||
|
stoppedPromise: null,
|
||||||
|
stoppedResolve: null,
|
||||||
|
stoppedReject: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const decodeAudioData = useCallback(async (context: AudioContext, buffer: ArrayBuffer): Promise<AudioBuffer> => {
|
||||||
|
const maybePromise = context.decodeAudioData(buffer);
|
||||||
|
if (maybePromise && typeof (maybePromise as Promise<AudioBuffer>).then === "function") {
|
||||||
|
return maybePromise as Promise<AudioBuffer>;
|
||||||
|
}
|
||||||
|
return await new Promise<AudioBuffer>((resolve, reject) => {
|
||||||
|
context.decodeAudioData(buffer, resolve, reject);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const emitPcmSegments = useCallback((pcm: Int16Array) => {
|
||||||
|
const outputRate = 16000;
|
||||||
|
const chunkSamples = outputRate; // ~1s
|
||||||
|
let pending = pcm;
|
||||||
|
while (pending.length >= chunkSamples) {
|
||||||
|
const chunk = pending.slice(0, chunkSamples);
|
||||||
|
pending = pending.slice(chunkSamples);
|
||||||
|
onPcmSegmentRef.current(int16ToBase64(chunk));
|
||||||
|
}
|
||||||
|
if (pending.length > 0) {
|
||||||
|
onPcmSegmentRef.current(int16ToBase64(pending));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const start = useCallback(async () => {
|
const start = useCallback(async () => {
|
||||||
const missingNavigator =
|
const missingNavigator =
|
||||||
@@ -111,7 +161,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
throw new Error("AudioContext unavailable");
|
throw new Error("AudioContext unavailable");
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const rawStream = await navigator.mediaDevices.getUserMedia({
|
||||||
audio: {
|
audio: {
|
||||||
channelCount: 1,
|
channelCount: 1,
|
||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
@@ -120,54 +170,131 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const stream = rawStream as MediaStream;
|
||||||
|
|
||||||
const context = new AudioContextCtor();
|
const context = new AudioContextCtor();
|
||||||
const source = context.createMediaStreamSource(stream);
|
|
||||||
const processor = context.createScriptProcessor(4096, 1, 1);
|
|
||||||
const gain = context.createGain();
|
|
||||||
gain.gain.value = 0;
|
|
||||||
|
|
||||||
const outputRate = 16000;
|
try {
|
||||||
const chunkSamples = outputRate; // ~1s
|
const source = context.createMediaStreamSource(stream);
|
||||||
|
const processor = context.createScriptProcessor(4096, 1, 1);
|
||||||
|
const gain = context.createGain();
|
||||||
|
gain.gain.value = 0;
|
||||||
|
|
||||||
refs.current.started = true;
|
const outputRate = 16000;
|
||||||
|
const chunkSamples = outputRate; // ~1s
|
||||||
|
|
||||||
processor.onaudioprocess = (event) => {
|
refs.current.started = true;
|
||||||
if (!refs.current.started) {
|
refs.current.mode = "pcm";
|
||||||
return;
|
|
||||||
}
|
|
||||||
const input = event.inputBuffer.getChannelData(0);
|
|
||||||
|
|
||||||
let sumSquares = 0;
|
processor.onaudioprocess = (event) => {
|
||||||
for (let i = 0; i < input.length; i++) {
|
if (!refs.current.started) {
|
||||||
const sample = input[i];
|
return;
|
||||||
sumSquares += sample * sample;
|
}
|
||||||
}
|
const input = event.inputBuffer.getChannelData(0);
|
||||||
const rms = Math.sqrt(sumSquares / Math.max(1, input.length));
|
|
||||||
const normalized = Math.min(1, Math.max(0, rms * 2));
|
|
||||||
setVolume(normalized);
|
|
||||||
|
|
||||||
const next = resampleToPcm16(input, context.sampleRate, outputRate);
|
let sumSquares = 0;
|
||||||
refs.current.pending = concatInt16(refs.current.pending, next);
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const sample = input[i];
|
||||||
|
sumSquares += sample * sample;
|
||||||
|
}
|
||||||
|
const rms = Math.sqrt(sumSquares / Math.max(1, input.length));
|
||||||
|
const normalized = Math.min(1, Math.max(0, rms * 2));
|
||||||
|
setVolume(normalized);
|
||||||
|
|
||||||
while (refs.current.pending.length >= chunkSamples) {
|
const next = resampleToPcm16(input, context.sampleRate, outputRate);
|
||||||
const chunk = refs.current.pending.slice(0, chunkSamples);
|
refs.current.pending = concatInt16(refs.current.pending, next);
|
||||||
refs.current.pending = refs.current.pending.slice(chunkSamples);
|
|
||||||
onPcmSegmentRef.current(int16ToBase64(chunk));
|
while (refs.current.pending.length >= chunkSamples) {
|
||||||
}
|
const chunk = refs.current.pending.slice(0, chunkSamples);
|
||||||
|
refs.current.pending = refs.current.pending.slice(chunkSamples);
|
||||||
|
onPcmSegmentRef.current(int16ToBase64(chunk));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.connect(processor);
|
||||||
|
processor.connect(gain);
|
||||||
|
gain.connect(context.destination);
|
||||||
|
|
||||||
|
refs.current = {
|
||||||
|
...refs.current,
|
||||||
|
stream,
|
||||||
|
context,
|
||||||
|
source,
|
||||||
|
processor,
|
||||||
|
gain,
|
||||||
|
pending: new Int16Array(0),
|
||||||
|
started: true,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall back to MediaRecorder for environments where MediaStreamAudioSourceNode
|
||||||
|
// isn't available (e.g., Playwright tests with a stubbed getUserMedia).
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecorderCtor = typeof window !== "undefined" ? (window as any).MediaRecorder : undefined;
|
||||||
|
if (!RecorderCtor) {
|
||||||
|
throw new Error("MediaRecorder unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorder = new RecorderCtor(stream, { mimeType: "audio/webm;codecs=opus" } as MediaRecorderOptions) as MediaRecorder;
|
||||||
|
|
||||||
|
const recorderRefs: RecorderRefs = {
|
||||||
|
recorder,
|
||||||
|
audioChunks: [],
|
||||||
|
stoppedPromise: null,
|
||||||
|
stoppedResolve: null,
|
||||||
|
stoppedReject: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
source.connect(processor);
|
recorder.ondataavailable = (event: any) => {
|
||||||
processor.connect(gain);
|
const data: Blob | undefined = event?.data;
|
||||||
gain.connect(context.destination);
|
if (data) {
|
||||||
|
recorderRefs.audioChunks.push(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
recorder.onerror = (event: any) => {
|
||||||
|
recorderRefs.stoppedReject?.(event);
|
||||||
|
};
|
||||||
|
recorder.addEventListener("stop", () => {
|
||||||
|
try {
|
||||||
|
const blob = recorderRefs.audioChunks.length > 0 ? new Blob(recorderRefs.audioChunks, { type: recorder.mimeType }) : new Blob([], { type: recorder.mimeType });
|
||||||
|
recorderRefs.stoppedResolve?.(blob);
|
||||||
|
} catch (err) {
|
||||||
|
recorderRefs.stoppedReject?.(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
refs.current = { stream, context, source, processor, gain, pending: new Int16Array(0), started: true };
|
recorderRefs.stoppedPromise = new Promise<Blob>((resolve, reject) => {
|
||||||
|
recorderRefs.stoppedResolve = resolve;
|
||||||
|
recorderRefs.stoppedReject = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
recorder.start();
|
||||||
|
} catch (err) {
|
||||||
|
throw err instanceof Error ? err : new Error(String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
refs.current = {
|
||||||
|
...refs.current,
|
||||||
|
stream,
|
||||||
|
context,
|
||||||
|
source: null,
|
||||||
|
processor: null,
|
||||||
|
gain: null,
|
||||||
|
pending: new Int16Array(0),
|
||||||
|
started: true,
|
||||||
|
mode: "recorder",
|
||||||
|
recorder: recorderRefs,
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
const stop = useCallback(async () => {
|
||||||
refs.current.started = false;
|
refs.current.started = false;
|
||||||
setVolume(0);
|
setVolume(0);
|
||||||
|
|
||||||
const { processor, source, gain, context, stream, pending } = refs.current;
|
const { processor, source, gain, context, stream, pending, mode, recorder } =
|
||||||
|
refs.current;
|
||||||
|
|
||||||
if (processor) {
|
if (processor) {
|
||||||
try {
|
try {
|
||||||
@@ -204,6 +331,32 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === "recorder" && recorder.recorder) {
|
||||||
|
try {
|
||||||
|
if (recorder.recorder.state === "recording") {
|
||||||
|
recorder.recorder.stop();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore stop errors; we still need to stop tracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "recorder" && recorder.stoppedPromise && context) {
|
||||||
|
try {
|
||||||
|
const blob = await recorder.stoppedPromise;
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
if (arrayBuffer.byteLength > 0) {
|
||||||
|
const decoded = await decodeAudioData(context, arrayBuffer);
|
||||||
|
const floatPcm = decoded.getChannelData(0);
|
||||||
|
const pcm16 = resampleToPcm16(floatPcm, decoded.sampleRate, 16000);
|
||||||
|
emitPcmSegments(pcm16);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onErrorRef.current?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (context) {
|
if (context) {
|
||||||
try {
|
try {
|
||||||
await context.close();
|
await context.close();
|
||||||
@@ -216,8 +369,24 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
onPcmSegmentRef.current(int16ToBase64(pending));
|
onPcmSegmentRef.current(int16ToBase64(pending));
|
||||||
}
|
}
|
||||||
|
|
||||||
refs.current = { stream: null, context: null, source: null, processor: null, gain: null, pending: new Int16Array(0), started: false };
|
refs.current = {
|
||||||
}, []);
|
stream: null,
|
||||||
|
context: null,
|
||||||
|
source: null,
|
||||||
|
processor: null,
|
||||||
|
gain: null,
|
||||||
|
pending: new Int16Array(0),
|
||||||
|
started: false,
|
||||||
|
mode: "pcm",
|
||||||
|
recorder: {
|
||||||
|
recorder: null,
|
||||||
|
audioChunks: [],
|
||||||
|
stoppedPromise: null,
|
||||||
|
stoppedResolve: null,
|
||||||
|
stoppedReject: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [decodeAudioData, emitPcmSegments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -244,4 +413,3 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
|
|||||||
[start, stop, volume]
|
[start, stop, volume]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
|
|
||||||
Reference in New Issue
Block a user