feat: add git branch/worktree setup, message queueing, file explorer improvements, and server restart
This commit is contained in:
Generated
+153
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ScrollViewStyleReset } from "expo-router/html";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
|
// Ensure Unistyles runs before Expo Router statically renders each page.
|
||||||
|
import "../styles/unistyles";
|
||||||
|
|
||||||
|
export default function Root({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||||
|
/>
|
||||||
|
{/* Reset scroll styles so React Native Web views behave like native. */}
|
||||||
|
<ScrollViewStyleReset />
|
||||||
|
</head>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -210,13 +210,9 @@ export default function AgentScreen() {
|
|||||||
if (!id) {
|
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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./use-audio-player.native";
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export interface AudioPlayerOptions {
|
||||||
|
isDetecting?: () => boolean;
|
||||||
|
isSpeaking?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web shim for the native two-way audio player.
|
||||||
|
* We currently don't support realtime voice playback on web.
|
||||||
|
*/
|
||||||
|
export function useAudioPlayer(_options?: AudioPlayerOptions) {
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
play: async () => {
|
||||||
|
console.warn("[AudioPlayer] Web playback is disabled");
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
console.warn("[AudioPlayer] Web playback stop noop");
|
||||||
|
},
|
||||||
|
isPlaying: () => false,
|
||||||
|
clearQueue: () => {},
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./use-speechmatics-audio.native";
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export interface SpeechmaticsAudioConfig {
|
||||||
|
onAudioSegment?: (segment: { audioData: string; isLast: boolean }) => void;
|
||||||
|
onSpeechStart?: () => void;
|
||||||
|
onSpeechEnd?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
volumeThreshold: number;
|
||||||
|
silenceDuration: number;
|
||||||
|
speechConfirmationDuration: number;
|
||||||
|
detectionGracePeriod: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechmaticsAudio {
|
||||||
|
start: () => Promise<void>;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
toggleMute: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
isDetecting: boolean;
|
||||||
|
isMuted: boolean;
|
||||||
|
volume: number;
|
||||||
|
segmentDuration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web shim for the Speechmatics two-way audio hook.
|
||||||
|
* Real-time microphone capture is currently unsupported on web.
|
||||||
|
*/
|
||||||
|
export function useSpeechmaticsAudio(
|
||||||
|
_config: SpeechmaticsAudioConfig
|
||||||
|
): SpeechmaticsAudio {
|
||||||
|
const [isActive, setIsActive] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
const start = useCallback(async () => {
|
||||||
|
console.warn("[SpeechmaticsAudio] Web microphone capture disabled");
|
||||||
|
setIsActive(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useCallback(async () => {
|
||||||
|
setIsActive(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
setIsMuted((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
toggleMute,
|
||||||
|
isActive,
|
||||||
|
isSpeaking: false,
|
||||||
|
isDetecting: false,
|
||||||
|
isMuted,
|
||||||
|
volume: 0,
|
||||||
|
segmentDuration: 0,
|
||||||
|
}),
|
||||||
|
[start, stop, toggleMute, isActive, isMuted]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string
|
|||||||
|
|
||||||
function createUniqueTimelineId(
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user