feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart

This commit is contained in:
Mohamed Boudra
2025-11-22 23:20:42 +01:00
parent 9feec81047
commit 95bacf8fc7
40 changed files with 3759 additions and 644 deletions
+153
View File
@@ -17,6 +17,7 @@
"@openai/codex-sdk": "^0.58.0" "@openai/codex-sdk": "^0.58.0"
}, },
"devDependencies": { "devDependencies": {
"patch-package": "^8.0.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
}, },
@@ -7987,6 +7988,13 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/@yarnpkg/lockfile": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/abort-controller": { "node_modules/abort-controller": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -12699,6 +12707,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/find-yarn-workspace-root": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"micromatch": "^4.0.2"
}
},
"node_modules/flat-cache": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@@ -14497,6 +14515,26 @@
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stable-stringify": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"isarray": "^2.0.5",
"jsonify": "^0.0.1",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
@@ -14529,6 +14567,16 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/jsonify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
"dev": true,
"license": "Public Domain",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz",
@@ -14615,6 +14663,16 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/klaw-sync": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.1.11"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
@@ -16480,6 +16538,91 @@
"cross-spawn": "^7.0.3" "cross-spawn": "^7.0.3"
} }
}, },
"node_modules/patch-package": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@yarnpkg/lockfile": "^1.1.0",
"chalk": "^4.1.2",
"ci-info": "^3.7.0",
"cross-spawn": "^7.0.3",
"find-yarn-workspace-root": "^2.0.0",
"fs-extra": "^10.0.0",
"json-stable-stringify": "^1.0.2",
"klaw-sync": "^6.0.0",
"minimist": "^1.2.6",
"open": "^7.4.2",
"semver": "^7.5.3",
"slash": "^2.0.0",
"tmp": "^0.2.4",
"yaml": "^2.2.2"
},
"bin": {
"patch-package": "index.js"
},
"engines": {
"node": ">=14",
"npm": ">5"
}
},
"node_modules/patch-package/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/patch-package/node_modules/open": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0",
"is-wsl": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/patch-package/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/patch-package/node_modules/slash": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -19406,6 +19549,16 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.14"
}
},
"node_modules/tmpl": { "node_modules/tmpl": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+3 -1
View File
@@ -15,9 +15,11 @@
"start": "npm run start --workspace=@paseo/server", "start": "npm run start --workspace=@paseo/server",
"android": "npm run android --workspace=@paseo/app", "android": "npm run android --workspace=@paseo/app",
"ios": "npm run ios --workspace=@paseo/app", "ios": "npm run ios --workspace=@paseo/app",
"web": "npm run web --workspace=@paseo/app" "web": "npm run web --workspace=@paseo/app",
"postinstall": "patch-package"
}, },
"devDependencies": { "devDependencies": {
"patch-package": "^8.0.1",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API", "description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
+2 -1
View File
@@ -1,2 +1,3 @@
import "expo-router/entry"; // Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles"; import "./src/styles/unistyles";
import "expo-router/entry";
+23
View File
@@ -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>
);
}
+21 -27
View File
@@ -210,13 +210,9 @@ export default function AgentScreen() {
if (!id) { if (!id) {
return; return;
} }
if (!agent?.persistence) {
handleCloseMenu();
return;
}
handleCloseMenu(); handleCloseMenu();
refreshAgent({ agentId: id }); refreshAgent({ agentId: id });
}, [agent?.persistence, handleCloseMenu, id, refreshAgent]); }, [handleCloseMenu, id, refreshAgent]);
if (!agent) { if (!agent) {
return ( return (
@@ -294,28 +290,26 @@ export default function AgentScreen() {
<Folder size={20} color={theme.colors.foreground} /> <Folder size={20} color={theme.colors.foreground} />
<Text style={styles.menuItemText}>Browse Files</Text> <Text style={styles.menuItemText}>Browse Files</Text>
</Pressable> </Pressable>
{agent.persistence && ( <Pressable
<Pressable onPress={handleRefreshAgent}
onPress={handleRefreshAgent} style={[
style={[ styles.menuItem,
styles.menuItem, isInitializing ? styles.menuItemDisabled : null,
isInitializing ? styles.menuItemDisabled : null, ]}
]} disabled={isInitializing}
disabled={isInitializing} >
> <RotateCcw size={20} color={theme.colors.foreground} />
<RotateCcw size={20} color={theme.colors.foreground} /> <Text style={styles.menuItemText}>
<Text style={styles.menuItemText}> {isInitializing ? "Refreshing..." : "Refresh"}
{isInitializing ? "Refreshing..." : "Refresh"} </Text>
</Text> {isInitializing && (
{isInitializing && ( <ActivityIndicator
<ActivityIndicator size="small"
size="small" color={theme.colors.primary}
color={theme.colors.primary} style={styles.menuItemSpinner}
style={styles.menuItemSpinner} />
/> )}
)} </Pressable>
</Pressable>
)}
</View> </View>
</View> </View>
</Modal> </Modal>
+341 -63
View File
@@ -4,24 +4,29 @@ import {
FlatList, FlatList,
Image as RNImage, Image as RNImage,
ListRenderItemInfo, ListRenderItemInfo,
ViewToken,
NativeScrollEvent, NativeScrollEvent,
NativeSyntheticEvent, NativeSyntheticEvent,
Pressable, Pressable,
ScrollView, ScrollView,
Text, Text,
View, View,
BackHandler,
useWindowDimensions,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 * as Clipboard from "expo-clipboard";
import { import {
Copy, Copy,
Check, Check,
ArrowLeft,
File, File,
FileText, FileText,
Folder, Folder,
Image as ImageIcon, Image as ImageIcon,
LayoutGrid,
List as ListIcon,
X,
} from "lucide-react-native"; } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { useSession, type ExplorerEntry } from "@/contexts/session-context"; import { useSession, type ExplorerEntry } from "@/contexts/session-context";
@@ -42,7 +47,9 @@ export default function FileExplorerScreen() {
fileExplorer, fileExplorer,
requestDirectoryListing, requestDirectoryListing,
requestFilePreview, requestFilePreview,
navigateExplorerBack,
} = useSession(); } = useSession();
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null); const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
const pendingPathParamRef = useRef<string | null>(null); const pendingPathParamRef = useRef<string | null>(null);
const pendingFileParamRef = 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 copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null); const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
const listScrollOffsetRef = useRef(0); 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 normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw)); const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
const derivedDirectoryFromFile = normalizedFileParam const derivedDirectoryFromFile = normalizedFileParam
? deriveDirectoryFromFile(normalizedFileParam) ? deriveDirectoryFromFile(normalizedFileParam)
: null; : null;
const initialTargetDirectory = normalizedPathParam ?? derivedDirectoryFromFile ?? ".";
const agent = agentId ? agents.get(agentId) : undefined; const agent = agentId ? agents.get(agentId) : undefined;
const explorerState = agentId ? fileExplorer.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 currentPath = explorerState?.currentPath ?? ".";
const pendingRequest = explorerState?.pendingRequest ?? null; const pendingRequest = explorerState?.pendingRequest ?? null;
const isExplorerLoading = explorerState?.isLoading ?? false; const isExplorerLoading = explorerState?.isLoading ?? false;
@@ -81,6 +99,49 @@ export default function FileExplorerScreen() {
? explorerState?.files.get(selectedEntryPath) ? explorerState?.files.get(selectedEntryPath)
: null; : null;
const shouldShowPreview = Boolean(selectedEntryPath); 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(() => { useEffect(() => {
setSelectedEntryPath(null); setSelectedEntryPath(null);
@@ -91,28 +152,15 @@ export default function FileExplorerScreen() {
return; return;
} }
const targetOffset = listScrollOffsetRef.current; const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? listScrollOffsetRef.current;
if (!listScrollRef.current) { queueScrollRestore(savedOffset);
return; }, [activePath, queueScrollRestore, shouldShowPreview]);
}
listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false });
}, [shouldShowPreview]);
useEffect(() => { useEffect(() => {
listScrollOffsetRef.current = 0; const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? 0;
listScrollRef.current?.scrollToOffset({ offset: 0, animated: false }); listScrollOffsetRef.current = savedOffset;
}, [activePath]); queueScrollRestore(savedOffset);
}, [activePath, queueScrollRestore]);
const parentPath = useMemo(() => {
if (activePath === ".") {
return null;
}
const segments = activePath.split("/");
segments.pop();
const nextPath = segments.join("/");
return nextPath.length === 0 ? "." : nextPath;
}, [activePath]);
useEffect(() => { useEffect(() => {
setCopiedPath(null); setCopiedPath(null);
@@ -179,14 +227,6 @@ export default function FileExplorerScreen() {
[agentId, requestDirectoryListing, requestFilePreview] [agentId, requestDirectoryListing, requestFilePreview]
); );
const handleNavigateUp = useCallback(() => {
if (!agentId || !parentPath) {
return;
}
setSelectedEntryPath(null);
requestDirectoryListing(agentId, parentPath);
}, [agentId, parentPath, requestDirectoryListing]);
const handleCopyPath = useCallback(async (path: string) => { const handleCopyPath = useCallback(async (path: string) => {
await Clipboard.setStringAsync(path); await Clipboard.setStringAsync(path);
setCopiedPath(path); setCopiedPath(path);
@@ -209,13 +249,87 @@ export default function FileExplorerScreen() {
const handleListScroll = useCallback( const handleListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => { (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( const renderEntry = useCallback(
({ item }: ListRenderItemInfo<ExplorerEntry>) => { ({ 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); const displayKind = getEntryDisplayKind(item);
return ( return (
<Pressable <Pressable
@@ -254,17 +368,23 @@ export default function FileExplorerScreen() {
</Pressable> </Pressable>
); );
}, },
[copiedPath, handleCopyPath, handleEntryPress, theme.colors] [
copiedPath,
explorerState?.files,
handleCopyPath,
handleEntryPress,
theme.colors,
thumbnailLoadingMap,
viewMode,
]
); );
const listHeaderComponent = useMemo(() => { const listHeaderComponent = useMemo(() => {
if (!parentPath && !showListLoadingBanner) {
return null;
}
return ( return (
<View style={styles.headerContainer}> <View style={styles.headerContainer}>
{parentPath && <UpRow label=".." onPress={handleNavigateUp} />} <View style={styles.headerRow}>
<ViewToggle viewMode={viewMode} onChange={setViewMode} />
</View>
{showListLoadingBanner && ( {showListLoadingBanner && (
<View style={styles.loadingBanner}> <View style={styles.loadingBanner}>
<ActivityIndicator size="small" /> <ActivityIndicator size="small" />
@@ -275,12 +395,20 @@ export default function FileExplorerScreen() {
)} )}
</View> </View>
); );
}, [activePath, handleNavigateUp, parentPath, showListLoadingBanner]); }, [activePath, showListLoadingBanner, viewMode]);
if (!agent) { if (!agent) {
return ( return (
<View style={styles.container}> <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}> <View style={styles.centerState}>
<Text style={styles.errorText}>Agent not found</Text> <Text style={styles.errorText}>Agent not found</Text>
</View> </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 ( return (
<View style={styles.container}> <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}> <View style={styles.content}>
{shouldShowPreview ? ( {shouldShowPreview ? (
<View style={styles.previewWrapper}> <View style={styles.previewWrapper}>
<UpRow label="Back to directory" onPress={() => setSelectedEntryPath(null)} />
<View style={styles.previewSection}> <View style={styles.previewSection}>
{isPreviewLoading && !preview ? ( {isPreviewLoading && !preview ? (
<View style={styles.centerState}> <View style={styles.centerState}>
@@ -359,14 +546,27 @@ export default function FileExplorerScreen() {
data={entries} data={entries}
renderItem={renderEntry} renderItem={renderEntry}
keyExtractor={(item) => item.path} 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} onScroll={handleListScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
onLayout={restoreQueuedScrollOffset}
onContentSizeChange={restoreQueuedScrollOffset}
ListHeaderComponent={listHeaderComponent} ListHeaderComponent={listHeaderComponent}
extraData={copiedPath} extraData={{ copiedPath, viewMode, thumbnailLoadingMap }}
initialNumToRender={20} initialNumToRender={20}
maxToRenderPerBatch={30} maxToRenderPerBatch={30}
windowSize={10} windowSize={10}
onViewableItemsChanged={handleViewableItemsChanged}
viewabilityConfig={viewabilityConfigRef.current}
/> />
)} )}
</View> </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 { function formatDirectoryLabel(path: string): string {
return path === "." ? "workspace root" : path; return path === "." ? "workspace root" : path;
} }
@@ -437,16 +666,6 @@ function deriveDirectoryFromFile(filePath: string): string {
return directory.length > 0 ? directory : "."; 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"; type EntryDisplayKind = "directory" | "image" | "text" | "other";
const IMAGE_EXTENSIONS = new Set([ const IMAGE_EXTENSIONS = new Set([
@@ -567,6 +786,10 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
}, },
headerRow: {
flexDirection: "row",
justifyContent: "flex-end",
},
loadingBanner: { loadingBanner: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -661,22 +884,32 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
upRow: { viewToggleContainer: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", borderRadius: theme.borderRadius.full,
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, 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, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold, fontWeight: theme.fontWeight.semibold,
}, },
closeButton: {
padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
textPreview: { textPreview: {
flex: 1, flex: 1,
}, },
@@ -688,6 +921,51 @@ const styles = StyleSheet.create((theme) => ({
fontFamily: "monospace", fontFamily: "monospace",
fontSize: theme.fontSize.sm, 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: { imagePreviewContainer: {
flex: 1, flex: 1,
alignItems: "center", alignItems: "center",
+31 -42
View File
@@ -121,41 +121,32 @@ export default function GitDiffScreen() {
<Text style={styles.filePath}>{file.path}</Text> <Text style={styles.filePath}>{file.path}</Text>
</View> </View>
<View style={styles.diffContent}> <View style={styles.diffContent}>
<ScrollView <View style={styles.diffLinesContainer}>
horizontal {file.lines.map((line, lineIndex) => (
nestedScrollEnabled <View
showsHorizontalScrollIndicator key={lineIndex}
bounces={false} style={[
overScrollMode="never" styles.diffLineContainer,
contentContainerStyle={styles.diffScrollContent} line.type === "add" && styles.addLineContainer,
> line.type === "remove" && styles.removeLineContainer,
<View style={styles.diffLinesContainer}> line.type === "header" && styles.headerLineContainer,
{file.lines.map((line, lineIndex) => ( line.type === "context" && styles.contextLineContainer,
<View ]}
key={lineIndex} >
<Text
style={[ style={[
styles.diffLineContainer, styles.diffLineText,
line.type === "add" && styles.addLineContainer, line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineContainer, line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineContainer, line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineContainer, line.type === "context" && styles.contextLineText,
]} ]}
> >
<Text {line.content}
style={[ </Text>
styles.diffLineText, </View>
line.type === "add" && styles.addLineText, ))}
line.type === "remove" && styles.removeLineText, </View>
line.type === "header" && styles.headerLineText,
line.type === "context" && styles.contextLineText,
]}
>
{line.content}
</Text>
</View>
))}
</View>
</ScrollView>
</View> </View>
</View> </View>
)) ))
@@ -175,6 +166,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
contentContainer: { contentContainer: {
padding: theme.spacing[4], padding: theme.spacing[4],
paddingBottom: theme.spacing[8],
}, },
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@@ -215,6 +207,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden", overflow: "hidden",
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
width: "100%",
}, },
fileHeader: { fileHeader: {
backgroundColor: theme.colors.muted, backgroundColor: theme.colors.muted,
@@ -231,27 +224,23 @@ const styles = StyleSheet.create((theme) => ({
diffContent: { diffContent: {
backgroundColor: theme.colors.card, backgroundColor: theme.colors.card,
}, },
diffScrollContent: {
flexDirection: "column",
alignItems: "flex-start",
paddingBottom: theme.spacing[2],
},
diffLinesContainer: { diffLinesContainer: {
alignSelf: "flex-start", width: "100%",
}, },
diffLineContainer: { diffLineContainer: {
minWidth: "100%", width: "100%",
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
alignSelf: "flex-start",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
}, },
diffLineText: { diffLineText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontFamily: "monospace", fontFamily: "monospace",
color: theme.colors.foreground, color: theme.colors.foreground,
flexShrink: 0, flexShrink: 1,
flexWrap: "wrap",
width: "100%",
}, },
addLineContainer: { addLineContainer: {
backgroundColor: theme.colors.palette.green[900], backgroundColor: theme.colors.palette.green[900],
+245 -31
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { import {
View, View,
Text, Text,
@@ -8,13 +8,23 @@ import {
Pressable, Pressable,
Alert, Alert,
ActivityIndicator, ActivityIndicator,
Platform,
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useSettings } from "@/hooks/use-settings"; import { useSettings } from "@/hooks/use-settings";
import { useSession } from "@/contexts/session-context";
import { theme as defaultTheme } from "@/styles/theme"; import { theme as defaultTheme } from "@/styles/theme";
import { BackHeader } from "@/components/headers/back-header"; 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) => ({ const styles = StyleSheet.create((theme) => ({
loadingContainer: { loadingContainer: {
flex: 1, flex: 1,
@@ -192,6 +202,24 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold, 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: { footer: {
borderTopWidth: theme.borderWidth[1], borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border, borderTopColor: theme.colors.border,
@@ -212,6 +240,7 @@ const styles = StyleSheet.create((theme) => ({
export default function SettingsScreen() { export default function SettingsScreen() {
const { settings, isLoading, updateSettings, resetSettings } = useSettings(); const { settings, isLoading, updateSettings, resetSettings } = useSettings();
const { restartServer, ws } = useSession();
const [serverUrl, setServerUrl] = useState(settings.serverUrl); const [serverUrl, setServerUrl] = useState(settings.serverUrl);
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker); const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
@@ -223,6 +252,137 @@ export default function SettingsScreen() {
success: boolean; success: boolean;
message: string; message: string;
} | null>(null); } | 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 // Update local state when settings load
useEffect(() => { 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() { async function handleTestConnection() {
if (!validateServerUrl(serverUrl)) { if (!validateServerUrl(serverUrl)) {
@@ -330,40 +543,21 @@ export default function SettingsScreen() {
setTestResult(null); setTestResult(null);
try { try {
const ws = new WebSocket(serverUrl); await testServerConnection(serverUrl);
setTestResult({
const timeout = setTimeout(() => { success: true,
ws.close(); message: "Connection successful",
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);
};
} catch (error) { } catch (error) {
const message =
error instanceof Error
? error.message
: "Connection failed - check URL and network";
setTestResult({ setTestResult({
success: false, success: false,
message: "Failed to create connection", message,
}); });
} finally {
setIsTesting(false); setIsTesting(false);
} }
} }
@@ -534,6 +728,26 @@ export default function SettingsScreen() {
<Pressable style={styles.resetButton} onPress={handleReset}> <Pressable style={styles.resetButton} onPress={handleReset}>
<Text style={styles.resetButtonText}>Reset to Defaults</Text> <Text style={styles.resetButtonText}>Reset to Defaults</Text>
</Pressable> </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> </View>
{/* App Info */} {/* App Info */}
+363 -25
View File
@@ -4,13 +4,15 @@ import {
Pressable, Pressable,
NativeSyntheticEvent, NativeSyntheticEvent,
TextInputContentSizeChangeEventData, TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
Image, Image,
Platform, Platform,
Text, Text,
ActivityIndicator,
} from "react-native"; } from "react-native";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useLayoutEffect } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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, { import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
@@ -32,18 +34,45 @@ interface AgentInputAreaProps {
agentId: string; agentId: string;
} }
const MIN_INPUT_HEIGHT = 40; const MIN_INPUT_HEIGHT = 32;
const MAX_INPUT_HEIGHT = 160; const MAX_INPUT_HEIGHT = 160;
const MAX_INPUT_WIDTH = 960;
const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2; const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2;
// Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated // Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated
// entering/exiting animations (see react-native-reanimated#8422), so guard them. // entering/exiting animations (see react-native-reanimated#8422), so guard them.
const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android"; 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_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeIn.duration(250);
const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.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) { export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles(); 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 { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
const [userInput, setUserInput] = useState(""); const [userInput, setUserInput] = useState("");
@@ -54,8 +83,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const [recordingVolume, setRecordingVolume] = useState(0); const [recordingVolume, setRecordingVolume] = useState(0);
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [transcribingRequestId, setTranscribingRequestId] = useState<string | null>(null); const [transcribingRequestId, setTranscribingRequestId] = useState<string | null>(null);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const recordingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(null);
const overlayTransition = useSharedValue(0); const overlayTransition = useSharedValue(0);
const { pickImages } = useImageAttachmentPicker(); 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() { async function handleSendMessage() {
if (!userInput.trim() || !ws.isConnected) return; if (!userInput.trim() || !ws.isConnected) return;
@@ -218,24 +256,108 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
}; };
}, []); }, []);
function handleContentSizeChange( function isTextAreaLike(value: unknown): value is TextAreaHandle {
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>, if (!value || typeof value !== "object") {
) { return false;
const contentHeight = event.nativeEvent.contentSize.height; }
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( const boundedHeight = Math.min(
MAX_INPUT_HEIGHT, MAX_INPUT_HEIGHT,
Math.max(MIN_INPUT_HEIGHT, contentHeight), Math.max(MIN_INPUT_HEIGHT, nextHeight),
); );
setInputHeight((currentHeight) => { setInputHeight((currentHeight) => {
if (Math.abs(currentHeight - boundedHeight) < 1) { if (Math.abs(currentHeight - boundedHeight) < 1) {
return currentHeight; return currentHeight;
} }
debugInputHeight("set-state", {
nextHeight,
boundedHeight,
previousHeight: currentHeight,
});
return boundedHeight; 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 agent = agents.get(agentId);
const isAgentRunning = agent?.status === "running"; const isAgentRunning = agent?.status === "running";
const hasText = userInput.trim().length > 0; const hasText = userInput.trim().length > 0;
@@ -243,6 +365,73 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const hasSendableContent = hasText || hasImages; const hasSendableContent = hasText || hasImages;
const shouldShowSendButton = !isAgentRunning && hasSendableContent; const shouldShowSendButton = !isAgentRunning && hasSendableContent;
const shouldShowVoiceControls = !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(() => { const overlayAnimatedStyle = useAnimatedStyle(() => {
return { return {
@@ -275,15 +464,59 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
} }
function handleCancelAgent() { function handleCancelAgent() {
if (!agent || agent.status !== "running") { if (!agent || agent.status !== "running" || isCancellingAgent) {
return; return;
} }
if (!ws.isConnected) { if (!ws.isConnected) {
return; return;
} }
setIsCancellingAgent(true);
cancelAgentRun(agentId); 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 = ( const realtimeButton = (
<Pressable <Pressable
onPress={handleRealtimePress} onPress={handleRealtimePress}
@@ -320,8 +553,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
{/* Input area */} {/* Input area */}
<View style={styles.inputAreaContainer}> <View style={styles.inputAreaContainer}>
{/* Regular input controls */} <View style={styles.inputAreaContent}>
<Animated.View style={[styles.inputContainer, inputAnimatedStyle]}> {/* 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 */} {/* Image preview pills */}
{hasImages && ( {hasImages && (
<View style={styles.imagePreviewContainer}> <View style={styles.imagePreviewContainer}>
@@ -338,6 +596,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
{/* Full-width text input */} {/* Full-width text input */}
<TextInput <TextInput
ref={textInputRef}
value={userInput} value={userInput}
onChangeText={setUserInput} onChangeText={setUserInput}
placeholder="Message agent..." placeholder="Message agent..."
@@ -350,6 +609,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT} scrollEnabled={inputHeight >= MAX_INPUT_HEIGHT}
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
editable={!isRecording && ws.isConnected} editable={!isRecording && ws.isConnected}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined}
/> />
{/* Button row below input */} {/* Button row below input */}
@@ -373,18 +633,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
<View style={styles.rightButtonGroup}> <View style={styles.rightButtonGroup}>
{isAgentRunning ? ( {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} {realtimeButton}
<Pressable <Pressable
onPress={handleCancelAgent} onPress={handleCancelAgent}
disabled={!ws.isConnected} disabled={!ws.isConnected || isCancellingAgent}
accessibilityLabel="Stop agent" accessibilityLabel={isCancellingAgent ? "Canceling agent" : "Stop agent"}
accessibilityRole="button" accessibilityRole="button"
style={[ style={[
styles.cancelButton, 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> </Pressable>
</> </>
) : shouldShowSendButton ? ( ) : shouldShowSendButton ? (
@@ -420,17 +695,20 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
) : null} ) : null}
</View> </View>
</View> </View>
</Animated.View> </Animated.View>
</View>
{/* Voice note recording overlay */} {/* Voice note recording overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}> <Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
<VoiceNoteRecordingOverlay <View style={styles.inputAreaContent}>
volume={recordingVolume} <VoiceNoteRecordingOverlay
duration={recordingDuration} volume={recordingVolume}
onCancel={handleCancelRecording} duration={recordingDuration}
onSend={handleSendRecording} onCancel={handleCancelRecording}
isTranscribing={transcribingRequestId !== null} onSend={handleSendRecording}
/> isTranscribing={transcribingRequestId !== null}
/>
</View>
</Animated.View> </Animated.View>
</View> </View>
</View> </View>
@@ -451,6 +729,12 @@ const styles = StyleSheet.create((theme) => ({
inputAreaContainer: { inputAreaContainer: {
position: "relative", position: "relative",
minHeight: FOOTER_HEIGHT, minHeight: FOOTER_HEIGHT,
alignItems: "center",
width: "100%",
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_INPUT_WIDTH,
}, },
inputContainer: { inputContainer: {
flexDirection: "column", flexDirection: "column",
@@ -458,6 +742,7 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
gap: theme.spacing[3], gap: theme.spacing[3],
minHeight: FOOTER_HEIGHT, minHeight: FOOTER_HEIGHT,
width: "100%",
}, },
overlayContainer: { overlayContainer: {
position: "absolute", position: "absolute",
@@ -465,6 +750,7 @@ const styles = StyleSheet.create((theme) => ({
right: 0, right: 0,
bottom: 0, bottom: 0,
height: FOOTER_HEIGHT, height: FOOTER_HEIGHT,
alignItems: "center",
}, },
imagePreviewContainer: { imagePreviewContainer: {
flexDirection: "row", flexDirection: "row",
@@ -499,6 +785,13 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
lineHeight: theme.fontSize.lg * 1.4, lineHeight: theme.fontSize.lg * 1.4,
...(IS_WEB
? {
outlineStyle: "none" as const,
outlineWidth: 0,
outlineColor: "transparent",
}
: {}),
}, },
buttonRow: { buttonRow: {
flexDirection: "row", flexDirection: "row",
@@ -559,7 +852,52 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}, },
queueButton: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.blue[600],
alignItems: "center",
justifyContent: "center",
},
buttonDisabled: { buttonDisabled: {
opacity: 0.5, 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],
},
})); }));
+4 -2
View File
@@ -18,10 +18,12 @@ export function AgentList({ agents }: AgentListProps) {
const [actionAgent, setActionAgent] = useState<Agent | null>(null); const [actionAgent, setActionAgent] = useState<Agent | null>(null);
const isActionSheetVisible = actionAgent !== 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(() => { const agentArray = useMemo(() => {
return Array.from(agents.values()).sort((a, b) => { 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]); }, [agents]);
+104 -63
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { useEffect, useMemo, useRef, useState, useCallback } from "react";
import type { ReactNode } from "react";
import { import {
View, View,
Text, Text,
@@ -39,6 +40,7 @@ import {
extractReadEntries, extractReadEntries,
} from "@/utils/tool-call-parsers"; } from "@/utils/tool-call-parsers";
const MAX_CHAT_WIDTH = 960;
export interface AgentStreamViewProps { export interface AgentStreamViewProps {
agentId: string; agentId: string;
agent: Agent; agent: Agent;
@@ -219,30 +221,31 @@ export function AgentStreamView({
} }
function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) { function renderStreamItem({ item }: ListRenderItemInfo<StreamItem>) {
let content: React.ReactNode = null;
switch (item.kind) { switch (item.kind) {
case "user_message": case "user_message":
return ( content = (
<UserMessage <UserMessage
message={item.text} message={item.text}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
/> />
); );
break;
case "assistant_message": case "assistant_message":
return ( content = (
<AssistantMessage <AssistantMessage
message={item.text} message={item.text}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress} onInlinePathPress={handleInlinePathPress}
/> />
); );
break;
case "thought": case "thought":
return ( content = <AgentThoughtMessage message={item.text} />;
<AgentThoughtMessage break;
message={item.text}
/>
);
case "tool_call": { case "tool_call": {
const { payload } = item; const { payload } = item;
@@ -250,7 +253,7 @@ export function AgentStreamView({
if (payload.source === "agent") { if (payload.source === "agent") {
const data = payload.data; const data = payload.data;
const toolLabel = data.displayName ?? `${data.server}/${data.tool}`; const toolLabel = data.displayName ?? `${data.server}/${data.tool}`;
return ( content = (
<ToolCall <ToolCall
toolName={toolLabel} toolName={toolLabel}
kind={data.kind} kind={data.kind}
@@ -264,22 +267,23 @@ export function AgentStreamView({
onOpenDetails={() => handleOpenToolCallDetails({ payload })} 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 })}
/>
);
} }
break;
const data = payload.data;
return (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
onOpenDetails={() => handleOpenToolCallDetails({ payload })}
/>
);
} }
case "activity_log": case "activity_log":
return ( content = (
<ActivityLog <ActivityLog
type={item.activityType} type={item.activityType}
message={item.message} message={item.message}
@@ -287,19 +291,27 @@ export function AgentStreamView({
metadata={item.metadata} metadata={item.metadata}
/> />
); );
break;
case "todo_list": case "todo_list":
return ( content = (
<TodoListCard <TodoListCard
provider={item.provider} provider={item.provider}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
items={item.items} items={item.items}
/> />
); );
break;
default: default:
return null; content = null;
} }
if (!content) {
return null;
}
return <View style={stylesheet.streamItemWrapper}>{content}</View>;
} }
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -318,24 +330,26 @@ export function AgentStreamView({
} }
return ( return (
<View style={stylesheet.listHeaderContent}> <View style={stylesheet.contentWrapper}>
{pendingPermissionItems.length > 0 ? ( <View style={stylesheet.listHeaderContent}>
<View style={stylesheet.permissionsContainer}> {pendingPermissionItems.length > 0 ? (
{pendingPermissionItems.map((permission) => ( <View style={stylesheet.permissionsContainer}>
<PermissionRequestCard {pendingPermissionItems.map((permission) => (
key={permission.key} <PermissionRequestCard
permission={permission} key={permission.key}
onResponse={onPermissionResponse} permission={permission}
/> onResponse={onPermissionResponse}
))} />
</View> ))}
) : null} </View>
) : null}
{showWorkingIndicator ? ( {showWorkingIndicator ? (
<View style={stylesheet.workingIndicatorWrapper}> <View style={stylesheet.workingIndicatorWrapper}>
<WorkingIndicator /> <WorkingIndicator />
</View> </View>
) : null} ) : null}
</View>
</View> </View>
); );
}, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]); }, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]);
@@ -371,7 +385,7 @@ export function AgentStreamView({
onMomentumScrollEnd={handleScrollEnd} onMomentumScrollEnd={handleScrollEnd}
scrollEventThrottle={16} scrollEventThrottle={16}
ListEmptyComponent={ ListEmptyComponent={
<View style={stylesheet.emptyState}> <View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}> <Text style={stylesheet.emptyStateText}>
Start chatting with this agent... Start chatting with this agent...
</Text> </Text>
@@ -395,15 +409,17 @@ export function AgentStreamView({
entering={scrollIndicatorFadeIn} entering={scrollIndicatorFadeIn}
exiting={scrollIndicatorFadeOut} exiting={scrollIndicatorFadeOut}
> >
<Pressable <View style={stylesheet.scrollToBottomInner}>
style={stylesheet.scrollToBottomButton} <Pressable
onPress={scrollToBottom} style={stylesheet.scrollToBottomButton}
> onPress={scrollToBottom}
<ChevronDown >
size={24} <ChevronDown
color={stylesheet.scrollToBottomIcon.color} size={24}
/> color={stylesheet.scrollToBottomIcon.color}
</Pressable> />
</Pressable>
</View>
</Animated.View> </Animated.View>
)} )}
@@ -462,16 +478,18 @@ function WorkingIndicator() {
const dotOne = useSharedValue(0); const dotOne = useSharedValue(0);
const dotTwo = useSharedValue(0); const dotTwo = useSharedValue(0);
const dotThree = useSharedValue(0); const dotThree = useSharedValue(0);
const bounceDuration = 600;
const bounceDelayOffset = 160;
useEffect(() => { useEffect(() => {
const sharedValues = [dotOne, dotTwo, dotThree]; const sharedValues = [dotOne, dotTwo, dotThree];
sharedValues.forEach((value, index) => { sharedValues.forEach((value, index) => {
value.value = withDelay( value.value = withDelay(
index * 120, index * bounceDelayOffset,
withRepeat( withRepeat(
withSequence( withSequence(
withTiming(1, { duration: 320 }), withTiming(1, { duration: bounceDuration }),
withTiming(0, { duration: 320 }) withTiming(0, { duration: bounceDuration })
), ),
-1, -1,
true true
@@ -487,19 +505,20 @@ function WorkingIndicator() {
}; };
}, [dotOne, dotTwo, dotThree]); }, [dotOne, dotTwo, dotThree]);
const translateDistance = -4;
const dotOneStyle = useAnimatedStyle(() => ({ const dotOneStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotOne.value * 0.7, opacity: 0.3 + dotOne.value * 0.7,
transform: [{ translateY: dotOne.value * -4 }], transform: [{ translateY: dotOne.value * translateDistance }],
})); }));
const dotTwoStyle = useAnimatedStyle(() => ({ const dotTwoStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotTwo.value * 0.7, opacity: 0.3 + dotTwo.value * 0.7,
transform: [{ translateY: dotTwo.value * -4 }], transform: [{ translateY: dotTwo.value * translateDistance }],
})); }));
const dotThreeStyle = useAnimatedStyle(() => ({ const dotThreeStyle = useAnimatedStyle(() => ({
opacity: 0.3 + dotThree.value * 0.7, opacity: 0.3 + dotThree.value * 0.7,
transform: [{ translateY: dotThree.value * -4 }], transform: [{ translateY: dotThree.value * translateDistance }],
})); }));
return ( return (
@@ -566,8 +585,13 @@ function PermissionRequestCard({
() => ({ () => ({
body: { body: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.base,
lineHeight: 20, lineHeight: 22,
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
}, },
paragraph: { paragraph: {
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
@@ -823,10 +847,20 @@ const stylesheet = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
backgroundColor: theme.colors.background, backgroundColor: theme.colors.background,
}, },
contentWrapper: {
width: "100%",
maxWidth: MAX_CHAT_WIDTH,
alignSelf: "center",
},
list: { list: {
flex: 1, flex: 1,
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
}, },
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CHAT_WIDTH,
alignSelf: "center",
},
emptyState: { emptyState: {
flex: 1, flex: 1,
alignItems: "center", alignItems: "center",
@@ -840,18 +874,19 @@ const stylesheet = StyleSheet.create((theme) => ({
gap: theme.spacing[3], gap: theme.spacing[3],
}, },
workingIndicatorWrapper: { workingIndicatorWrapper: {
alignItems: "center", alignItems: "flex-start",
}, },
workingIndicatorBubble: { workingIndicatorBubble: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4], paddingHorizontal: 0,
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[1],
paddingLeft: theme.spacing[1],
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.card, backgroundColor: "transparent",
borderWidth: theme.borderWidth[1], borderWidth: 0,
borderColor: theme.colors.border, alignSelf: "flex-start",
}, },
workingIndicatorText: { workingIndicatorText: {
color: theme.colors.mutedForeground, color: theme.colors.mutedForeground,
@@ -886,6 +921,12 @@ const stylesheet = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
pointerEvents: "box-none", pointerEvents: "box-none",
}, },
scrollToBottomInner: {
width: "100%",
maxWidth: MAX_CHAT_WIDTH,
alignSelf: "center",
alignItems: "center",
},
scrollToBottomButton: { scrollToBottomButton: {
width: 48, width: 48,
height: 48, height: 48,
File diff suppressed because it is too large Load Diff
+30 -3
View File
@@ -77,6 +77,9 @@ export function GlobalFooter() {
? FOOTER_HEIGHT * 2 + insets.bottom ? FOOTER_HEIGHT * 2 + insets.bottom
: FOOTER_HEIGHT + insets.bottom; : FOOTER_HEIGHT + insets.bottom;
const iconSize = 24;
const iconStyle = { width: iconSize, height: iconSize };
return ( return (
<> <>
<Animated.View <Animated.View
@@ -110,7 +113,13 @@ export function GlobalFooter() {
pressed && styles.buttonPressed, 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> <Text style={styles.footerButtonText}>Agents</Text>
</Pressable> </Pressable>
@@ -124,7 +133,13 @@ export function GlobalFooter() {
pressed && styles.buttonPressed, 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> <Text style={styles.footerButtonText}>New Agent</Text>
</Pressable> </Pressable>
@@ -137,7 +152,13 @@ export function GlobalFooter() {
pressed && !ws.isConnected && styles.buttonPressed, 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> <Text style={styles.footerButtonText}>Realtime</Text>
</Pressable> </Pressable>
</View> </View>
@@ -180,6 +201,12 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[3], paddingVertical: theme.spacing[3],
gap: theme.spacing[1], gap: theme.spacing[1],
}, },
footerIconWrapper: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
footerButtonText: { footerButtonText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -8,9 +8,10 @@ import { ScreenHeader } from "./screen-header";
interface BackHeaderProps { interface BackHeaderProps {
title?: string; title?: string;
rightContent?: ReactNode; rightContent?: ReactNode;
onBack?: () => void;
} }
export function BackHeader({ title, rightContent }: BackHeaderProps) { export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
return ( return (
@@ -18,7 +19,7 @@ export function BackHeader({ title, rightContent }: BackHeaderProps) {
left={ left={
<> <>
<Pressable <Pressable
onPress={() => router.back()} onPress={onBack ?? (() => router.back())}
style={styles.backButton} style={styles.backButton}
> >
<ArrowLeft size={20} color={theme.colors.foreground} /> <ArrowLeft size={20} color={theme.colors.foreground} />
+84 -17
View File
@@ -21,7 +21,7 @@ import {
Brain, Brain,
Search, Search,
} from "lucide-react-native"; } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { baseColors, theme } from "@/styles/theme"; import { baseColors, theme } from "@/styles/theme";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
@@ -166,6 +166,11 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
lineHeight: 24, lineHeight: 24,
}, },
markdownText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
lineHeight: 22,
},
markdownParagraph: { markdownParagraph: {
marginTop: 0, marginTop: 0,
marginBottom: theme.spacing[2], 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 { function isLikelyPathToken(value: string): boolean {
if (!value || value.length > 300) { if (!value || value.length > 300) {
return false; return false;
@@ -347,9 +336,87 @@ export const AssistantMessage = memo(function AssistantMessage({
isStreaming = false, isStreaming = false,
onInlinePathPress, onInlinePathPress,
}: AssistantMessageProps) { }: AssistantMessageProps) {
const { theme } = useUnistyles();
const fadeAnim = useRef(new Animated.Value(0.3)).current; const fadeAnim = useRef(new Animated.Value(0.3)).current;
const lastPathRef = useRef<string | null>(null); 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(() => { const markdownRules = useMemo(() => {
if (!onInlinePathPress) { if (!onInlinePathPress) {
return undefined; return undefined;
+179 -34
View File
@@ -9,6 +9,7 @@ import type {
AgentStreamEventPayload, AgentStreamEventPayload,
SessionInboundMessage, SessionInboundMessage,
WSInboundMessage, WSInboundMessage,
GitSetupOptions,
} from "@server/server/messages"; } from "@server/server/messages";
import type { import type {
AgentLifecycleStatus, AgentLifecycleStatus,
@@ -83,6 +84,7 @@ export interface Agent {
status: AgentLifecycleStatus; status: AgentLifecycleStatus;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
lastUserMessageAt: Date | null;
lastActivityAt: Date; lastActivityAt: Date;
sessionId: string | null; sessionId: string | null;
capabilities: AgentCapabilityFlags; capabilities: AgentCapabilityFlags;
@@ -144,6 +146,8 @@ export interface AgentFileExplorerState {
lastError: string | null; lastError: string | null;
pendingRequest: ExplorerRequestState | null; pendingRequest: ExplorerRequestState | null;
currentPath: string; currentPath: string;
history: string[];
lastVisitedPath: string;
} }
const createExplorerState = (): AgentFileExplorerState => ({ const createExplorerState = (): AgentFileExplorerState => ({
@@ -153,17 +157,30 @@ const createExplorerState = (): AgentFileExplorerState => ({
lastError: null, lastError: null,
pendingRequest: null, pendingRequest: null,
currentPath: ".", 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 { function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent {
const createdAt = new Date(snapshot.createdAt); const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt); const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
return { return {
id: snapshot.id, id: snapshot.id,
provider: snapshot.provider, provider: snapshot.provider,
status: snapshot.status as AgentLifecycleStatus, status: snapshot.status as AgentLifecycleStatus,
createdAt, createdAt,
updatedAt, updatedAt,
lastUserMessageAt,
lastActivityAt: updatedAt, lastActivityAt: updatedAt,
sessionId: snapshot.sessionId, sessionId: snapshot.sessionId,
capabilities: snapshot.capabilities, capabilities: snapshot.capabilities,
@@ -202,6 +219,12 @@ interface SessionContextValue {
setAgentStreamState: (state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void; setAgentStreamState: (state: Map<string, StreamItem[]> | ((prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>)) => void;
initializingAgents: Map<string, boolean>; 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 and commands
agents: Map<string, Agent>; agents: Map<string, Agent>;
setAgents: (agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>)) => void; setAgents: (agents: Map<string, Agent> | ((prev: Map<string, Agent>) => Map<string, Agent>)) => void;
@@ -218,17 +241,25 @@ interface SessionContextValue {
// File explorer // File explorer
fileExplorer: Map<string, AgentFileExplorerState>; 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; requestFilePreview: (agentId: string, path: string) => void;
navigateExplorerBack: (agentId: string) => string | null;
// Helpers // Helpers
restartServer: (reason?: string) => void;
initializeAgent: (params: { agentId: string; requestId?: string }) => void; initializeAgent: (params: { agentId: string; requestId?: string }) => void;
refreshAgent: (params: { agentId: string; requestId?: string }) => void; refreshAgent: (params: { agentId: string; requestId?: string }) => void;
cancelAgentRun: (agentId: string) => void; cancelAgentRun: (agentId: string) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>; sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>; sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise<void>;
deleteAgent: (agentId: string) => 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; resumeAgent: (options: { handle: AgentPersistenceHandle; overrides?: Partial<AgentSessionConfig>; requestId?: string }) => void;
setAgentMode: (agentId: string, modeId: string) => void; setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => 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 [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map()); const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(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 activeAudioGroupsRef = useRef<Set<string>>(new Set());
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
// Buffer for streaming audio chunks // Buffer for streaming audio chunks
interface AudioChunk { interface AudioChunk {
@@ -354,14 +388,42 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
setAgents(nextAgents); setAgents(nextAgents);
setPendingPermissions(nextPermissions); setPendingPermissions(nextPermissions);
setCommands(new Map(commandsList.map((c) => [c.id, c as Command]))); setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
setAgentStreamState(() => { setAgentStreamState((prev) => {
const next = new Map<string, StreamItem[]>(); if (prev.size === 0) {
for (const snapshot of agentsList) { return prev;
next.set(snapshot.id, []);
} }
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) => { const unsubAgentState = ws.on("agent_state", (message) => {
@@ -948,28 +1010,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [ws]); }, [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[]) => { const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => {
// Generate unique message ID for deduplication // Generate unique message ID for deduplication
const messageId = generateMessageId(); const messageId = generateMessageId();
@@ -997,10 +1037,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
// Use FileSystem.File to read the image as base64 // Use FileSystem.File to read the image as base64
const file = new FileSystem.File(imageUri); const file = new FileSystem.File(imageUri);
const base64 = file.base64Sync(); const base64 = file.base64Sync();
// Get MIME type from the file // Get MIME type from the file
const mimeType = file.type || 'image/jpeg'; const mimeType = file.type || 'image/jpeg';
imagesData.push({ imagesData.push({
data: base64, data: base64,
mimeType, mimeType,
@@ -1025,6 +1065,60 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [ws]); }, [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) => { const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob, requestId?: string) => {
try { try {
// Convert blob to base64 // Convert blob to base64
@@ -1060,12 +1154,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
} }
}, [ws]); }, [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 = { const msg: WSInboundMessage = {
type: "session", type: "session",
message: { message: {
type: "create_agent_request", type: "create_agent_request",
config, config,
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
...(git ? { git } : {}),
...(worktreeName ? { worktreeName } : {}), ...(worktreeName ? { worktreeName } : {}),
...(requestId ? { requestId } : {}), ...(requestId ? { requestId } : {}),
}, },
@@ -1127,14 +1224,18 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [ws]); }, [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 normalizedPath = path && path.length > 0 ? path : ".";
const shouldRecordHistory = options?.recordHistory ?? true;
updateExplorerState(agentId, (state) => ({ updateExplorerState(agentId, (state) => ({
...state, ...state,
isLoading: true, isLoading: true,
lastError: null, lastError: null,
pendingRequest: { path: normalizedPath, mode: "list" }, pendingRequest: { path: normalizedPath, mode: "list" },
currentPath: normalizedPath, currentPath: normalizedPath,
history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history,
lastVisitedPath: normalizedPath,
})); }));
const msg: WSInboundMessage = { const msg: WSInboundMessage = {
@@ -1170,6 +1271,44 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [updateExplorerState, ws]); }, [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 = { const value: SessionContextValue = {
ws, ws,
audioPlayer, audioPlayer,
@@ -1194,8 +1333,14 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
gitDiffs, gitDiffs,
requestGitDiff, requestGitDiff,
fileExplorer, fileExplorer,
draftInputs,
setDraftInputs,
queuedMessages,
setQueuedMessages,
requestDirectoryListing, requestDirectoryListing,
requestFilePreview, requestFilePreview,
navigateExplorerBack,
restartServer,
initializeAgent, initializeAgent,
refreshAgent, refreshAgent,
cancelAgentRun, cancelAgentRun,
+1
View File
@@ -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: () => {},
}),
[]
);
}
+1
View File
@@ -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]
);
}
+5 -2
View File
@@ -35,7 +35,7 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string
function createUniqueTimelineId( function createUniqueTimelineId(
state: StreamItem[], state: StreamItem[],
prefix: "assistant" | "thought" | "user" | "tool", prefix: string,
text: string, text: string,
timestamp: Date timestamp: Date
): string { ): string {
@@ -892,9 +892,12 @@ function appendTodoList(
return next; return next;
} }
const idSeed = `${provider}:${JSON.stringify(normalizedItems)}`;
const entryId = createUniqueTimelineId(state, "todo", idSeed, timestamp);
const entry: TodoListItem = { const entry: TodoListItem = {
kind: "todo_list", kind: "todo_list",
id: createTimelineId("todo", `${provider}:${JSON.stringify(normalizedItems)}`, timestamp), id: entryId,
timestamp, timestamp,
provider, provider,
items: normalizedItems, items: normalizedItems,
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "Paseo backend server", "description": "Paseo backend server",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "NODE_ENV=development node --watch --import tsx src/server/index.ts", "dev": "NODE_ENV=development node --import tsx src/server/index.ts",
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", "dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "tsc -p tsconfig.server.json", "build": "tsc -p tsconfig.server.json",
"start": "NODE_ENV=production node dist/server/index.js", "start": "NODE_ENV=production node dist/server/index.js",
@@ -33,6 +33,7 @@ export type AgentSnapshot = {
cwd: string; cwd: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
lastUserMessageAt: Date | null;
status: AgentLifecycleStatus; status: AgentLifecycleStatus;
sessionId: string | null; sessionId: string | null;
capabilities: AgentCapabilityFlags; capabilities: AgentCapabilityFlags;
@@ -85,6 +86,7 @@ type ManagedAgent = {
lastUsage?: AgentUsage; lastUsage?: AgentUsage;
lastError?: string; lastError?: string;
historyPrimed: boolean; historyPrimed: boolean;
lastUserMessageAt: Date | null;
}; };
type SubscriptionRecord = { type SubscriptionRecord = {
@@ -317,12 +319,14 @@ export class AgentManager {
raw: options?.raw, raw: options?.raw,
}; };
agent.updatedAt = new Date(); agent.updatedAt = new Date();
agent.lastUserMessageAt = agent.updatedAt;
this.recordTimeline(agent, item); this.recordTimeline(agent, item);
this.dispatchStream(agentId, { this.dispatchStream(agentId, {
type: "timeline", type: "timeline",
item, item,
provider: agent.provider, provider: agent.provider,
}); });
this.emitState(agent);
} }
streamAgent( streamAgent(
@@ -344,7 +348,12 @@ export class AgentManager {
agent.lastError = undefined; agent.lastError = undefined;
this.emitState(agent); this.emitState(agent);
let finalized = false;
const finalize = (error?: string) => { const finalize = (error?: string) => {
if (finalized) {
return;
}
finalized = true;
agent.pendingRun = null; agent.pendingRun = null;
agent.status = error ? "error" : "idle"; agent.status = error ? "error" : "idle";
agent.lastError = error; agent.lastError = error;
@@ -366,6 +375,10 @@ export class AgentManager {
error instanceof Error ? error.message : "Agent stream failed"; error instanceof Error ? error.message : "Agent stream failed";
finalize(message); finalize(message);
throw error; throw error;
} finally {
// Ensure we always clear the pending run and emit state when the stream is
// cancelled early (e.g., via .return()) so the UI can exit the cancelling state.
finalize();
} }
})(); })();
} }
@@ -403,6 +416,11 @@ export class AgentManager {
return Array.from(agent.pendingPermissions.values()); return Array.from(agent.pendingPermissions.values());
} }
async primeAgentHistory(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId);
await this.primeHistory(agent);
}
private async registerSession( private async registerSession(
session: AgentSession, session: AgentSession,
config: AgentSessionConfig, config: AgentSessionConfig,
@@ -430,6 +448,7 @@ export class AgentManager {
timeline: [], timeline: [],
persistence: session.describePersistence(), persistence: session.describePersistence(),
historyPrimed: false, historyPrimed: false,
lastUserMessageAt: null,
}; };
this.agents.set(agentId, managed); this.agents.set(agentId, managed);
@@ -438,7 +457,6 @@ export class AgentManager {
await this.refreshSessionState(managed); await this.refreshSessionState(managed);
managed.status = "idle"; managed.status = "idle";
this.emitState(managed); this.emitState(managed);
void this.primeHistory(managed);
return this.toSnapshot(managed); return this.toSnapshot(managed);
} }
@@ -473,7 +491,7 @@ export class AgentManager {
agent.historyPrimed = true; agent.historyPrimed = true;
try { try {
for await (const event of agent.session.streamHistory()) { for await (const event of agent.session.streamHistory()) {
this.handleStreamEvent(agent, event); this.handleStreamEvent(agent, event, { fromHistory: true });
} }
} catch { } catch {
// ignore history failures // ignore history failures
@@ -482,7 +500,8 @@ export class AgentManager {
private handleStreamEvent( private handleStreamEvent(
agent: ManagedAgent, agent: ManagedAgent,
event: AgentStreamEvent event: AgentStreamEvent,
options?: { fromHistory?: boolean }
): void { ): void {
agent.updatedAt = new Date(); agent.updatedAt = new Date();
@@ -492,6 +511,13 @@ export class AgentManager {
break; break;
case "timeline": case "timeline":
this.recordTimeline(agent, event.item); this.recordTimeline(agent, event.item);
if (
!options?.fromHistory &&
event.item.type === "user_message"
) {
agent.lastUserMessageAt = new Date();
this.emitState(agent);
}
break; break;
case "turn_completed": case "turn_completed":
agent.lastUsage = event.usage; agent.lastUsage = event.usage;
@@ -557,6 +583,7 @@ export class AgentManager {
cwd: agent.cwd, cwd: agent.cwd,
createdAt: agent.createdAt, createdAt: agent.createdAt,
updatedAt: agent.updatedAt, updatedAt: agent.updatedAt,
lastUserMessageAt: agent.lastUserMessageAt,
status: agent.status, status: agent.status,
sessionId: agent.sessionId, sessionId: agent.sessionId,
capabilities: agent.capabilities, capabilities: agent.capabilities,
@@ -14,6 +14,7 @@ function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
cwd: "/tmp/project", cwd: "/tmp/project",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
lastUserMessageAt: null,
status: "idle", status: "idle",
sessionId: null, sessionId: null,
capabilities: { capabilities: {
@@ -33,6 +33,8 @@ const STORED_AGENT_SCHEMA = z.object({
cwd: z.string(), cwd: z.string(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
lastActivityAt: z.string().optional(),
lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(), title: z.string().nullable().optional(),
lastStatus: z.string().nullable().optional(), lastStatus: z.string().nullable().optional(),
lastModeId: z.string().nullable().optional(), lastModeId: z.string().nullable().optional(),
@@ -121,6 +123,8 @@ export class AgentRegistry {
cwd, cwd,
createdAt: existing?.createdAt ?? now, createdAt: existing?.createdAt ?? now,
updatedAt: now, updatedAt: now,
lastActivityAt: existing?.lastActivityAt ?? existing?.updatedAt ?? now,
lastUserMessageAt: existing?.lastUserMessageAt ?? null,
title: existing?.title ?? null, title: existing?.title ?? null,
lastStatus: existing?.lastStatus ?? null, lastStatus: existing?.lastStatus ?? null,
lastModeId: nextModeId, lastModeId: nextModeId,
@@ -138,15 +142,19 @@ export class AgentRegistry {
if (!existing) { if (!existing) {
const record: StoredAgentRecord = { const record: StoredAgentRecord = {
id: snapshot.id, id: snapshot.id,
provider: snapshot.provider, provider: snapshot.provider,
cwd: snapshot.cwd, cwd: snapshot.cwd,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
title: null, lastActivityAt: snapshot.updatedAt.toISOString(),
lastStatus: snapshot.status, lastUserMessageAt: snapshot.lastUserMessageAt
lastModeId: snapshot.currentModeId ?? null, ? snapshot.lastUserMessageAt.toISOString()
config: null, : null,
persistence: snapshot.persistence ?? null, title: null,
lastStatus: snapshot.status,
lastModeId: snapshot.currentModeId ?? null,
config: null,
persistence: snapshot.persistence ?? null,
}; };
this.cache.set(snapshot.id, record); this.cache.set(snapshot.id, record);
await this.flush(); await this.flush();
@@ -157,6 +165,10 @@ export class AgentRegistry {
provider: snapshot.provider, provider: snapshot.provider,
cwd: snapshot.cwd, cwd: snapshot.cwd,
updatedAt: now, updatedAt: now,
lastActivityAt: snapshot.updatedAt.toISOString(),
lastUserMessageAt: snapshot.lastUserMessageAt
? snapshot.lastUserMessageAt.toISOString()
: existing.lastUserMessageAt ?? null,
lastStatus: snapshot.status, lastStatus: snapshot.status,
lastModeId: snapshot.currentModeId ?? null, lastModeId: snapshot.currentModeId ?? null,
persistence: snapshot.persistence ?? existing.persistence ?? null, persistence: snapshot.persistence ?? existing.persistence ?? null,
@@ -126,6 +126,7 @@ export type PersistedAgentDescriptor = {
title: string | null; title: string | null;
lastActivityAt: Date; lastActivityAt: Date;
persistence: AgentPersistenceHandle; persistence: AgentPersistenceHandle;
timeline: AgentTimelineItem[];
}; };
export type AgentSessionConfig = { export type AgentSessionConfig = {
@@ -24,10 +24,7 @@ import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js";
import { AgentRegistry } from "./agent-registry.js"; import { AgentRegistry } from "./agent-registry.js";
import { createWorktree } from "../../utils/worktree.js"; import { createWorktree } from "../../utils/worktree.js";
import { import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
WaitForAgentTracker,
type WaitForAgentCanceler,
} from "./wait-for-agent-tracker.js";
export interface AgentMcpServerOptions { export interface AgentMcpServerOptions {
agentManager: AgentManager; agentManager: AgentManager;
@@ -229,6 +226,7 @@ export async function createAgentMcpServer(
options: AgentMcpServerOptions options: AgentMcpServerOptions
): Promise<McpServer> { ): Promise<McpServer> {
const { agentManager, agentRegistry } = options; const { agentManager, agentRegistry } = options;
const waitTracker = new WaitForAgentTracker();
const server = new McpServer({ const server = new McpServer({
name: "agent-mcp", name: "agent-mcp",
@@ -289,6 +287,7 @@ export async function createAgentMcpServer(
const worktree = await createWorktree({ const worktree = await createWorktree({
branchName: worktreeName, branchName: worktreeName,
cwd: resolvedCwd, cwd: resolvedCwd,
worktreeSlug: worktreeName,
}); });
resolvedCwd = worktree.worktreePath; resolvedCwd = worktree.worktreePath;
} }
@@ -41,11 +41,12 @@ describe("ClaudeAgentClient (SDK integration)", () => {
}; };
const session = await client.createSession(config); const session = await client.createSession(config);
const marker = "CLAUDE_ACK_TOKEN";
const result = await session.run( const result = await session.run(
"Reply with the single word ACK and then stop." `Reply with the exact text ${marker} and then stop.`
); );
expect(result.finalText.toLowerCase()).toContain("ack"); expect(result.finalText).toContain(marker);
await session.close(); await session.close();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
@@ -89,6 +90,85 @@ describe("ClaudeAgentClient (SDK integration)", () => {
120_000 120_000
); );
test(
"emits a single assistant message in the hydrated stream",
async () => {
const cwd = tmpCwd();
const client = new ClaudeAgentClient();
const config: AgentSessionConfig = {
provider: "claude",
cwd,
extra: { claude: { maxThinkingTokens: 2048 } },
};
const session = await client.createSession(config);
const updates: StreamHydrationUpdate[] = [];
try {
const events = session.stream("Reply with the exact words HELLO WORLD.");
for await (const event of events) {
await autoApprove(session, event);
recordTimelineUpdate(updates, event);
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
const state = hydrateStreamState(updates);
const assistantMessages = state.filter(
(item): item is Extract<StreamItem, { kind: "assistant_message" }> =>
item.kind === "assistant_message"
);
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].text.toLowerCase()).toContain("hello world");
},
150_000
);
test(
"shows the command inside pending tool calls",
async () => {
const cwd = tmpCwd();
const client = new ClaudeAgentClient();
const config: AgentSessionConfig = {
provider: "claude",
cwd,
extra: { claude: { maxThinkingTokens: 2048 } },
};
const session = await client.createSession(config);
let pendingDisplay: string | null = null;
const events = session.stream("Run the exact command `pwd` via Bash and stop.");
try {
for await (const event of events) {
await autoApprove(session, event);
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.server.toLowerCase().includes("bash") &&
event.item.status === "pending"
) {
pendingDisplay = event.item.displayName ?? null;
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
} finally {
await session.close();
rmSync(cwd, { recursive: true, force: true });
}
expect(pendingDisplay).toBeTruthy();
expect(pendingDisplay?.toLowerCase()).toContain("pwd");
},
150_000
);
test( test(
"tracks permission + tool lifecycle when editing a file", "tracks permission + tool lifecycle when editing a file",
async () => { async () => {
@@ -384,46 +464,40 @@ describe("ClaudeAgentClient (SDK integration)", () => {
assertHydratedReplica( assertHydratedReplica(
commandTool!, commandTool!,
hydratedMap, hydratedMap,
(data) => (data.parsedCommand?.output ?? "").includes(cwd), (data) =>
rawContainsText(data.raw, cwd) ||
rawContainsText(data.result, cwd),
({ live, hydrated }) => { ({ live, hydrated }) => {
expect((live.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd"); expect(rawContainsText(live.raw, cwd)).toBe(true);
expect(live.parsedCommand?.output ?? "").toContain(cwd); expect(rawContainsText(hydrated.raw, cwd)).toBe(true);
expect((hydrated.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd"); expect((live.displayName ?? "").toLowerCase()).toContain("pwd");
expect(hydrated.parsedCommand?.output ?? "").toContain(cwd); expect((hydrated.displayName ?? "").toLowerCase()).toContain("pwd");
} }
); );
assertHydratedReplica( assertHydratedReplica(
editTool!, editTool!,
hydratedMap, hydratedMap,
(data) => (data) =>
Array.isArray(data.parsedEdits) && Array.isArray((data.result as any)?.files) &&
data.parsedEdits.some( ((data.result as any).files as Array<{ path?: string }>).some((entry) =>
(entry) => (entry.path ?? "").includes("hydrate-proof.txt")
(entry.filePath ?? "").includes("hydrate-proof.txt") &&
entry.diffLines.some((line) => line.content.includes("HYDRATION_PROOF_LINE_TWO"))
), ),
({ live, hydrated }) => { ({ live, hydrated }) => {
const liveDiff = JSON.stringify(live.parsedEdits ?? []); const liveDiff = JSON.stringify(live.result ?? live.raw ?? {});
const hydratedDiff = JSON.stringify(hydrated.parsedEdits ?? []); const hydratedDiff = JSON.stringify(hydrated.result ?? hydrated.raw ?? {});
expect(liveDiff).toContain("HYDRATION_PROOF_LINE_ONE"); expect(liveDiff).toContain("hydrate-proof.txt");
expect(liveDiff).toContain("HYDRATION_PROOF_LINE_TWO"); expect(hydratedDiff).toContain("hydrate-proof.txt");
expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_ONE");
expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_TWO");
} }
); );
assertHydratedReplica( assertHydratedReplica(
readTool!, readTool!,
hydratedMap, hydratedMap,
(data) => (data) =>
Array.isArray(data.parsedReads) && rawContainsText(data.raw, "HYDRATION_PROOF_LINE_ONE") &&
data.parsedReads.some( rawContainsText(data.raw, "HYDRATION_PROOF_LINE_TWO"),
(entry) =>
(entry.filePath ?? "").includes("hydrate-proof.txt") &&
entry.content.includes("HYDRATION_PROOF_LINE_TWO")
),
({ live, hydrated }) => { ({ live, hydrated }) => {
const liveReads = JSON.stringify(live.parsedReads ?? []); const liveReads = JSON.stringify(live.raw ?? {});
const hydratedReads = JSON.stringify(hydrated.parsedReads ?? []); const hydratedReads = JSON.stringify(hydrated.raw ?? {});
expect(liveReads).toContain("HYDRATION_PROOF_LINE_ONE"); expect(liveReads).toContain("HYDRATION_PROOF_LINE_ONE");
expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_ONE"); expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_ONE");
expect(liveReads).toContain("HYDRATION_PROOF_LINE_TWO"); expect(liveReads).toContain("HYDRATION_PROOF_LINE_TWO");
@@ -207,10 +207,15 @@ class ClaudeAgentSession implements AgentSession {
private currentMode: PermissionMode; private currentMode: PermissionMode;
private availableModes: AgentMode[] = DEFAULT_MODES; private availableModes: AgentMode[] = DEFAULT_MODES;
private toolUseCache = new Map<string, ToolUseCacheEntry>(); private toolUseCache = new Map<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>(); private pendingPermissions = new Map<string, PendingPermission>();
private eventQueue: Pushable<AgentStreamEvent> | null = null; private eventQueue: Pushable<AgentStreamEvent> | null = null;
private persistedHistory: AgentTimelineItem[] = []; private persistedHistory: AgentTimelineItem[] = [];
private historyPending = false; private historyPending = false;
private turnCancelRequested = false;
private streamedAssistantTextThisTurn = false;
private streamedReasoningThisTurn = false;
constructor( constructor(
config: ClaudeAgentConfig, config: ClaudeAgentConfig,
@@ -266,6 +271,17 @@ class ClaudeAgentSession implements AgentSession {
const sdkMessage = this.toSdkUserMessage(prompt); const sdkMessage = this.toSdkUserMessage(prompt);
const queue = new Pushable<AgentStreamEvent>(); const queue = new Pushable<AgentStreamEvent>();
this.eventQueue = queue; this.eventQueue = queue;
let finishedNaturally = false;
let cancelIssued = false;
const requestCancel = () => {
if (cancelIssued) {
return;
}
cancelIssued = true;
this.turnCancelRequested = true;
void this.interruptActiveTurn();
queue.end();
};
if (this.historyPending && this.persistedHistory.length > 0) { if (this.historyPending && this.persistedHistory.length > 0) {
for (const item of this.persistedHistory) { for (const item of this.persistedHistory) {
queue.push({ type: "timeline", item, provider: "claude" }); queue.push({ type: "timeline", item, provider: "claude" });
@@ -279,10 +295,14 @@ class ClaudeAgentSession implements AgentSession {
for await (const event of queue) { for await (const event of queue) {
yield event; yield event;
if (event.type === "turn_completed" || event.type === "turn_failed") { if (event.type === "turn_completed" || event.type === "turn_failed") {
finishedNaturally = true;
break; break;
} }
} }
} finally { } finally {
if (!finishedNaturally && !cancelIssued) {
requestCancel();
}
if (this.eventQueue === queue) { if (this.eventQueue === queue) {
this.eventQueue = null; this.eventQueue = null;
} }
@@ -501,6 +521,8 @@ class ClaudeAgentSession implements AgentSession {
} }
private async forwardPromptEvents(message: SDKUserMessage, queue: Pushable<AgentStreamEvent>) { private async forwardPromptEvents(message: SDKUserMessage, queue: Pushable<AgentStreamEvent>) {
this.streamedAssistantTextThisTurn = false;
this.streamedReasoningThisTurn = false;
try { try {
for await (const sdkEvent of this.processPrompt(message)) { for await (const sdkEvent of this.processPrompt(message)) {
const events = this.translateMessageToEvents(sdkEvent); const events = this.translateMessageToEvents(sdkEvent);
@@ -509,16 +531,30 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
} catch (error) { } catch (error) {
queue.push({ if (!this.turnCancelRequested) {
type: "turn_failed", queue.push({
provider: "claude", type: "turn_failed",
error: error instanceof Error ? error.message : "Claude stream failed", provider: "claude",
}); error: error instanceof Error ? error.message : "Claude stream failed",
});
}
} finally { } finally {
this.turnCancelRequested = false;
queue.end(); queue.end();
} }
} }
private async interruptActiveTurn(): Promise<void> {
if (!this.query || typeof this.query.interrupt !== "function") {
return;
}
try {
await this.query.interrupt();
} catch (error) {
console.warn("[ClaudeAgentSession] Failed to interrupt active turn:", error);
}
}
private translateMessageToEvents(message: SDKMessage): AgentStreamEvent[] { private translateMessageToEvents(message: SDKMessage): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [{ type: "provider_event", provider: "claude", raw: message }]; const events: AgentStreamEvent[] = [{ type: "provider_event", provider: "claude", raw: message }];
@@ -539,7 +575,10 @@ class ClaudeAgentSession implements AgentSession {
break; break;
} }
case "assistant": { case "assistant": {
const timelineItems = this.mapBlocksToTimeline(message.message.content); const timelineItems = this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: this.streamedAssistantTextThisTurn,
suppressReasoning: this.streamedReasoningThisTurn,
});
for (const item of timelineItems) { for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" }); events.push({ type: "timeline", item, provider: "claude" });
} }
@@ -811,14 +850,33 @@ class ClaudeAgentSession implements AgentSession {
} }
private convertHistoryEntry(entry: any): AgentTimelineItem[] { private convertHistoryEntry(entry: any): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)); return convertClaudeHistoryEntry(entry, (content) =>
this.mapBlocksToTimeline(content, { context: "history" })
);
} }
private mapBlocksToTimeline(content: string | ClaudeContentChunk[]): AgentTimelineItem[] { private mapBlocksToTimeline(
content: string | ClaudeContentChunk[],
options?: {
context?: "live" | "history";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
}
): AgentTimelineItem[] {
const context = options?.context ?? "live";
const suppressAssistant = options?.suppressAssistantText ?? false;
const suppressReasoning = options?.suppressReasoning ?? false;
if (typeof content === "string") { if (typeof content === "string") {
if (!content || content === "[Request interrupted by user for tool use]") { if (!content || content === "[Request interrupted by user for tool use]") {
return []; return [];
} }
if (context === "live") {
this.streamedAssistantTextThisTurn = true;
}
if (suppressAssistant) {
return [];
}
return [{ type: "assistant_message", text: content, raw: content }]; return [{ type: "assistant_message", text: content, raw: content }];
} }
@@ -828,13 +886,23 @@ class ClaudeAgentSession implements AgentSession {
case "text": case "text":
case "text_delta": case "text_delta":
if (block.text && block.text !== "[Request interrupted by user for tool use]") { if (block.text && block.text !== "[Request interrupted by user for tool use]") {
items.push({ type: "assistant_message", text: block.text, raw: block }); if (context === "live") {
this.streamedAssistantTextThisTurn = true;
}
if (!suppressAssistant) {
items.push({ type: "assistant_message", text: block.text, raw: block });
}
} }
break; break;
case "thinking": case "thinking":
case "thinking_delta": case "thinking_delta":
if (block.thinking) { if (block.thinking) {
items.push({ type: "reasoning", text: block.thinking, raw: block }); if (context === "live") {
this.streamedReasoningThisTurn = true;
}
if (!suppressReasoning) {
items.push({ type: "reasoning", text: block.thinking, raw: block });
}
} }
break; break;
case "tool_use": case "tool_use":
@@ -915,6 +983,23 @@ class ClaudeAgentSession implements AgentSession {
} }
private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] { private mapPartialEvent(event: SDKPartialAssistantMessage["event"]): AgentTimelineItem[] {
if (event.type === "content_block_start" && (event.content_block as ClaudeContentChunk | undefined)?.type === "tool_use") {
const block = event.content_block as ClaudeContentChunk;
if (typeof event.index === "number" && typeof block?.id === "string") {
this.toolUseIndexToId.set(event.index, block.id);
this.toolUseInputBuffers.delete(block.id);
}
} else if (event.type === "content_block_delta" && (event.delta as ClaudeContentChunk | undefined)?.type === "input_json_delta") {
this.handleToolInputDelta(event.index, (event.delta as { partial_json?: string })?.partial_json);
return [];
} else if (event.type === "content_block_stop" && typeof event.index === "number") {
const toolId = this.toolUseIndexToId.get(event.index);
if (toolId) {
this.toolUseIndexToId.delete(event.index);
this.toolUseInputBuffers.delete(toolId);
}
}
switch (event.type) { switch (event.type) {
case "content_block_start": case "content_block_start":
return this.mapBlocksToTimeline([event.content_block as ClaudeContentChunk]); return this.mapBlocksToTimeline([event.content_block as ClaudeContentChunk]);
@@ -956,18 +1041,8 @@ class ClaudeAgentSession implements AgentSession {
if (block.type === "tool_use") { if (block.type === "tool_use") {
const input = this.normalizeToolInput(block.input); const input = this.normalizeToolInput(block.input);
existing.input = input;
if (input) { if (input) {
if (this.isCommandTool(existing.name, input)) { this.applyToolInput(existing, input);
existing.classification = "command";
existing.commandText = this.extractCommandText(input) ?? existing.commandText;
} else {
const files = this.extractFileChanges(input);
if (files?.length) {
existing.classification = "file_change";
existing.files = files;
}
}
} }
} }
@@ -975,6 +1050,41 @@ class ClaudeAgentSession implements AgentSession {
return existing; return existing;
} }
private handleToolInputDelta(index: number | undefined, partialJson: string | undefined): void {
if (typeof index !== "number" || typeof partialJson !== "string") {
return;
}
const toolId = this.toolUseIndexToId.get(index);
if (!toolId) {
return;
}
const buffer = (this.toolUseInputBuffers.get(toolId) ?? "") + partialJson;
this.toolUseInputBuffers.set(toolId, buffer);
let parsed: unknown;
try {
parsed = JSON.parse(buffer);
} catch {
return;
}
const entry = this.toolUseCache.get(toolId);
const normalized = this.normalizeToolInput(parsed);
if (!entry || !normalized) {
return;
}
this.applyToolInput(entry, normalized);
this.toolUseCache.set(toolId, entry);
this.pushToolCall({
server: entry.server,
tool: entry.name,
status: "pending",
callId: toolId,
displayName: this.buildToolDisplayName(entry),
kind: this.getToolKind(entry.classification),
input: normalized,
raw: { type: "tool_use", id: toolId, input: parsed },
});
}
private normalizeToolInput(input: unknown): Record<string, unknown> | null { private normalizeToolInput(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object") { if (!input || typeof input !== "object") {
return null; return null;
@@ -982,6 +1092,20 @@ class ClaudeAgentSession implements AgentSession {
return input as Record<string, unknown>; return input as Record<string, unknown>;
} }
private applyToolInput(entry: ToolUseCacheEntry, input: Record<string, unknown>): void {
entry.input = input;
if (this.isCommandTool(entry.name, input)) {
entry.classification = "command";
entry.commandText = this.extractCommandText(input) ?? entry.commandText;
} else {
const files = this.extractFileChanges(input);
if (files?.length) {
entry.classification = "file_change";
entry.files = files;
}
}
}
private isCommandTool(name: string, input: Record<string, unknown>): boolean { private isCommandTool(name: string, input: Record<string, unknown>): boolean {
const normalized = name.toLowerCase(); const normalized = name.toLowerCase();
if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("terminal") || normalized.includes("command")) { if (normalized.includes("bash") || normalized.includes("shell") || normalized.includes("terminal") || normalized.includes("command")) {
@@ -1247,6 +1371,8 @@ async function collectRecentClaudeSessions(root: string, limit: number): Promise
.slice(0, limit); .slice(0, limit);
} }
const CLAUDE_PERSISTED_TIMELINE_LIMIT = 20;
async function parseClaudeSessionDescriptor( async function parseClaudeSessionDescriptor(
filePath: string, filePath: string,
mtime: Date mtime: Date
@@ -1261,6 +1387,7 @@ async function parseClaudeSessionDescriptor(
let sessionId: string | null = null; let sessionId: string | null = null;
let cwd: string | null = null; let cwd: string | null = null;
let title: string | null = null; let title: string | null = null;
const timeline: AgentTimelineItem[] = [];
for (const rawLine of content.split(/\r?\n/)) { for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim(); const line = rawLine.trim();
@@ -1277,8 +1404,19 @@ async function parseClaudeSessionDescriptor(
if (!cwd && typeof entry.cwd === "string") { if (!cwd && typeof entry.cwd === "string") {
cwd = entry.cwd; cwd = entry.cwd;
} }
if (!title && entry.type === "user" && entry.message) { if (entry.type === "user" && entry.message) {
title = extractClaudeUserText(entry.message); const text = extractClaudeUserText(entry.message);
if (text) {
if (!title) {
title = text;
}
timeline.push({ type: "user_message", text });
}
} else if (entry.type === "assistant" && entry.message) {
const text = extractClaudeUserText(entry.message);
if (text) {
timeline.push({ type: "assistant_message", text });
}
} }
if (sessionId && cwd && title) { if (sessionId && cwd && title) {
break; break;
@@ -1306,6 +1444,7 @@ async function parseClaudeSessionDescriptor(
title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`, title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime, lastActivityAt: mtime,
persistence, persistence,
timeline: timeline.slice(0, CLAUDE_PERSISTED_TIMELINE_LIMIT),
}; };
} }
@@ -1,11 +1,27 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs"; import { mkdtempSync, rmSync, promises as fs } from "node:fs";
import type { Dirent } from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { CodexAgentClient } from "./codex-agent.js"; import { CodexAgentClient, isSyntheticRolloutUserMessage } from "./codex-agent.js";
import { hydrateStreamState, type StreamItem } from "../../../../../app/src/types/stream.js"; import {
import type { AgentProvider, AgentSessionConfig, AgentStreamEvent, AgentPermissionRequest } from "../agent-sdk-types.js"; hydrateStreamState,
isAgentToolCallItem,
type AgentToolCallItem,
type StreamItem,
} from "../../../../../app/src/types/stream.js";
import {
serializeAgentStreamEvent,
type AgentStreamEventPayload,
} from "../../messages.js";
import type {
AgentProvider,
AgentSessionConfig,
AgentStreamEvent,
AgentPermissionRequest,
AgentPersistenceHandle,
} from "../agent-sdk-types.js";
function tmpCwd(): string { function tmpCwd(): string {
return mkdtempSync(path.join(os.tmpdir(), "codex-agent-e2e-")); return mkdtempSync(path.join(os.tmpdir(), "codex-agent-e2e-"));
@@ -32,38 +48,6 @@ function log(message: string): void {
console.info(`[CodexAgentTest] ${message}`); console.info(`[CodexAgentTest] ${message}`);
} }
function createStubCodexBinary(events: Record<string, unknown>[]): { path: string; cleanup: () => void } {
const dir = mkdtempSync(path.join(os.tmpdir(), "codex-stub-"));
const filePath = path.join(dir, "codex-stub.js");
const script = `#!/usr/bin/env node
const events = ${JSON.stringify(events)};
const emit = () => {
for (const event of events) {
process.stdout.write(JSON.stringify(event) + "\\n");
}
};
if (process.stdin.isTTY) {
emit();
process.exit(0);
} else {
process.stdin.resume();
process.stdin.on("data", () => {});
process.stdin.on("end", () => {
emit();
process.exit(0);
});
}`;
writeFileSync(filePath, script, "utf8");
chmodSync(filePath, 0o755);
return {
path: filePath,
cleanup: () => {
rmSync(dir, { recursive: true, force: true });
},
};
}
describe("CodexAgentClient (SDK integration)", () => { describe("CodexAgentClient (SDK integration)", () => {
test( test(
"responds with text", "responds with text",
@@ -140,6 +124,154 @@ describe("CodexAgentClient (SDK integration)", () => {
180_000 180_000
); );
test(
"emits Codex tool calls that hydrate into specific UI entries",
async () => {
const cwd = tmpCwd();
const restoreSessionDir = useTempCodexSessionDir();
const client = new CodexAgentClient();
const config: AgentSessionConfig = { provider: "codex", cwd };
type HydrationEntry = { event: AgentStreamEventPayload; timestamp: Date };
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
try {
session = await client.createSession(config);
log("Streaming Codex run for tool call hydration test");
const hydrationUpdates: HydrationEntry[] = [];
const stream = session.stream(
[
"1. Run the command `pwd` using your shell tool and report the output.",
"2. Next, use your apply_patch editing tool (not the shell) to create a file named codex-stream.log containing exactly the single line 'ok'.",
"3. After the patch succeeds, stop.",
].join("\n")
);
for await (const event of stream) {
if (event.type === "timeline" || event.type === "provider_event") {
hydrationUpdates.push({
event: serializeAgentStreamEvent(event),
timestamp: new Date(),
});
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
const streamItems = hydrateStreamState(hydrationUpdates);
const commandEntry = streamItems.find(
(item): item is AgentToolCallItem =>
isAgentToolCallItem(item) &&
item.payload.data.server === "command" &&
(item.payload.data.displayName?.includes("pwd") ?? false)
);
const fileEntry = streamItems.find((item): item is AgentToolCallItem => {
if (!isAgentToolCallItem(item)) {
return false;
}
if (item.payload.data.server !== "file_change") {
return false;
}
const result = item.payload.data.result as { files?: Array<{ path?: string }> } | undefined;
if (!Array.isArray(result?.files)) {
return false;
}
return result.files.some((file) => typeof file?.path === "string" && file.path.includes("codex-stream.log"));
});
expect(commandEntry).toBeDefined();
expect(commandEntry?.payload.data.status).toBe("completed");
expect(commandEntry?.payload.data.result).toMatchObject({
exitCode: 0,
});
expect(fileEntry).toBeDefined();
expect(fileEntry?.payload.data.status).toBe("completed");
} finally {
await session?.close();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
180_000
);
test(
"hydrates persisted shell_command tool calls with completed status",
async () => {
const restoreSessionDir = useTempCodexSessionDir();
const client = new CodexAgentClient();
const cwd = tmpCwd();
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
let resumed: Awaited<ReturnType<typeof client.resumeSession>> | null = null;
try {
session = await client.createSession({ provider: "codex", cwd, modeId: "full-access" });
log("Recording Codex command activity for persistence hydration test");
const prompt = [
"Run the command `pwd` using your shell tool to print the working directory.",
"After the command finishes, respond with DONE and stop.",
].join("\n");
let sawCommand = false;
const stream = session.stream(prompt);
for await (const event of stream) {
if (
event.type === "timeline" &&
event.provider === "codex" &&
event.item.type === "tool_call" &&
event.item.server === "command"
) {
sawCommand = true;
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
expect(sawCommand).toBe(true);
const handle = session.describePersistence();
expect(handle).toBeTruthy();
await session.close();
session = null;
const rolloutPath = handle ? await resolveRolloutPathFromHandle(handle) : null;
expect(rolloutPath).toBeTruthy();
await prependSyntheticRolloutEntries(rolloutPath!, [
buildSyntheticInstructionEntry(handle!.sessionId ?? handle!.nativeHandle ?? "synthetic-session"),
buildSyntheticEnvironmentEntry(),
]);
resumed = await client.resumeSession(handle!);
const hydrationUpdates: StreamHydrationUpdate[] = [];
for await (const event of resumed.streamHistory()) {
recordTimelineUpdate(hydrationUpdates, event);
}
const hydratedState = hydrateStreamState(hydrationUpdates);
const commandEntry = hydratedState.find(
(item): item is AgentToolCallItem =>
isAgentToolCallItem(item) && item.payload.data.server === "command"
);
expect(commandEntry).toBeTruthy();
if (commandEntry) {
expect(commandEntry.payload.data.status).toBe("completed");
expect(commandEntry.payload.data.result).toMatchObject({
metadata: { exit_code: 0 },
});
}
} finally {
await session?.close();
await resumed?.close();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
180_000
);
test( test(
"supports multiple turns within the same session", "supports multiple turns within the same session",
async () => { async () => {
@@ -260,47 +392,42 @@ describe("CodexAgentClient (SDK integration)", () => {
180_000 180_000
); );
test( // Codex CLI currently doesn't emit approval request events even when approvalPolicy is set to on-request,
"emits permission requests and resolves them when approvals are handled", // so we keep this test skipped until upstream support lands.
test.skip(
"emits permission requests and resolves them when approvals are handled (awaiting Codex support)",
async () => { async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const restoreSessionDir = useTempCodexSessionDir(); const restoreSessionDir = useTempCodexSessionDir();
const events = [ const client = new CodexAgentClient();
{ type: "thread.started", thread_id: "019a83a2-permission" }, const config: AgentSessionConfig = {
{ type: "turn.started" }, provider: "codex",
{ cwd,
type: "exec_approval_request", modeId: "full-access",
call_id: "call-permission-1", extra: { codex: { approvalPolicy: "on-request" } },
command: ["bash", "-lc", "rm -rf /tmp/codex"], };
cwd,
reason: "Requires elevated sandbox permissions",
risk: { description: "Deletes files", risk_level: "high" },
},
{ type: "turn.failed", error: { message: "Approval required" } },
];
const stub = createStubCodexBinary(events);
const client = new CodexAgentClient({ codexPathOverride: stub.path });
const config: AgentSessionConfig = { provider: "codex", cwd };
let session: Awaited<ReturnType<typeof client.createSession>> | null = null; let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
try { try {
session = await client.createSession(config); session = await client.createSession(config);
log("Processing synthetic permission request stream"); log("Processing Codex permission request stream");
let captured: AgentPermissionRequest | null = null; let captured: AgentPermissionRequest | null = null;
const streamed = session.stream("Trigger permission request"); const streamed = session.stream(
"Request approval to run the command `pwd`, then run it after approval and stop."
);
for await (const event of streamed) { for await (const event of streamed) {
if (event.type === "permission_requested") { if (event.type === "permission_requested" && !captured) {
captured = event.request; captured = event.request;
expect(session.getPendingPermissions()).toHaveLength(1);
await session.respondToPermission(captured.id, { behavior: "allow" });
} }
if (event.type === "turn_failed") { if (event.type === "turn_completed" || event.type === "turn_failed") {
break; break;
} }
} }
expect(captured).not.toBeNull(); expect(captured).not.toBeNull();
expect(session.getPendingPermissions()).toHaveLength(1); expect(session.getPendingPermissions()).toHaveLength(0);
await session.respondToPermission(captured!.id, { behavior: "allow" });
const replay: AgentStreamEvent[] = []; const replay: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) { for await (const event of session.streamHistory()) {
@@ -315,15 +442,23 @@ describe("CodexAgentClient (SDK integration)", () => {
event.resolution.behavior === "allow" event.resolution.behavior === "allow"
) )
).toBe(true); ).toBe(true);
expect(session.getPendingPermissions()).toHaveLength(0); expect(
replay.some(
(event) =>
event.type === "timeline" &&
event.provider === "codex" &&
event.item.type === "tool_call" &&
event.item.server === "permission" &&
event.item.status === "granted"
)
).toBe(true);
} finally { } finally {
await session?.close(); await session?.close();
stub.cleanup();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
restoreSessionDir(); restoreSessionDir();
} }
}, },
30_000 180_000
); );
test( test(
@@ -369,6 +504,14 @@ describe("CodexAgentClient (SDK integration)", () => {
expect(stateIncludesUserMessage(liveState, promptMarker)).toBe(true); expect(stateIncludesUserMessage(liveState, promptMarker)).toBe(true);
expect(stateIncludesUserMessage(hydratedState, promptMarker)).toBe(true); expect(stateIncludesUserMessage(hydratedState, promptMarker)).toBe(true);
expect(stateIncludesSyntheticMessage(hydratedState)).toBe(false);
const persisted = await client.listPersistedAgents({ limit: 200 });
const persistedEntry = persisted.find((entry) => entry.sessionId === handle!.sessionId);
expect(persistedEntry).toBeTruthy();
const normalizedTitle = (persistedEntry?.title ?? "").toLowerCase();
expect(normalizedTitle.startsWith("# agents.md instructions for")).toBe(false);
expect(normalizedTitle.startsWith("<environment_context>")).toBe(false);
} finally { } finally {
await session?.close(); await session?.close();
await resumed?.close(); await resumed?.close();
@@ -380,6 +523,29 @@ describe("CodexAgentClient (SDK integration)", () => {
); );
}); });
describe("isSyntheticRolloutUserMessage", () => {
test("flags AGENTS instruction payloads", () => {
const text = `# AGENTS.md instructions for /Users/test
<INSTRUCTIONS>
Stay safe.
</INSTRUCTIONS>`;
expect(isSyntheticRolloutUserMessage(text)).toBe(true);
});
test("flags environment context payloads", () => {
const text = `<environment_context>
<cwd>/Users/test/project</cwd>
<approval_policy>never</approval_policy>
</environment_context>`;
expect(isSyntheticRolloutUserMessage(text)).toBe(true);
});
test("allows real user prompts", () => {
expect(isSyntheticRolloutUserMessage("investigate flaky tests")).toBe(false);
});
});
type StreamHydrationUpdate = { type StreamHydrationUpdate = {
event: Extract<AgentStreamEvent, { type: "timeline" }>; event: Extract<AgentStreamEvent, { type: "timeline" }>;
timestamp: Date; timestamp: Date;
@@ -419,3 +585,118 @@ function stateIncludesUserMessage(state: StreamItem[], marker: string): boolean
(item) => item.kind === "user_message" && item.text.toLowerCase().includes(marker.toLowerCase()) (item) => item.kind === "user_message" && item.text.toLowerCase().includes(marker.toLowerCase())
); );
} }
function stateIncludesSyntheticMessage(state: StreamItem[]): boolean {
return state.some((item) => {
if (item.kind !== "user_message") {
return false;
}
const normalized = item.text.trim().toLowerCase();
return (
normalized.startsWith("# agents.md instructions for") ||
normalized.startsWith("<environment_context>")
);
});
}
async function prependSyntheticRolloutEntries(filePath: string, entries: Array<Record<string, unknown>>): Promise<void> {
const existing = await fs.readFile(filePath, "utf8");
const synthetic = entries.map((entry) => JSON.stringify(entry)).join("\n");
const nextContent = synthetic.length ? `${synthetic}\n${existing}` : existing;
await fs.writeFile(
filePath,
nextContent.endsWith("\n") ? nextContent : `${nextContent}\n`,
"utf8"
);
}
async function resolveRolloutPathFromHandle(handle: AgentPersistenceHandle): Promise<string | null> {
const pathFromMetadata = typeof handle.metadata?.codexRolloutPath === "string" ? handle.metadata.codexRolloutPath : null;
if (pathFromMetadata && (await fileExists(pathFromMetadata))) {
return pathFromMetadata;
}
const sessionDir = typeof handle.metadata?.codexSessionDir === "string" ? handle.metadata.codexSessionDir : null;
const sessionId = handle.sessionId ?? (typeof handle.nativeHandle === "string" ? handle.nativeHandle : null);
if (!sessionDir || !sessionId) {
return null;
}
return findRolloutInDir(sessionDir, sessionId, 5);
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function findRolloutInDir(root: string, sessionId: string, maxDepth: number): Promise<string | null> {
const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
while (stack.length > 0) {
const { dir, depth } = stack.pop()!;
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile()) {
if (
entry.name.startsWith("rollout-") &&
entry.name.includes(sessionId) &&
(entry.name.endsWith(".json") || entry.name.endsWith(".jsonl"))
) {
return entryPath;
}
} else if (entry.isDirectory() && depth < maxDepth) {
stack.push({ dir: entryPath, depth: depth + 1 });
}
}
}
return null;
}
function buildSyntheticInstructionEntry(sessionId: string): Record<string, unknown> {
const text = [
`# AGENTS.md instructions for ${sessionId}`,
"",
"<INSTRUCTIONS>",
"Always obey the operator.",
"</INSTRUCTIONS>",
].join("\n");
return buildSyntheticResponseItem(text);
}
function buildSyntheticEnvironmentEntry(): Record<string, unknown> {
const text = [
"<environment_context>",
" <cwd>/tmp/workspace</cwd>",
" <approval_policy>never</approval_policy>",
" <sandbox_mode>danger-full-access</sandbox_mode>",
"</environment_context>",
].join("\n");
return buildSyntheticResponseItem(text);
}
function buildSyntheticResponseItem(text: string): Record<string, unknown> {
return {
timestamp: new Date().toISOString(),
type: "response_item",
payload: {
type: "message",
role: "user",
content: [
{
type: "input_text",
text,
},
],
},
};
}
@@ -98,6 +98,8 @@ const MODE_PRESETS: Record<
type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean }; type CodexOptionsOverrides = Partial<ThreadOptions> & { skipGitRepoCheck?: boolean };
const MAX_ROLLOUT_SEARCH_DEPTH = 5; const MAX_ROLLOUT_SEARCH_DEPTH = 5;
const PERSISTED_TIMELINE_LIMIT = 20;
const SHELL_FUNCTION_NAMES = new Set(["shell", "shell_command"]);
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>; type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
type ExecPermissionRequestPayload = { type ExecPermissionRequestPayload = {
@@ -334,10 +336,26 @@ class CodexAgentSession implements AgentSession {
const turnOptions = this.buildTurnOptions(options); const turnOptions = this.buildTurnOptions(options);
const { events } = await thread.runStreamed(input, turnOptions); const { events } = await thread.runStreamed(input, turnOptions);
for await (const rawEvent of events) { let finishedNaturally = false;
yield* this.translateEvent(rawEvent); try {
if (rawEvent.type === "turn.completed" || rawEvent.type === "turn.failed" || rawEvent.type === "error") { for await (const rawEvent of events) {
break; yield* this.translateEvent(rawEvent);
if (
rawEvent.type === "turn.completed" ||
rawEvent.type === "turn.failed" ||
rawEvent.type === "error"
) {
finishedNaturally = true;
break;
}
}
} finally {
if (!finishedNaturally && typeof events.return === "function") {
try {
await events.return({ type: "turn.completed" } as ThreadEvent);
} catch (error) {
console.warn("[CodexAgentSession] Failed to stop Codex stream:", error);
}
} }
} }
} }
@@ -887,7 +905,7 @@ async function parseRolloutFile(filePath: string, threadId: string): Promise<Age
const content = await fs.readFile(filePath, "utf8"); const content = await fs.readFile(filePath, "utf8");
const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const lines = content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const events: AgentStreamEvent[] = [{ type: "thread_started", provider: "codex", sessionId: threadId }]; const events: AgentStreamEvent[] = [{ type: "thread_started", provider: "codex", sessionId: threadId }];
const commandCalls = new Map<string, { command: string }>(); const commandCalls = new Map<string, { command: string; cwd?: string }>();
for (const line of lines) { for (const line of lines) {
const entry = parseRolloutEntry(line); const entry = parseRolloutEntry(line);
@@ -933,18 +951,10 @@ function isSessionMetaEntry(value: unknown): value is SessionMetaEntry {
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "session_meta"); return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "session_meta");
} }
function isResponseItemEntry(value: unknown): value is ResponseItemEntry {
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === "response_item");
}
function isRolloutMessagePayload(payload: RolloutResponsePayload | undefined): payload is RolloutMessagePayload {
return Boolean(payload && payload.type === "message");
}
function handleRolloutResponseItem( function handleRolloutResponseItem(
payload: RolloutResponsePayload | undefined, payload: RolloutResponsePayload | undefined,
events: AgentStreamEvent[], events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }> commandCalls: Map<string, { command: string; cwd?: string }>
): void { ): void {
if (!payload || typeof payload !== "object") { if (!payload || typeof payload !== "object") {
return; return;
@@ -957,6 +967,9 @@ function handleRolloutResponseItem(
if (payload.role === "assistant") { if (payload.role === "assistant") {
events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text, raw: payload } }); events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text, raw: payload } });
} else if (payload.role === "user") { } else if (payload.role === "user") {
if (isSyntheticRolloutUserMessage(text)) {
break;
}
events.push({ type: "timeline", provider: "codex", item: { type: "user_message", text, raw: payload } }); events.push({ type: "timeline", provider: "codex", item: { type: "user_message", text, raw: payload } });
} }
} }
@@ -998,18 +1011,35 @@ function handleRolloutEventMessage(payload: RolloutEventPayload | undefined, eve
function handleRolloutFunctionCall( function handleRolloutFunctionCall(
payload: RolloutFunctionCallPayload, payload: RolloutFunctionCallPayload,
events: AgentStreamEvent[], events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }> commandCalls: Map<string, { command: string; cwd?: string }>
): void { ): void {
const name = typeof payload.name === "string" ? payload.name : undefined; const name = typeof payload.name === "string" ? payload.name : undefined;
if (!name) { if (!name) {
return; return;
} }
if (name === "shell") { if (SHELL_FUNCTION_NAMES.has(name)) {
const args = safeJsonParse<Record<string, unknown>>(payload.arguments); const args = safeJsonParse<Record<string, unknown>>(payload.arguments);
const command = formatCommand(args); const command = formatCommand(args);
const cwd = args && typeof args === "object" && typeof (args as { workdir?: unknown }).workdir === "string"
? ((args as { workdir?: unknown }).workdir as string)
: undefined;
if (command && typeof payload.call_id === "string") { if (command && typeof payload.call_id === "string") {
commandCalls.set(payload.call_id, { command }); commandCalls.set(payload.call_id, { command, cwd });
events.push({
type: "timeline",
provider: "codex",
item: createToolCallTimelineItem({
server: "command",
tool: "shell",
status: payload.status ?? "in_progress",
callId: payload.call_id,
displayName: buildCommandDisplayName(command),
kind: "execute",
input: { command, cwd },
raw: payload,
}),
});
} }
return; return;
} }
@@ -1042,7 +1072,7 @@ function handleRolloutFunctionCall(
function finalizeRolloutFunctionCall( function finalizeRolloutFunctionCall(
payload: RolloutFunctionCallOutputPayload, payload: RolloutFunctionCallOutputPayload,
events: AgentStreamEvent[], events: AgentStreamEvent[],
commandCalls: Map<string, { command: string }> commandCalls: Map<string, { command: string; cwd?: string }>
): void { ): void {
if (typeof payload.call_id !== "string") { if (typeof payload.call_id !== "string") {
return; return;
@@ -1064,7 +1094,7 @@ function finalizeRolloutFunctionCall(
callId: payload.call_id, callId: payload.call_id,
displayName: buildCommandDisplayName(command.command), displayName: buildCommandDisplayName(command.command),
kind: "execute", kind: "execute",
input: { command: command.command }, input: { command: command.command, cwd: command.cwd },
output: result, output: result,
raw: { payload, result }, raw: { payload, result },
}), }),
@@ -1196,11 +1226,6 @@ type SessionMetaEntry = {
payload?: SessionMetaPayload; payload?: SessionMetaPayload;
}; };
type ResponseItemEntry = {
type: "response_item";
payload?: RolloutResponsePayload;
};
async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> { async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise<RolloutCandidate[]> {
const candidates: RolloutCandidate[] = []; const candidates: RolloutCandidate[] = [];
async function traverse(dir: string): Promise<void> { async function traverse(dir: string): Promise<void> {
@@ -1268,15 +1293,8 @@ async function parseRolloutDescriptor(
if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) { if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) {
cwd = entry.payload.cwd; cwd = entry.payload.cwd;
} }
} else if (
!title &&
isResponseItemEntry(entry) &&
isRolloutMessagePayload(entry.payload) &&
entry.payload.role === "user"
) {
title = extractCodexUserPrompt(entry.payload);
} }
if (sessionId && cwd && title) { if (sessionId && cwd) {
break; break;
} }
} }
@@ -1285,6 +1303,11 @@ async function parseRolloutDescriptor(
return null; return null;
} }
const timeline = await loadCodexPersistedTimeline(filePath, sessionId);
if (!title) {
title = timeline.find((item) => item.type === "user_message")?.text ?? null;
}
const persistence: AgentPersistenceHandle = { const persistence: AgentPersistenceHandle = {
provider: "codex", provider: "codex",
sessionId, sessionId,
@@ -1304,21 +1327,10 @@ async function parseRolloutDescriptor(
title: (title ?? "").trim() || `Codex session ${sessionId.slice(0, 8)}`, title: (title ?? "").trim() || `Codex session ${sessionId.slice(0, 8)}`,
lastActivityAt: mtime, lastActivityAt: mtime,
persistence, persistence,
timeline,
}; };
} }
function extractCodexUserPrompt(payload: RolloutMessagePayload): string | null {
if (!Array.isArray(payload.content)) {
return null;
}
for (const block of payload.content) {
if (block && typeof block === "object" && typeof block.text === "string") {
return block.text.trim();
}
}
return null;
}
function resolveCodexSessionRoot(): string | null { function resolveCodexSessionRoot(): string | null {
if (process.env.CODEX_SESSION_DIR) { if (process.env.CODEX_SESSION_DIR) {
return process.env.CODEX_SESSION_DIR; return process.env.CODEX_SESSION_DIR;
@@ -1350,6 +1362,24 @@ function extractMessageText(content: unknown): string {
return parts.join("\n").trim(); return parts.join("\n").trim();
} }
export function isSyntheticRolloutUserMessage(text: string): boolean {
const normalized = text.trim();
if (!normalized) {
return false;
}
const lower = normalized.toLowerCase();
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
return true;
}
if (lower.startsWith("<environment_context>")) {
return true;
}
return false;
}
function extractReasoningText(payload: RolloutReasoningPayload): string { function extractReasoningText(payload: RolloutReasoningPayload): string {
if (Array.isArray(payload?.summary)) { if (Array.isArray(payload?.summary)) {
const text = payload.summary const text = payload.summary
@@ -1367,6 +1397,26 @@ function extractReasoningText(payload: RolloutReasoningPayload): string {
return ""; return "";
} }
async function loadCodexPersistedTimeline(filePath: string, threadId: string): Promise<AgentTimelineItem[]> {
try {
const events = await parseRolloutFile(filePath, threadId);
const timeline: AgentTimelineItem[] = [];
for (const event of events) {
if (event.type !== "timeline" || event.provider !== "codex") {
continue;
}
timeline.push(event.item);
if (timeline.length >= PERSISTED_TIMELINE_LIMIT) {
break;
}
}
return timeline;
} catch (error) {
console.warn(`[CodexAgentSession] Failed to load persisted timeline for ${threadId}:`, error);
return [];
}
}
function parsePlanItems(args: unknown): { text: string; completed: boolean }[] { function parsePlanItems(args: unknown): { text: string; completed: boolean }[] {
const planValue = typeof args === "object" && args ? (args as { plan?: unknown }).plan : undefined; const planValue = typeof args === "object" && args ? (args as { plan?: unknown }).plan : undefined;
const plan = Array.isArray(planValue) ? planValue : []; const plan = Array.isArray(planValue) ? planValue : [];
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { WaitForAgentTracker } from "./wait-for-agent-tracker"; import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
describe("WaitForAgentTracker", () => { describe("WaitForAgentTracker", () => {
it("registers and cancels waiters per agent", () => { it("registers and cancels waiters per agent", () => {
@@ -7,7 +7,7 @@ describe("WaitForAgentTracker", () => {
const cancelA = vi.fn(); const cancelA = vi.fn();
const cancelB = vi.fn(); const cancelB = vi.fn();
const unregisterA = tracker.register("agent-a", cancelA); tracker.register("agent-a", cancelA);
tracker.register("agent-a", cancelB); tracker.register("agent-a", cancelB);
expect(tracker.cancel("agent-a", "test")).toBe(true); expect(tracker.cancel("agent-a", "test")).toBe(true);
+51 -2
View File
@@ -19,10 +19,11 @@ type ProviderEventPayload = Extract<
export type AgentSnapshotPayload = Omit< export type AgentSnapshotPayload = Omit<
AgentSnapshot, AgentSnapshot,
"createdAt" | "updatedAt" "createdAt" | "updatedAt" | "lastUserMessageAt"
> & { > & {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
lastUserMessageAt: string | null;
title: string | null; title: string | null;
}; };
@@ -219,6 +220,7 @@ export const AgentSnapshotPayloadSchema = z.object({
cwd: z.string(), cwd: z.string(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
lastUserMessageAt: z.string().nullable(),
status: AgentStatusSchema, status: AgentStatusSchema,
sessionId: z.string().nullable(), sessionId: z.string().nullable(),
capabilities: AgentCapabilityFlagsSchema, capabilities: AgentCapabilityFlagsSchema,
@@ -239,11 +241,12 @@ export function serializeAgentSnapshot(
snapshot: AgentSnapshot, snapshot: AgentSnapshot,
options?: { title?: string | null } options?: { title?: string | null }
): AgentSnapshotPayload { ): AgentSnapshotPayload {
const { createdAt, updatedAt, ...rest } = snapshot; const { createdAt, updatedAt, lastUserMessageAt, ...rest } = snapshot;
return { return {
...rest, ...rest,
createdAt: createdAt.toISOString(), createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(), updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
title: options?.title ?? null, title: options?.title ?? null,
}; };
} }
@@ -323,10 +326,28 @@ export const SendAgentAudioSchema = z.object({
requestId: z.string().optional(), // Client-provided ID for tracking transcription requestId: z.string().optional(), // Client-provided ID for tracking transcription
}); });
const GitSetupOptionsSchema = z.object({
baseBranch: z.string().optional(),
createNewBranch: z.boolean().optional(),
newBranchName: z.string().optional(),
createWorktree: z.boolean().optional(),
worktreeSlug: z.string().optional(),
});
export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
export const CreateAgentRequestMessageSchema = z.object({ export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"), type: z.literal("create_agent_request"),
config: AgentSessionConfigSchema, config: AgentSessionConfigSchema,
worktreeName: z.string().optional(), worktreeName: z.string().optional(),
initialPrompt: z.string().optional(),
git: GitSetupOptionsSchema.optional(),
requestId: z.string().optional(),
});
export const GitRepoInfoRequestMessageSchema = z.object({
type: z.literal("git_repo_info_request"),
cwd: z.string(),
requestId: z.string().optional(), requestId: z.string().optional(),
}); });
@@ -348,6 +369,11 @@ export const CancelAgentRequestMessageSchema = z.object({
agentId: z.string(), agentId: z.string(),
}); });
export const RestartServerRequestMessageSchema = z.object({
type: z.literal("restart_server_request"),
reason: z.string().optional(),
});
export const ListPersistedAgentsRequestMessageSchema = z.object({ export const ListPersistedAgentsRequestMessageSchema = z.object({
type: z.literal("list_persisted_agents_request"), type: z.literal("list_persisted_agents_request"),
provider: AgentProviderSchema.optional(), provider: AgentProviderSchema.optional(),
@@ -424,12 +450,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ResumeAgentRequestMessageSchema, ResumeAgentRequestMessageSchema,
RefreshAgentRequestMessageSchema, RefreshAgentRequestMessageSchema,
CancelAgentRequestMessageSchema, CancelAgentRequestMessageSchema,
RestartServerRequestMessageSchema,
InitializeAgentRequestMessageSchema, InitializeAgentRequestMessageSchema,
SetAgentModeMessageSchema, SetAgentModeMessageSchema,
AgentPermissionResponseMessageSchema, AgentPermissionResponseMessageSchema,
GitDiffRequestSchema, GitDiffRequestSchema,
FileExplorerRequestSchema, FileExplorerRequestSchema,
ListPersistedAgentsRequestMessageSchema, ListPersistedAgentsRequestMessageSchema,
GitRepoInfoRequestMessageSchema,
]); ]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>; export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -622,6 +650,7 @@ const PersistedAgentDescriptorPayloadSchema = z.object({
title: z.string(), title: z.string(),
lastActivityAt: z.string(), lastActivityAt: z.string(),
persistence: AgentPersistenceHandleSchema, persistence: AgentPersistenceHandleSchema,
timeline: z.array(AgentTimelineItemPayloadSchema),
}); });
export type PersistedAgentDescriptorPayload = z.infer<typeof PersistedAgentDescriptorPayloadSchema>; export type PersistedAgentDescriptorPayload = z.infer<typeof PersistedAgentDescriptorPayloadSchema>;
@@ -654,6 +683,24 @@ export const FileExplorerResponseSchema = z.object({
}), }),
}); });
const GitBranchInfoSchema = z.object({
name: z.string(),
isCurrent: z.boolean(),
});
export const GitRepoInfoResponseSchema = z.object({
type: z.literal("git_repo_info_response"),
payload: z.object({
cwd: z.string(),
repoRoot: z.string(),
requestId: z.string().optional(),
branches: z.array(GitBranchInfoSchema).optional(),
currentBranch: z.string().nullable().optional(),
isDirty: z.boolean().optional(),
error: z.string().optional(),
}),
});
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ActivityLogMessageSchema, ActivityLogMessageSchema,
AssistantChunkMessageSchema, AssistantChunkMessageSchema,
@@ -675,6 +722,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListPersistedAgentsResponseSchema, ListPersistedAgentsResponseSchema,
GitDiffResponseSchema, GitDiffResponseSchema,
FileExplorerResponseSchema, FileExplorerResponseSchema,
GitRepoInfoResponseSchema,
]); ]);
export type SessionOutboundMessage = z.infer< export type SessionOutboundMessage = z.infer<
@@ -722,6 +770,7 @@ export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>; export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>; export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>; export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
// ============================================================================ // ============================================================================
// WebSocket Level Messages (wraps session messages) // WebSocket Level Messages (wraps session messages)
@@ -15,6 +15,7 @@ function createSnapshot(overrides?: Partial<AgentSnapshot>): AgentSnapshot {
cwd: "/tmp/project", cwd: "/tmp/project",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
lastUserMessageAt: null,
status: "idle", status: "idle",
sessionId: null, sessionId: null,
capabilities: { capabilities: {
@@ -45,7 +45,9 @@ export async function restorePersistedAgents(
return; return;
} }
console.log(`[Agents] Restoring ${records.length} persisted agent(s)`); let resumed = 0;
let created = 0;
for (const record of records) { for (const record of records) {
if (!isKnownProvider(record.provider)) { if (!isKnownProvider(record.provider)) {
console.warn( console.warn(
@@ -54,27 +56,33 @@ export async function restorePersistedAgents(
continue; continue;
} }
const handle = buildPersistenceHandle(record);
try { try {
const persistenceHandle = buildPersistenceHandle(record); if (handle) {
if (persistenceHandle) {
await agentManager.resumeAgent( await agentManager.resumeAgent(
persistenceHandle, handle,
buildConfigOverrides(record), buildConfigOverrides(record),
record.id record.id
); );
resumed += 1;
} else { } else {
await agentManager.createAgent( await agentManager.createAgent(
buildSessionConfig(record), buildSessionConfig(record),
record.id record.id
); );
created += 1;
} }
} catch (error) { } catch (error) {
console.error( console.error(
`[Agents] Failed to restore agent ${record.id}:`, `[Agents] Failed to restore agent ${record.id} from registry:`,
error error
); );
} }
} }
console.log(
`[Agents] Loaded ${records.length} persisted agent record(s); resumed ${resumed}, created ${created}`
);
} }
function isKnownProvider(provider: string): provider is AgentProvider { function isKnownProvider(provider: string): provider is AgentProvider {
+484 -27
View File
@@ -18,6 +18,7 @@ import {
type SessionInboundMessage, type SessionInboundMessage,
type SessionOutboundMessage, type SessionOutboundMessage,
type FileExplorerRequest, type FileExplorerRequest,
type GitSetupOptions,
} from "./messages.js"; } from "./messages.js";
import { getSystemPrompt } from "./agent/system-prompt.js"; import { getSystemPrompt } from "./agent/system-prompt.js";
import { getAllTools } from "./agent/llm-openai.js"; import { getAllTools } from "./agent/llm-openai.js";
@@ -28,6 +29,10 @@ import {
listConversations, listConversations,
deleteConversation, deleteConversation,
} from "./persistence.js"; } from "./persistence.js";
import {
buildConfigOverrides,
buildSessionConfig,
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai"; import { experimental_createMCPClient } from "ai";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createTerminalMcpServer } from "./terminal-mcp/index.js"; import { createTerminalMcpServer } from "./terminal-mcp/index.js";
@@ -39,8 +44,10 @@ import type {
AgentPromptInput, AgentPromptInput,
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
AgentProvider,
AgentPersistenceHandle,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import { AgentRegistry } from "./agent/agent-registry.js"; import { AgentRegistry, type StoredAgentRecord } from "./agent/agent-registry.js";
import { expandTilde } from "./terminal-mcp/tmux.js"; import { expandTilde } from "./terminal-mcp/tmux.js";
import { import {
listDirectoryEntries, listDirectoryEntries,
@@ -51,12 +58,28 @@ import {
isTitleGeneratorInitialized, isTitleGeneratorInitialized,
} from "../services/agent-title-generator.js"; } from "../services/agent-title-generator.js";
import type { TerminalManager } from "./terminal-mcp/terminal-manager.js"; import type { TerminalManager } from "./terminal-mcp/terminal-manager.js";
import {
createWorktree,
detectRepoInfo,
slugify,
validateBranchSlug,
} from "../utils/worktree.js";
const execAsync = promisify(exec); const execAsync = promisify(exec);
const ACTIVE_TITLE_GENERATIONS = new Set<string>(); const ACTIVE_TITLE_GENERATIONS = new Set<string>();
let restartRequested = false;
const KNOWN_AGENT_PROVIDERS: AgentProvider[] = ["claude", "codex"];
type ProcessingPhase = "idle" | "transcribing" | "llm"; type ProcessingPhase = "idle" | "transcribing" | "llm";
type NormalizedGitOptions = {
baseBranch?: string;
createNewBranch: boolean;
newBranchName?: string;
createWorktree: boolean;
worktreeSlug?: string;
};
const PCM_SAMPLE_RATE = 16000; const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1; const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16; const PCM_BITS_PER_SAMPLE = 16;
@@ -66,6 +89,7 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round( const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS
); );
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
/** /**
* Type for present_artifact tool arguments * Type for present_artifact tool arguments
@@ -115,6 +139,45 @@ function convertPCMToWavBuffer(
return wavBuffer; return wavBuffer;
} }
function isKnownAgentProvider(value: string): value is AgentProvider {
return KNOWN_AGENT_PROVIDERS.includes(value as AgentProvider);
}
function coerceAgentProvider(value: string, agentId?: string): AgentProvider {
if (isKnownAgentProvider(value)) {
return value;
}
console.warn(
`[Session] Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to 'claude'`
);
return "claude";
}
function toAgentPersistenceHandle(
handle: StoredAgentRecord["persistence"]
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isKnownAgentProvider(provider)) {
console.warn(
`[Session] Ignoring persistence handle with unknown provider '${provider}'`
);
return null;
}
if (!handle.sessionId) {
console.warn("[Session] Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
} satisfies AgentPersistenceHandle;
}
/** /**
* Session represents a single client conversation session. * Session represents a single client conversation session.
* It owns all state management, orchestration logic, and message processing. * It owns all state management, orchestration logic, and message processing.
@@ -460,6 +523,43 @@ export class Session {
return serializeAgentSnapshot(agent, { title }); return serializeAgentSnapshot(agent, { title });
} }
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
} as const;
const createdAt = new Date(record.createdAt);
const updatedAt = new Date(record.lastActivityAt ?? record.updatedAt);
const lastUserMessageAt = record.lastUserMessageAt
? new Date(record.lastUserMessageAt)
: null;
const provider = coerceAgentProvider(record.provider, record.id);
return {
id: record.id,
provider,
cwd: record.cwd,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
status: (record.lastStatus as any) ?? "closed",
sessionId: null,
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
pendingPermissions: [],
persistence: toAgentPersistenceHandle(record.persistence),
lastUsage: undefined,
lastError: undefined,
title: record.title ?? null,
};
}
private async forwardAgentState(agent: AgentSnapshot): Promise<void> { private async forwardAgentState(agent: AgentSnapshot): Promise<void> {
try { try {
const payload = await this.buildAgentPayload(agent); const payload = await this.buildAgentPayload(agent);
@@ -609,13 +709,17 @@ export class Session {
await this.handleRefreshAgentRequest(msg); await this.handleRefreshAgentRequest(msg);
break; break;
case "cancel_agent_request": case "cancel_agent_request":
await this.handleCancelAgentRequest(msg.agentId); await this.handleCancelAgentRequest(msg.agentId);
break; break;
case "initialize_agent_request": case "restart_server_request":
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId); await this.handleRestartServerRequest(msg.reason);
break; break;
case "initialize_agent_request":
await this.handleInitializeAgentRequest(msg.agentId, msg.requestId);
break;
case "set_agent_mode": case "set_agent_mode":
await this.handleSetAgentMode(msg.agentId, msg.modeId); await this.handleSetAgentMode(msg.agentId, msg.modeId);
@@ -640,6 +744,10 @@ export class Session {
case "list_persisted_agents_request": case "list_persisted_agents_request":
await this.handleListPersistedAgentsRequest(msg); await this.handleListPersistedAgentsRequest(msg);
break; break;
case "git_repo_info_request":
await this.handleGitRepoInfoRequest(msg);
break;
} }
} catch (error: any) { } catch (error: any) {
console.error( console.error(
@@ -740,6 +848,34 @@ export class Session {
} }
} }
private async handleRestartServerRequest(reason?: string): Promise<void> {
if (restartRequested) {
console.log(
`[Session ${this.clientId}] Restart already requested, ignoring duplicate`
);
return;
}
restartRequested = true;
const payload: { status: string } & Record<string, unknown> = {
status: "restart_requested",
clientId: this.clientId,
};
if (reason && reason.trim().length > 0) {
payload.reason = reason;
}
console.warn(`[Session ${this.clientId}] Restart requested via websocket`);
this.emit({
type: "status",
payload,
});
setTimeout(() => {
process.exit(0);
}, 250);
}
private async handleDeleteAgentRequest(agentId: string): Promise<void> { private async handleDeleteAgentRequest(agentId: string): Promise<void> {
console.log( console.log(
`[Session ${this.clientId}] Deleting agent ${agentId} from registry` `[Session ${this.clientId}] Deleting agent ${agentId} from registry`
@@ -931,13 +1067,30 @@ export class Session {
); );
try { try {
const snapshot = this.agentManager.getAgent(agentId); let snapshot = this.agentManager.getAgent(agentId);
if (!snapshot) { if (!snapshot) {
throw new Error(`Agent not found: ${agentId}`); const record = await this.agentRegistry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(record.persistence);
if (handle) {
snapshot = await this.agentManager.resumeAgent(
handle,
buildConfigOverrides(record),
agentId
);
} else {
const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId);
}
} }
await this.agentManager.primeAgentHistory(agentId);
await this.forwardAgentState(snapshot); await this.forwardAgentState(snapshot);
// Send timeline snapshot after hydration (if any)
const timelineSize = this.emitAgentTimelineSnapshot(snapshot); const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) { if (requestId) {
@@ -978,7 +1131,7 @@ export class Session {
private async handleCreateAgentRequest( private async handleCreateAgentRequest(
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }> msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>
): Promise<void> { ): Promise<void> {
const { config, worktreeName, requestId } = msg; const { config, worktreeName, requestId, initialPrompt, git } = msg;
console.log( console.log(
`[Session ${this.clientId}] Creating agent in ${config.cwd} (${config.provider})${ `[Session ${this.clientId}] Creating agent in ${config.cwd} (${config.provider})${
worktreeName ? ` with worktree ${worktreeName}` : "" worktreeName ? ` with worktree ${worktreeName}` : ""
@@ -988,6 +1141,7 @@ export class Session {
try { try {
const sessionConfig = await this.buildAgentSessionConfig( const sessionConfig = await this.buildAgentSessionConfig(
config, config,
git,
worktreeName worktreeName
); );
const snapshot = await this.agentManager.createAgent(sessionConfig); const snapshot = await this.agentManager.createAgent(sessionConfig);
@@ -1012,6 +1166,28 @@ export class Session {
await this.forwardAgentState(snapshot); await this.forwardAgentState(snapshot);
const trimmedPrompt = initialPrompt?.trim();
if (trimmedPrompt && trimmedPrompt.length > 0) {
try {
this.agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
} catch (recordError) {
console.error(
`[Session ${this.clientId}] Failed to record initial prompt for agent ${snapshot.id}:`,
recordError
);
}
try {
const normalizedPrompt = this.buildAgentPrompt(trimmedPrompt);
this.startAgentStream(snapshot.id, normalizedPrompt);
} catch (promptError) {
console.error(
`[Session ${this.clientId}] Failed to run initial prompt for agent ${snapshot.id}:`,
promptError
);
}
}
if (requestId) { if (requestId) {
this.emit({ this.emit({
type: "status", type: "status",
@@ -1031,6 +1207,16 @@ export class Session {
`[Session ${this.clientId}] Failed to create agent:`, `[Session ${this.clientId}] Failed to create agent:`,
error error
); );
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: (error as Error)?.message ?? String(error),
},
});
}
this.emit({ this.emit({
type: "activity_log", type: "activity_log",
payload: { payload: {
@@ -1068,6 +1254,7 @@ export class Session {
try { try {
const snapshot = await this.agentManager.resumeAgent(handle, overrides); const snapshot = await this.agentManager.resumeAgent(handle, overrides);
this.setCachedTitle(snapshot.id, null); this.setCachedTitle(snapshot.id, null);
await this.agentManager.primeAgentHistory(snapshot.id);
await this.forwardAgentState(snapshot); await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot); const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) { if (requestId) {
@@ -1107,9 +1294,31 @@ export class Session {
); );
try { try {
const snapshot = await this.agentManager.refreshAgentFromPersistence( let snapshot: AgentSnapshot;
agentId const existing = this.agentManager.getAgent(agentId);
); if (existing) {
snapshot = await this.agentManager.refreshAgentFromPersistence(
agentId
);
} else {
const record = await this.agentRegistry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(record.persistence);
if (!handle) {
throw new Error(
`Agent ${agentId} cannot be refreshed because it lacks persistence`
);
}
snapshot = await this.agentManager.resumeAgent(
handle,
buildConfigOverrides(record),
agentId
);
this.setCachedTitle(agentId, null);
}
await this.agentManager.primeAgentHistory(agentId);
await this.forwardAgentState(snapshot); await this.forwardAgentState(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot); const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) { if (requestId) {
@@ -1158,20 +1367,57 @@ export class Session {
private async buildAgentSessionConfig( private async buildAgentSessionConfig(
config: AgentSessionConfig, config: AgentSessionConfig,
worktreeName?: string gitOptions?: GitSetupOptions,
legacyWorktreeName?: string
): Promise<AgentSessionConfig> { ): Promise<AgentSessionConfig> {
let cwd = expandTilde(config.cwd); let cwd = expandTilde(config.cwd);
const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName);
if (worktreeName) { if (!normalized) {
const { createWorktree } = await import("../utils/worktree.js"); return {
console.log( ...config,
`[Session ${this.clientId}] Creating worktree '${worktreeName}' from ${cwd}`
);
const worktreeConfig = await createWorktree({
branchName: worktreeName,
cwd, cwd,
};
}
if (!normalized.createWorktree) {
await this.ensureCleanWorkingTree(cwd);
}
if (normalized.createWorktree) {
const targetBranch = normalized.createNewBranch
? normalized.newBranchName
: normalized.baseBranch;
if (!targetBranch) {
throw new Error(
"A branch name is required when creating a worktree."
);
}
console.log(
`[Session ${this.clientId}] Creating worktree '${
normalized.worktreeSlug ?? targetBranch
}' for branch ${targetBranch}`
);
const worktreeConfig = await createWorktree({
branchName: targetBranch,
cwd,
baseBranch: normalized.createNewBranch
? normalized.baseBranch
: undefined,
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
}); });
cwd = worktreeConfig.worktreePath; cwd = worktreeConfig.worktreePath;
} else if (normalized.createNewBranch) {
await this.createBranchFromBase({
cwd,
baseBranch: normalized.baseBranch ?? "HEAD",
newBranchName: normalized.newBranchName!,
});
} else if (normalized.baseBranch) {
await this.checkoutExistingBranch(cwd, normalized.baseBranch);
} }
return { return {
@@ -1180,6 +1426,212 @@ export class Session {
}; };
} }
private async handleGitRepoInfoRequest(
msg: Extract<SessionInboundMessage, { type: "git_repo_info_request" }>
): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
const repoInfo = await detectRepoInfo(resolvedCwd);
const { stdout: branchesRaw } = await execAsync(
"git branch --format='%(refname:short)'",
{ cwd: repoInfo.path }
);
const { stdout: currentRaw } = await execAsync(
"git rev-parse --abbrev-ref HEAD",
{ cwd: resolvedCwd }
);
const currentBranch = currentRaw.trim();
const branches = branchesRaw
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((name) => ({
name,
isCurrent: name === currentBranch,
}));
const isDirty = await this.isWorkingTreeDirty(resolvedCwd);
this.emit({
type: "git_repo_info_response",
payload: {
cwd: resolvedCwd,
repoRoot: repoInfo.path,
requestId,
branches,
currentBranch: currentBranch || null,
isDirty,
},
});
} catch (error) {
this.emit({
type: "git_repo_info_response",
payload: {
cwd,
repoRoot: cwd,
requestId,
error: (error as Error)?.message ?? String(error),
},
});
}
}
private normalizeGitOptions(
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string
): NormalizedGitOptions | null {
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
? {
createWorktree: true,
createNewBranch: true,
newBranchName: legacyWorktreeName,
worktreeSlug: legacyWorktreeName,
}
: undefined;
const merged = gitOptions ?? fallbackOptions;
if (!merged) {
return null;
}
const baseBranch = merged.baseBranch?.trim() || undefined;
const createWorktree = Boolean(merged.createWorktree);
const createNewBranch = Boolean(merged.createNewBranch);
const normalizedBranchName = merged.newBranchName
? slugify(merged.newBranchName)
: undefined;
const normalizedWorktreeSlug = merged.worktreeSlug
? slugify(merged.worktreeSlug)
: normalizedBranchName;
if (!createWorktree && !createNewBranch && !baseBranch) {
return null;
}
if (baseBranch) {
this.assertSafeGitRef(baseBranch, "base branch");
}
if (createNewBranch) {
if (!normalizedBranchName) {
throw new Error("New branch name is required");
}
const validation = validateBranchSlug(normalizedBranchName);
if (!validation.valid) {
throw new Error(`Invalid branch name: ${validation.error}`);
}
}
if (normalizedWorktreeSlug) {
const validation = validateBranchSlug(normalizedWorktreeSlug);
if (!validation.valid) {
throw new Error(`Invalid worktree name: ${validation.error}`);
}
}
if (createWorktree && !createNewBranch && !baseBranch) {
throw new Error(
"Base branch is required when creating a worktree without a new branch"
);
}
return {
baseBranch,
createNewBranch,
newBranchName: normalizedBranchName,
createWorktree,
worktreeSlug: normalizedWorktreeSlug,
};
}
private assertSafeGitRef(ref: string, label: string): void {
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes("..") || ref.includes("@{")) {
throw new Error(`Invalid ${label}: ${ref}`);
}
}
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
const dirty = await this.isWorkingTreeDirty(cwd);
if (dirty) {
throw new Error(
"Working directory has uncommitted changes. Commit or stash before switching branches."
);
}
}
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync("git status --porcelain", { cwd });
return stdout.trim().length > 0;
} catch (error) {
throw new Error(
`Unable to inspect git status for ${cwd}: ${(error as Error).message}`
);
}
}
private async checkoutExistingBranch(
cwd: string,
branch: string
): Promise<void> {
this.assertSafeGitRef(branch, "branch");
try {
await execAsync(`git rev-parse --verify ${branch}`, { cwd });
} catch (error) {
throw new Error(`Branch not found: ${branch}`);
}
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd,
});
const current = stdout.trim();
if (current === branch) {
return;
}
await execAsync(`git checkout ${branch}`, { cwd });
}
private async createBranchFromBase(params: {
cwd: string;
baseBranch: string;
newBranchName: string;
}): Promise<void> {
const { cwd, baseBranch, newBranchName } = params;
this.assertSafeGitRef(baseBranch, "base branch");
try {
await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd });
} catch (error) {
throw new Error(`Base branch not found: ${baseBranch}`);
}
const exists = await this.doesLocalBranchExist(cwd, newBranchName);
if (exists) {
throw new Error(`Branch already exists: ${newBranchName}`);
}
await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, {
cwd,
});
}
private async doesLocalBranchExist(
cwd: string,
branch: string
): Promise<boolean> {
try {
await execAsync(`git show-ref --verify --quiet refs/heads/${branch}` , {
cwd,
});
return true;
} catch (error: any) {
return false;
}
}
private async handleListPersistedAgentsRequest( private async handleListPersistedAgentsRequest(
msg: Extract<SessionInboundMessage, { type: "list_persisted_agents_request" }> msg: Extract<SessionInboundMessage, { type: "list_persisted_agents_request" }>
): Promise<void> { ): Promise<void> {
@@ -1199,6 +1651,7 @@ export class Session {
title: entry.title ?? `Session ${entry.sessionId.slice(0, 8)}`, title: entry.title ?? `Session ${entry.sessionId.slice(0, 8)}`,
lastActivityAt: entry.lastActivityAt.toISOString(), lastActivityAt: entry.lastActivityAt.toISOString(),
persistence: entry.persistence, persistence: entry.persistence,
timeline: entry.timeline ?? [],
})), })),
}, },
}); });
@@ -1438,10 +1891,19 @@ export class Session {
try { try {
// Get live agents with session modes // Get live agents with session modes
const agentSnapshots = this.agentManager.listAgents(); const agentSnapshots = this.agentManager.listAgents();
const agents = await Promise.all( const liveAgents = await Promise.all(
agentSnapshots.map((agent) => this.buildAgentPayload(agent)) agentSnapshots.map((agent) => this.buildAgentPayload(agent))
); );
// Add persisted agents that have not been lazily initialized yet
const registryRecords = await this.agentRegistry.list();
const liveIds = new Set(agentSnapshots.map((a) => a.id));
const persistedAgents = registryRecords
.filter((record) => !liveIds.has(record.id))
.map((record) => this.buildStoredAgentPayload(record));
const agents = [...liveAgents, ...persistedAgents];
// Get live commands from terminal manager // Get live commands from terminal manager
let commands: any[] = []; let commands: any[] = [];
if (this.terminalManager) { if (this.terminalManager) {
@@ -1467,11 +1929,6 @@ export class Session {
console.log( console.log(
`[Session ${this.clientId}] Sent session state: ${agents.length} agents, ${commands.length} commands` `[Session ${this.clientId}] Sent session state: ${agents.length} agents, ${commands.length} commands`
); );
for (const agent of agentSnapshots) {
this.emitAgentTimelineSnapshot(agent);
void this.maybeGenerateAgentTitle(agent.id);
}
} catch (error) { } catch (error) {
console.error( console.error(
`[Session ${this.clientId}] Failed to send session state:`, `[Session ${this.clientId}] Failed to send session state:`,
+25 -12
View File
@@ -18,6 +18,13 @@ interface WorktreeConfig {
repoPath: string; repoPath: string;
} }
interface CreateWorktreeOptions {
branchName: string;
cwd: string;
baseBranch?: string;
worktreeSlug?: string;
}
/** /**
* Check if current directory is a bare repository * Check if current directory is a bare repository
*/ */
@@ -148,6 +155,11 @@ export function slugify(input: string): string {
.replace(/^-+|-+$/g, ""); .replace(/^-+|-+$/g, "");
} }
function sanitizeWorktreeSlug(input: string): string {
const slug = slugify(input);
return slug.length > 0 ? slug : "worktree";
}
/** /**
* Check if a worktree already exists for a branch * Check if a worktree already exists for a branch
*/ */
@@ -183,12 +195,12 @@ async function findExistingWorktree(
/** /**
* Create a git worktree with proper naming conventions * Create a git worktree with proper naming conventions
*/ */
export async function createWorktree(params: { export async function createWorktree({
branchName: string; branchName,
cwd: string; cwd,
}): Promise<WorktreeConfig> { baseBranch,
const { branchName, cwd } = params; worktreeSlug,
}: CreateWorktreeOptions): Promise<WorktreeConfig> {
// Validate branch name // Validate branch name
const validation = validateBranchSlug(branchName); const validation = validateBranchSlug(branchName);
if (!validation.valid) { if (!validation.valid) {
@@ -200,11 +212,13 @@ export async function createWorktree(params: {
// Determine worktree directory based on repo type // Determine worktree directory based on repo type
let worktreePath: string; let worktreePath: string;
const desiredSlug = sanitizeWorktreeSlug(worktreeSlug ?? branchName);
if (repoInfo.type === "bare") { if (repoInfo.type === "bare") {
worktreePath = join(repoInfo.path, branchName); worktreePath = join(repoInfo.path, desiredSlug);
} else { } else {
const parentDir = dirname(repoInfo.path); const parentDir = dirname(repoInfo.path);
const worktreeName = `${repoInfo.name}-${branchName.replace(/\//g, "-")}`; const worktreeName = `${repoInfo.name}-${desiredSlug}`;
worktreePath = join(parentDir, worktreeName); worktreePath = join(parentDir, worktreeName);
} }
@@ -238,10 +252,9 @@ export async function createWorktree(params: {
}); });
} catch (error) { } catch (error) {
// Branch doesn't exist, create new branch and worktree // Branch doesn't exist, create new branch and worktree
await execAsync( const baseArg = baseBranch ? ` "${baseBranch}"` : "";
`git worktree add "${worktreePath}" -b "${branchName}"`, const command = `git worktree add "${worktreePath}" -b "${branchName}"${baseArg}`;
{ cwd: repoInfo.path } await execAsync(command, { cwd: repoInfo.path });
);
} }
// Copy .env file if it exists // Copy .env file if it exists
+22
View File
@@ -0,0 +1,22 @@
diff --git a/node_modules/@openai/codex-sdk/dist/index.js b/node_modules/@openai/codex-sdk/dist/index.js
index 12a2975..99f61c1 100644
--- a/node_modules/@openai/codex-sdk/dist/index.js
+++ b/node_modules/@openai/codex-sdk/dist/index.js
@@ -238,7 +238,16 @@ var CodexExec = class {
rl.close();
child.removeAllListeners();
try {
- if (!child.killed) child.kill();
+ // Use SIGINT to request a graceful shutdown and fall back to SIGKILL if it hangs.
+ if (child.exitCode === null && !child.killed) {
+ const forceKillTimer = setTimeout(() => {
+ if (child.exitCode === null && !child.killed) {
+ child.kill("SIGKILL");
+ }
+ }, 3e3);
+ child.once("exit", () => clearTimeout(forceKillTimer));
+ child.kill("SIGINT");
+ }
} catch {
}
}