feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { ScrollViewStyleReset } from "expo-router/html";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
// Ensure Unistyles runs before Expo Router statically renders each page.
|
||||
import "../styles/unistyles";
|
||||
|
||||
export default function Root({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
{/* Reset scroll styles so React Native Web views behave like native. */}
|
||||
<ScrollViewStyleReset />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -210,13 +210,9 @@ export default function AgentScreen() {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (!agent?.persistence) {
|
||||
handleCloseMenu();
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
refreshAgent({ agentId: id });
|
||||
}, [agent?.persistence, handleCloseMenu, id, refreshAgent]);
|
||||
}, [handleCloseMenu, id, refreshAgent]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
@@ -294,28 +290,26 @@ export default function AgentScreen() {
|
||||
<Folder size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||
</Pressable>
|
||||
{agent.persistence && (
|
||||
<Pressable
|
||||
onPress={handleRefreshAgent}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
isInitializing ? styles.menuItemDisabled : null,
|
||||
]}
|
||||
disabled={isInitializing}
|
||||
>
|
||||
<RotateCcw size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>
|
||||
{isInitializing ? "Refreshing..." : "Refresh"}
|
||||
</Text>
|
||||
{isInitializing && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.primary}
|
||||
style={styles.menuItemSpinner}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={handleRefreshAgent}
|
||||
style={[
|
||||
styles.menuItem,
|
||||
isInitializing ? styles.menuItemDisabled : null,
|
||||
]}
|
||||
disabled={isInitializing}
|
||||
>
|
||||
<RotateCcw size={20} color={theme.colors.foreground} />
|
||||
<Text style={styles.menuItemText}>
|
||||
{isInitializing ? "Refreshing..." : "Refresh"}
|
||||
</Text>
|
||||
{isInitializing && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.primary}
|
||||
style={styles.menuItemSpinner}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
@@ -4,24 +4,29 @@ import {
|
||||
FlatList,
|
||||
Image as RNImage,
|
||||
ListRenderItemInfo,
|
||||
ViewToken,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
BackHandler,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
Image as ImageIcon,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
import { useSession, type ExplorerEntry } from "@/contexts/session-context";
|
||||
@@ -42,7 +47,9 @@ export default function FileExplorerScreen() {
|
||||
fileExplorer,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
navigateExplorerBack,
|
||||
} = useSession();
|
||||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||||
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
|
||||
const pendingPathParamRef = useRef<string | null>(null);
|
||||
const pendingFileParamRef = useRef<string | null>(null);
|
||||
@@ -50,16 +57,27 @@ export default function FileExplorerScreen() {
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
|
||||
const listScrollOffsetRef = useRef(0);
|
||||
const scrollOffsetsByPathRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingScrollRestoreRef = useRef<number | null>(null);
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
|
||||
const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
|
||||
const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
|
||||
const derivedDirectoryFromFile = normalizedFileParam
|
||||
? deriveDirectoryFromFile(normalizedFileParam)
|
||||
: null;
|
||||
const initialTargetDirectory = normalizedPathParam ?? derivedDirectoryFromFile ?? ".";
|
||||
|
||||
const agent = agentId ? agents.get(agentId) : undefined;
|
||||
const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
|
||||
const history = explorerState?.history ?? [];
|
||||
const lastKnownDirectory = history[history.length - 1];
|
||||
const rememberedDirectory = explorerState?.lastVisitedPath;
|
||||
const initialTargetDirectory =
|
||||
normalizedPathParam ??
|
||||
derivedDirectoryFromFile ??
|
||||
rememberedDirectory ??
|
||||
lastKnownDirectory ??
|
||||
".";
|
||||
const currentPath = explorerState?.currentPath ?? ".";
|
||||
const pendingRequest = explorerState?.pendingRequest ?? null;
|
||||
const isExplorerLoading = explorerState?.isLoading ?? false;
|
||||
@@ -81,6 +99,49 @@ export default function FileExplorerScreen() {
|
||||
? explorerState?.files.get(selectedEntryPath)
|
||||
: null;
|
||||
const shouldShowPreview = Boolean(selectedEntryPath);
|
||||
const pendingThumbnailPathsRef = useRef<Set<string>>(new Set());
|
||||
const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState<Record<string, boolean>>({});
|
||||
const viewabilityConfigRef = useRef({ itemVisiblePercentThreshold: 50 });
|
||||
const gridColumnCount = useMemo(() => {
|
||||
if (windowWidth >= 1500) {
|
||||
return 6;
|
||||
}
|
||||
if (windowWidth >= 1200) {
|
||||
return 5;
|
||||
}
|
||||
if (windowWidth >= 960) {
|
||||
return 4;
|
||||
}
|
||||
if (windowWidth >= 720) {
|
||||
return 3;
|
||||
}
|
||||
if (windowWidth >= 520) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}, [windowWidth]);
|
||||
const listColumns = viewMode === "grid" ? gridColumnCount : 1;
|
||||
const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list";
|
||||
|
||||
const restoreQueuedScrollOffset = useCallback(() => {
|
||||
if (pendingScrollRestoreRef.current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!listScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetOffset = pendingScrollRestoreRef.current;
|
||||
listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false });
|
||||
listScrollOffsetRef.current = targetOffset;
|
||||
pendingScrollRestoreRef.current = null;
|
||||
}, []);
|
||||
|
||||
const queueScrollRestore = useCallback((offset: number) => {
|
||||
pendingScrollRestoreRef.current = offset;
|
||||
requestAnimationFrame(restoreQueuedScrollOffset);
|
||||
}, [restoreQueuedScrollOffset]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntryPath(null);
|
||||
@@ -91,28 +152,15 @@ export default function FileExplorerScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetOffset = listScrollOffsetRef.current;
|
||||
if (!listScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false });
|
||||
}, [shouldShowPreview]);
|
||||
const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? listScrollOffsetRef.current;
|
||||
queueScrollRestore(savedOffset);
|
||||
}, [activePath, queueScrollRestore, shouldShowPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
listScrollOffsetRef.current = 0;
|
||||
listScrollRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}, [activePath]);
|
||||
|
||||
const parentPath = useMemo(() => {
|
||||
if (activePath === ".") {
|
||||
return null;
|
||||
}
|
||||
const segments = activePath.split("/");
|
||||
segments.pop();
|
||||
const nextPath = segments.join("/");
|
||||
return nextPath.length === 0 ? "." : nextPath;
|
||||
}, [activePath]);
|
||||
const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? 0;
|
||||
listScrollOffsetRef.current = savedOffset;
|
||||
queueScrollRestore(savedOffset);
|
||||
}, [activePath, queueScrollRestore]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopiedPath(null);
|
||||
@@ -179,14 +227,6 @@ export default function FileExplorerScreen() {
|
||||
[agentId, requestDirectoryListing, requestFilePreview]
|
||||
);
|
||||
|
||||
const handleNavigateUp = useCallback(() => {
|
||||
if (!agentId || !parentPath) {
|
||||
return;
|
||||
}
|
||||
setSelectedEntryPath(null);
|
||||
requestDirectoryListing(agentId, parentPath);
|
||||
}, [agentId, parentPath, requestDirectoryListing]);
|
||||
|
||||
const handleCopyPath = useCallback(async (path: string) => {
|
||||
await Clipboard.setStringAsync(path);
|
||||
setCopiedPath(path);
|
||||
@@ -209,13 +249,87 @@ export default function FileExplorerScreen() {
|
||||
|
||||
const handleListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
listScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
const offset = event.nativeEvent.contentOffset.y;
|
||||
listScrollOffsetRef.current = offset;
|
||||
scrollOffsetsByPathRef.current.set(activePath, offset);
|
||||
},
|
||||
[]
|
||||
[activePath]
|
||||
);
|
||||
|
||||
const handleCloseExplorer = useCallback(() => {
|
||||
if (agentId) {
|
||||
router.replace({ pathname: "/agent/[id]", params: { id: agentId } });
|
||||
return;
|
||||
}
|
||||
|
||||
router.back();
|
||||
}, [agentId]);
|
||||
|
||||
const handleBackNavigation = useCallback(() => {
|
||||
if (!agentId) {
|
||||
router.back();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shouldShowPreview) {
|
||||
setSelectedEntryPath(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((explorerState?.history?.length ?? 0) > 1) {
|
||||
navigateExplorerBack(agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
handleCloseExplorer();
|
||||
return true;
|
||||
}, [agentId, explorerState?.history?.length, handleCloseExplorer, navigateExplorerBack, shouldShowPreview]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const subscription = BackHandler.addEventListener("hardwareBackPress", handleBackNavigation);
|
||||
return () => subscription.remove();
|
||||
}, [handleBackNavigation])
|
||||
);
|
||||
|
||||
const renderEntry = useCallback(
|
||||
({ item }: ListRenderItemInfo<ExplorerEntry>) => {
|
||||
if (viewMode === "grid") {
|
||||
const preview = explorerState?.files.get(item.path);
|
||||
const isImage = getEntryDisplayKind(item) === "image";
|
||||
const isLoadingThumb = Boolean(thumbnailLoadingMap[item.path]);
|
||||
return (
|
||||
<Pressable
|
||||
style={styles.gridCard}
|
||||
onPress={() => handleEntryPress(item)}
|
||||
>
|
||||
<View
|
||||
style={[styles.gridThumbnail, isImage && styles.gridImageBackground]}
|
||||
>
|
||||
{isImage && preview?.content ? (
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
}}
|
||||
style={styles.gridImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : isImage && isLoadingThumb ? (
|
||||
<ActivityIndicator size="small" />
|
||||
) : (
|
||||
renderEntryIcon(getEntryDisplayKind(item), theme.colors)
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.gridName} numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={styles.gridMeta} numberOfLines={1}>
|
||||
{formatFileSize({ size: item.size })}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const displayKind = getEntryDisplayKind(item);
|
||||
return (
|
||||
<Pressable
|
||||
@@ -254,17 +368,23 @@ export default function FileExplorerScreen() {
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
[copiedPath, handleCopyPath, handleEntryPress, theme.colors]
|
||||
[
|
||||
copiedPath,
|
||||
explorerState?.files,
|
||||
handleCopyPath,
|
||||
handleEntryPress,
|
||||
theme.colors,
|
||||
thumbnailLoadingMap,
|
||||
viewMode,
|
||||
]
|
||||
);
|
||||
|
||||
const listHeaderComponent = useMemo(() => {
|
||||
if (!parentPath && !showListLoadingBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.headerContainer}>
|
||||
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />}
|
||||
<View style={styles.headerRow}>
|
||||
<ViewToggle viewMode={viewMode} onChange={setViewMode} />
|
||||
</View>
|
||||
{showListLoadingBanner && (
|
||||
<View style={styles.loadingBanner}>
|
||||
<ActivityIndicator size="small" />
|
||||
@@ -275,12 +395,20 @@ export default function FileExplorerScreen() {
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, [activePath, handleNavigateUp, parentPath, showListLoadingBanner]);
|
||||
}, [activePath, showListLoadingBanner, viewMode]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title="Files" />
|
||||
<BackHeader
|
||||
title="Files"
|
||||
onBack={handleBackNavigation}
|
||||
rightContent={
|
||||
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
|
||||
<X size={18} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
@@ -288,14 +416,73 @@ export default function FileExplorerScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const handleViewableItemsChanged = useCallback(
|
||||
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
|
||||
if (!agentId || viewMode !== "grid") {
|
||||
return;
|
||||
}
|
||||
|
||||
viewableItems.forEach((token) => {
|
||||
const item = token.item as ExplorerEntry | undefined;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getEntryDisplayKind(item) !== "image") {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPreview = explorerState?.files.get(item.path);
|
||||
if (hasPreview || pendingThumbnailPathsRef.current.has(item.path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingThumbnailPathsRef.current.add(item.path);
|
||||
setThumbnailLoadingMap((prev) => ({ ...prev, [item.path]: true }));
|
||||
requestFilePreview(agentId, item.path);
|
||||
});
|
||||
},
|
||||
[agentId, explorerState?.files, requestFilePreview, viewMode]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!explorerState) {
|
||||
return;
|
||||
}
|
||||
setThumbnailLoadingMap((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
Object.keys(prev).forEach((path) => {
|
||||
if (explorerState.files.has(path)) {
|
||||
delete next[path];
|
||||
pendingThumbnailPathsRef.current.delete(path);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [explorerState?.files.size]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingThumbnailPathsRef.current.clear();
|
||||
setThumbnailLoadingMap({});
|
||||
}, [activePath, viewMode]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<BackHeader title={selectedEntryPath ?? (activePath || ".")} />
|
||||
<BackHeader
|
||||
title={selectedEntryPath ?? (activePath || ".")}
|
||||
onBack={handleBackNavigation}
|
||||
rightContent={
|
||||
<Pressable style={styles.closeButton} onPress={handleCloseExplorer}>
|
||||
<X size={18} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
|
||||
<View style={styles.content}>
|
||||
{shouldShowPreview ? (
|
||||
<View style={styles.previewWrapper}>
|
||||
<UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} />
|
||||
<View style={styles.previewSection}>
|
||||
{isPreviewLoading && !preview ? (
|
||||
<View style={styles.centerState}>
|
||||
@@ -359,14 +546,27 @@ export default function FileExplorerScreen() {
|
||||
data={entries}
|
||||
renderItem={renderEntry}
|
||||
keyExtractor={(item) => item.path}
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
contentContainerStyle={
|
||||
viewMode === "grid" ? styles.gridContent : styles.entriesContent
|
||||
}
|
||||
columnWrapperStyle={
|
||||
viewMode === "grid" && listColumns > 1
|
||||
? styles.gridColumnWrapper
|
||||
: undefined
|
||||
}
|
||||
numColumns={listColumns}
|
||||
key={listKey}
|
||||
onScroll={handleListScroll}
|
||||
scrollEventThrottle={16}
|
||||
onLayout={restoreQueuedScrollOffset}
|
||||
onContentSizeChange={restoreQueuedScrollOffset}
|
||||
ListHeaderComponent={listHeaderComponent}
|
||||
extraData={copiedPath}
|
||||
extraData={{ copiedPath, viewMode, thumbnailLoadingMap }}
|
||||
initialNumToRender={20}
|
||||
maxToRenderPerBatch={30}
|
||||
windowSize={10}
|
||||
onViewableItemsChanged={handleViewableItemsChanged}
|
||||
viewabilityConfig={viewabilityConfigRef.current}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -376,6 +576,35 @@ export default function FileExplorerScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
function ViewToggle({
|
||||
viewMode,
|
||||
onChange,
|
||||
}: {
|
||||
viewMode: "list" | "grid";
|
||||
onChange: (mode: "list" | "grid") => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View style={styles.viewToggleContainer}>
|
||||
<Pressable
|
||||
style={[styles.viewToggleButton, viewMode === "list" && styles.viewToggleActive]}
|
||||
onPress={() => onChange("list")}
|
||||
>
|
||||
<ListIcon size={16} color={theme.colors.foreground} />
|
||||
<Text style={styles.viewToggleText}>List</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.viewToggleButton, viewMode === "grid" && styles.viewToggleActive]}
|
||||
onPress={() => onChange("grid")}
|
||||
>
|
||||
<LayoutGrid size={16} color={theme.colors.foreground} />
|
||||
<Text style={styles.viewToggleText}>Gallery</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDirectoryLabel(path: string): string {
|
||||
return path === "." ? "workspace root" : path;
|
||||
}
|
||||
@@ -437,16 +666,6 @@ function deriveDirectoryFromFile(filePath: string): string {
|
||||
return directory.length > 0 ? directory : ".";
|
||||
}
|
||||
|
||||
function UpRow({ label, onPress }: { label: string; onPress: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Pressable style={styles.upRow} onPress={onPress}>
|
||||
<ArrowLeft size={16} color={theme.colors.foreground} />
|
||||
<Text style={styles.upRowText}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
type EntryDisplayKind = "directory" | "image" | "text" | "other";
|
||||
|
||||
const IMAGE_EXTENSIONS = new Set([
|
||||
@@ -567,6 +786,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
loadingBanner: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
@@ -661,22 +884,32 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
upRow: {
|
||||
viewToggleContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
overflow: "hidden",
|
||||
},
|
||||
upRowText: {
|
||||
viewToggleButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
viewToggleActive: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
viewToggleText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
closeButton: {
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
textPreview: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -688,6 +921,51 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontFamily: "monospace",
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
gridContent: {
|
||||
paddingBottom: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
gridColumnWrapper: {
|
||||
justifyContent: "space-between",
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
gridCard: {
|
||||
flex: 1,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.card,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
},
|
||||
gridThumbnail: {
|
||||
width: "100%",
|
||||
aspectRatio: 1,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
gridImageBackground: {
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
gridImage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
gridName: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
gridMeta: {
|
||||
color: theme.colors.mutedForeground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -121,41 +121,32 @@ export default function GitDiffScreen() {
|
||||
<Text style={styles.filePath}>{file.path}</Text>
|
||||
</View>
|
||||
<View style={styles.diffContent}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
nestedScrollEnabled
|
||||
showsHorizontalScrollIndicator
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
contentContainerStyle={styles.diffScrollContent}
|
||||
>
|
||||
<View style={styles.diffLinesContainer}>
|
||||
{file.lines.map((line, lineIndex) => (
|
||||
<View
|
||||
key={lineIndex}
|
||||
<View style={styles.diffLinesContainer}>
|
||||
{file.lines.map((line, lineIndex) => (
|
||||
<View
|
||||
key={lineIndex}
|
||||
style={[
|
||||
styles.diffLineContainer,
|
||||
line.type === "add" && styles.addLineContainer,
|
||||
line.type === "remove" && styles.removeLineContainer,
|
||||
line.type === "header" && styles.headerLineContainer,
|
||||
line.type === "context" && styles.contextLineContainer,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineContainer,
|
||||
line.type === "add" && styles.addLineContainer,
|
||||
line.type === "remove" && styles.removeLineContainer,
|
||||
line.type === "header" && styles.headerLineContainer,
|
||||
line.type === "context" && styles.contextLineContainer,
|
||||
styles.diffLineText,
|
||||
line.type === "add" && styles.addLineText,
|
||||
line.type === "remove" && styles.removeLineText,
|
||||
line.type === "header" && styles.headerLineText,
|
||||
line.type === "context" && styles.contextLineText,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.diffLineText,
|
||||
line.type === "add" && styles.addLineText,
|
||||
line.type === "remove" && styles.removeLineText,
|
||||
line.type === "header" && styles.headerLineText,
|
||||
line.type === "context" && styles.contextLineText,
|
||||
]}
|
||||
>
|
||||
{line.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
{line.content}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
@@ -175,6 +166,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
contentContainer: {
|
||||
padding: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[8],
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
@@ -215,6 +207,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overflow: "hidden",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
width: "100%",
|
||||
},
|
||||
fileHeader: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
@@ -231,27 +224,23 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffContent: {
|
||||
backgroundColor: theme.colors.card,
|
||||
},
|
||||
diffScrollContent: {
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
diffLinesContainer: {
|
||||
alignSelf: "flex-start",
|
||||
width: "100%",
|
||||
},
|
||||
diffLineContainer: {
|
||||
minWidth: "100%",
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[1],
|
||||
alignSelf: "flex-start",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
diffLineText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: "monospace",
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 0,
|
||||
flexShrink: 1,
|
||||
flexWrap: "wrap",
|
||||
width: "100%",
|
||||
},
|
||||
addLineContainer: {
|
||||
backgroundColor: theme.colors.palette.green[900],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -8,13 +8,23 @@ import {
|
||||
Pressable,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { BackHeader } from "@/components/headers/back-header";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}, ms);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
@@ -192,6 +202,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
restartButton: {
|
||||
padding: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginTop: theme.spacing[3],
|
||||
backgroundColor: theme.colors.destructive,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
restartButtonDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
restartButtonText: {
|
||||
color: theme.colors.destructiveForeground,
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
footer: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
@@ -212,6 +240,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
|
||||
const { restartServer, ws } = useSession();
|
||||
|
||||
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
|
||||
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
|
||||
@@ -223,6 +252,137 @@ export default function SettingsScreen() {
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const wsIsConnectedRef = useRef(ws.isConnected);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
wsIsConnectedRef.current = ws.isConnected;
|
||||
}, [ws.isConnected]);
|
||||
|
||||
const waitForCondition = useCallback(
|
||||
async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (!isMountedRef.current) {
|
||||
return false;
|
||||
}
|
||||
if (predicate()) {
|
||||
return true;
|
||||
}
|
||||
await delay(intervalMs);
|
||||
}
|
||||
return predicate();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const testServerConnection = useCallback((url: string, timeoutMs = 5000) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let wsConnection: WebSocket | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (wsConnection) {
|
||||
wsConnection.onopen = null;
|
||||
wsConnection.onerror = null;
|
||||
wsConnection.onclose = null;
|
||||
try {
|
||||
wsConnection.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const succeed = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(message));
|
||||
};
|
||||
|
||||
try {
|
||||
wsConnection = new WebSocket(url);
|
||||
} catch {
|
||||
fail("Failed to create connection");
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
fail("Connection timeout - server did not respond");
|
||||
}, timeoutMs);
|
||||
|
||||
wsConnection.onopen = () => succeed();
|
||||
wsConnection.onerror = () => fail("Connection failed - check URL and network");
|
||||
wsConnection.onclose = () => fail("Connection failed - check URL and network");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const waitForServerRestart = useCallback(async () => {
|
||||
const maxAttempts = 12;
|
||||
const retryDelayMs = 2500;
|
||||
const disconnectTimeoutMs = 7000;
|
||||
const reconnectTimeoutMs = 10000;
|
||||
|
||||
if (wsIsConnectedRef.current) {
|
||||
await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await testServerConnection(settings.serverUrl);
|
||||
const reconnected = await waitForCondition(
|
||||
() => wsIsConnectedRef.current,
|
||||
reconnectTimeoutMs
|
||||
);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
if (!reconnected) {
|
||||
Alert.alert(
|
||||
"Server reachable",
|
||||
"The server came back online but the app has not reconnected yet."
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed`,
|
||||
error
|
||||
);
|
||||
if (attempt === maxAttempts) {
|
||||
if (isMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Unable to reconnect",
|
||||
"The server did not come back online. Please verify the daemon restarted."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await delay(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}, [settings.serverUrl, testServerConnection, waitForCondition]);
|
||||
|
||||
// Update local state when settings load
|
||||
useEffect(() => {
|
||||
@@ -315,6 +475,59 @@ export default function SettingsScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const restartConfirmationMessage =
|
||||
"This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts.";
|
||||
|
||||
const beginServerRestart = useCallback(() => {
|
||||
if (!wsIsConnectedRef.current) {
|
||||
Alert.alert(
|
||||
"Not Connected",
|
||||
"Connect to the server before attempting a restart."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
restartServer("settings_screen_restart");
|
||||
} catch (error) {
|
||||
setIsRestarting(false);
|
||||
Alert.alert(
|
||||
"Error",
|
||||
"Failed to send restart request. Please ensure you are connected to the server."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void waitForServerRestart();
|
||||
}, [restartServer, waitForServerRestart]);
|
||||
|
||||
function handleRestartServer() {
|
||||
if (Platform.OS === "web") {
|
||||
const hasBrowserConfirm =
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof (globalThis as any).confirm === "function";
|
||||
|
||||
const confirmed = hasBrowserConfirm
|
||||
? (globalThis as any).confirm(restartConfirmationMessage)
|
||||
: true;
|
||||
|
||||
if (confirmed) {
|
||||
beginServerRestart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert("Restart Server", restartConfirmationMessage, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Restart",
|
||||
style: "destructive",
|
||||
onPress: beginServerRestart,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
async function handleTestConnection() {
|
||||
if (!validateServerUrl(serverUrl)) {
|
||||
@@ -330,40 +543,21 @@ export default function SettingsScreen() {
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(serverUrl);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close();
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: "Connection timeout - server did not respond",
|
||||
});
|
||||
setIsTesting(false);
|
||||
}, 5000);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout);
|
||||
ws.close();
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: "Connection successful",
|
||||
});
|
||||
setIsTesting(false);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: "Connection failed - check URL and network",
|
||||
});
|
||||
setIsTesting(false);
|
||||
};
|
||||
await testServerConnection(serverUrl);
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: "Connection successful",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Connection failed - check URL and network";
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: "Failed to create connection",
|
||||
message,
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
}
|
||||
@@ -534,6 +728,26 @@ export default function SettingsScreen() {
|
||||
<Pressable style={styles.resetButton} onPress={handleReset}>
|
||||
<Text style={styles.resetButtonText}>Reset to Defaults</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.restartButton,
|
||||
isRestarting && styles.restartButtonDisabled,
|
||||
]}
|
||||
onPress={handleRestartServer}
|
||||
disabled={isRestarting}
|
||||
>
|
||||
{isRestarting && (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={defaultTheme.colors.destructiveForeground}
|
||||
style={{ marginRight: defaultTheme.spacing[2] }}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.restartButtonText}>
|
||||
{isRestarting ? "Restarting..." : "Restart Server"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* App Info */}
|
||||
|
||||
@@ -4,13 +4,15 @@ import {
|
||||
Pressable,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
TextInputKeyPressEventData,
|
||||
Image,
|
||||
Platform,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useLayoutEffect } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X } from "lucide-react-native";
|
||||
import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
@@ -32,18 +34,45 @@ interface AgentInputAreaProps {
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
const MIN_INPUT_HEIGHT = 40;
|
||||
const MIN_INPUT_HEIGHT = 32;
|
||||
const MAX_INPUT_HEIGHT = 160;
|
||||
const MAX_INPUT_WIDTH = 960;
|
||||
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
|
||||
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
|
||||
// entering/exiting animations (see react-native-reanimated#8422), so guard them.
|
||||
const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android";
|
||||
const REALTIME_FADE_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeIn.duration(250);
|
||||
const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.duration(250);
|
||||
const IS_WEB = Platform.OS === "web";
|
||||
const SHOULD_DEBUG_INPUT_HEIGHT = IS_WEB;
|
||||
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
||||
TextInputKeyPressEventData & {
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
>;
|
||||
type TextAreaHandle = {
|
||||
scrollHeight?: number;
|
||||
style?: {
|
||||
height?: string;
|
||||
overflowY?: string;
|
||||
} & Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { ws, sendAgentMessage, sendAgentAudio, agents, cancelAgentRun } = useSession();
|
||||
const {
|
||||
ws,
|
||||
sendAgentMessage,
|
||||
sendAgentAudio,
|
||||
agents,
|
||||
cancelAgentRun,
|
||||
draftInputs,
|
||||
setDraftInputs,
|
||||
queuedMessages: queuedMessagesByAgent,
|
||||
setQueuedMessages: setQueuedMessagesByAgent,
|
||||
} = useSession();
|
||||
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
||||
|
||||
const [userInput, setUserInput] = useState("");
|
||||
@@ -54,8 +83,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
const [recordingVolume, setRecordingVolume] = useState(0);
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [transcribingRequestId, setTranscribingRequestId] = useState<string | null>(null);
|
||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||
|
||||
const recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
|
||||
const overlayTransition = useSharedValue(0);
|
||||
const { pickImages } = useImageAttachmentPicker();
|
||||
|
||||
@@ -65,6 +96,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const debugInputHeight = (label: string, payload: Record<string, unknown>) => {
|
||||
if (!SHOULD_DEBUG_INPUT_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
console.log(`[AgentInput][InputHeight] ${label}`, payload);
|
||||
};
|
||||
|
||||
async function handleSendMessage() {
|
||||
if (!userInput.trim() || !ws.isConnected) return;
|
||||
|
||||
@@ -218,24 +256,108 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleContentSizeChange(
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||
) {
|
||||
const contentHeight = event.nativeEvent.contentSize.height;
|
||||
function isTextAreaLike(value: unknown): value is TextAreaHandle {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as TextAreaHandle;
|
||||
return typeof candidate.scrollHeight === "number" && typeof candidate.style === "object";
|
||||
}
|
||||
|
||||
function getWebTextArea(): TextAreaHandle | null {
|
||||
if (!IS_WEB) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const node = textInputRef.current;
|
||||
if (!node) {
|
||||
debugInputHeight("missing-ref", {});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isTextAreaLike(node)) {
|
||||
debugInputHeight("using-ref", {
|
||||
scrollHeight: node.scrollHeight,
|
||||
inlineHeight: node.style?.height,
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
if (typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function") {
|
||||
const native = (node as { getNativeRef?: () => unknown }).getNativeRef?.();
|
||||
if (isTextAreaLike(native)) {
|
||||
debugInputHeight("using-native-ref", {
|
||||
scrollHeight: native.scrollHeight,
|
||||
inlineHeight: native.style?.height,
|
||||
});
|
||||
return native;
|
||||
}
|
||||
}
|
||||
|
||||
debugInputHeight("no-textarea-found", {});
|
||||
return null;
|
||||
}
|
||||
|
||||
function measureWebInputHeight(source: string): boolean {
|
||||
if (!IS_WEB) {
|
||||
return false;
|
||||
}
|
||||
const element = getWebTextArea();
|
||||
if (!element?.style || typeof element.scrollHeight !== "number") {
|
||||
debugInputHeight(`${source}-missing`, {});
|
||||
return false;
|
||||
}
|
||||
const previousHeight = element.style.height;
|
||||
element.style.height = "auto";
|
||||
const measuredHeight = element.scrollHeight;
|
||||
element.style.height = previousHeight ?? "";
|
||||
const bounded = Math.min(
|
||||
MAX_INPUT_HEIGHT,
|
||||
Math.max(MIN_INPUT_HEIGHT, measuredHeight),
|
||||
);
|
||||
debugInputHeight(source, {
|
||||
measuredHeight,
|
||||
bounded,
|
||||
scrollHeight: element.scrollHeight,
|
||||
inlineHeight: previousHeight,
|
||||
});
|
||||
setBoundedInputHeight(bounded);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setBoundedInputHeight(nextHeight: number) {
|
||||
const boundedHeight = Math.min(
|
||||
MAX_INPUT_HEIGHT,
|
||||
Math.max(MIN_INPUT_HEIGHT, contentHeight),
|
||||
Math.max(MIN_INPUT_HEIGHT, nextHeight),
|
||||
);
|
||||
|
||||
setInputHeight((currentHeight) => {
|
||||
if (Math.abs(currentHeight - boundedHeight) < 1) {
|
||||
return currentHeight;
|
||||
}
|
||||
|
||||
debugInputHeight("set-state", {
|
||||
nextHeight,
|
||||
boundedHeight,
|
||||
previousHeight: currentHeight,
|
||||
});
|
||||
return boundedHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function handleContentSizeChange(
|
||||
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>,
|
||||
) {
|
||||
if (IS_WEB && measureWebInputHeight("web-measure")) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugInputHeight("native-measure", {
|
||||
height: event.nativeEvent.contentSize.height,
|
||||
width: event.nativeEvent.contentSize.width,
|
||||
});
|
||||
setBoundedInputHeight(event.nativeEvent.contentSize.height);
|
||||
}
|
||||
|
||||
const agent = agents.get(agentId);
|
||||
const isAgentRunning = agent?.status === "running";
|
||||
const hasText = userInput.trim().length > 0;
|
||||
@@ -243,6 +365,73 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
const hasSendableContent = hasText || hasImages;
|
||||
const shouldShowSendButton = !isAgentRunning && hasSendableContent;
|
||||
const shouldShowVoiceControls = !isAgentRunning && !hasSendableContent;
|
||||
const queuedMessages = queuedMessagesByAgent.get(agentId) ?? [];
|
||||
const shouldHandleDesktopSubmit = IS_WEB;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!IS_WEB) {
|
||||
return;
|
||||
}
|
||||
measureWebInputHeight("layout-effect");
|
||||
}, [userInput]);
|
||||
|
||||
function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) {
|
||||
if (!shouldHandleDesktopSubmit) {
|
||||
return;
|
||||
}
|
||||
if (event.nativeEvent.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
const { metaKey, ctrlKey, shiftKey } = event.nativeEvent;
|
||||
if (shiftKey || metaKey || ctrlKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!shouldShowSendButton || isProcessing || !ws.isConnected) {
|
||||
return;
|
||||
}
|
||||
void handleSendMessage();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAgentRunning || !ws.isConnected) {
|
||||
setIsCancellingAgent(false);
|
||||
}
|
||||
}, [isAgentRunning, ws.isConnected]);
|
||||
|
||||
// Hydrate draft only when switching agents
|
||||
useEffect(() => {
|
||||
const draft = draftInputs.get(agentId);
|
||||
if (!draft) {
|
||||
setUserInput("");
|
||||
setSelectedImages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setUserInput(draft.text);
|
||||
setSelectedImages(draft.images);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agentId]);
|
||||
|
||||
// Persist drafts into context with change detection to avoid render loops
|
||||
useEffect(() => {
|
||||
setDraftInputs((prev) => {
|
||||
const existing = prev.get(agentId);
|
||||
const isSameText = existing?.text === userInput;
|
||||
const existingImages = existing?.images ?? [];
|
||||
const isSameImages =
|
||||
existingImages.length === selectedImages.length &&
|
||||
existingImages.every((img, idx) => img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType);
|
||||
|
||||
if (isSameText && isSameImages) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, { text: userInput, images: selectedImages });
|
||||
return next;
|
||||
});
|
||||
}, [agentId, userInput, selectedImages, setDraftInputs]);
|
||||
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -275,15 +464,59 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
}
|
||||
|
||||
function handleCancelAgent() {
|
||||
if (!agent || agent.status !== "running") {
|
||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!ws.isConnected) {
|
||||
return;
|
||||
}
|
||||
setIsCancellingAgent(true);
|
||||
cancelAgentRun(agentId);
|
||||
}
|
||||
|
||||
function updateQueue(updater: (current: typeof queuedMessages) => typeof queuedMessages) {
|
||||
setQueuedMessagesByAgent((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleQueueCurrentInput() {
|
||||
if (!hasSendableContent) return;
|
||||
|
||||
const newItem = {
|
||||
id: generateMessageId(),
|
||||
text: userInput.trim(),
|
||||
images: selectedImages.length ? selectedImages : undefined,
|
||||
};
|
||||
|
||||
updateQueue((current) => [...current, newItem]);
|
||||
setUserInput("");
|
||||
setSelectedImages([]);
|
||||
setInputHeight(MIN_INPUT_HEIGHT);
|
||||
}
|
||||
|
||||
function handleEditQueuedMessage(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
setUserInput(item.text);
|
||||
setSelectedImages(item.images ?? []);
|
||||
}
|
||||
|
||||
async function handleSendQueuedNow(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item || !ws.isConnected) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
|
||||
// Cancels current agent run before sending queued prompt
|
||||
handleCancelAgent();
|
||||
await sendAgentMessage(agentId, item.text, item.images?.map((img) => img.uri));
|
||||
}
|
||||
|
||||
const realtimeButton = (
|
||||
<Pressable
|
||||
onPress={handleRealtimePress}
|
||||
@@ -320,8 +553,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
|
||||
{/* Input area */}
|
||||
<View style={styles.inputAreaContainer}>
|
||||
{/* Regular input controls */}
|
||||
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
{/* Regular input controls */}
|
||||
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}>
|
||||
{/* Queue list */}
|
||||
{queuedMessages.length > 0 && (
|
||||
<View style={styles.queueContainer}>
|
||||
{queuedMessages.map((item) => (
|
||||
<View key={item.id} style={styles.queueItem}>
|
||||
<Text style={styles.queueText} numberOfLines={2} ellipsizeMode="tail">
|
||||
{item.text}
|
||||
</Text>
|
||||
<View style={styles.queueActions}>
|
||||
<Pressable onPress={() => handleEditQueuedMessage(item.id)} style={styles.queueActionButton}>
|
||||
<Pencil size={14} color={theme.colors.foreground} />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => handleSendQueuedNow(item.id)}
|
||||
style={[styles.queueActionButton, styles.queueSendButton]}
|
||||
>
|
||||
<ArrowUp size={14} color={theme.colors.background} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Image preview pills */}
|
||||
{hasImages && (
|
||||
<View style={styles.imagePreviewContainer}>
|
||||
@@ -338,6 +596,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
|
||||
{/* Full-width text input */}
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
placeholder="Message agent..."
|
||||
@@ -350,6 +609,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
editable={!isRecording && ws.isConnected}
|
||||
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
|
||||
/>
|
||||
|
||||
{/* Button row below input */}
|
||||
@@ -373,18 +633,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
<View style={styles.rightButtonGroup}>
|
||||
{isAgentRunning ? (
|
||||
<>
|
||||
{hasSendableContent && (
|
||||
<Pressable
|
||||
onPress={handleQueueCurrentInput}
|
||||
disabled={!ws.isConnected}
|
||||
accessibilityLabel="Queue message while agent is running"
|
||||
accessibilityRole="button"
|
||||
style={[styles.queueButton, !ws.isConnected && styles.buttonDisabled]}
|
||||
>
|
||||
<ArrowUp size={20} color="white" />
|
||||
</Pressable>
|
||||
)}
|
||||
{realtimeButton}
|
||||
<Pressable
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!ws.isConnected}
|
||||
accessibilityLabel="Stop agent"
|
||||
disabled={!ws.isConnected || isCancellingAgent}
|
||||
accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
(!ws.isConnected || isCancellingAgent) && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Square size={18} color={theme.colors.background} fill={theme.colors.background} />
|
||||
{isCancellingAgent ? (
|
||||
<ActivityIndicator size="small" color="white" />
|
||||
) : (
|
||||
<Square size={18} color="white" fill="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
) : shouldShowSendButton ? (
|
||||
@@ -420,17 +695,20 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Voice note recording overlay */}
|
||||
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
|
||||
<VoiceNoteRecordingOverlay
|
||||
volume={recordingVolume}
|
||||
duration={recordingDuration}
|
||||
onCancel={handleCancelRecording}
|
||||
onSend={handleSendRecording}
|
||||
isTranscribing={transcribingRequestId !== null}
|
||||
/>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<VoiceNoteRecordingOverlay
|
||||
volume={recordingVolume}
|
||||
duration={recordingDuration}
|
||||
onCancel={handleCancelRecording}
|
||||
onSend={handleSendRecording}
|
||||
isTranscribing={transcribingRequestId !== null}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -451,6 +729,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
inputAreaContainer: {
|
||||
position: "relative",
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
},
|
||||
inputAreaContent: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_INPUT_WIDTH,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: "column",
|
||||
@@ -458,6 +742,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
minHeight: FOOTER_HEIGHT,
|
||||
width: "100%",
|
||||
},
|
||||
overlayContainer: {
|
||||
position: "absolute",
|
||||
@@ -465,6 +750,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: FOOTER_HEIGHT,
|
||||
alignItems: "center",
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
flexDirection: "row",
|
||||
@@ -499,6 +785,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.lg,
|
||||
lineHeight: theme.fontSize.lg * 1.4,
|
||||
...(IS_WEB
|
||||
? {
|
||||
outlineStyle: "none" as const,
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: "row",
|
||||
@@ -559,7 +852,52 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
queueButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
queueContainer: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
queueItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.card,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
queueText: {
|
||||
flex: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
queueActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
queueActionButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.muted,
|
||||
},
|
||||
queueSendButton: {
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -18,10 +18,12 @@ export function AgentList({ agents }: AgentListProps) {
|
||||
const [actionAgent, setActionAgent] = useState<Agent | null>(null);
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
|
||||
// Sort agents by lastActivityAt (most recent first)
|
||||
// Sort agents by the persisted timestamp of the last user message, falling back to last activity
|
||||
const agentArray = useMemo(() => {
|
||||
return Array.from(agents.values()).sort((a, b) => {
|
||||
return b.lastActivityAt.getTime() - a.lastActivityAt.getTime();
|
||||
const sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime();
|
||||
const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime();
|
||||
return sortB - sortA;
|
||||
});
|
||||
}, [agents]);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
extractReadEntries,
|
||||
} from "@/utils/tool-call-parsers";
|
||||
|
||||
const MAX_CHAT_WIDTH = 960;
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
agent: Agent;
|
||||
@@ -219,30 +221,31 @@ export function AgentStreamView({
|
||||
}
|
||||
|
||||
function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) {
|
||||
let content: React.ReactNode = null;
|
||||
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
return (
|
||||
content = (
|
||||
<UserMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "assistant_message":
|
||||
return (
|
||||
content = (
|
||||
<AssistantMessage
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
onInlinePathPress={handleInlinePathPress}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "thought":
|
||||
return (
|
||||
<AgentThoughtMessage
|
||||
message={item.text}
|
||||
/>
|
||||
);
|
||||
content = <AgentThoughtMessage message={item.text} />;
|
||||
break;
|
||||
|
||||
case "tool_call": {
|
||||
const { payload } = item;
|
||||
@@ -250,7 +253,7 @@ export function AgentStreamView({
|
||||
if (payload.source === "agent") {
|
||||
const data = payload.data;
|
||||
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
|
||||
return (
|
||||
content = (
|
||||
<ToolCall
|
||||
toolName={toolLabel}
|
||||
kind={data.kind}
|
||||
@@ -264,22 +267,23 @@ export function AgentStreamView({
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const data = payload.data;
|
||||
content = (
|
||||
<ToolCall
|
||||
toolName={data.toolName}
|
||||
args={data.arguments}
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const data = payload.data;
|
||||
return (
|
||||
<ToolCall
|
||||
toolName={data.toolName}
|
||||
args={data.arguments}
|
||||
result={data.result}
|
||||
status={data.status}
|
||||
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "activity_log":
|
||||
return (
|
||||
content = (
|
||||
<ActivityLog
|
||||
type={item.activityType}
|
||||
message={item.message}
|
||||
@@ -287,19 +291,27 @@ export function AgentStreamView({
|
||||
metadata={item.metadata}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case "todo_list":
|
||||
return (
|
||||
content = (
|
||||
<TodoListCard
|
||||
provider={item.provider}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
items={item.items}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
return null;
|
||||
content = null;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <View style={stylesheet.streamItemWrapper}>{content}</View>;
|
||||
}
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -318,24 +330,26 @@ export function AgentStreamView({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={stylesheet.listHeaderContent}>
|
||||
{pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View style={stylesheet.listHeaderContent}>
|
||||
{pendingPermissionItems.length > 0 ? (
|
||||
<View style={stylesheet.permissionsContainer}>
|
||||
{pendingPermissionItems.map((permission) => (
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
onResponse={onPermissionResponse}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{showWorkingIndicator ? (
|
||||
<View style={stylesheet.workingIndicatorWrapper}>
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
{showWorkingIndicator ? (
|
||||
<View style={stylesheet.workingIndicatorWrapper}>
|
||||
<WorkingIndicator />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]);
|
||||
@@ -371,7 +385,7 @@ export function AgentStreamView({
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={stylesheet.emptyState}>
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
<Text style={stylesheet.emptyStateText}>
|
||||
Start chatting with this agent...
|
||||
</Text>
|
||||
@@ -395,15 +409,17 @@ export function AgentStreamView({
|
||||
entering={scrollIndicatorFadeIn}
|
||||
exiting={scrollIndicatorFadeOut}
|
||||
>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={stylesheet.scrollToBottomInner}>
|
||||
<Pressable
|
||||
style={stylesheet.scrollToBottomButton}
|
||||
onPress={scrollToBottom}
|
||||
>
|
||||
<ChevronDown
|
||||
size={24}
|
||||
color={stylesheet.scrollToBottomIcon.color}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
@@ -462,16 +478,18 @@ function WorkingIndicator() {
|
||||
const dotOne = useSharedValue(0);
|
||||
const dotTwo = useSharedValue(0);
|
||||
const dotThree = useSharedValue(0);
|
||||
const bounceDuration = 600;
|
||||
const bounceDelayOffset = 160;
|
||||
|
||||
useEffect(() => {
|
||||
const sharedValues = [dotOne, dotTwo, dotThree];
|
||||
sharedValues.forEach((value, index) => {
|
||||
value.value = withDelay(
|
||||
index * 120,
|
||||
index * bounceDelayOffset,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(1, { duration: 320 }),
|
||||
withTiming(0, { duration: 320 })
|
||||
withTiming(1, { duration: bounceDuration }),
|
||||
withTiming(0, { duration: bounceDuration })
|
||||
),
|
||||
-1,
|
||||
true
|
||||
@@ -487,19 +505,20 @@ function WorkingIndicator() {
|
||||
};
|
||||
}, [dotOne, dotTwo, dotThree]);
|
||||
|
||||
const translateDistance = -4;
|
||||
const dotOneStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotOne.value * 0.7,
|
||||
transform: [{ translateY: dotOne.value * -4 }],
|
||||
transform: [{ translateY: dotOne.value * translateDistance }],
|
||||
}));
|
||||
|
||||
const dotTwoStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotTwo.value * 0.7,
|
||||
transform: [{ translateY: dotTwo.value * -4 }],
|
||||
transform: [{ translateY: dotTwo.value * translateDistance }],
|
||||
}));
|
||||
|
||||
const dotThreeStyle = useAnimatedStyle(() => ({
|
||||
opacity: 0.3 + dotThree.value * 0.7,
|
||||
transform: [{ translateY: dotThree.value * -4 }],
|
||||
transform: [{ translateY: dotThree.value * translateDistance }],
|
||||
}));
|
||||
|
||||
return (
|
||||
@@ -566,8 +585,13 @@ function PermissionRequestCard({
|
||||
() => ({
|
||||
body: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
paragraph: {
|
||||
marginBottom: theme.spacing[1],
|
||||
@@ -823,10 +847,20 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
contentWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CHAT_WIDTH,
|
||||
alignSelf: "center",
|
||||
},
|
||||
list: {
|
||||
flex: 1,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
streamItemWrapper: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CHAT_WIDTH,
|
||||
alignSelf: "center",
|
||||
},
|
||||
emptyState: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
@@ -840,18 +874,19 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
workingIndicatorWrapper: {
|
||||
alignItems: "center",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
workingIndicatorBubble: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.card,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 0,
|
||||
alignSelf: "flex-start",
|
||||
},
|
||||
workingIndicatorText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
@@ -886,6 +921,12 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
scrollToBottomInner: {
|
||||
width: "100%",
|
||||
maxWidth: MAX_CHAT_WIDTH,
|
||||
alignSelf: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
scrollToBottomButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,9 @@ export function GlobalFooter() {
|
||||
? FOOTER_HEIGHT * 2 + insets.bottom
|
||||
: FOOTER_HEIGHT + insets.bottom;
|
||||
|
||||
const iconSize = 24;
|
||||
const iconStyle = { width: iconSize, height: iconSize };
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
@@ -110,7 +113,13 @@ export function GlobalFooter() {
|
||||
pressed && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<Users size={20} color={theme.colors.foreground} />
|
||||
<View style={styles.footerIconWrapper}>
|
||||
<Users
|
||||
size={iconSize}
|
||||
color={theme.colors.foreground}
|
||||
style={iconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.footerButtonText}>Agents</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -124,7 +133,13 @@ export function GlobalFooter() {
|
||||
pressed && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<Plus size={20} color={theme.colors.foreground} />
|
||||
<View style={styles.footerIconWrapper}>
|
||||
<Plus
|
||||
size={iconSize}
|
||||
color={theme.colors.foreground}
|
||||
style={iconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.footerButtonText}>New Agent</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -137,7 +152,13 @@ export function GlobalFooter() {
|
||||
pressed && !ws.isConnected && styles.buttonPressed,
|
||||
]}
|
||||
>
|
||||
<AudioLines size={20} color={theme.colors.foreground} />
|
||||
<View style={styles.footerIconWrapper}>
|
||||
<AudioLines
|
||||
size={iconSize}
|
||||
color={theme.colors.foreground}
|
||||
style={iconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.footerButtonText}>Realtime</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -180,6 +201,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footerIconWrapper: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
footerButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
|
||||
@@ -8,9 +8,10 @@ import { ScreenHeader } from "./screen-header";
|
||||
interface BackHeaderProps {
|
||||
title?: string;
|
||||
rightContent?: ReactNode;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function BackHeader({ title, rightContent }: BackHeaderProps) {
|
||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
@@ -18,7 +19,7 @@ export function BackHeader({ title, rightContent }: BackHeaderProps) {
|
||||
left={
|
||||
<>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
onPress={onBack ?? (() => router.back())}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={20} color={theme.colors.foreground} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Brain,
|
||||
Search,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { baseColors, theme } from "@/styles/theme";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
@@ -166,6 +166,11 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 24,
|
||||
},
|
||||
markdownText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
markdownParagraph: {
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[2],
|
||||
@@ -241,22 +246,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const markdownStyles = {
|
||||
body: assistantMessageStylesheet.markdownBody,
|
||||
paragraph: assistantMessageStylesheet.markdownParagraph,
|
||||
strong: assistantMessageStylesheet.markdownStrong,
|
||||
em: assistantMessageStylesheet.markdownEm,
|
||||
code_inline: assistantMessageStylesheet.markdownCodeInline,
|
||||
code_block: assistantMessageStylesheet.markdownCodeBlock,
|
||||
fence: assistantMessageStylesheet.markdownFence,
|
||||
link: assistantMessageStylesheet.markdownLink,
|
||||
bullet_list: assistantMessageStylesheet.markdownList,
|
||||
ordered_list: assistantMessageStylesheet.markdownList,
|
||||
list_item: assistantMessageStylesheet.markdownListItem,
|
||||
blockquote: assistantMessageStylesheet.markdownBlockquote,
|
||||
blockquote_text: assistantMessageStylesheet.markdownBlockquoteText,
|
||||
};
|
||||
|
||||
function isLikelyPathToken(value: string): boolean {
|
||||
if (!value || value.length > 300) {
|
||||
return false;
|
||||
@@ -347,9 +336,87 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
isStreaming = false,
|
||||
onInlinePathPress,
|
||||
}: AssistantMessageProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const fadeAnim = useRef(new Animated.Value(0.3)).current;
|
||||
const lastPathRef = useRef<string | null>(null);
|
||||
|
||||
const markdownStyles = useMemo(
|
||||
() => ({
|
||||
body: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 24,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
paragraph: {
|
||||
marginTop: 0,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
strong: {
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
},
|
||||
em: {
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
code_inline: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
},
|
||||
code_block: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
},
|
||||
fence: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.secondaryForeground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginVertical: theme.spacing[2],
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
},
|
||||
link: {
|
||||
color: theme.colors.primary,
|
||||
textDecorationLine: "underline" as const,
|
||||
},
|
||||
bullet_list: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
ordered_list: {
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
list_item: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
blockquote: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: theme.colors.primary,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
marginVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
blockquote_text: {
|
||||
color: theme.colors.foreground,
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
}),
|
||||
[theme]
|
||||
);
|
||||
|
||||
const markdownRules = useMemo(() => {
|
||||
if (!onInlinePathPress) {
|
||||
return undefined;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AgentStreamEventPayload,
|
||||
SessionInboundMessage,
|
||||
WSInboundMessage,
|
||||
GitSetupOptions,
|
||||
} from "@server/server/messages";
|
||||
import type {
|
||||
AgentLifecycleStatus,
|
||||
@@ -83,6 +84,7 @@ export interface Agent {
|
||||
status: AgentLifecycleStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
lastUserMessageAt: Date | null;
|
||||
lastActivityAt: Date;
|
||||
sessionId: string | null;
|
||||
capabilities: AgentCapabilityFlags;
|
||||
@@ -144,6 +146,8 @@ export interface AgentFileExplorerState {
|
||||
lastError: string | null;
|
||||
pendingRequest: ExplorerRequestState | null;
|
||||
currentPath: string;
|
||||
history: string[];
|
||||
lastVisitedPath: string;
|
||||
}
|
||||
|
||||
const createExplorerState = (): AgentFileExplorerState => ({
|
||||
@@ -153,17 +157,30 @@ const createExplorerState = (): AgentFileExplorerState => ({
|
||||
lastError: null,
|
||||
pendingRequest: null,
|
||||
currentPath: ".",
|
||||
history: ["."],
|
||||
lastVisitedPath: ".",
|
||||
});
|
||||
|
||||
const pushHistory = (history: string[], path: string): string[] => {
|
||||
const normalizedHistory = history.length === 0 ? ["."] : history;
|
||||
const last = normalizedHistory[normalizedHistory.length - 1];
|
||||
if (last === path) {
|
||||
return normalizedHistory;
|
||||
}
|
||||
return [...normalizedHistory, path];
|
||||
};
|
||||
|
||||
function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent {
|
||||
const createdAt = new Date(snapshot.createdAt);
|
||||
const updatedAt = new Date(snapshot.updatedAt);
|
||||
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
|
||||
return {
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
status: snapshot.status as AgentLifecycleStatus,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
lastUserMessageAt,
|
||||
lastActivityAt: updatedAt,
|
||||
sessionId: snapshot.sessionId,
|
||||
capabilities: snapshot.capabilities,
|
||||
@@ -202,6 +219,12 @@ interface SessionContextValue {
|
||||
setAgentStreamState: (state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
|
||||
initializingAgents: Map<string, boolean>;
|
||||
|
||||
// Queued messages and draft input per agent
|
||||
draftInputs: Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }>;
|
||||
setDraftInputs: (value: Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }> | ((prev: Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }>) => Map<string, { text: string; images: Array<{ uri: string; mimeType: string }> }>)) => void;
|
||||
queuedMessages: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>;
|
||||
setQueuedMessages: (value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void;
|
||||
|
||||
// Agents and commands
|
||||
agents: Map<string, Agent>;
|
||||
setAgents: (agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>)) => void;
|
||||
@@ -218,17 +241,25 @@ interface SessionContextValue {
|
||||
|
||||
// File explorer
|
||||
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||
requestDirectoryListing: (agentId: string, path: string) => void;
|
||||
requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void;
|
||||
requestFilePreview: (agentId: string, path: string) => void;
|
||||
navigateExplorerBack: (agentId: string) => string | null;
|
||||
|
||||
// Helpers
|
||||
restartServer: (reason?: string) => void;
|
||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||
cancelAgentRun: (agentId: string) => void;
|
||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
|
||||
deleteAgent: (agentId: string) => void;
|
||||
createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void;
|
||||
createAgent: (options: {
|
||||
config: AgentSessionConfig;
|
||||
initialPrompt: string;
|
||||
git?: GitSetupOptions;
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
}) => void;
|
||||
resumeAgent: (options: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => void;
|
||||
setAgentMode: (agentId: string, modeId: string) => void;
|
||||
respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void;
|
||||
@@ -274,7 +305,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
||||
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
||||
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(new Map());
|
||||
const [draftInputs, setDraftInputs] = useState<SessionContextValue["draftInputs"]>(new Map());
|
||||
const [queuedMessages, setQueuedMessages] = useState<SessionContextValue["queuedMessages"]>(new Map());
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||
|
||||
// Buffer for streaming audio chunks
|
||||
interface AudioChunk {
|
||||
@@ -354,14 +388,42 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
setAgents(nextAgents);
|
||||
setPendingPermissions(nextPermissions);
|
||||
setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
|
||||
setAgentStreamState(() => {
|
||||
const next = new Map<string, StreamItem[]>();
|
||||
for (const snapshot of agentsList) {
|
||||
next.set(snapshot.id, []);
|
||||
setAgentStreamState((prev) => {
|
||||
if (prev.size === 0) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
|
||||
const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id));
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
|
||||
for (const agentId of prev.keys()) {
|
||||
if (!validAgentIds.has(agentId)) {
|
||||
next.delete(agentId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setInitializingAgents((prev) => {
|
||||
if (prev.size === 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id));
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
|
||||
for (const agentId of prev.keys()) {
|
||||
if (!validAgentIds.has(agentId)) {
|
||||
next.delete(agentId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? next : prev;
|
||||
});
|
||||
setInitializingAgents(new Map());
|
||||
});
|
||||
|
||||
const unsubAgentState = ws.on("agent_state", (message) => {
|
||||
@@ -948,28 +1010,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const cancelAgentRun = useCallback((agentId: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "cancel_agent_request",
|
||||
agentId,
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const deleteAgent = useCallback((agentId: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "delete_agent_request",
|
||||
agentId,
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
|
||||
// Generate unique message ID for deduplication
|
||||
const messageId = generateMessageId();
|
||||
@@ -997,10 +1037,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
// Use FileSystem.File to read the image as base64
|
||||
const file = new FileSystem.File(imageUri);
|
||||
const base64 = file.base64Sync();
|
||||
|
||||
|
||||
// Get MIME type from the file
|
||||
const mimeType = file.type || 'image/jpeg';
|
||||
|
||||
|
||||
imagesData.push({
|
||||
data: base64,
|
||||
mimeType,
|
||||
@@ -1025,6 +1065,60 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
// Auto-flush queued messages when an agent transitions from running -> not running
|
||||
useEffect(() => {
|
||||
for (const [agentId, agent] of agents.entries()) {
|
||||
const prevStatus = previousAgentStatusRef.current.get(agentId);
|
||||
if (prevStatus === "running" && agent.status !== "running") {
|
||||
const queue = queuedMessages.get(agentId);
|
||||
if (queue && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
void sendAgentMessage(agentId, next.text, next.images?.map((img) => img.uri));
|
||||
setQueuedMessages((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(agentId, rest);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
previousAgentStatusRef.current.set(agentId, agent.status);
|
||||
}
|
||||
}, [agents, queuedMessages, sendAgentMessage]);
|
||||
|
||||
const cancelAgentRun = useCallback((agentId: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "cancel_agent_request",
|
||||
agentId,
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const deleteAgent = useCallback((agentId: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "delete_agent_request",
|
||||
agentId,
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const restartServer = useCallback((reason?: string) => {
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "restart_server_request",
|
||||
...(reason && reason.trim().length > 0 ? { reason } : {}),
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
|
||||
const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob, requestId?: string) => {
|
||||
try {
|
||||
// Convert blob to base64
|
||||
@@ -1060,12 +1154,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
}
|
||||
}, [ws]);
|
||||
|
||||
const createAgent = useCallback(({ config, worktreeName, requestId }: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => {
|
||||
const createAgent = useCallback(({ config, initialPrompt, git, worktreeName, requestId }: { config: AgentSessionConfig; initialPrompt: string; git?: GitSetupOptions; worktreeName?: string; requestId?: string }) => {
|
||||
const trimmedPrompt = initialPrompt.trim();
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "create_agent_request",
|
||||
config,
|
||||
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
||||
...(git ? { git } : {}),
|
||||
...(worktreeName ? { worktreeName } : {}),
|
||||
...(requestId ? { requestId } : {}),
|
||||
},
|
||||
@@ -1127,14 +1224,18 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [ws]);
|
||||
|
||||
const requestDirectoryListing = useCallback((agentId: string, path: string) => {
|
||||
const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
||||
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||
const shouldRecordHistory = options?.recordHistory ?? true;
|
||||
|
||||
updateExplorerState(agentId, (state) => ({
|
||||
...state,
|
||||
isLoading: true,
|
||||
lastError: null,
|
||||
pendingRequest: { path: normalizedPath, mode: "list" },
|
||||
currentPath: normalizedPath,
|
||||
history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history,
|
||||
lastVisitedPath: normalizedPath,
|
||||
}));
|
||||
|
||||
const msg: WSInboundMessage = {
|
||||
@@ -1170,6 +1271,44 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
ws.send(msg);
|
||||
}, [updateExplorerState, ws]);
|
||||
|
||||
const navigateExplorerBack = useCallback((agentId: string) => {
|
||||
let targetPath: string | null = null;
|
||||
|
||||
updateExplorerState(agentId, (state) => {
|
||||
if (!state.history || state.history.length <= 1) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextHistory = state.history.slice(0, -1);
|
||||
targetPath = nextHistory[nextHistory.length - 1] ?? ".";
|
||||
|
||||
return {
|
||||
...state,
|
||||
isLoading: true,
|
||||
lastError: null,
|
||||
pendingRequest: { path: targetPath, mode: "list" },
|
||||
currentPath: targetPath,
|
||||
history: nextHistory,
|
||||
};
|
||||
});
|
||||
|
||||
if (!targetPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const msg: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "file_explorer_request",
|
||||
agentId,
|
||||
path: targetPath,
|
||||
mode: "list",
|
||||
},
|
||||
};
|
||||
ws.send(msg);
|
||||
return targetPath;
|
||||
}, [updateExplorerState, ws]);
|
||||
|
||||
const value: SessionContextValue = {
|
||||
ws,
|
||||
audioPlayer,
|
||||
@@ -1194,8 +1333,14 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
||||
gitDiffs,
|
||||
requestGitDiff,
|
||||
fileExplorer,
|
||||
draftInputs,
|
||||
setDraftInputs,
|
||||
queuedMessages,
|
||||
setQueuedMessages,
|
||||
requestDirectoryListing,
|
||||
requestFilePreview,
|
||||
navigateExplorerBack,
|
||||
restartServer,
|
||||
initializeAgent,
|
||||
refreshAgent,
|
||||
cancelAgentRun,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./use-audio-player.native";
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export interface AudioPlayerOptions {
|
||||
isDetecting?: () => boolean;
|
||||
isSpeaking?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web shim for the native two-way audio player.
|
||||
* We currently don't support realtime voice playback on web.
|
||||
*/
|
||||
export function useAudioPlayer(_options?: AudioPlayerOptions) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
play: async () => {
|
||||
console.warn("[AudioPlayer] Web playback is disabled");
|
||||
return 0;
|
||||
},
|
||||
stop: () => {
|
||||
console.warn("[AudioPlayer] Web playback stop noop");
|
||||
},
|
||||
isPlaying: () => false,
|
||||
clearQueue: () => {},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./use-speechmatics-audio.native";
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
export interface SpeechmaticsAudioConfig {
|
||||
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
|
||||
onSpeechStart?: () => void;
|
||||
onSpeechEnd?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
volumeThreshold: number;
|
||||
silenceDuration: number;
|
||||
speechConfirmationDuration: number;
|
||||
detectionGracePeriod: number;
|
||||
}
|
||||
|
||||
export interface SpeechmaticsAudio {
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
toggleMute: () => void;
|
||||
isActive: boolean;
|
||||
isSpeaking: boolean;
|
||||
isDetecting: boolean;
|
||||
isMuted: boolean;
|
||||
volume: number;
|
||||
segmentDuration: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web shim for the Speechmatics two-way audio hook.
|
||||
* Real-time microphone capture is currently unsupported on web.
|
||||
*/
|
||||
export function useSpeechmaticsAudio(
|
||||
_config: SpeechmaticsAudioConfig
|
||||
): SpeechmaticsAudio {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
console.warn("[SpeechmaticsAudio] Web microphone capture disabled");
|
||||
setIsActive(true);
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
setIsActive(false);
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
setIsMuted((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
start,
|
||||
stop,
|
||||
toggleMute,
|
||||
isActive,
|
||||
isSpeaking: false,
|
||||
isDetecting: false,
|
||||
isMuted,
|
||||
volume: 0,
|
||||
segmentDuration: 0,
|
||||
}),
|
||||
[start, stop, toggleMute, isActive, isMuted]
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string
|
||||
|
||||
function createUniqueTimelineId(
|
||||
state: StreamItem[],
|
||||
prefix: "assistant" | "thought" | "user" | "tool",
|
||||
prefix: string,
|
||||
text: string,
|
||||
timestamp: Date
|
||||
): string {
|
||||
@@ -892,9 +892,12 @@ function appendTodoList(
|
||||
return next;
|
||||
}
|
||||
|
||||
const idSeed = `${provider}:${JSON.stringify(normalizedItems)}`;
|
||||
const entryId = createUniqueTimelineId(state, "todo", idSeed, timestamp);
|
||||
|
||||
const entry: TodoListItem = {
|
||||
kind: "todo_list",
|
||||
id: createTimelineId("todo", `${provider}:${JSON.stringify(normalizedItems)}`, timestamp),
|
||||
id: entryId,
|
||||
timestamp,
|
||||
provider,
|
||||
items: normalizedItems,
|
||||
|
||||
Reference in New Issue
Block a user