diff --git a/package-lock.json b/package-lock.json index 646ff4798..af9d61f3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@openai/codex-sdk": "^0.58.0" }, "devDependencies": { + "patch-package": "^8.0.1", "typescript": "^5.9.3" } }, @@ -7987,6 +7988,13 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -12699,6 +12707,16 @@ "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": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -14497,6 +14515,26 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "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": { "version": "1.0.1", "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" } }, + "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": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", @@ -14615,6 +14663,16 @@ "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -16480,6 +16538,91 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -19406,6 +19549,16 @@ "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": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/package.json b/package.json index d7d6406d7..a2a3ab956 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "start": "npm run start --workspace=@paseo/server", "android": "npm run android --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": { + "patch-package": "^8.0.1", "typescript": "^5.9.3" }, "description": "Paseo: voice-controlled development environment with OpenAI Realtime API", diff --git a/packages/app/index.ts b/packages/app/index.ts index 80b707a67..6ed2cf260 100644 --- a/packages/app/index.ts +++ b/packages/app/index.ts @@ -1,2 +1,3 @@ -import "expo-router/entry"; +// Configure Unistyles before Expo Router pulls in any components using StyleSheet. import "./src/styles/unistyles"; +import "expo-router/entry"; diff --git a/packages/app/src/app/+html.tsx b/packages/app/src/app/+html.tsx new file mode 100644 index 000000000..b83239ef7 --- /dev/null +++ b/packages/app/src/app/+html.tsx @@ -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 ( + + + + + + {/* Reset scroll styles so React Native Web views behave like native. */} + + + {children} + + ); +} diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index 6f049f760..2d663bc59 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -210,13 +210,9 @@ export default function AgentScreen() { if (!id) { return; } - if (!agent?.persistence) { - handleCloseMenu(); - return; - } handleCloseMenu(); refreshAgent({ agentId: id }); - }, [agent?.persistence, handleCloseMenu, id, refreshAgent]); + }, [handleCloseMenu, id, refreshAgent]); if (!agent) { return ( @@ -294,28 +290,26 @@ export default function AgentScreen() { Browse Files - {agent.persistence && ( - - - - {isInitializing ? "Refreshing..." : "Refresh"} - - {isInitializing && ( - - )} - - )} + + + + {isInitializing ? "Refreshing..." : "Refresh"} + + {isInitializing && ( + + )} + diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx index 42507410d..b1b533e9d 100644 --- a/packages/app/src/app/file-explorer.tsx +++ b/packages/app/src/app/file-explorer.tsx @@ -4,24 +4,29 @@ import { FlatList, Image as RNImage, ListRenderItemInfo, + ViewToken, NativeScrollEvent, NativeSyntheticEvent, Pressable, ScrollView, Text, View, + BackHandler, + useWindowDimensions, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useLocalSearchParams } from "expo-router"; +import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { Copy, Check, - ArrowLeft, File, FileText, Folder, Image as ImageIcon, + LayoutGrid, + List as ListIcon, + X, } from "lucide-react-native"; import { BackHeader } from "@/components/headers/back-header"; import { useSession, type ExplorerEntry } from "@/contexts/session-context"; @@ -42,7 +47,9 @@ export default function FileExplorerScreen() { fileExplorer, requestDirectoryListing, requestFilePreview, + navigateExplorerBack, } = useSession(); + const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [selectedEntryPath, setSelectedEntryPath] = useState(null); const pendingPathParamRef = useRef(null); const pendingFileParamRef = useRef(null); @@ -50,16 +57,27 @@ export default function FileExplorerScreen() { const copyTimeoutRef = useRef | null>(null); const listScrollRef = useRef | null>(null); const listScrollOffsetRef = useRef(0); + const scrollOffsetsByPathRef = useRef>(new Map()); + const pendingScrollRestoreRef = useRef(null); + const { width: windowWidth } = useWindowDimensions(); const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw)); const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw)); const derivedDirectoryFromFile = normalizedFileParam ? deriveDirectoryFromFile(normalizedFileParam) : null; - const initialTargetDirectory = normalizedPathParam ?? derivedDirectoryFromFile ?? "."; const agent = agentId ? agents.get(agentId) : undefined; const explorerState = agentId ? fileExplorer.get(agentId) : undefined; + const history = explorerState?.history ?? []; + const lastKnownDirectory = history[history.length - 1]; + const rememberedDirectory = explorerState?.lastVisitedPath; + const initialTargetDirectory = + normalizedPathParam ?? + derivedDirectoryFromFile ?? + rememberedDirectory ?? + lastKnownDirectory ?? + "."; const currentPath = explorerState?.currentPath ?? "."; const pendingRequest = explorerState?.pendingRequest ?? null; const isExplorerLoading = explorerState?.isLoading ?? false; @@ -81,6 +99,49 @@ export default function FileExplorerScreen() { ? explorerState?.files.get(selectedEntryPath) : null; const shouldShowPreview = Boolean(selectedEntryPath); + const pendingThumbnailPathsRef = useRef>(new Set()); + const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState>({}); + const viewabilityConfigRef = useRef({ itemVisiblePercentThreshold: 50 }); + const gridColumnCount = useMemo(() => { + if (windowWidth >= 1500) { + return 6; + } + if (windowWidth >= 1200) { + return 5; + } + if (windowWidth >= 960) { + return 4; + } + if (windowWidth >= 720) { + return 3; + } + if (windowWidth >= 520) { + return 2; + } + return 1; + }, [windowWidth]); + const listColumns = viewMode === "grid" ? gridColumnCount : 1; + const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list"; + + const restoreQueuedScrollOffset = useCallback(() => { + if (pendingScrollRestoreRef.current === null) { + return; + } + + if (!listScrollRef.current) { + return; + } + + const targetOffset = pendingScrollRestoreRef.current; + listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false }); + listScrollOffsetRef.current = targetOffset; + pendingScrollRestoreRef.current = null; + }, []); + + const queueScrollRestore = useCallback((offset: number) => { + pendingScrollRestoreRef.current = offset; + requestAnimationFrame(restoreQueuedScrollOffset); + }, [restoreQueuedScrollOffset]); useEffect(() => { setSelectedEntryPath(null); @@ -91,28 +152,15 @@ export default function FileExplorerScreen() { return; } - const targetOffset = listScrollOffsetRef.current; - if (!listScrollRef.current) { - return; - } - - listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false }); - }, [shouldShowPreview]); + const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? listScrollOffsetRef.current; + queueScrollRestore(savedOffset); + }, [activePath, queueScrollRestore, shouldShowPreview]); useEffect(() => { - listScrollOffsetRef.current = 0; - listScrollRef.current?.scrollToOffset({ offset: 0, animated: false }); - }, [activePath]); - - const parentPath = useMemo(() => { - if (activePath === ".") { - return null; - } - const segments = activePath.split("/"); - segments.pop(); - const nextPath = segments.join("/"); - return nextPath.length === 0 ? "." : nextPath; - }, [activePath]); + const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? 0; + listScrollOffsetRef.current = savedOffset; + queueScrollRestore(savedOffset); + }, [activePath, queueScrollRestore]); useEffect(() => { setCopiedPath(null); @@ -179,14 +227,6 @@ export default function FileExplorerScreen() { [agentId, requestDirectoryListing, requestFilePreview] ); - const handleNavigateUp = useCallback(() => { - if (!agentId || !parentPath) { - return; - } - setSelectedEntryPath(null); - requestDirectoryListing(agentId, parentPath); - }, [agentId, parentPath, requestDirectoryListing]); - const handleCopyPath = useCallback(async (path: string) => { await Clipboard.setStringAsync(path); setCopiedPath(path); @@ -209,13 +249,87 @@ export default function FileExplorerScreen() { const handleListScroll = useCallback( (event: NativeSyntheticEvent) => { - listScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + const offset = event.nativeEvent.contentOffset.y; + listScrollOffsetRef.current = offset; + scrollOffsetsByPathRef.current.set(activePath, offset); }, - [] + [activePath] + ); + + const handleCloseExplorer = useCallback(() => { + if (agentId) { + router.replace({ pathname: "/agent/[id]", params: { id: agentId } }); + return; + } + + router.back(); + }, [agentId]); + + const handleBackNavigation = useCallback(() => { + if (!agentId) { + router.back(); + return true; + } + + if (shouldShowPreview) { + setSelectedEntryPath(null); + return true; + } + + if ((explorerState?.history?.length ?? 0) > 1) { + navigateExplorerBack(agentId); + return true; + } + + handleCloseExplorer(); + return true; + }, [agentId, explorerState?.history?.length, handleCloseExplorer, navigateExplorerBack, shouldShowPreview]); + + useFocusEffect( + useCallback(() => { + const subscription = BackHandler.addEventListener("hardwareBackPress", handleBackNavigation); + return () => subscription.remove(); + }, [handleBackNavigation]) ); const renderEntry = useCallback( ({ item }: ListRenderItemInfo) => { + if (viewMode === "grid") { + const preview = explorerState?.files.get(item.path); + const isImage = getEntryDisplayKind(item) === "image"; + const isLoadingThumb = Boolean(thumbnailLoadingMap[item.path]); + return ( + handleEntryPress(item)} + > + + {isImage && preview?.content ? ( + + ) : isImage && isLoadingThumb ? ( + + ) : ( + renderEntryIcon(getEntryDisplayKind(item), theme.colors) + )} + + + {item.name} + + + {formatFileSize({ size: item.size })} + + + ); + } + const displayKind = getEntryDisplayKind(item); return ( ); }, - [copiedPath, handleCopyPath, handleEntryPress, theme.colors] + [ + copiedPath, + explorerState?.files, + handleCopyPath, + handleEntryPress, + theme.colors, + thumbnailLoadingMap, + viewMode, + ] ); const listHeaderComponent = useMemo(() => { - if (!parentPath && !showListLoadingBanner) { - return null; - } - return ( - {parentPath && } + + + {showListLoadingBanner && ( @@ -275,12 +395,20 @@ export default function FileExplorerScreen() { )} ); - }, [activePath, handleNavigateUp, parentPath, showListLoadingBanner]); + }, [activePath, showListLoadingBanner, viewMode]); if (!agent) { return ( - + + + + } + /> Agent not found @@ -288,14 +416,73 @@ export default function FileExplorerScreen() { ); } + const handleViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: Array }) => { + 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 ( - + + + + } + /> {shouldShowPreview ? ( - setSelectedEntryPath(null)} /> {isPreviewLoading && !preview ? ( @@ -359,14 +546,27 @@ export default function FileExplorerScreen() { data={entries} renderItem={renderEntry} keyExtractor={(item) => item.path} - contentContainerStyle={styles.entriesContent} + contentContainerStyle={ + viewMode === "grid" ? styles.gridContent : styles.entriesContent + } + columnWrapperStyle={ + viewMode === "grid" && listColumns > 1 + ? styles.gridColumnWrapper + : undefined + } + numColumns={listColumns} + key={listKey} onScroll={handleListScroll} scrollEventThrottle={16} + onLayout={restoreQueuedScrollOffset} + onContentSizeChange={restoreQueuedScrollOffset} ListHeaderComponent={listHeaderComponent} - extraData={copiedPath} + extraData={{ copiedPath, viewMode, thumbnailLoadingMap }} initialNumToRender={20} maxToRenderPerBatch={30} windowSize={10} + onViewableItemsChanged={handleViewableItemsChanged} + viewabilityConfig={viewabilityConfigRef.current} /> )} @@ -376,6 +576,35 @@ export default function FileExplorerScreen() { ); } +function ViewToggle({ + viewMode, + onChange, +}: { + viewMode: "list" | "grid"; + onChange: (mode: "list" | "grid") => void; +}) { + const { theme } = useUnistyles(); + + return ( + + onChange("list")} + > + + List + + onChange("grid")} + > + + Gallery + + + ); +} + function formatDirectoryLabel(path: string): string { return path === "." ? "workspace root" : path; } @@ -437,16 +666,6 @@ function deriveDirectoryFromFile(filePath: string): string { return directory.length > 0 ? directory : "."; } -function UpRow({ label, onPress }: { label: string; onPress: () => void }) { - const { theme } = useUnistyles(); - return ( - - - {label} - - ); -} - type EntryDisplayKind = "directory" | "image" | "text" | "other"; const IMAGE_EXTENSIONS = new Set([ @@ -567,6 +786,10 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], paddingBottom: theme.spacing[2], }, + headerRow: { + flexDirection: "row", + justifyContent: "flex-end", + }, loadingBanner: { flexDirection: "row", alignItems: "center", @@ -661,22 +884,32 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, - upRow: { + viewToggleContainer: { flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, + borderRadius: theme.borderRadius.full, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, - backgroundColor: theme.colors.background, + overflow: "hidden", }, - upRowText: { + viewToggleButton: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + }, + viewToggleActive: { + backgroundColor: theme.colors.muted, + }, + viewToggleText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, + closeButton: { + padding: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + }, textPreview: { flex: 1, }, @@ -688,6 +921,51 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: "monospace", fontSize: theme.fontSize.sm, }, + gridContent: { + paddingBottom: theme.spacing[4], + paddingHorizontal: theme.spacing[1], + }, + gridColumnWrapper: { + justifyContent: "space-between", + marginBottom: theme.spacing[2], + }, + gridCard: { + flex: 1, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + padding: theme.spacing[2], + gap: theme.spacing[2], + backgroundColor: theme.colors.card, + marginHorizontal: theme.spacing[1], + marginBottom: theme.spacing[1], + minWidth: 0, + }, + gridThumbnail: { + width: "100%", + aspectRatio: 1, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + overflow: "hidden", + backgroundColor: theme.colors.muted, + }, + gridImageBackground: { + backgroundColor: theme.colors.background, + }, + gridImage: { + width: "100%", + height: "100%", + }, + gridName: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + gridMeta: { + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.xs, + }, imagePreviewContainer: { flex: 1, alignItems: "center", diff --git a/packages/app/src/app/git-diff.tsx b/packages/app/src/app/git-diff.tsx index 135f6a0da..b256f9e59 100644 --- a/packages/app/src/app/git-diff.tsx +++ b/packages/app/src/app/git-diff.tsx @@ -121,41 +121,32 @@ export default function GitDiffScreen() { {file.path} - - - {file.lines.map((line, lineIndex) => ( - + {file.lines.map((line, lineIndex) => ( + + - - {line.content} - - - ))} - - + {line.content} + + + ))} + )) @@ -175,6 +166,7 @@ const styles = StyleSheet.create((theme) => ({ }, contentContainer: { padding: theme.spacing[4], + paddingBottom: theme.spacing[8], }, loadingContainer: { flex: 1, @@ -215,6 +207,7 @@ const styles = StyleSheet.create((theme) => ({ overflow: "hidden", borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, + width: "100%", }, fileHeader: { backgroundColor: theme.colors.muted, @@ -231,27 +224,23 @@ const styles = StyleSheet.create((theme) => ({ diffContent: { backgroundColor: theme.colors.card, }, - diffScrollContent: { - flexDirection: "column", - alignItems: "flex-start", - paddingBottom: theme.spacing[2], - }, diffLinesContainer: { - alignSelf: "flex-start", + width: "100%", }, diffLineContainer: { - minWidth: "100%", + width: "100%", paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], - alignSelf: "flex-start", flexDirection: "row", - alignItems: "center", + alignItems: "flex-start", }, diffLineText: { fontSize: theme.fontSize.xs, fontFamily: "monospace", color: theme.colors.foreground, - flexShrink: 0, + flexShrink: 1, + flexWrap: "wrap", + width: "100%", }, addLineContainer: { backgroundColor: theme.colors.palette.green[900], diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 0e7bc9ba5..8d096a943 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import { View, Text, @@ -8,13 +8,23 @@ import { Pressable, Alert, ActivityIndicator, + Platform, } from "react-native"; import { router } from "expo-router"; import { StyleSheet } from "react-native-unistyles"; import { useSettings } from "@/hooks/use-settings"; +import { useSession } from "@/contexts/session-context"; import { theme as defaultTheme } from "@/styles/theme"; import { BackHeader } from "@/components/headers/back-header"; +const delay = (ms: number) => + new Promise((resolve) => { + const timeout = setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, ms); + }); + const styles = StyleSheet.create((theme) => ({ loadingContainer: { flex: 1, @@ -192,6 +202,24 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, + restartButton: { + padding: theme.spacing[4], + borderRadius: theme.borderRadius.lg, + marginTop: theme.spacing[3], + backgroundColor: theme.colors.destructive, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + }, + restartButtonDisabled: { + opacity: theme.opacity[50], + }, + restartButtonText: { + color: theme.colors.destructiveForeground, + textAlign: "center", + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + }, footer: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, @@ -212,6 +240,7 @@ const styles = StyleSheet.create((theme) => ({ export default function SettingsScreen() { const { settings, isLoading, updateSettings, resetSettings } = useSettings(); + const { restartServer, ws } = useSession(); const [serverUrl, setServerUrl] = useState(settings.serverUrl); const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker); @@ -223,6 +252,137 @@ export default function SettingsScreen() { success: boolean; message: string; } | null>(null); + const [isRestarting, setIsRestarting] = useState(false); + const isMountedRef = useRef(true); + const wsIsConnectedRef = useRef(ws.isConnected); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + wsIsConnectedRef.current = ws.isConnected; + }, [ws.isConnected]); + + const waitForCondition = useCallback( + async (predicate: () => boolean, timeoutMs: number, intervalMs = 250) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isMountedRef.current) { + return false; + } + if (predicate()) { + return true; + } + await delay(intervalMs); + } + return predicate(); + }, + [] + ); + + const testServerConnection = useCallback((url: string, timeoutMs = 5000) => { + return new Promise((resolve, reject) => { + let wsConnection: WebSocket | null = null; + let timeoutId: ReturnType | null = null; + let settled = false; + + const cleanup = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + if (wsConnection) { + wsConnection.onopen = null; + wsConnection.onerror = null; + wsConnection.onclose = null; + try { + wsConnection.close(); + } catch { + // no-op + } + } + }; + + const succeed = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + + const fail = (message: string) => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error(message)); + }; + + try { + wsConnection = new WebSocket(url); + } catch { + fail("Failed to create connection"); + return; + } + + timeoutId = setTimeout(() => { + fail("Connection timeout - server did not respond"); + }, timeoutMs); + + wsConnection.onopen = () => succeed(); + wsConnection.onerror = () => fail("Connection failed - check URL and network"); + wsConnection.onclose = () => fail("Connection failed - check URL and network"); + }); + }, []); + + const waitForServerRestart = useCallback(async () => { + const maxAttempts = 12; + const retryDelayMs = 2500; + const disconnectTimeoutMs = 7000; + const reconnectTimeoutMs = 10000; + + if (wsIsConnectedRef.current) { + await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs); + } + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await testServerConnection(settings.serverUrl); + const reconnected = await waitForCondition( + () => wsIsConnectedRef.current, + reconnectTimeoutMs + ); + + if (isMountedRef.current) { + setIsRestarting(false); + if (!reconnected) { + Alert.alert( + "Server reachable", + "The server came back online but the app has not reconnected yet." + ); + } + } + return; + } catch (error) { + console.warn( + `[Settings] Restart poll attempt ${attempt}/${maxAttempts} failed`, + error + ); + if (attempt === maxAttempts) { + if (isMountedRef.current) { + setIsRestarting(false); + Alert.alert( + "Unable to reconnect", + "The server did not come back online. Please verify the daemon restarted." + ); + } + return; + } + await delay(retryDelayMs); + } + } + }, [settings.serverUrl, testServerConnection, waitForCondition]); // Update local state when settings load useEffect(() => { @@ -315,6 +475,59 @@ export default function SettingsScreen() { ); } + const restartConfirmationMessage = + "This will immediately stop the Voice Dev backend process. The app will disconnect until it restarts."; + + const beginServerRestart = useCallback(() => { + if (!wsIsConnectedRef.current) { + Alert.alert( + "Not Connected", + "Connect to the server before attempting a restart." + ); + return; + } + + setIsRestarting(true); + try { + restartServer("settings_screen_restart"); + } catch (error) { + setIsRestarting(false); + Alert.alert( + "Error", + "Failed to send restart request. Please ensure you are connected to the server." + ); + return; + } + + void waitForServerRestart(); + }, [restartServer, waitForServerRestart]); + + function handleRestartServer() { + if (Platform.OS === "web") { + const hasBrowserConfirm = + typeof globalThis !== "undefined" && + typeof (globalThis as any).confirm === "function"; + + const confirmed = hasBrowserConfirm + ? (globalThis as any).confirm(restartConfirmationMessage) + : true; + + if (confirmed) { + beginServerRestart(); + } + return; + } + + Alert.alert("Restart Server", restartConfirmationMessage, [ + { text: "Cancel", style: "cancel" }, + { + text: "Restart", + style: "destructive", + onPress: beginServerRestart, + }, + ]); + } + async function handleTestConnection() { if (!validateServerUrl(serverUrl)) { @@ -330,40 +543,21 @@ export default function SettingsScreen() { setTestResult(null); try { - const ws = new WebSocket(serverUrl); - - const timeout = setTimeout(() => { - ws.close(); - setTestResult({ - success: false, - message: "Connection timeout - server did not respond", - }); - setIsTesting(false); - }, 5000); - - ws.onopen = () => { - clearTimeout(timeout); - ws.close(); - setTestResult({ - success: true, - message: "Connection successful", - }); - setIsTesting(false); - }; - - ws.onerror = () => { - clearTimeout(timeout); - setTestResult({ - success: false, - message: "Connection failed - check URL and network", - }); - setIsTesting(false); - }; + await testServerConnection(serverUrl); + setTestResult({ + success: true, + message: "Connection successful", + }); } catch (error) { + const message = + error instanceof Error + ? error.message + : "Connection failed - check URL and network"; setTestResult({ success: false, - message: "Failed to create connection", + message, }); + } finally { setIsTesting(false); } } @@ -534,6 +728,26 @@ export default function SettingsScreen() { Reset to Defaults + + + {isRestarting && ( + + )} + + {isRestarting ? "Restarting..." : "Restart Server"} + + {/* App Info */} diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 856863c6a..54014b8ca 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -4,13 +4,15 @@ import { Pressable, NativeSyntheticEvent, TextInputContentSizeChangeEventData, + TextInputKeyPressEventData, Image, Platform, Text, + ActivityIndicator, } from "react-native"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useLayoutEffect } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, ArrowUp, AudioLines, Square, Paperclip, X } from "lucide-react-native"; +import { Mic, ArrowUp, AudioLines, Square, Paperclip, X, Pencil } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, @@ -32,18 +34,45 @@ interface AgentInputAreaProps { agentId: string; } -const MIN_INPUT_HEIGHT = 40; +const MIN_INPUT_HEIGHT = 32; const MAX_INPUT_HEIGHT = 160; +const MAX_INPUT_WIDTH = 960; const BASE_VERTICAL_PADDING = (FOOTER_HEIGHT - MIN_INPUT_HEIGHT) / 2; // Android currently crashes inside ViewGroup.dispatchDraw when running Reanimated // entering/exiting animations (see react-native-reanimated#8422), so guard them. const SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS = Platform.OS === "android"; const REALTIME_FADE_IN = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeIn.duration(250); const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : FadeOut.duration(250); +const IS_WEB = Platform.OS === "web"; +const SHOULD_DEBUG_INPUT_HEIGHT = IS_WEB; +type WebTextInputKeyPressEvent = NativeSyntheticEvent< + TextInputKeyPressEventData & { + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + } +>; +type TextAreaHandle = { + scrollHeight?: number; + style?: { + height?: string; + overflowY?: string; + } & Record; +}; export function AgentInputArea({ agentId }: AgentInputAreaProps) { const { theme } = useUnistyles(); - const { ws, sendAgentMessage, sendAgentAudio, agents, cancelAgentRun } = useSession(); + const { + ws, + sendAgentMessage, + sendAgentAudio, + agents, + cancelAgentRun, + draftInputs, + setDraftInputs, + queuedMessages: queuedMessagesByAgent, + setQueuedMessages: setQueuedMessagesByAgent, + } = useSession(); const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); const [userInput, setUserInput] = useState(""); @@ -54,8 +83,10 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { const [recordingVolume, setRecordingVolume] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0); const [transcribingRequestId, setTranscribingRequestId] = useState(null); + const [isCancellingAgent, setIsCancellingAgent] = useState(false); const recordingIntervalRef = useRef | null>(null); + const textInputRef = useRef unknown }) | null>(null); const overlayTransition = useSharedValue(0); const { pickImages } = useImageAttachmentPicker(); @@ -65,6 +96,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { }, }); + const debugInputHeight = (label: string, payload: Record) => { + if (!SHOULD_DEBUG_INPUT_HEIGHT) { + return; + } + console.log(`[AgentInput][InputHeight] ${label}`, payload); + }; + async function handleSendMessage() { if (!userInput.trim() || !ws.isConnected) return; @@ -218,24 +256,108 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { }; }, []); - function handleContentSizeChange( - event: NativeSyntheticEvent, - ) { - const contentHeight = event.nativeEvent.contentSize.height; + function isTextAreaLike(value: unknown): value is TextAreaHandle { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as TextAreaHandle; + return typeof candidate.scrollHeight === "number" && typeof candidate.style === "object"; + } + + function getWebTextArea(): TextAreaHandle | null { + if (!IS_WEB) { + return null; + } + + const node = textInputRef.current; + if (!node) { + debugInputHeight("missing-ref", {}); + return null; + } + + if (isTextAreaLike(node)) { + debugInputHeight("using-ref", { + scrollHeight: node.scrollHeight, + inlineHeight: node.style?.height, + }); + return node; + } + + if (typeof (node as { getNativeRef?: () => unknown }).getNativeRef === "function") { + const native = (node as { getNativeRef?: () => unknown }).getNativeRef?.(); + if (isTextAreaLike(native)) { + debugInputHeight("using-native-ref", { + scrollHeight: native.scrollHeight, + inlineHeight: native.style?.height, + }); + return native; + } + } + + debugInputHeight("no-textarea-found", {}); + return null; + } + + function measureWebInputHeight(source: string): boolean { + if (!IS_WEB) { + return false; + } + const element = getWebTextArea(); + if (!element?.style || typeof element.scrollHeight !== "number") { + debugInputHeight(`${source}-missing`, {}); + return false; + } + const previousHeight = element.style.height; + element.style.height = "auto"; + const measuredHeight = element.scrollHeight; + element.style.height = previousHeight ?? ""; + const bounded = Math.min( + MAX_INPUT_HEIGHT, + Math.max(MIN_INPUT_HEIGHT, measuredHeight), + ); + debugInputHeight(source, { + measuredHeight, + bounded, + scrollHeight: element.scrollHeight, + inlineHeight: previousHeight, + }); + setBoundedInputHeight(bounded); + return true; + } + + function setBoundedInputHeight(nextHeight: number) { const boundedHeight = Math.min( MAX_INPUT_HEIGHT, - Math.max(MIN_INPUT_HEIGHT, contentHeight), + Math.max(MIN_INPUT_HEIGHT, nextHeight), ); setInputHeight((currentHeight) => { if (Math.abs(currentHeight - boundedHeight) < 1) { return currentHeight; } - + debugInputHeight("set-state", { + nextHeight, + boundedHeight, + previousHeight: currentHeight, + }); return boundedHeight; }); } + function handleContentSizeChange( + event: NativeSyntheticEvent, + ) { + if (IS_WEB && measureWebInputHeight("web-measure")) { + return; + } + + debugInputHeight("native-measure", { + height: event.nativeEvent.contentSize.height, + width: event.nativeEvent.contentSize.width, + }); + setBoundedInputHeight(event.nativeEvent.contentSize.height); + } + const agent = agents.get(agentId); const isAgentRunning = agent?.status === "running"; const hasText = userInput.trim().length > 0; @@ -243,6 +365,73 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { const hasSendableContent = hasText || hasImages; const shouldShowSendButton = !isAgentRunning && hasSendableContent; const shouldShowVoiceControls = !isAgentRunning && !hasSendableContent; + const queuedMessages = queuedMessagesByAgent.get(agentId) ?? []; + const shouldHandleDesktopSubmit = IS_WEB; + + useLayoutEffect(() => { + if (!IS_WEB) { + return; + } + measureWebInputHeight("layout-effect"); + }, [userInput]); + + function handleDesktopSubmitKeyPress(event: WebTextInputKeyPressEvent) { + if (!shouldHandleDesktopSubmit) { + return; + } + if (event.nativeEvent.key !== "Enter") { + return; + } + const { metaKey, ctrlKey, shiftKey } = event.nativeEvent; + if (shiftKey || metaKey || ctrlKey) { + return; + } + event.preventDefault(); + if (!shouldShowSendButton || isProcessing || !ws.isConnected) { + return; + } + void handleSendMessage(); + } + + useEffect(() => { + if (!isAgentRunning || !ws.isConnected) { + setIsCancellingAgent(false); + } + }, [isAgentRunning, ws.isConnected]); + + // Hydrate draft only when switching agents + useEffect(() => { + const draft = draftInputs.get(agentId); + if (!draft) { + setUserInput(""); + setSelectedImages([]); + return; + } + + setUserInput(draft.text); + setSelectedImages(draft.images); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentId]); + + // Persist drafts into context with change detection to avoid render loops + useEffect(() => { + setDraftInputs((prev) => { + const existing = prev.get(agentId); + const isSameText = existing?.text === userInput; + const existingImages = existing?.images ?? []; + const isSameImages = + existingImages.length === selectedImages.length && + existingImages.every((img, idx) => img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType); + + if (isSameText && isSameImages) { + return prev; + } + + const next = new Map(prev); + next.set(agentId, { text: userInput, images: selectedImages }); + return next; + }); + }, [agentId, userInput, selectedImages, setDraftInputs]); const overlayAnimatedStyle = useAnimatedStyle(() => { return { @@ -275,15 +464,59 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { } function handleCancelAgent() { - if (!agent || agent.status !== "running") { + if (!agent || agent.status !== "running" || isCancellingAgent) { return; } if (!ws.isConnected) { return; } + setIsCancellingAgent(true); cancelAgentRun(agentId); } + function updateQueue(updater: (current: typeof queuedMessages) => typeof queuedMessages) { + setQueuedMessagesByAgent((prev) => { + const next = new Map(prev); + next.set(agentId, updater(prev.get(agentId) ?? [])); + return next; + }); + } + + function handleQueueCurrentInput() { + if (!hasSendableContent) return; + + const newItem = { + id: generateMessageId(), + text: userInput.trim(), + images: selectedImages.length ? selectedImages : undefined, + }; + + updateQueue((current) => [...current, newItem]); + setUserInput(""); + setSelectedImages([]); + setInputHeight(MIN_INPUT_HEIGHT); + } + + function handleEditQueuedMessage(id: string) { + const item = queuedMessages.find((q) => q.id === id); + if (!item) return; + + updateQueue((current) => current.filter((q) => q.id !== id)); + setUserInput(item.text); + setSelectedImages(item.images ?? []); + } + + async function handleSendQueuedNow(id: string) { + const item = queuedMessages.find((q) => q.id === id); + if (!item || !ws.isConnected) return; + + updateQueue((current) => current.filter((q) => q.id !== id)); + + // Cancels current agent run before sending queued prompt + handleCancelAgent(); + await sendAgentMessage(agentId, item.text, item.images?.map((img) => img.uri)); + } + const realtimeButton = ( - {/* Regular input controls */} - + + {/* Regular input controls */} + + {/* Queue list */} + {queuedMessages.length > 0 && ( + + {queuedMessages.map((item) => ( + + + {item.text} + + + handleEditQueuedMessage(item.id)} style={styles.queueActionButton}> + + + handleSendQueuedNow(item.id)} + style={[styles.queueActionButton, styles.queueSendButton]} + > + + + + + ))} + + )} + {/* Image preview pills */} {hasImages && ( @@ -338,6 +596,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { {/* Full-width text input */} = MAX_INPUT_HEIGHT} onContentSizeChange={handleContentSizeChange} editable={!isRecording && ws.isConnected} + onKeyPress={shouldHandleDesktopSubmit ? handleDesktopSubmitKeyPress : undefined} /> {/* Button row below input */} @@ -373,18 +633,33 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { {isAgentRunning ? ( <> + {hasSendableContent && ( + + + + )} {realtimeButton} - + {isCancellingAgent ? ( + + ) : ( + + )} ) : shouldShowSendButton ? ( @@ -420,17 +695,20 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { ) : null} - + + {/* Voice note recording overlay */} - + + + @@ -451,6 +729,12 @@ const styles = StyleSheet.create((theme) => ({ inputAreaContainer: { position: "relative", minHeight: FOOTER_HEIGHT, + alignItems: "center", + width: "100%", + }, + inputAreaContent: { + width: "100%", + maxWidth: MAX_INPUT_WIDTH, }, inputContainer: { flexDirection: "column", @@ -458,6 +742,7 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[3], gap: theme.spacing[3], minHeight: FOOTER_HEIGHT, + width: "100%", }, overlayContainer: { position: "absolute", @@ -465,6 +750,7 @@ const styles = StyleSheet.create((theme) => ({ right: 0, bottom: 0, height: FOOTER_HEIGHT, + alignItems: "center", }, imagePreviewContainer: { flexDirection: "row", @@ -499,6 +785,13 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.lg, lineHeight: theme.fontSize.lg * 1.4, + ...(IS_WEB + ? { + outlineStyle: "none" as const, + outlineWidth: 0, + outlineColor: "transparent", + } + : {}), }, buttonRow: { flexDirection: "row", @@ -559,7 +852,52 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + queueButton: { + width: 40, + height: 40, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.palette.blue[600], + alignItems: "center", + justifyContent: "center", + }, buttonDisabled: { opacity: 0.5, }, + queueContainer: { + flexDirection: "column", + gap: theme.spacing[2], + }, + queueItem: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.card, + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + gap: theme.spacing[2], + }, + queueText: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + }, + queueActions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + queueActionButton: { + width: 32, + height: 32, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.muted, + }, + queueSendButton: { + backgroundColor: theme.colors.palette.blue[600], + }, })); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index aa2f29855..3e5ba5aa4 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -18,10 +18,12 @@ export function AgentList({ agents }: AgentListProps) { const [actionAgent, setActionAgent] = useState(null); const isActionSheetVisible = actionAgent !== null; - // Sort agents by lastActivityAt (most recent first) + // Sort agents by the persisted timestamp of the last user message, falling back to last activity const agentArray = useMemo(() => { return Array.from(agents.values()).sort((a, b) => { - return b.lastActivityAt.getTime() - a.lastActivityAt.getTime(); + const sortA = (a.lastUserMessageAt ?? a.lastActivityAt).getTime(); + const sortB = (b.lastUserMessageAt ?? b.lastActivityAt).getTime(); + return sortB - sortA; }); }, [agents]); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 86213180f..41befd361 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import type { ReactNode } from "react"; import { View, Text, @@ -39,6 +40,7 @@ import { extractReadEntries, } from "@/utils/tool-call-parsers"; +const MAX_CHAT_WIDTH = 960; export interface AgentStreamViewProps { agentId: string; agent: Agent; @@ -219,30 +221,31 @@ export function AgentStreamView({ } function renderStreamItem({ item }: ListRenderItemInfo) { + let content: React.ReactNode = null; + switch (item.kind) { case "user_message": - return ( + content = ( ); + break; case "assistant_message": - return ( + content = ( ); + break; case "thought": - return ( - - ); + content = ; + break; case "tool_call": { const { payload } = item; @@ -250,7 +253,7 @@ export function AgentStreamView({ if (payload.source === "agent") { const data = payload.data; const toolLabel = data.displayName ?? `${data.server}/${data.tool}`; - return ( + content = ( handleOpenToolCallDetails({ payload })} /> ); + } else { + const data = payload.data; + content = ( + handleOpenToolCallDetails({ payload })} + /> + ); } - - const data = payload.data; - return ( - handleOpenToolCallDetails({ payload })} - /> - ); + break; } case "activity_log": - return ( + content = ( ); + break; case "todo_list": - return ( + content = ( ); + break; default: - return null; + content = null; } + + if (!content) { + return null; + } + + return {content}; } const pendingPermissionItems = useMemo( @@ -318,24 +330,26 @@ export function AgentStreamView({ } return ( - - {pendingPermissionItems.length > 0 ? ( - - {pendingPermissionItems.map((permission) => ( - - ))} - - ) : null} + + + {pendingPermissionItems.length > 0 ? ( + + {pendingPermissionItems.map((permission) => ( + + ))} + + ) : null} - {showWorkingIndicator ? ( - - - - ) : null} + {showWorkingIndicator ? ( + + + + ) : null} + ); }, [onPermissionResponse, pendingPermissionItems, showWorkingIndicator]); @@ -371,7 +385,7 @@ export function AgentStreamView({ onMomentumScrollEnd={handleScrollEnd} scrollEventThrottle={16} ListEmptyComponent={ - + Start chatting with this agent... @@ -395,15 +409,17 @@ export function AgentStreamView({ entering={scrollIndicatorFadeIn} exiting={scrollIndicatorFadeOut} > - - - + + + + + )} @@ -462,16 +478,18 @@ function WorkingIndicator() { const dotOne = useSharedValue(0); const dotTwo = useSharedValue(0); const dotThree = useSharedValue(0); + const bounceDuration = 600; + const bounceDelayOffset = 160; useEffect(() => { const sharedValues = [dotOne, dotTwo, dotThree]; sharedValues.forEach((value, index) => { value.value = withDelay( - index * 120, + index * bounceDelayOffset, withRepeat( withSequence( - withTiming(1, { duration: 320 }), - withTiming(0, { duration: 320 }) + withTiming(1, { duration: bounceDuration }), + withTiming(0, { duration: bounceDuration }) ), -1, true @@ -487,19 +505,20 @@ function WorkingIndicator() { }; }, [dotOne, dotTwo, dotThree]); + const translateDistance = -4; const dotOneStyle = useAnimatedStyle(() => ({ opacity: 0.3 + dotOne.value * 0.7, - transform: [{ translateY: dotOne.value * -4 }], + transform: [{ translateY: dotOne.value * translateDistance }], })); const dotTwoStyle = useAnimatedStyle(() => ({ opacity: 0.3 + dotTwo.value * 0.7, - transform: [{ translateY: dotTwo.value * -4 }], + transform: [{ translateY: dotTwo.value * translateDistance }], })); const dotThreeStyle = useAnimatedStyle(() => ({ opacity: 0.3 + dotThree.value * 0.7, - transform: [{ translateY: dotThree.value * -4 }], + transform: [{ translateY: dotThree.value * translateDistance }], })); return ( @@ -566,8 +585,13 @@ function PermissionRequestCard({ () => ({ body: { color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - lineHeight: 20, + fontSize: theme.fontSize.base, + lineHeight: 22, + }, + text: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: 22, }, paragraph: { marginBottom: theme.spacing[1], @@ -823,10 +847,20 @@ const stylesheet = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.background, }, + contentWrapper: { + width: "100%", + maxWidth: MAX_CHAT_WIDTH, + alignSelf: "center", + }, list: { flex: 1, paddingHorizontal: theme.spacing[2], }, + streamItemWrapper: { + width: "100%", + maxWidth: MAX_CHAT_WIDTH, + alignSelf: "center", + }, emptyState: { flex: 1, alignItems: "center", @@ -840,18 +874,19 @@ const stylesheet = StyleSheet.create((theme) => ({ gap: theme.spacing[3], }, workingIndicatorWrapper: { - alignItems: "center", + alignItems: "flex-start", }, workingIndicatorBubble: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[2], + paddingHorizontal: 0, + paddingVertical: theme.spacing[1], + paddingLeft: theme.spacing[1], borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.card, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, + backgroundColor: "transparent", + borderWidth: 0, + alignSelf: "flex-start", }, workingIndicatorText: { color: theme.colors.mutedForeground, @@ -886,6 +921,12 @@ const stylesheet = StyleSheet.create((theme) => ({ alignItems: "center", pointerEvents: "box-none", }, + scrollToBottomInner: { + width: "100%", + maxWidth: MAX_CHAT_WIDTH, + alignSelf: "center", + alignItems: "center", + }, scrollToBottomButton: { width: 48, height: 48, diff --git a/packages/app/src/components/create-agent-modal.tsx b/packages/app/src/components/create-agent-modal.tsx index 544366d39..0f7236026 100644 --- a/packages/app/src/components/create-agent-modal.tsx +++ b/packages/app/src/components/create-agent-modal.tsx @@ -34,7 +34,13 @@ import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, } from "@server/server/agent/provider-manifest"; -import type { AgentProvider, AgentMode, AgentSessionConfig, AgentPersistenceHandle } from "@server/server/agent/agent-sdk-types"; +import type { + AgentProvider, + AgentMode, + AgentSessionConfig, + AgentPersistenceHandle, + AgentTimelineItem, +} from "@server/server/agent/agent-sdk-types"; import type { WSInboundMessage } from "@server/server/messages"; interface CreateAgentModalProps { @@ -49,7 +55,8 @@ const providerDefinitionMap = new Map( const fallbackDefinition = providerDefinitions[0]; const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude"; -const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? ""; +const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = + fallbackDefinition?.defaultModeId ?? ""; const BACKDROP_OPACITY = 0.55; const RESUME_PAGE_SIZE = 20; @@ -60,11 +67,20 @@ type ResumeCandidate = { title: string; lastActivityAt: Date; persistence: AgentPersistenceHandle; + timeline: AgentTimelineItem[]; }; type ResumeTab = "new" | "resume"; type ProviderFilter = "all" | AgentProvider; +type RepoInfoState = { + cwd: string; + repoRoot: string; + branches: Array<{ name: string; isCurrent: boolean }>; + currentBranch: string | null; + isDirty: boolean; +}; + function formatRelativeTime(date: Date): string { const now = Date.now(); const diffMs = now - date.getTime(); @@ -86,6 +102,18 @@ function formatRelativeTime(date: Date): string { return `${days}d ago`; } +function getResumePreview(candidate: ResumeCandidate): string { + for (const item of candidate.timeline) { + if (item.type === "user_message") { + const text = item.text.trim(); + if (text.length > 0) { + return text; + } + } + } + return candidate.title || candidate.cwd; +} + export function CreateAgentModal({ isVisible, onClose, @@ -103,20 +131,37 @@ export function CreateAgentModal({ const [isMounted, setIsMounted] = useState(isVisible); const [workingDir, setWorkingDir] = useState(""); - const [selectedProvider, setSelectedProvider] = useState( - DEFAULT_PROVIDER + const [initialPrompt, setInitialPrompt] = useState(""); + const [selectedProvider, setSelectedProvider] = + useState(DEFAULT_PROVIDER); + const [selectedMode, setSelectedMode] = useState( + DEFAULT_MODE_FOR_DEFAULT_PROVIDER ); - const [selectedMode, setSelectedMode] = useState(DEFAULT_MODE_FOR_DEFAULT_PROVIDER); - const [worktreeName, setWorktreeName] = useState(""); + const [baseBranch, setBaseBranch] = useState(""); + const [createNewBranch, setCreateNewBranch] = useState(false); + const [branchName, setBranchName] = useState(""); + const [createWorktree, setCreateWorktree] = useState(false); + const [worktreeSlug, setWorktreeSlug] = useState(""); + const [branchNameEdited, setBranchNameEdited] = useState(false); + const [worktreeSlugEdited, setWorktreeSlugEdited] = useState(false); const [errorMessage, setErrorMessage] = useState(""); const [isLoading, setIsLoading] = useState(false); const [activeTab, setActiveTab] = useState("new"); - const [resumeProviderFilter, setResumeProviderFilter] = useState("all"); + const [resumeProviderFilter, setResumeProviderFilter] = + useState("all"); const [resumeSearchQuery, setResumeSearchQuery] = useState(""); - const [resumeCandidates, setResumeCandidates] = useState([]); + const [resumeCandidates, setResumeCandidates] = useState( + [] + ); const [isResumeLoading, setIsResumeLoading] = useState(false); const [resumeError, setResumeError] = useState(null); + const [repoInfo, setRepoInfo] = useState(null); + const [repoInfoStatus, setRepoInfoStatus] = useState< + "idle" | "loading" | "ready" | "error" + >("idle"); + const [repoInfoError, setRepoInfoError] = useState(null); const pendingRequestIdRef = useRef(null); + const repoInfoRequestIdRef = useRef(null); const pendingNavigationAgentIdRef = useRef(null); @@ -138,7 +183,8 @@ export function CreateAgentModal({ [] ); const getProviderLabel = useCallback( - (provider: AgentProvider) => providerDefinitionMap.get(provider)?.label ?? provider, + (provider: AgentProvider) => + providerDefinitionMap.get(provider)?.label ?? provider, [] ); const agentDefinition = providerDefinitionMap.get(selectedProvider); @@ -161,7 +207,10 @@ export function CreateAgentModal({ const query = resumeSearchQuery.trim().toLowerCase(); return resumeCandidates .filter((candidate) => !activeSessionIds.has(candidate.sessionId)) - .filter((candidate) => providerFilter === "all" || candidate.provider === providerFilter) + .filter( + (candidate) => + providerFilter === "all" || candidate.provider === providerFilter + ) .filter((candidate) => { if (query.length === 0) { return true; @@ -171,7 +220,12 @@ export function CreateAgentModal({ return titleText.includes(query) || cwdText.includes(query); }) .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()); - }, [activeSessionIds, resumeCandidates, resumeProviderFilter, resumeSearchQuery]); + }, [ + activeSessionIds, + resumeCandidates, + resumeProviderFilter, + resumeSearchQuery, + ]); useEffect(() => { if (!agentDefinition) { @@ -196,12 +250,23 @@ export function CreateAgentModal({ const resetFormState = useCallback(() => { setWorkingDir(""); - setWorktreeName(""); + setInitialPrompt(""); setSelectedProvider(DEFAULT_PROVIDER); setSelectedMode(DEFAULT_MODE_FOR_DEFAULT_PROVIDER); + setBaseBranch(""); + setCreateNewBranch(false); + setBranchName(""); + setCreateWorktree(false); + setWorktreeSlug(""); + setBranchNameEdited(false); + setWorktreeSlugEdited(false); setErrorMessage(""); setIsLoading(false); + setRepoInfo(null); + setRepoInfoStatus("idle"); + setRepoInfoError(null); pendingRequestIdRef.current = null; + repoInfoRequestIdRef.current = null; }, []); const navigateToAgentIfNeeded = useCallback(() => { @@ -238,7 +303,10 @@ export function CreateAgentModal({ try { ws.send(msg); } catch (error) { - console.error("[CreateAgentModal] Failed to request persisted agents:", error); + console.error( + "[CreateAgentModal] Failed to request persisted agents:", + error + ); setIsResumeLoading(false); setResumeError("Unable to load saved agents. Please try again."); } @@ -248,9 +316,12 @@ export function CreateAgentModal({ useEffect(() => { if (!isVisible) { - console.log("[CreateAgentModal] visibility effect skipped (isVisible is false)", { - isMounted, - }); + console.log( + "[CreateAgentModal] visibility effect skipped (isVisible is false)", + { + isMounted, + } + ); return; } @@ -340,37 +411,123 @@ export function CreateAgentModal({ .replace(/^-+|-+$/g, ""); }, []); - const validateWorktreeName = useCallback((name: string): { valid: boolean; error?: string } => { - if (!name) { + const validateWorktreeName = useCallback( + (name: string): { valid: boolean; error?: string } => { + if (!name) { + return { valid: true }; + } + + if (name.length > 100) { + return { + valid: false, + error: "Worktree name too long (max 100 characters)", + }; + } + + if (!/^[a-z0-9-]+$/.test(name)) { + return { + valid: false, + error: "Must contain only lowercase letters, numbers, and hyphens", + }; + } + + if (name.startsWith("-") || name.endsWith("-")) { + return { valid: false, error: "Cannot start or end with a hyphen" }; + } + + if (name.includes("--")) { + return { valid: false, error: "Cannot have consecutive hyphens" }; + } + return { valid: true }; - } - - if (name.length > 100) { - return { valid: false, error: "Worktree name too long (max 100 characters)" }; - } - - if (!/^[a-z0-9-]+$/.test(name)) { - return { - valid: false, - error: "Must contain only lowercase letters, numbers, and hyphens", - }; - } - - if (name.startsWith("-") || name.endsWith("-")) { - return { valid: false, error: "Cannot start or end with a hyphen" }; - } - - if (name.includes("--")) { - return { valid: false, error: "Cannot have consecutive hyphens" }; - } - - return { valid: true }; - }, []); + }, + [] + ); const handleClose = useCallback(() => { onClose(); }, [onClose]); + useEffect(() => { + const slug = slugifyWorktreeName(initialPrompt); + if (!branchNameEdited) { + setBranchName(slug); + } + if (!worktreeSlugEdited) { + setWorktreeSlug(slug); + } + }, [initialPrompt, branchNameEdited, worktreeSlugEdited, slugifyWorktreeName]); + + const requestRepoInfo = useCallback( + (path: string) => { + const trimmed = path.trim(); + if (!trimmed) { + setRepoInfo(null); + setRepoInfoStatus("idle"); + setRepoInfoError(null); + repoInfoRequestIdRef.current = null; + return; + } + + const requestId = generateMessageId(); + repoInfoRequestIdRef.current = requestId; + setRepoInfoStatus("loading"); + setRepoInfoError(null); + + ws.send({ + type: "session", + message: { + type: "git_repo_info_request", + cwd: trimmed, + requestId, + }, + }); + }, + [ws] + ); + + useEffect(() => { + requestRepoInfo(workingDir); + }, [requestRepoInfo, workingDir]); + + useEffect(() => { + const unsubscribe = ws.on("git_repo_info_response", (message) => { + if (message.type !== "git_repo_info_response") { + return; + } + + if ( + repoInfoRequestIdRef.current && + message.payload.requestId && + message.payload.requestId !== repoInfoRequestIdRef.current + ) { + return; + } + + if (message.payload.error) { + setRepoInfo(null); + setRepoInfoStatus("error"); + setRepoInfoError(message.payload.error); + return; + } + + setRepoInfo({ + cwd: message.payload.cwd, + repoRoot: message.payload.repoRoot, + branches: message.payload.branches ?? [], + currentBranch: message.payload.currentBranch ?? null, + isDirty: Boolean(message.payload.isDirty), + }); + setRepoInfoStatus("ready"); + setRepoInfoError(null); + setBaseBranch((prev) => prev || message.payload.currentBranch || ""); + }); + + return () => { + unsubscribe(); + }; + }, [ws]); + const handleCreate = useCallback(async () => { const trimmedPath = workingDir.trim(); if (!trimmedPath) { @@ -378,20 +535,23 @@ export function CreateAgentModal({ return; } + const trimmedPrompt = initialPrompt.trim(); + if (!trimmedPrompt) { + setErrorMessage("Initial prompt is required"); + return; + } + if (isLoading) { return; } - const worktree = worktreeName.trim(); - - if (worktree) { - const validation = validateWorktreeName(worktree); - if (!validation.valid) { - setErrorMessage(`Invalid worktree name: ${validation.error}`); - return; - } + if (gitBlockingError) { + setErrorMessage(gitBlockingError); + return; } + const trimmedBaseBranch = baseBranch.trim(); + try { await addRecentPath(trimmedPath); } catch (error) { @@ -404,7 +564,8 @@ export function CreateAgentModal({ setIsLoading(true); setErrorMessage(""); - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + const modeId = + modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; const config: AgentSessionConfig = { provider: selectedProvider, @@ -412,10 +573,27 @@ export function CreateAgentModal({ ...(modeId ? { modeId } : {}), }; + const gitOptions = + createNewBranch || createWorktree || trimmedBaseBranch + ? { + ...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}), + ...(createNewBranch + ? { createNewBranch: true, newBranchName: branchName.trim() } + : {}), + ...(createWorktree + ? { + createWorktree: true, + worktreeSlug: (worktreeSlug || branchName).trim(), + } + : {}), + } + : undefined; + try { createAgent({ config, - worktreeName: worktree || undefined, + initialPrompt: trimmedPrompt, + git: gitOptions, requestId, }); } catch (error) { @@ -426,7 +604,13 @@ export function CreateAgentModal({ } }, [ workingDir, - worktreeName, + initialPrompt, + baseBranch, + createNewBranch, + branchName, + createWorktree, + worktreeSlug, + repoInfo, selectedMode, modeOptions, selectedProvider, @@ -435,7 +619,7 @@ export function CreateAgentModal({ addRecentPath, createAgent, ]); - + const handleResumeCandidatePress = useCallback( (candidate: ResumeCandidate) => { if (isLoading) { @@ -462,16 +646,20 @@ export function CreateAgentModal({ > - {item.title} + {getResumePreview(item)} + + + {formatRelativeTime(item.lastActivityAt)} - {formatRelativeTime(item.lastActivityAt)} {item.cwd} - {getProviderLabel(item.provider)} + + {getProviderLabel(item.provider)} + Tap to resume @@ -492,6 +680,7 @@ export function CreateAgentModal({ title: item.title ?? `Session ${item.sessionId.slice(0, 8)}`, lastActivityAt: new Date(item.lastActivityAt), persistence: item.persistence, + timeline: item.timeline ?? [], })) as ResumeCandidate[]; setResumeCandidates(mapped); @@ -509,9 +698,30 @@ export function CreateAgentModal({ return; } - const payload = message.payload as { status: string; agentId?: string; requestId?: string }; + const payload = message.payload as { + status: string; + agentId?: string; + requestId?: string; + error?: string; + }; + + if (payload.status === "agent_create_failed") { + const expectedRequestId = pendingRequestIdRef.current; + if (!expectedRequestId || payload.requestId !== expectedRequestId) { + return; + } + pendingRequestIdRef.current = null; + setIsLoading(false); + setErrorMessage( + payload.error ?? + "Failed to create agent. Resolve git issues or try again." + ); + return; + } + if ( - (payload.status !== "agent_created" && payload.status !== "agent_resumed") || + (payload.status !== "agent_created" && + payload.status !== "agent_resumed") || !payload.agentId ) { return; @@ -538,21 +748,80 @@ export function CreateAgentModal({ if (!isVisible || activeTab !== "resume") { return; } - const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter; + const provider = + resumeProviderFilter === "all" ? undefined : resumeProviderFilter; requestResumeCandidates(provider); }, [activeTab, isVisible, requestResumeCandidates, resumeProviderFilter]); const refreshResumeList = useCallback(() => { - const provider = resumeProviderFilter === "all" ? undefined : resumeProviderFilter; + const provider = + resumeProviderFilter === "all" ? undefined : resumeProviderFilter; requestResumeCandidates(provider); }, [requestResumeCandidates, resumeProviderFilter]); const shouldRender = isVisible || isMounted; + const gitBlockingError = useMemo(() => { + const trimmedBase = baseBranch.trim(); + const requiresBase = + createNewBranch || createWorktree || trimmedBase.length > 0; + + if (requiresBase && !trimmedBase) { + return "Select a base branch before launching the agent"; + } + + if (createNewBranch) { + const slug = branchName.trim(); + const validation = validateWorktreeName(slug); + if (!slug || !validation.valid) { + return `Invalid branch name: ${validation.error ?? "Must use lowercase letters, numbers, or hyphens"}`; + } + } + + if (createWorktree) { + const slug = (worktreeSlug || branchName).trim(); + const validation = validateWorktreeName(slug); + if (!slug || !validation.valid) { + return `Invalid worktree name: ${validation.error ?? "Must use lowercase letters, numbers, or hyphens"}`; + } + } + + if (!createWorktree && repoInfo?.isDirty) { + const intendsCheckout = + createNewBranch || + (trimmedBase.length > 0 && trimmedBase !== repoInfo.currentBranch); + if (intendsCheckout) { + return "Working directory has uncommitted changes. Clean up or create a worktree first."; + } + } + + return null; + }, [ + baseBranch, + branchName, + createNewBranch, + createWorktree, + repoInfo, + validateWorktreeName, + worktreeSlug, + ]); + const workingDirIsEmpty = !workingDir.trim(); - const headerPaddingTop = useMemo(() => insets.top + defaultTheme.spacing[4], [insets.top]); - const horizontalPaddingLeft = useMemo(() => defaultTheme.spacing[6] + insets.left, [insets.left]); - const horizontalPaddingRight = useMemo(() => defaultTheme.spacing[6] + insets.right, [insets.right]); + const promptIsEmpty = !initialPrompt.trim(); + const createDisabled = + workingDirIsEmpty || promptIsEmpty || Boolean(gitBlockingError) || isLoading; + const headerPaddingTop = useMemo( + () => insets.top + defaultTheme.spacing[4], + [insets.top] + ); + const horizontalPaddingLeft = useMemo( + () => defaultTheme.spacing[6] + insets.left, + [insets.left] + ); + const horizontalPaddingRight = useMemo( + () => defaultTheme.spacing[6] + insets.right, + [insets.right] + ); const handleSheetLayout = useCallback((event: LayoutChangeEvent) => { const { height, y } = event.nativeEvent.layout; @@ -560,17 +829,17 @@ export function CreateAgentModal({ }, []); if (!shouldRender) { - console.log("[CreateAgentModal] render skipped", { - isVisible, - isMounted, - }); + // console.log("[CreateAgentModal] render skipped", { + // isVisible, + // isMounted, + // }); return null; } - console.log("[CreateAgentModal] rendering modal", { - isVisible, - isMounted, - }); + // console.log("[CreateAgentModal] rendering modal", { + // isVisible, + // isMounted, + // }); return ( setActiveTab(tab.id)} - style={[styles.tabButton, isActive && styles.tabButtonActive]} + style={[ + styles.tabButton, + isActive && styles.tabButtonActive, + ]} > - {tab.label} + + {tab.label} + ); })} @@ -667,15 +948,53 @@ export function CreateAgentModal({ /> - { - const slugified = slugifyWorktreeName(text); - setWorktreeName(slugified); + setInitialPrompt(text); setErrorMessage(""); }} /> + + { + setBaseBranch(value); + setErrorMessage(""); + }} + branches={repoInfo?.branches ?? []} + status={repoInfoStatus} + repoError={repoInfoError} + warning={!createWorktree && repoInfo?.isDirty ? "Working directory has uncommitted changes" : null} + createNewBranch={createNewBranch} + onToggleCreateNewBranch={(next) => { + setCreateNewBranch(next); + if (!next) { + setBranchName(""); + setBranchNameEdited(false); + } + }} + branchName={branchName} + onBranchNameChange={(value) => { + setBranchName(slugifyWorktreeName(value)); + setBranchNameEdited(true); + }} + createWorktree={createWorktree} + onToggleCreateWorktree={(next) => { + setCreateWorktree(next); + if (!next) { + setWorktreeSlug(""); + setWorktreeSlugEdited(false); + } + }} + worktreeSlug={worktreeSlug} + onWorktreeSlugChange={(value) => { + setWorktreeSlug(slugifyWorktreeName(value)); + setWorktreeSlugEdited(true); + }} + gitValidationError={gitBlockingError} + /> {isLoading ? ( - + Creating... ) : ( @@ -727,7 +1048,10 @@ export function CreateAgentModal({ setResumeProviderFilter(option.id)} - style={[styles.providerFilterButton, isActive && styles.providerFilterButtonActive]} + style={[ + styles.providerFilterButton, + isActive && styles.providerFilterButtonActive, + ]} > - + Refresh - {resumeError ? {resumeError} : null} + {resumeError ? ( + {resumeError} + ) : null} {isResumeLoading ? ( - - Loading saved agents... + + + Loading saved agents... + ) : filteredResumeCandidates.length === 0 ? ( No agents found - We'll load the latest Claude and Codex sessions from your local history. + We'll load the latest Claude and Codex sessions from your + local history. - + Try Again @@ -774,8 +1112,12 @@ export function CreateAgentModal({ `${item.provider}:${item.sessionId}`} - ItemSeparatorComponent={() => } + keyExtractor={(item) => + `${item.provider}:${item.sessionId}` + } + ItemSeparatorComponent={() => ( + + )} showsVerticalScrollIndicator={false} contentContainerStyle={styles.resumeListContent} /> @@ -797,7 +1139,13 @@ interface ModalHeaderProps { title: string; } -function ModalHeader({ paddingTop, paddingLeft, paddingRight, onClose, title }: ModalHeaderProps): ReactElement { +function ModalHeader({ + paddingTop, + paddingLeft, + paddingRight, + onClose, + title, +}: ModalHeaderProps): ReactElement { return ( {title} @@ -836,7 +1184,9 @@ function WorkingDirectorySection({ autoCorrect={false} editable={!isLoading} /> - {errorMessage ? {errorMessage} : null} + {errorMessage ? ( + {errorMessage} + ) : null} {recentPaths.length > 0 ? ( {recentPaths.map((path) => ( - onChangeWorkingDir(path)}> + onChangeWorkingDir(path)} + > {path} @@ -873,7 +1227,9 @@ function AssistantSelector({ onSelect, }: AssistantSelectorProps): ReactElement { return ( - + Assistant {providerDefinitions.map((definition) => { @@ -893,7 +1249,9 @@ function AssistantSelector({ {isSelected ? : null} @@ -924,10 +1282,14 @@ function ModeSelector({ onSelect, }: ModeSelectorProps): ReactElement { return ( - + Permissions {modeOptions.length === 0 ? ( - This assistant does not expose selectable permissions. + + This assistant does not expose selectable permissions. + ) : ( {modeOptions.map((mode) => { @@ -947,14 +1309,20 @@ function ModeSelector({ {isSelected ? : null} {mode.label} - {mode.description ? {mode.description} : null} + {mode.description ? ( + + {mode.description} + + ) : null} @@ -966,33 +1334,203 @@ function ModeSelector({ ); } -interface WorktreeSectionProps { +interface PromptSectionProps { value: string; isLoading: boolean; onChange: (value: string) => void; } -function WorktreeSection({ value, isLoading, onChange }: WorktreeSectionProps): ReactElement { +function PromptSection({ value, isLoading, onChange }: PromptSectionProps): ReactElement { return ( - Worktree Name (Optional) + Initial Prompt - - Create a git worktree for isolated development. Must be lowercase, alphanumeric, and hyphens only. - ); } +interface GitOptionsSectionProps { + baseBranch: string; + onBaseBranchChange: (value: string) => void; + branches: Array<{ name: string; isCurrent: boolean }>; + status: "idle" | "loading" | "ready" | "error"; + repoError: string | null; + warning: string | null; + createNewBranch: boolean; + onToggleCreateNewBranch: (value: boolean) => void; + branchName: string; + onBranchNameChange: (value: string) => void; + createWorktree: boolean; + onToggleCreateWorktree: (value: boolean) => void; + worktreeSlug: string; + onWorktreeSlugChange: (value: string) => void; + gitValidationError: string | null; +} + +function GitOptionsSection({ + baseBranch, + onBaseBranchChange, + branches, + status, + repoError, + warning, + createNewBranch, + onToggleCreateNewBranch, + branchName, + onBranchNameChange, + createWorktree, + onToggleCreateWorktree, + worktreeSlug, + onWorktreeSlugChange, + gitValidationError, +}: GitOptionsSectionProps): ReactElement { + const suggestedBranches = branches.slice(0, 6); + const currentBranchLabel = branches.find((branch) => branch.isCurrent)?.name; + + return ( + + Git Setup + + Choose a base branch, then optionally create a feature branch or isolated worktree. + + + + + {status === "loading" ? ( + Inspecting repository… + ) : null} + {repoError ? {repoError} : null} + {warning && !gitValidationError ? ( + {warning} + ) : null} + + {suggestedBranches.length > 0 ? ( + + {suggestedBranches.map((branch) => { + const isActive = branch.name === baseBranch; + return ( + onBaseBranchChange(branch.name)} + > + + {branch.name} + + + ); + })} + + ) : null} + + + {createNewBranch ? ( + + ) : null} + + + {createWorktree ? ( + + ) : null} + + {gitValidationError ? ( + {gitValidationError} + ) : null} + + ); +} + +interface ToggleRowProps { + label: string; + description?: string; + value: boolean; + onToggle: (value: boolean) => void; + disabled?: boolean; +} + +function ToggleRow({ label, description, value, onToggle, disabled }: ToggleRowProps): ReactElement { + return ( + { + if (!disabled) { + onToggle(!value); + } + }} + style={[styles.toggleRow, disabled && styles.toggleRowDisabled]} + > + + {value ? : null} + + + {label} + {description ? {description} : null} + + + ); +} + const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1, @@ -1098,6 +1636,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.base, }, + promptInput: { + minHeight: theme.spacing[24], + textAlignVertical: "top", + }, inputDisabled: { opacity: theme.opacity[50], }, @@ -1105,6 +1647,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.palette.red[500], fontSize: theme.fontSize.sm, }, + warningText: { + color: theme.colors.palette.orange[400], + fontSize: theme.fontSize.sm, + }, helperText: { color: theme.colors.mutedForeground, fontSize: theme.fontSize.sm, @@ -1128,6 +1674,31 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, + branchChipRow: { + flexDirection: "row", + gap: theme.spacing[2], + paddingVertical: theme.spacing[2], + }, + branchChip: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.full, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[1], + backgroundColor: theme.colors.muted, + }, + branchChipActive: { + borderColor: theme.colors.palette.blue[500], + backgroundColor: theme.colors.palette.blue[500], + }, + branchChipText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, + branchChipTextActive: { + color: theme.colors.palette.white, + fontWeight: theme.fontWeight.semibold, + }, selectorRow: { flexDirection: "row", gap: theme.spacing[4], @@ -1178,6 +1749,46 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + toggleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + toggleRowDisabled: { + opacity: theme.opacity[50], + }, + checkbox: { + width: 22, + height: 22, + borderRadius: theme.borderRadius.sm, + borderWidth: theme.borderWidth[2], + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + checkboxChecked: { + borderColor: theme.colors.palette.blue[500], + backgroundColor: theme.colors.palette.blue[500], + }, + checkboxDisabled: { + borderColor: theme.colors.border, + }, + checkboxDot: { + width: 10, + height: 10, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.palette.white, + }, + toggleTextContainer: { + flex: 1, + gap: theme.spacing[1], + }, + toggleLabel: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.semibold, + }, radioOuterSelected: { borderColor: theme.colors.palette.blue[500], }, diff --git a/packages/app/src/components/global-footer.tsx b/packages/app/src/components/global-footer.tsx index e0d571382..fb89c4d90 100644 --- a/packages/app/src/components/global-footer.tsx +++ b/packages/app/src/components/global-footer.tsx @@ -77,6 +77,9 @@ export function GlobalFooter() { ? FOOTER_HEIGHT * 2 + insets.bottom : FOOTER_HEIGHT + insets.bottom; + const iconSize = 24; + const iconStyle = { width: iconSize, height: iconSize }; + return ( <> - + + + Agents @@ -124,7 +133,13 @@ export function GlobalFooter() { pressed && styles.buttonPressed, ]} > - + + + New Agent @@ -137,7 +152,13 @@ export function GlobalFooter() { pressed && !ws.isConnected && styles.buttonPressed, ]} > - + + + Realtime @@ -180,6 +201,12 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[3], gap: theme.spacing[1], }, + footerIconWrapper: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, footerButtonText: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index 7ee9e7dcd..a43d22f82 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -8,9 +8,10 @@ import { ScreenHeader } from "./screen-header"; interface BackHeaderProps { title?: string; rightContent?: ReactNode; + onBack?: () => void; } -export function BackHeader({ title, rightContent }: BackHeaderProps) { +export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { const { theme } = useUnistyles(); return ( @@ -18,7 +19,7 @@ export function BackHeader({ title, rightContent }: BackHeaderProps) { left={ <> router.back()} + onPress={onBack ?? (() => router.back())} style={styles.backButton} > diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index d14074b7f..792e3c6ae 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -21,7 +21,7 @@ import { Brain, Search, } from "lucide-react-native"; -import { StyleSheet } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { baseColors, theme } from "@/styles/theme"; import { Colors } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; @@ -166,6 +166,11 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, lineHeight: 24, }, + markdownText: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: 22, + }, markdownParagraph: { marginTop: 0, marginBottom: theme.spacing[2], @@ -241,22 +246,6 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ }, })); -const markdownStyles = { - body: assistantMessageStylesheet.markdownBody, - paragraph: assistantMessageStylesheet.markdownParagraph, - strong: assistantMessageStylesheet.markdownStrong, - em: assistantMessageStylesheet.markdownEm, - code_inline: assistantMessageStylesheet.markdownCodeInline, - code_block: assistantMessageStylesheet.markdownCodeBlock, - fence: assistantMessageStylesheet.markdownFence, - link: assistantMessageStylesheet.markdownLink, - bullet_list: assistantMessageStylesheet.markdownList, - ordered_list: assistantMessageStylesheet.markdownList, - list_item: assistantMessageStylesheet.markdownListItem, - blockquote: assistantMessageStylesheet.markdownBlockquote, - blockquote_text: assistantMessageStylesheet.markdownBlockquoteText, -}; - function isLikelyPathToken(value: string): boolean { if (!value || value.length > 300) { return false; @@ -347,9 +336,87 @@ export const AssistantMessage = memo(function AssistantMessage({ isStreaming = false, onInlinePathPress, }: AssistantMessageProps) { + const { theme } = useUnistyles(); const fadeAnim = useRef(new Animated.Value(0.3)).current; const lastPathRef = useRef(null); + const markdownStyles = useMemo( + () => ({ + body: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + lineHeight: 24, + }, + text: { + color: theme.colors.foreground, + }, + paragraph: { + marginTop: 0, + marginBottom: theme.spacing[2], + }, + strong: { + fontWeight: theme.fontWeight.bold, + }, + em: { + fontStyle: "italic" as const, + }, + code_inline: { + backgroundColor: theme.colors.secondary, + color: theme.colors.secondaryForeground, + paddingHorizontal: theme.spacing[2], + paddingVertical: 2, + borderRadius: theme.borderRadius.sm, + fontFamily: "monospace", + fontSize: 13, + }, + code_block: { + backgroundColor: theme.colors.secondary, + color: theme.colors.secondaryForeground, + padding: theme.spacing[3], + borderRadius: theme.borderRadius.md, + fontFamily: "monospace", + fontSize: 13, + }, + fence: { + backgroundColor: theme.colors.secondary, + borderColor: theme.colors.border, + color: theme.colors.secondaryForeground, + padding: theme.spacing[3], + borderRadius: theme.borderRadius.md, + marginVertical: theme.spacing[2], + fontFamily: "monospace", + fontSize: 13, + }, + link: { + color: theme.colors.primary, + textDecorationLine: "underline" as const, + }, + bullet_list: { + marginBottom: theme.spacing[2], + }, + ordered_list: { + marginBottom: theme.spacing[2], + }, + list_item: { + marginBottom: theme.spacing[1], + }, + blockquote: { + backgroundColor: theme.colors.secondary, + borderLeftWidth: 4, + borderLeftColor: theme.colors.primary, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + marginVertical: theme.spacing[2], + borderRadius: theme.borderRadius.sm, + }, + blockquote_text: { + color: theme.colors.foreground, + fontStyle: "italic" as const, + }, + }), + [theme] + ); + const markdownRules = useMemo(() => { if (!onInlinePathPress) { return undefined; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 13247f273..2d967494a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -9,6 +9,7 @@ import type { AgentStreamEventPayload, SessionInboundMessage, WSInboundMessage, + GitSetupOptions, } from "@server/server/messages"; import type { AgentLifecycleStatus, @@ -83,6 +84,7 @@ export interface Agent { status: AgentLifecycleStatus; createdAt: Date; updatedAt: Date; + lastUserMessageAt: Date | null; lastActivityAt: Date; sessionId: string | null; capabilities: AgentCapabilityFlags; @@ -144,6 +146,8 @@ export interface AgentFileExplorerState { lastError: string | null; pendingRequest: ExplorerRequestState | null; currentPath: string; + history: string[]; + lastVisitedPath: string; } const createExplorerState = (): AgentFileExplorerState => ({ @@ -153,17 +157,30 @@ const createExplorerState = (): AgentFileExplorerState => ({ lastError: null, pendingRequest: null, currentPath: ".", + history: ["."], + lastVisitedPath: ".", }); +const pushHistory = (history: string[], path: string): string[] => { + const normalizedHistory = history.length === 0 ? ["."] : history; + const last = normalizedHistory[normalizedHistory.length - 1]; + if (last === path) { + return normalizedHistory; + } + return [...normalizedHistory, path]; +}; + function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent { const createdAt = new Date(snapshot.createdAt); const updatedAt = new Date(snapshot.updatedAt); + const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null; return { id: snapshot.id, provider: snapshot.provider, status: snapshot.status as AgentLifecycleStatus, createdAt, updatedAt, + lastUserMessageAt, lastActivityAt: updatedAt, sessionId: snapshot.sessionId, capabilities: snapshot.capabilities, @@ -202,6 +219,12 @@ interface SessionContextValue { setAgentStreamState: (state: Map | ((prev: Map) => Map)) => void; initializingAgents: Map; + // Queued messages and draft input per agent + draftInputs: Map }>; + setDraftInputs: (value: Map }> | ((prev: Map }>) => Map }>)) => void; + queuedMessages: Map }>>; + setQueuedMessages: (value: Map }>> | ((prev: Map }>>) => Map }>>)) => void; + // Agents and commands agents: Map; setAgents: (agents: Map | ((prev: Map) => Map)) => void; @@ -218,17 +241,25 @@ interface SessionContextValue { // File explorer fileExplorer: Map; - requestDirectoryListing: (agentId: string, path: string) => void; + requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void; requestFilePreview: (agentId: string, path: string) => void; + navigateExplorerBack: (agentId: string) => string | null; // Helpers + restartServer: (reason?: string) => void; initializeAgent: (params: { agentId: string; requestId?: string }) => void; refreshAgent: (params: { agentId: string; requestId?: string }) => void; cancelAgentRun: (agentId: string) => void; sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise; sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise; 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; requestId?: string }) => void; setAgentMode: (agentId: string, modeId: string) => void; respondToPermission: (agentId: string, requestId: string, response: AgentPermissionResponse) => void; @@ -274,7 +305,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const [pendingPermissions, setPendingPermissions] = useState>(new Map()); const [gitDiffs, setGitDiffs] = useState>(new Map()); const [fileExplorer, setFileExplorer] = useState>(new Map()); + const [draftInputs, setDraftInputs] = useState(new Map()); + const [queuedMessages, setQueuedMessages] = useState(new Map()); const activeAudioGroupsRef = useRef>(new Set()); + const previousAgentStatusRef = useRef>(new Map()); // Buffer for streaming audio chunks interface AudioChunk { @@ -354,14 +388,42 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { setAgents(nextAgents); setPendingPermissions(nextPermissions); setCommands(new Map(commandsList.map((c) => [c.id, c as Command]))); - setAgentStreamState(() => { - const next = new Map(); - for (const snapshot of agentsList) { - next.set(snapshot.id, []); + setAgentStreamState((prev) => { + if (prev.size === 0) { + return prev; } - return next; + + const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id)); + let changed = false; + const next = new Map(prev); + + for (const agentId of prev.keys()) { + if (!validAgentIds.has(agentId)) { + next.delete(agentId); + changed = true; + } + } + + return changed ? next : prev; + }); + setInitializingAgents((prev) => { + if (prev.size === 0) { + return prev; + } + + const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id)); + let changed = false; + const next = new Map(prev); + + for (const agentId of prev.keys()) { + if (!validAgentIds.has(agentId)) { + next.delete(agentId); + changed = true; + } + } + + return changed ? next : prev; }); - setInitializingAgents(new Map()); }); const unsubAgentState = ws.on("agent_state", (message) => { @@ -948,28 +1010,6 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [ws]); - const cancelAgentRun = useCallback((agentId: string) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "cancel_agent_request", - agentId, - }, - }; - ws.send(msg); - }, [ws]); - - const deleteAgent = useCallback((agentId: string) => { - const msg: WSInboundMessage = { - type: "session", - message: { - type: "delete_agent_request", - agentId, - }, - }; - ws.send(msg); - }, [ws]); - const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => { // Generate unique message ID for deduplication const messageId = generateMessageId(); @@ -997,10 +1037,10 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { // Use FileSystem.File to read the image as base64 const file = new FileSystem.File(imageUri); const base64 = file.base64Sync(); - + // Get MIME type from the file const mimeType = file.type || 'image/jpeg'; - + imagesData.push({ data: base64, mimeType, @@ -1025,6 +1065,60 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [ws]); + // Auto-flush queued messages when an agent transitions from running -> not running + useEffect(() => { + for (const [agentId, agent] of agents.entries()) { + const prevStatus = previousAgentStatusRef.current.get(agentId); + if (prevStatus === "running" && agent.status !== "running") { + const queue = queuedMessages.get(agentId); + if (queue && queue.length > 0) { + const [next, ...rest] = queue; + void sendAgentMessage(agentId, next.text, next.images?.map((img) => img.uri)); + setQueuedMessages((prev) => { + const updated = new Map(prev); + updated.set(agentId, rest); + return updated; + }); + } + } + previousAgentStatusRef.current.set(agentId, agent.status); + } + }, [agents, queuedMessages, sendAgentMessage]); + + const cancelAgentRun = useCallback((agentId: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "cancel_agent_request", + agentId, + }, + }; + ws.send(msg); + }, [ws]); + + const deleteAgent = useCallback((agentId: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "delete_agent_request", + agentId, + }, + }; + ws.send(msg); + }, [ws]); + + const restartServer = useCallback((reason?: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "restart_server_request", + ...(reason && reason.trim().length > 0 ? { reason } : {}), + }, + }; + ws.send(msg); + }, [ws]); + + const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob, requestId?: string) => { try { // Convert blob to base64 @@ -1060,12 +1154,15 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { } }, [ws]); - const createAgent = useCallback(({ config, worktreeName, requestId }: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => { + const createAgent = useCallback(({ config, initialPrompt, git, worktreeName, requestId }: { config: AgentSessionConfig; initialPrompt: string; git?: GitSetupOptions; worktreeName?: string; requestId?: string }) => { + const trimmedPrompt = initialPrompt.trim(); const msg: WSInboundMessage = { type: "session", message: { type: "create_agent_request", config, + ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), + ...(git ? { git } : {}), ...(worktreeName ? { worktreeName } : {}), ...(requestId ? { requestId } : {}), }, @@ -1127,14 +1224,18 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [ws]); - const requestDirectoryListing = useCallback((agentId: string, path: string) => { + const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => { const normalizedPath = path && path.length > 0 ? path : "."; + const shouldRecordHistory = options?.recordHistory ?? true; + updateExplorerState(agentId, (state) => ({ ...state, isLoading: true, lastError: null, pendingRequest: { path: normalizedPath, mode: "list" }, currentPath: normalizedPath, + history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history, + lastVisitedPath: normalizedPath, })); const msg: WSInboundMessage = { @@ -1170,6 +1271,44 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [updateExplorerState, ws]); + const navigateExplorerBack = useCallback((agentId: string) => { + let targetPath: string | null = null; + + updateExplorerState(agentId, (state) => { + if (!state.history || state.history.length <= 1) { + return state; + } + + const nextHistory = state.history.slice(0, -1); + targetPath = nextHistory[nextHistory.length - 1] ?? "."; + + return { + ...state, + isLoading: true, + lastError: null, + pendingRequest: { path: targetPath, mode: "list" }, + currentPath: targetPath, + history: nextHistory, + }; + }); + + if (!targetPath) { + return null; + } + + const msg: WSInboundMessage = { + type: "session", + message: { + type: "file_explorer_request", + agentId, + path: targetPath, + mode: "list", + }, + }; + ws.send(msg); + return targetPath; + }, [updateExplorerState, ws]); + const value: SessionContextValue = { ws, audioPlayer, @@ -1194,8 +1333,14 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { gitDiffs, requestGitDiff, fileExplorer, + draftInputs, + setDraftInputs, + queuedMessages, + setQueuedMessages, requestDirectoryListing, requestFilePreview, + navigateExplorerBack, + restartServer, initializeAgent, refreshAgent, cancelAgentRun, diff --git a/packages/app/src/hooks/use-audio-player.d.ts b/packages/app/src/hooks/use-audio-player.d.ts new file mode 100644 index 000000000..432d966da --- /dev/null +++ b/packages/app/src/hooks/use-audio-player.d.ts @@ -0,0 +1 @@ +export * from "./use-audio-player.native"; diff --git a/packages/app/src/hooks/use-audio-player.ts b/packages/app/src/hooks/use-audio-player.native.ts similarity index 100% rename from packages/app/src/hooks/use-audio-player.ts rename to packages/app/src/hooks/use-audio-player.native.ts diff --git a/packages/app/src/hooks/use-audio-player.web.ts b/packages/app/src/hooks/use-audio-player.web.ts new file mode 100644 index 000000000..53884a62e --- /dev/null +++ b/packages/app/src/hooks/use-audio-player.web.ts @@ -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: () => {}, + }), + [] + ); +} diff --git a/packages/app/src/hooks/use-speechmatics-audio.d.ts b/packages/app/src/hooks/use-speechmatics-audio.d.ts new file mode 100644 index 000000000..d634fc55b --- /dev/null +++ b/packages/app/src/hooks/use-speechmatics-audio.d.ts @@ -0,0 +1 @@ +export * from "./use-speechmatics-audio.native"; diff --git a/packages/app/src/hooks/use-speechmatics-audio.ts b/packages/app/src/hooks/use-speechmatics-audio.native.ts similarity index 100% rename from packages/app/src/hooks/use-speechmatics-audio.ts rename to packages/app/src/hooks/use-speechmatics-audio.native.ts diff --git a/packages/app/src/hooks/use-speechmatics-audio.web.ts b/packages/app/src/hooks/use-speechmatics-audio.web.ts new file mode 100644 index 000000000..2d62884b7 --- /dev/null +++ b/packages/app/src/hooks/use-speechmatics-audio.web.ts @@ -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; + stop: () => Promise; + 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] + ); +} diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 8e490a373..3b808c9ec 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -35,7 +35,7 @@ function createTimelineId(prefix: string, text: string, timestamp: Date): string function createUniqueTimelineId( state: StreamItem[], - prefix: "assistant" | "thought" | "user" | "tool", + prefix: string, text: string, timestamp: Date ): string { @@ -892,9 +892,12 @@ function appendTodoList( return next; } + const idSeed = `${provider}:${JSON.stringify(normalizedItems)}`; + const entryId = createUniqueTimelineId(state, "todo", idSeed, timestamp); + const entry: TodoListItem = { kind: "todo_list", - id: createTimelineId("todo", `${provider}:${JSON.stringify(normalizedItems)}`, timestamp), + id: entryId, timestamp, provider, items: normalizedItems, diff --git a/packages/server/package.json b/packages/server/package.json index b6c106896..c6e9620a5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -4,7 +4,7 @@ "description": "Paseo backend server", "type": "module", "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", "build": "tsc -p tsconfig.server.json", "start": "NODE_ENV=production node dist/server/index.js", diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 76e8e4679..b66d3bc27 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -33,6 +33,7 @@ export type AgentSnapshot = { cwd: string; createdAt: Date; updatedAt: Date; + lastUserMessageAt: Date | null; status: AgentLifecycleStatus; sessionId: string | null; capabilities: AgentCapabilityFlags; @@ -85,6 +86,7 @@ type ManagedAgent = { lastUsage?: AgentUsage; lastError?: string; historyPrimed: boolean; + lastUserMessageAt: Date | null; }; type SubscriptionRecord = { @@ -317,12 +319,14 @@ export class AgentManager { raw: options?.raw, }; agent.updatedAt = new Date(); + agent.lastUserMessageAt = agent.updatedAt; this.recordTimeline(agent, item); this.dispatchStream(agentId, { type: "timeline", item, provider: agent.provider, }); + this.emitState(agent); } streamAgent( @@ -344,7 +348,12 @@ export class AgentManager { agent.lastError = undefined; this.emitState(agent); + let finalized = false; const finalize = (error?: string) => { + if (finalized) { + return; + } + finalized = true; agent.pendingRun = null; agent.status = error ? "error" : "idle"; agent.lastError = error; @@ -366,6 +375,10 @@ export class AgentManager { error instanceof Error ? error.message : "Agent stream failed"; finalize(message); 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()); } + async primeAgentHistory(agentId: string): Promise { + const agent = this.requireAgent(agentId); + await this.primeHistory(agent); + } + private async registerSession( session: AgentSession, config: AgentSessionConfig, @@ -430,6 +448,7 @@ export class AgentManager { timeline: [], persistence: session.describePersistence(), historyPrimed: false, + lastUserMessageAt: null, }; this.agents.set(agentId, managed); @@ -438,7 +457,6 @@ export class AgentManager { await this.refreshSessionState(managed); managed.status = "idle"; this.emitState(managed); - void this.primeHistory(managed); return this.toSnapshot(managed); } @@ -473,7 +491,7 @@ export class AgentManager { agent.historyPrimed = true; try { for await (const event of agent.session.streamHistory()) { - this.handleStreamEvent(agent, event); + this.handleStreamEvent(agent, event, { fromHistory: true }); } } catch { // ignore history failures @@ -482,7 +500,8 @@ export class AgentManager { private handleStreamEvent( agent: ManagedAgent, - event: AgentStreamEvent + event: AgentStreamEvent, + options?: { fromHistory?: boolean } ): void { agent.updatedAt = new Date(); @@ -492,6 +511,13 @@ export class AgentManager { break; case "timeline": this.recordTimeline(agent, event.item); + if ( + !options?.fromHistory && + event.item.type === "user_message" + ) { + agent.lastUserMessageAt = new Date(); + this.emitState(agent); + } break; case "turn_completed": agent.lastUsage = event.usage; @@ -557,6 +583,7 @@ export class AgentManager { cwd: agent.cwd, createdAt: agent.createdAt, updatedAt: agent.updatedAt, + lastUserMessageAt: agent.lastUserMessageAt, status: agent.status, sessionId: agent.sessionId, capabilities: agent.capabilities, diff --git a/packages/server/src/server/agent/agent-registry.test.ts b/packages/server/src/server/agent/agent-registry.test.ts index 2d3b24f92..b1375ac36 100644 --- a/packages/server/src/server/agent/agent-registry.test.ts +++ b/packages/server/src/server/agent/agent-registry.test.ts @@ -14,6 +14,7 @@ function createSnapshot(overrides?: Partial): AgentSnapshot { cwd: "/tmp/project", createdAt: now, updatedAt: now, + lastUserMessageAt: null, status: "idle", sessionId: null, capabilities: { diff --git a/packages/server/src/server/agent/agent-registry.ts b/packages/server/src/server/agent/agent-registry.ts index fd916af91..9f4258fe6 100644 --- a/packages/server/src/server/agent/agent-registry.ts +++ b/packages/server/src/server/agent/agent-registry.ts @@ -33,6 +33,8 @@ const STORED_AGENT_SCHEMA = z.object({ cwd: z.string(), createdAt: z.string(), updatedAt: z.string(), + lastActivityAt: z.string().optional(), + lastUserMessageAt: z.string().nullable().optional(), title: z.string().nullable().optional(), lastStatus: z.string().nullable().optional(), lastModeId: z.string().nullable().optional(), @@ -121,6 +123,8 @@ export class AgentRegistry { cwd, createdAt: existing?.createdAt ?? now, updatedAt: now, + lastActivityAt: existing?.lastActivityAt ?? existing?.updatedAt ?? now, + lastUserMessageAt: existing?.lastUserMessageAt ?? null, title: existing?.title ?? null, lastStatus: existing?.lastStatus ?? null, lastModeId: nextModeId, @@ -138,15 +142,19 @@ export class AgentRegistry { if (!existing) { const record: StoredAgentRecord = { id: snapshot.id, - provider: snapshot.provider, - cwd: snapshot.cwd, - createdAt: now, - updatedAt: now, - title: null, - lastStatus: snapshot.status, - lastModeId: snapshot.currentModeId ?? null, - config: null, - persistence: snapshot.persistence ?? null, + provider: snapshot.provider, + cwd: snapshot.cwd, + createdAt: now, + updatedAt: now, + lastActivityAt: snapshot.updatedAt.toISOString(), + lastUserMessageAt: snapshot.lastUserMessageAt + ? snapshot.lastUserMessageAt.toISOString() + : null, + title: null, + lastStatus: snapshot.status, + lastModeId: snapshot.currentModeId ?? null, + config: null, + persistence: snapshot.persistence ?? null, }; this.cache.set(snapshot.id, record); await this.flush(); @@ -157,6 +165,10 @@ export class AgentRegistry { provider: snapshot.provider, cwd: snapshot.cwd, updatedAt: now, + lastActivityAt: snapshot.updatedAt.toISOString(), + lastUserMessageAt: snapshot.lastUserMessageAt + ? snapshot.lastUserMessageAt.toISOString() + : existing.lastUserMessageAt ?? null, lastStatus: snapshot.status, lastModeId: snapshot.currentModeId ?? null, persistence: snapshot.persistence ?? existing.persistence ?? null, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 2d7d89aeb..48ff8c0f8 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -126,6 +126,7 @@ export type PersistedAgentDescriptor = { title: string | null; lastActivityAt: Date; persistence: AgentPersistenceHandle; + timeline: AgentTimelineItem[]; }; export type AgentSessionConfig = { diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index da2378388..3fef065ba 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -24,10 +24,7 @@ import { curateAgentActivity } from "./activity-curator.js"; import { AGENT_PROVIDER_DEFINITIONS } from "./provider-manifest.js"; import { AgentRegistry } from "./agent-registry.js"; import { createWorktree } from "../../utils/worktree.js"; -import { - WaitForAgentTracker, - type WaitForAgentCanceler, -} from "./wait-for-agent-tracker.js"; +import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; export interface AgentMcpServerOptions { agentManager: AgentManager; @@ -229,6 +226,7 @@ export async function createAgentMcpServer( options: AgentMcpServerOptions ): Promise { const { agentManager, agentRegistry } = options; + const waitTracker = new WaitForAgentTracker(); const server = new McpServer({ name: "agent-mcp", @@ -289,6 +287,7 @@ export async function createAgentMcpServer( const worktree = await createWorktree({ branchName: worktreeName, cwd: resolvedCwd, + worktreeSlug: worktreeName, }); resolvedCwd = worktree.worktreePath; } diff --git a/packages/server/src/server/agent/providers/claude-agent.test.ts b/packages/server/src/server/agent/providers/claude-agent.test.ts index 3998f6c1d..be0442afd 100644 --- a/packages/server/src/server/agent/providers/claude-agent.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.test.ts @@ -41,11 +41,12 @@ describe("ClaudeAgentClient (SDK integration)", () => { }; const session = await client.createSession(config); + const marker = "CLAUDE_ACK_TOKEN"; 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(); rmSync(cwd, { recursive: true, force: true }); @@ -89,6 +90,85 @@ describe("ClaudeAgentClient (SDK integration)", () => { 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 => + 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( "tracks permission + tool lifecycle when editing a file", async () => { @@ -384,46 +464,40 @@ describe("ClaudeAgentClient (SDK integration)", () => { assertHydratedReplica( commandTool!, hydratedMap, - (data) => (data.parsedCommand?.output ?? "").includes(cwd), + (data) => + rawContainsText(data.raw, cwd) || + rawContainsText(data.result, cwd), ({ live, hydrated }) => { - expect((live.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd"); - expect(live.parsedCommand?.output ?? "").toContain(cwd); - expect((hydrated.parsedCommand?.command ?? "").toLowerCase()).toContain("pwd"); - expect(hydrated.parsedCommand?.output ?? "").toContain(cwd); + expect(rawContainsText(live.raw, cwd)).toBe(true); + expect(rawContainsText(hydrated.raw, cwd)).toBe(true); + expect((live.displayName ?? "").toLowerCase()).toContain("pwd"); + expect((hydrated.displayName ?? "").toLowerCase()).toContain("pwd"); } ); assertHydratedReplica( editTool!, hydratedMap, (data) => - Array.isArray(data.parsedEdits) && - data.parsedEdits.some( - (entry) => - (entry.filePath ?? "").includes("hydrate-proof.txt") && - entry.diffLines.some((line) => line.content.includes("HYDRATION_PROOF_LINE_TWO")) + Array.isArray((data.result as any)?.files) && + ((data.result as any).files as Array<{ path?: string }>).some((entry) => + (entry.path ?? "").includes("hydrate-proof.txt") ), ({ live, hydrated }) => { - const liveDiff = JSON.stringify(live.parsedEdits ?? []); - const hydratedDiff = JSON.stringify(hydrated.parsedEdits ?? []); - expect(liveDiff).toContain("HYDRATION_PROOF_LINE_ONE"); - expect(liveDiff).toContain("HYDRATION_PROOF_LINE_TWO"); - expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_ONE"); - expect(hydratedDiff).toContain("HYDRATION_PROOF_LINE_TWO"); + const liveDiff = JSON.stringify(live.result ?? live.raw ?? {}); + const hydratedDiff = JSON.stringify(hydrated.result ?? hydrated.raw ?? {}); + expect(liveDiff).toContain("hydrate-proof.txt"); + expect(hydratedDiff).toContain("hydrate-proof.txt"); } ); assertHydratedReplica( readTool!, hydratedMap, (data) => - Array.isArray(data.parsedReads) && - data.parsedReads.some( - (entry) => - (entry.filePath ?? "").includes("hydrate-proof.txt") && - entry.content.includes("HYDRATION_PROOF_LINE_TWO") - ), + rawContainsText(data.raw, "HYDRATION_PROOF_LINE_ONE") && + rawContainsText(data.raw, "HYDRATION_PROOF_LINE_TWO"), ({ live, hydrated }) => { - const liveReads = JSON.stringify(live.parsedReads ?? []); - const hydratedReads = JSON.stringify(hydrated.parsedReads ?? []); + const liveReads = JSON.stringify(live.raw ?? {}); + const hydratedReads = JSON.stringify(hydrated.raw ?? {}); expect(liveReads).toContain("HYDRATION_PROOF_LINE_ONE"); expect(hydratedReads).toContain("HYDRATION_PROOF_LINE_ONE"); expect(liveReads).toContain("HYDRATION_PROOF_LINE_TWO"); diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index b077d0b2c..f5fe1027e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -207,10 +207,15 @@ class ClaudeAgentSession implements AgentSession { private currentMode: PermissionMode; private availableModes: AgentMode[] = DEFAULT_MODES; private toolUseCache = new Map(); + private toolUseIndexToId = new Map(); + private toolUseInputBuffers = new Map(); private pendingPermissions = new Map(); private eventQueue: Pushable | null = null; private persistedHistory: AgentTimelineItem[] = []; private historyPending = false; + private turnCancelRequested = false; + private streamedAssistantTextThisTurn = false; + private streamedReasoningThisTurn = false; constructor( config: ClaudeAgentConfig, @@ -266,6 +271,17 @@ class ClaudeAgentSession implements AgentSession { const sdkMessage = this.toSdkUserMessage(prompt); const queue = new Pushable(); 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) { for (const item of this.persistedHistory) { queue.push({ type: "timeline", item, provider: "claude" }); @@ -279,10 +295,14 @@ class ClaudeAgentSession implements AgentSession { for await (const event of queue) { yield event; if (event.type === "turn_completed" || event.type === "turn_failed") { + finishedNaturally = true; break; } } } finally { + if (!finishedNaturally && !cancelIssued) { + requestCancel(); + } if (this.eventQueue === queue) { this.eventQueue = null; } @@ -501,6 +521,8 @@ class ClaudeAgentSession implements AgentSession { } private async forwardPromptEvents(message: SDKUserMessage, queue: Pushable) { + this.streamedAssistantTextThisTurn = false; + this.streamedReasoningThisTurn = false; try { for await (const sdkEvent of this.processPrompt(message)) { const events = this.translateMessageToEvents(sdkEvent); @@ -509,16 +531,30 @@ class ClaudeAgentSession implements AgentSession { } } } catch (error) { - queue.push({ - type: "turn_failed", - provider: "claude", - error: error instanceof Error ? error.message : "Claude stream failed", - }); + if (!this.turnCancelRequested) { + queue.push({ + type: "turn_failed", + provider: "claude", + error: error instanceof Error ? error.message : "Claude stream failed", + }); + } } finally { + this.turnCancelRequested = false; queue.end(); } } + private async interruptActiveTurn(): Promise { + 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[] { const events: AgentStreamEvent[] = [{ type: "provider_event", provider: "claude", raw: message }]; @@ -539,7 +575,10 @@ class ClaudeAgentSession implements AgentSession { break; } 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) { events.push({ type: "timeline", item, provider: "claude" }); } @@ -811,14 +850,33 @@ class ClaudeAgentSession implements AgentSession { } 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 (!content || content === "[Request interrupted by user for tool use]") { return []; } + if (context === "live") { + this.streamedAssistantTextThisTurn = true; + } + if (suppressAssistant) { + return []; + } return [{ type: "assistant_message", text: content, raw: content }]; } @@ -828,13 +886,23 @@ class ClaudeAgentSession implements AgentSession { case "text": case "text_delta": 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; case "thinking": case "thinking_delta": 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; case "tool_use": @@ -915,6 +983,23 @@ class ClaudeAgentSession implements AgentSession { } 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) { case "content_block_start": return this.mapBlocksToTimeline([event.content_block as ClaudeContentChunk]); @@ -956,18 +1041,8 @@ class ClaudeAgentSession implements AgentSession { if (block.type === "tool_use") { const input = this.normalizeToolInput(block.input); - existing.input = input; if (input) { - if (this.isCommandTool(existing.name, 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; - } - } + this.applyToolInput(existing, input); } } @@ -975,6 +1050,41 @@ class ClaudeAgentSession implements AgentSession { 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 | null { if (!input || typeof input !== "object") { return null; @@ -982,6 +1092,20 @@ class ClaudeAgentSession implements AgentSession { return input as Record; } + private applyToolInput(entry: ToolUseCacheEntry, input: Record): 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): boolean { const normalized = name.toLowerCase(); 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); } +const CLAUDE_PERSISTED_TIMELINE_LIMIT = 20; + async function parseClaudeSessionDescriptor( filePath: string, mtime: Date @@ -1261,6 +1387,7 @@ async function parseClaudeSessionDescriptor( let sessionId: string | null = null; let cwd: string | null = null; let title: string | null = null; + const timeline: AgentTimelineItem[] = []; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); @@ -1277,8 +1404,19 @@ async function parseClaudeSessionDescriptor( if (!cwd && typeof entry.cwd === "string") { cwd = entry.cwd; } - if (!title && entry.type === "user" && entry.message) { - title = extractClaudeUserText(entry.message); + if (entry.type === "user" && 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) { break; @@ -1306,6 +1444,7 @@ async function parseClaudeSessionDescriptor( title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`, lastActivityAt: mtime, persistence, + timeline: timeline.slice(0, CLAUDE_PERSISTED_TIMELINE_LIMIT), }; } diff --git a/packages/server/src/server/agent/providers/codex-agent.test.ts b/packages/server/src/server/agent/providers/codex-agent.test.ts index a6cfdf7f7..60650216b 100644 --- a/packages/server/src/server/agent/providers/codex-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-agent.test.ts @@ -1,11 +1,27 @@ 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 path from "node:path"; -import { CodexAgentClient } from "./codex-agent.js"; -import { hydrateStreamState, type StreamItem } from "../../../../../app/src/types/stream.js"; -import type { AgentProvider, AgentSessionConfig, AgentStreamEvent, AgentPermissionRequest } from "../agent-sdk-types.js"; +import { CodexAgentClient, isSyntheticRolloutUserMessage } from "./codex-agent.js"; +import { + 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 { return mkdtempSync(path.join(os.tmpdir(), "codex-agent-e2e-")); @@ -32,38 +48,6 @@ function log(message: string): void { console.info(`[CodexAgentTest] ${message}`); } -function createStubCodexBinary(events: Record[]): { 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)", () => { test( "responds with text", @@ -140,6 +124,154 @@ describe("CodexAgentClient (SDK integration)", () => { 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> | 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> | null = null; + let resumed: Awaited> | 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( "supports multiple turns within the same session", async () => { @@ -260,47 +392,42 @@ describe("CodexAgentClient (SDK integration)", () => { 180_000 ); - test( - "emits permission requests and resolves them when approvals are handled", + // Codex CLI currently doesn't emit approval request events even when approvalPolicy is set to on-request, + // 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 () => { const cwd = tmpCwd(); const restoreSessionDir = useTempCodexSessionDir(); - const events = [ - { type: "thread.started", thread_id: "019a83a2-permission" }, - { type: "turn.started" }, - { - type: "exec_approval_request", - call_id: "call-permission-1", - 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 }; + const client = new CodexAgentClient(); + const config: AgentSessionConfig = { + provider: "codex", + cwd, + modeId: "full-access", + extra: { codex: { approvalPolicy: "on-request" } }, + }; let session: Awaited> | null = null; try { session = await client.createSession(config); - log("Processing synthetic permission request stream"); + log("Processing Codex permission request stream"); 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) { - if (event.type === "permission_requested") { + if (event.type === "permission_requested" && !captured) { 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; } } expect(captured).not.toBeNull(); - expect(session.getPendingPermissions()).toHaveLength(1); - - await session.respondToPermission(captured!.id, { behavior: "allow" }); + expect(session.getPendingPermissions()).toHaveLength(0); const replay: AgentStreamEvent[] = []; for await (const event of session.streamHistory()) { @@ -315,15 +442,23 @@ describe("CodexAgentClient (SDK integration)", () => { event.resolution.behavior === "allow" ) ).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 { await session?.close(); - stub.cleanup(); rmSync(cwd, { recursive: true, force: true }); restoreSessionDir(); } }, - 30_000 + 180_000 ); test( @@ -369,6 +504,14 @@ describe("CodexAgentClient (SDK integration)", () => { expect(stateIncludesUserMessage(liveState, 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("")).toBe(false); } finally { await session?.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 + + +Stay safe. +`; + expect(isSyntheticRolloutUserMessage(text)).toBe(true); + }); + + test("flags environment context payloads", () => { + const text = ` + /Users/test/project + never +`; + expect(isSyntheticRolloutUserMessage(text)).toBe(true); + }); + + test("allows real user prompts", () => { + expect(isSyntheticRolloutUserMessage("investigate flaky tests")).toBe(false); + }); +}); + type StreamHydrationUpdate = { event: Extract; timestamp: Date; @@ -419,3 +585,118 @@ function stateIncludesUserMessage(state: StreamItem[], marker: string): boolean (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("") + ); + }); +} + +async function prependSyntheticRolloutEntries(filePath: string, entries: Array>): Promise { + 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 { + 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 { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function findRolloutInDir(root: string, sessionId: string, maxDepth: number): Promise { + 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 { + const text = [ + `# AGENTS.md instructions for ${sessionId}`, + "", + "", + "Always obey the operator.", + "", + ].join("\n"); + return buildSyntheticResponseItem(text); +} + +function buildSyntheticEnvironmentEntry(): Record { + const text = [ + "", + " /tmp/workspace", + " never", + " danger-full-access", + "", + ].join("\n"); + return buildSyntheticResponseItem(text); +} + +function buildSyntheticResponseItem(text: string): Record { + return { + timestamp: new Date().toISOString(), + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text, + }, + ], + }, + }; +} diff --git a/packages/server/src/server/agent/providers/codex-agent.ts b/packages/server/src/server/agent/providers/codex-agent.ts index dec7e1749..15ea0522f 100644 --- a/packages/server/src/server/agent/providers/codex-agent.ts +++ b/packages/server/src/server/agent/providers/codex-agent.ts @@ -98,6 +98,8 @@ const MODE_PRESETS: Record< type CodexOptionsOverrides = Partial & { skipGitRepoCheck?: boolean }; const MAX_ROLLOUT_SEARCH_DEPTH = 5; +const PERSISTED_TIMELINE_LIMIT = 20; +const SHELL_FUNCTION_NAMES = new Set(["shell", "shell_command"]); type ToolCallTimelineItem = Extract; type ExecPermissionRequestPayload = { @@ -334,10 +336,26 @@ class CodexAgentSession implements AgentSession { const turnOptions = this.buildTurnOptions(options); const { events } = await thread.runStreamed(input, turnOptions); - for await (const rawEvent of events) { - yield* this.translateEvent(rawEvent); - if (rawEvent.type === "turn.completed" || rawEvent.type === "turn.failed" || rawEvent.type === "error") { - break; + let finishedNaturally = false; + try { + for await (const rawEvent of events) { + 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 line.trim()).filter(Boolean); const events: AgentStreamEvent[] = [{ type: "thread_started", provider: "codex", sessionId: threadId }]; - const commandCalls = new Map(); + const commandCalls = new Map(); for (const line of lines) { 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"); } -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( payload: RolloutResponsePayload | undefined, events: AgentStreamEvent[], - commandCalls: Map + commandCalls: Map ): void { if (!payload || typeof payload !== "object") { return; @@ -957,6 +967,9 @@ function handleRolloutResponseItem( if (payload.role === "assistant") { events.push({ type: "timeline", provider: "codex", item: { type: "assistant_message", text, raw: payload } }); } else if (payload.role === "user") { + if (isSyntheticRolloutUserMessage(text)) { + break; + } 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( payload: RolloutFunctionCallPayload, events: AgentStreamEvent[], - commandCalls: Map + commandCalls: Map ): void { const name = typeof payload.name === "string" ? payload.name : undefined; if (!name) { return; } - if (name === "shell") { + if (SHELL_FUNCTION_NAMES.has(name)) { const args = safeJsonParse>(payload.arguments); 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") { - 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; } @@ -1042,7 +1072,7 @@ function handleRolloutFunctionCall( function finalizeRolloutFunctionCall( payload: RolloutFunctionCallOutputPayload, events: AgentStreamEvent[], - commandCalls: Map + commandCalls: Map ): void { if (typeof payload.call_id !== "string") { return; @@ -1064,7 +1094,7 @@ function finalizeRolloutFunctionCall( callId: payload.call_id, displayName: buildCommandDisplayName(command.command), kind: "execute", - input: { command: command.command }, + input: { command: command.command, cwd: command.cwd }, output: result, raw: { payload, result }, }), @@ -1196,11 +1226,6 @@ type SessionMetaEntry = { payload?: SessionMetaPayload; }; -type ResponseItemEntry = { - type: "response_item"; - payload?: RolloutResponsePayload; -}; - async function collectRecentRolloutFiles(rootDir: string, limit: number): Promise { const candidates: RolloutCandidate[] = []; async function traverse(dir: string): Promise { @@ -1268,15 +1293,8 @@ async function parseRolloutDescriptor( if (typeof entry.payload.cwd === "string" && entry.payload.cwd.length > 0) { 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; } } @@ -1285,6 +1303,11 @@ async function parseRolloutDescriptor( return null; } + const timeline = await loadCodexPersistedTimeline(filePath, sessionId); + if (!title) { + title = timeline.find((item) => item.type === "user_message")?.text ?? null; + } + const persistence: AgentPersistenceHandle = { provider: "codex", sessionId, @@ -1304,21 +1327,10 @@ async function parseRolloutDescriptor( title: (title ?? "").trim() || `Codex session ${sessionId.slice(0, 8)}`, lastActivityAt: mtime, 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 { if (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(); } +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("")) { + return true; + } + + if (lower.startsWith("")) { + return true; + } + + return false; +} + function extractReasoningText(payload: RolloutReasoningPayload): string { if (Array.isArray(payload?.summary)) { const text = payload.summary @@ -1367,6 +1397,26 @@ function extractReasoningText(payload: RolloutReasoningPayload): string { return ""; } +async function loadCodexPersistedTimeline(filePath: string, threadId: string): Promise { + 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 }[] { const planValue = typeof args === "object" && args ? (args as { plan?: unknown }).plan : undefined; const plan = Array.isArray(planValue) ? planValue : []; diff --git a/packages/server/src/server/agent/wait-for-agent-tracker.test.ts b/packages/server/src/server/agent/wait-for-agent-tracker.test.ts index e8d53c23f..3a2657eb0 100644 --- a/packages/server/src/server/agent/wait-for-agent-tracker.test.ts +++ b/packages/server/src/server/agent/wait-for-agent-tracker.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { WaitForAgentTracker } from "./wait-for-agent-tracker"; +import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; describe("WaitForAgentTracker", () => { it("registers and cancels waiters per agent", () => { @@ -7,7 +7,7 @@ describe("WaitForAgentTracker", () => { const cancelA = vi.fn(); const cancelB = vi.fn(); - const unregisterA = tracker.register("agent-a", cancelA); + tracker.register("agent-a", cancelA); tracker.register("agent-a", cancelB); expect(tracker.cancel("agent-a", "test")).toBe(true); diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index ee9f40850..38dc1ddc7 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -19,10 +19,11 @@ type ProviderEventPayload = Extract< export type AgentSnapshotPayload = Omit< AgentSnapshot, - "createdAt" | "updatedAt" + "createdAt" | "updatedAt" | "lastUserMessageAt" > & { createdAt: string; updatedAt: string; + lastUserMessageAt: string | null; title: string | null; }; @@ -219,6 +220,7 @@ export const AgentSnapshotPayloadSchema = z.object({ cwd: z.string(), createdAt: z.string(), updatedAt: z.string(), + lastUserMessageAt: z.string().nullable(), status: AgentStatusSchema, sessionId: z.string().nullable(), capabilities: AgentCapabilityFlagsSchema, @@ -239,11 +241,12 @@ export function serializeAgentSnapshot( snapshot: AgentSnapshot, options?: { title?: string | null } ): AgentSnapshotPayload { - const { createdAt, updatedAt, ...rest } = snapshot; + const { createdAt, updatedAt, lastUserMessageAt, ...rest } = snapshot; return { ...rest, createdAt: createdAt.toISOString(), updatedAt: updatedAt.toISOString(), + lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null, title: options?.title ?? null, }; } @@ -323,10 +326,28 @@ export const SendAgentAudioSchema = z.object({ 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; + export const CreateAgentRequestMessageSchema = z.object({ type: z.literal("create_agent_request"), config: AgentSessionConfigSchema, 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(), }); @@ -348,6 +369,11 @@ export const CancelAgentRequestMessageSchema = z.object({ agentId: z.string(), }); +export const RestartServerRequestMessageSchema = z.object({ + type: z.literal("restart_server_request"), + reason: z.string().optional(), +}); + export const ListPersistedAgentsRequestMessageSchema = z.object({ type: z.literal("list_persisted_agents_request"), provider: AgentProviderSchema.optional(), @@ -424,12 +450,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, CancelAgentRequestMessageSchema, + RestartServerRequestMessageSchema, InitializeAgentRequestMessageSchema, SetAgentModeMessageSchema, AgentPermissionResponseMessageSchema, GitDiffRequestSchema, FileExplorerRequestSchema, ListPersistedAgentsRequestMessageSchema, + GitRepoInfoRequestMessageSchema, ]); export type SessionInboundMessage = z.infer; @@ -622,6 +650,7 @@ const PersistedAgentDescriptorPayloadSchema = z.object({ title: z.string(), lastActivityAt: z.string(), persistence: AgentPersistenceHandleSchema, + timeline: z.array(AgentTimelineItemPayloadSchema), }); export type PersistedAgentDescriptorPayload = z.infer; @@ -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", [ ActivityLogMessageSchema, AssistantChunkMessageSchema, @@ -675,6 +722,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListPersistedAgentsResponseSchema, GitDiffResponseSchema, FileExplorerResponseSchema, + GitRepoInfoResponseSchema, ]); export type SessionOutboundMessage = z.infer< @@ -722,6 +770,7 @@ export type GitDiffRequest = z.infer; export type GitDiffResponse = z.infer; export type FileExplorerRequest = z.infer; export type FileExplorerResponse = z.infer; +export type RestartServerRequestMessage = z.infer; // ============================================================================ // WebSocket Level Messages (wraps session messages) diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index 961ae5326..3f8733be7 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -15,6 +15,7 @@ function createSnapshot(overrides?: Partial): AgentSnapshot { cwd: "/tmp/project", createdAt: now, updatedAt: now, + lastUserMessageAt: null, status: "idle", sessionId: null, capabilities: { diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index d76eaefa4..60033efc6 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -45,7 +45,9 @@ export async function restorePersistedAgents( return; } - console.log(`[Agents] Restoring ${records.length} persisted agent(s)`); + let resumed = 0; + let created = 0; + for (const record of records) { if (!isKnownProvider(record.provider)) { console.warn( @@ -54,27 +56,33 @@ export async function restorePersistedAgents( continue; } + const handle = buildPersistenceHandle(record); try { - const persistenceHandle = buildPersistenceHandle(record); - if (persistenceHandle) { + if (handle) { await agentManager.resumeAgent( - persistenceHandle, + handle, buildConfigOverrides(record), record.id ); + resumed += 1; } else { await agentManager.createAgent( buildSessionConfig(record), record.id ); + created += 1; } } catch (error) { console.error( - `[Agents] Failed to restore agent ${record.id}:`, + `[Agents] Failed to restore agent ${record.id} from registry:`, error ); } } + + console.log( + `[Agents] Loaded ${records.length} persisted agent record(s); resumed ${resumed}, created ${created}` + ); } function isKnownProvider(provider: string): provider is AgentProvider { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bd1ea75d6..76fdc9aaf 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -18,6 +18,7 @@ import { type SessionInboundMessage, type SessionOutboundMessage, type FileExplorerRequest, + type GitSetupOptions, } from "./messages.js"; import { getSystemPrompt } from "./agent/system-prompt.js"; import { getAllTools } from "./agent/llm-openai.js"; @@ -28,6 +29,10 @@ import { listConversations, deleteConversation, } from "./persistence.js"; +import { + buildConfigOverrides, + buildSessionConfig, +} from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createTerminalMcpServer } from "./terminal-mcp/index.js"; @@ -39,8 +44,10 @@ import type { AgentPromptInput, AgentSessionConfig, AgentStreamEvent, + AgentProvider, + AgentPersistenceHandle, } 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 { listDirectoryEntries, @@ -51,12 +58,28 @@ import { isTitleGeneratorInitialized, } from "../services/agent-title-generator.js"; import type { TerminalManager } from "./terminal-mcp/terminal-manager.js"; +import { + createWorktree, + detectRepoInfo, + slugify, + validateBranchSlug, +} from "../utils/worktree.js"; const execAsync = promisify(exec); const ACTIVE_TITLE_GENERATIONS = new Set(); +let restartRequested = false; +const KNOWN_AGENT_PROVIDERS: AgentProvider[] = ["claude", "codex"]; type ProcessingPhase = "idle" | "transcribing" | "llm"; +type NormalizedGitOptions = { + baseBranch?: string; + createNewBranch: boolean; + newBranchName?: string; + createWorktree: boolean; + worktreeSlug?: string; +}; + const PCM_SAMPLE_RATE = 16000; const PCM_CHANNELS = 1; const PCM_BITS_PER_SAMPLE = 16; @@ -66,6 +89,7 @@ const MIN_STREAMING_SEGMENT_DURATION_MS = 1000; const MIN_STREAMING_SEGMENT_BYTES = Math.round( PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS ); +const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/; /** * Type for present_artifact tool arguments @@ -115,6 +139,45 @@ function convertPCMToWavBuffer( 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. * It owns all state management, orchestration logic, and message processing. @@ -460,6 +523,43 @@ export class Session { 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 { try { const payload = await this.buildAgentPayload(agent); @@ -609,13 +709,17 @@ export class Session { await this.handleRefreshAgentRequest(msg); break; - case "cancel_agent_request": - await this.handleCancelAgentRequest(msg.agentId); - break; + case "cancel_agent_request": + await this.handleCancelAgentRequest(msg.agentId); + break; - case "initialize_agent_request": - await this.handleInitializeAgentRequest(msg.agentId, msg.requestId); - break; + case "restart_server_request": + await this.handleRestartServerRequest(msg.reason); + break; + + case "initialize_agent_request": + await this.handleInitializeAgentRequest(msg.agentId, msg.requestId); + break; case "set_agent_mode": await this.handleSetAgentMode(msg.agentId, msg.modeId); @@ -640,6 +744,10 @@ export class Session { case "list_persisted_agents_request": await this.handleListPersistedAgentsRequest(msg); break; + + case "git_repo_info_request": + await this.handleGitRepoInfoRequest(msg); + break; } } catch (error: any) { console.error( @@ -740,6 +848,34 @@ export class Session { } } + private async handleRestartServerRequest(reason?: string): Promise { + if (restartRequested) { + console.log( + `[Session ${this.clientId}] Restart already requested, ignoring duplicate` + ); + return; + } + + restartRequested = true; + const payload: { status: string } & Record = { + 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 { console.log( `[Session ${this.clientId}] Deleting agent ${agentId} from registry` @@ -931,13 +1067,30 @@ export class Session { ); try { - const snapshot = this.agentManager.getAgent(agentId); + let snapshot = this.agentManager.getAgent(agentId); 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); + // Send timeline snapshot after hydration (if any) const timelineSize = this.emitAgentTimelineSnapshot(snapshot); if (requestId) { @@ -978,7 +1131,7 @@ export class Session { private async handleCreateAgentRequest( msg: Extract ): Promise { - const { config, worktreeName, requestId } = msg; + const { config, worktreeName, requestId, initialPrompt, git } = msg; console.log( `[Session ${this.clientId}] Creating agent in ${config.cwd} (${config.provider})${ worktreeName ? ` with worktree ${worktreeName}` : "" @@ -988,6 +1141,7 @@ export class Session { try { const sessionConfig = await this.buildAgentSessionConfig( config, + git, worktreeName ); const snapshot = await this.agentManager.createAgent(sessionConfig); @@ -1012,6 +1166,28 @@ export class Session { 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) { this.emit({ type: "status", @@ -1031,6 +1207,16 @@ export class Session { `[Session ${this.clientId}] Failed to create agent:`, error ); + if (requestId) { + this.emit({ + type: "status", + payload: { + status: "agent_create_failed", + requestId, + error: (error as Error)?.message ?? String(error), + }, + }); + } this.emit({ type: "activity_log", payload: { @@ -1068,6 +1254,7 @@ export class Session { try { const snapshot = await this.agentManager.resumeAgent(handle, overrides); this.setCachedTitle(snapshot.id, null); + await this.agentManager.primeAgentHistory(snapshot.id); await this.forwardAgentState(snapshot); const timelineSize = this.emitAgentTimelineSnapshot(snapshot); if (requestId) { @@ -1107,9 +1294,31 @@ export class Session { ); try { - const snapshot = await this.agentManager.refreshAgentFromPersistence( - agentId - ); + let snapshot: AgentSnapshot; + 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); const timelineSize = this.emitAgentTimelineSnapshot(snapshot); if (requestId) { @@ -1158,20 +1367,57 @@ export class Session { private async buildAgentSessionConfig( config: AgentSessionConfig, - worktreeName?: string + gitOptions?: GitSetupOptions, + legacyWorktreeName?: string ): Promise { let cwd = expandTilde(config.cwd); + const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName); - if (worktreeName) { - const { createWorktree } = await import("../utils/worktree.js"); - console.log( - `[Session ${this.clientId}] Creating worktree '${worktreeName}' from ${cwd}` - ); - const worktreeConfig = await createWorktree({ - branchName: worktreeName, + if (!normalized) { + return { + ...config, 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; + } 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 { @@ -1180,6 +1426,212 @@ export class Session { }; } + private async handleGitRepoInfoRequest( + msg: Extract + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await execAsync(`git show-ref --verify --quiet refs/heads/${branch}` , { + cwd, + }); + return true; + } catch (error: any) { + return false; + } + } + private async handleListPersistedAgentsRequest( msg: Extract ): Promise { @@ -1199,6 +1651,7 @@ export class Session { title: entry.title ?? `Session ${entry.sessionId.slice(0, 8)}`, lastActivityAt: entry.lastActivityAt.toISOString(), persistence: entry.persistence, + timeline: entry.timeline ?? [], })), }, }); @@ -1438,10 +1891,19 @@ export class Session { try { // Get live agents with session modes const agentSnapshots = this.agentManager.listAgents(); - const agents = await Promise.all( + const liveAgents = await Promise.all( 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 let commands: any[] = []; if (this.terminalManager) { @@ -1467,11 +1929,6 @@ export class Session { console.log( `[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) { console.error( `[Session ${this.clientId}] Failed to send session state:`, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 051089893..0a4b9e3e3 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -18,6 +18,13 @@ interface WorktreeConfig { repoPath: string; } +interface CreateWorktreeOptions { + branchName: string; + cwd: string; + baseBranch?: string; + worktreeSlug?: string; +} + /** * Check if current directory is a bare repository */ @@ -148,6 +155,11 @@ export function slugify(input: string): string { .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 */ @@ -183,12 +195,12 @@ async function findExistingWorktree( /** * Create a git worktree with proper naming conventions */ -export async function createWorktree(params: { - branchName: string; - cwd: string; -}): Promise { - const { branchName, cwd } = params; - +export async function createWorktree({ + branchName, + cwd, + baseBranch, + worktreeSlug, +}: CreateWorktreeOptions): Promise { // Validate branch name const validation = validateBranchSlug(branchName); if (!validation.valid) { @@ -200,11 +212,13 @@ export async function createWorktree(params: { // Determine worktree directory based on repo type let worktreePath: string; + const desiredSlug = sanitizeWorktreeSlug(worktreeSlug ?? branchName); + if (repoInfo.type === "bare") { - worktreePath = join(repoInfo.path, branchName); + worktreePath = join(repoInfo.path, desiredSlug); } else { const parentDir = dirname(repoInfo.path); - const worktreeName = `${repoInfo.name}-${branchName.replace(/\//g, "-")}`; + const worktreeName = `${repoInfo.name}-${desiredSlug}`; worktreePath = join(parentDir, worktreeName); } @@ -238,10 +252,9 @@ export async function createWorktree(params: { }); } catch (error) { // Branch doesn't exist, create new branch and worktree - await execAsync( - `git worktree add "${worktreePath}" -b "${branchName}"`, - { cwd: repoInfo.path } - ); + const baseArg = baseBranch ? ` "${baseBranch}"` : ""; + const command = `git worktree add "${worktreePath}" -b "${branchName}"${baseArg}`; + await execAsync(command, { cwd: repoInfo.path }); } // Copy .env file if it exists diff --git a/patches/@openai+codex-sdk+0.58.0.patch b/patches/@openai+codex-sdk+0.58.0.patch new file mode 100644 index 000000000..8c07d3eb8 --- /dev/null +++ b/patches/@openai+codex-sdk+0.58.0.patch @@ -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 { + } + }