feat(agent): add git diff viewer and improve permission handling
- Add git diff viewer screen accessible via agent dropdown menu - Implement git_diff_request/response WebSocket messages - Add dropdown menu to agent screen header with "View Changes" option - Clear pending permissions when starting new agent turn to prevent conflicts - Treat agent refusals as completed rather than failed state - Add test coverage for permission handling edge cases - Configure vitest to run in single-threaded mode for stability - Remove redundant wrapper in agent stream view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="agent/[id]" />
|
<Stack.Screen name="agent/[id]" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="audio-test" />
|
<Stack.Screen name="audio-test" />
|
||||||
|
<Stack.Screen name="git-diff" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<GlobalFooter />
|
<GlobalFooter />
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useCallback, useState } from "react";
|
||||||
import { View, Text, ActivityIndicator } from "react-native";
|
import { View, Text, ActivityIndicator, Pressable, Modal, Dimensions } from "react-native";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { MoreVertical, GitBranch } from "lucide-react-native";
|
||||||
import { BackHeader } from "@/components/headers/back-header";
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
import { useSession } from "@/contexts/session-context";
|
import { useSession } from "@/contexts/session-context";
|
||||||
import { useFooterControls } from "@/contexts/footer-controls-context";
|
import { useFooterControls } from "@/contexts/footer-controls-context";
|
||||||
|
|
||||||
|
const DROPDOWN_WIDTH = 220;
|
||||||
|
|
||||||
export default function AgentScreen() {
|
export default function AgentScreen() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const {
|
const {
|
||||||
agents,
|
agents,
|
||||||
@@ -24,6 +28,9 @@ export default function AgentScreen() {
|
|||||||
initializeAgent,
|
initializeAgent,
|
||||||
} = useSession();
|
} = useSession();
|
||||||
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
const { registerFooterControls, unregisterFooterControls } = useFooterControls();
|
||||||
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
|
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const menuButtonRef = useRef<View>(null);
|
||||||
|
|
||||||
// Keyboard animation
|
// Keyboard animation
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
@@ -90,6 +97,34 @@ export default function AgentScreen() {
|
|||||||
};
|
};
|
||||||
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
|
}, [agentControls, agent, isInitializing, registerFooterControls, unregisterFooterControls]);
|
||||||
|
|
||||||
|
const handleOpenMenu = useCallback(() => {
|
||||||
|
menuButtonRef.current?.measureInWindow((x, y, width, height) => {
|
||||||
|
const screenWidth = Dimensions.get("window").width;
|
||||||
|
const verticalOffset = 6;
|
||||||
|
const horizontalMargin = 16;
|
||||||
|
const desiredLeft = x + width - DROPDOWN_WIDTH;
|
||||||
|
const maxLeft = screenWidth - DROPDOWN_WIDTH - horizontalMargin;
|
||||||
|
const clampedLeft = Math.min(Math.max(desiredLeft, horizontalMargin), maxLeft);
|
||||||
|
|
||||||
|
setMenuPosition({
|
||||||
|
top: y + height + verticalOffset,
|
||||||
|
left: clampedLeft,
|
||||||
|
});
|
||||||
|
setMenuVisible(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseMenu = useCallback(() => {
|
||||||
|
setMenuVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewChanges = useCallback(() => {
|
||||||
|
handleCloseMenu();
|
||||||
|
if (id) {
|
||||||
|
router.push(`/git-diff?agentId=${id}`);
|
||||||
|
}
|
||||||
|
}, [id, router, handleCloseMenu]);
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -104,7 +139,16 @@ export default function AgentScreen() {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<BackHeader title={agent.title || "Agent"} />
|
<BackHeader
|
||||||
|
title={agent.title || "Agent"}
|
||||||
|
rightContent={
|
||||||
|
<View ref={menuButtonRef} collapsable={false}>
|
||||||
|
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||||
|
<MoreVertical size={20} color={theme.colors.foreground} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content Area with Keyboard Animation */}
|
{/* Content Area with Keyboard Animation */}
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
@@ -127,6 +171,34 @@ export default function AgentScreen() {
|
|||||||
)}
|
)}
|
||||||
</ReanimatedAnimated.View>
|
</ReanimatedAnimated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
<Modal
|
||||||
|
visible={menuVisible}
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={handleCloseMenu}
|
||||||
|
>
|
||||||
|
<View style={styles.menuOverlay}>
|
||||||
|
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.dropdownMenu,
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: menuPosition.top,
|
||||||
|
left: menuPosition.left,
|
||||||
|
width: DROPDOWN_WIDTH,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
|
||||||
|
<GitBranch size={20} color={theme.colors.foreground} />
|
||||||
|
<Text style={styles.menuItemText}>View Changes</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,4 +234,37 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.lg,
|
fontSize: theme.fontSize.lg,
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
},
|
},
|
||||||
|
menuButton: {
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
},
|
||||||
|
menuOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
menuBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
},
|
||||||
|
dropdownMenu: {
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[2],
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
menuItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
},
|
||||||
|
menuItemText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.normal,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { View, Text, ScrollView, ActivityIndicator } from "react-native";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { BackHeader } from "@/components/headers/back-header";
|
||||||
|
import { useSession } from "@/contexts/session-context";
|
||||||
|
|
||||||
|
interface ParsedDiffFile {
|
||||||
|
path: string;
|
||||||
|
lines: Array<{
|
||||||
|
type: "add" | "remove" | "context" | "header";
|
||||||
|
content: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDiff(diffText: string): ParsedDiffFile[] {
|
||||||
|
if (!diffText || diffText.trim().length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: ParsedDiffFile[] = [];
|
||||||
|
const sections = diffText.split(/^diff --git /m).filter(Boolean);
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
const lines = section.split("\n");
|
||||||
|
const firstLine = lines[0];
|
||||||
|
|
||||||
|
const pathMatch = firstLine.match(/a\/(.*?) b\//);
|
||||||
|
const path = pathMatch ? pathMatch[1] : "unknown";
|
||||||
|
|
||||||
|
const parsedLines: ParsedDiffFile["lines"] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
|
||||||
|
if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("index ")) {
|
||||||
|
parsedLines.push({ type: "header", content: line });
|
||||||
|
} else if (line.startsWith("+")) {
|
||||||
|
parsedLines.push({ type: "add", content: line });
|
||||||
|
} else if (line.startsWith("-")) {
|
||||||
|
parsedLines.push({ type: "remove", content: line });
|
||||||
|
} else {
|
||||||
|
parsedLines.push({ type: "context", content: line });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({ path, lines: parsedLines });
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GitDiffScreen() {
|
||||||
|
const { agentId } = useLocalSearchParams<{ agentId: string }>();
|
||||||
|
const { agents, gitDiffs, requestGitDiff } = useSession();
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const agent = agentId ? agents.get(agentId) : undefined;
|
||||||
|
const diffText = agentId ? gitDiffs.get(agentId) : undefined;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!agentId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffText !== undefined) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestGitDiff(agentId);
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [agentId, diffText, requestGitDiff]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (diffText !== undefined) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [diffText]);
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<BackHeader title="Changes" />
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>Agent not found</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isError = diffText?.startsWith("Error:");
|
||||||
|
const parsedFiles = isError || !diffText ? [] : parseDiff(diffText);
|
||||||
|
const hasChanges = parsedFiles.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<BackHeader title="Changes" />
|
||||||
|
|
||||||
|
<ScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer}>
|
||||||
|
{isLoading ? (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
<Text style={styles.loadingText}>Loading changes...</Text>
|
||||||
|
</View>
|
||||||
|
) : isError ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{diffText}</Text>
|
||||||
|
</View>
|
||||||
|
) : !hasChanges ? (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>No changes</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
parsedFiles.map((file, fileIndex) => (
|
||||||
|
<View key={fileIndex} style={styles.fileSection}>
|
||||||
|
<View style={styles.fileHeader}>
|
||||||
|
<Text style={styles.filePath}>{file.path}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.diffContent}>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
nestedScrollEnabled
|
||||||
|
showsHorizontalScrollIndicator
|
||||||
|
bounces={false}
|
||||||
|
overScrollMode="never"
|
||||||
|
contentContainerStyle={styles.diffScrollContent}
|
||||||
|
>
|
||||||
|
<View style={styles.diffLinesContainer}>
|
||||||
|
{file.lines.map((line, lineIndex) => (
|
||||||
|
<Text
|
||||||
|
key={lineIndex}
|
||||||
|
style={[
|
||||||
|
styles.diffLine,
|
||||||
|
line.type === "add" && styles.addLine,
|
||||||
|
line.type === "remove" && styles.removeLine,
|
||||||
|
line.type === "header" && styles.headerLine,
|
||||||
|
line.type === "context" && styles.contextLine,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line.content}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: theme.spacing[16],
|
||||||
|
gap: theme.spacing[4],
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: theme.spacing[16],
|
||||||
|
paddingHorizontal: theme.spacing[6],
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: theme.spacing[16],
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
},
|
||||||
|
fileSection: {
|
||||||
|
marginBottom: theme.spacing[6],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
overflow: "hidden",
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
fileHeader: {
|
||||||
|
backgroundColor: theme.colors.muted,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
filePath: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
},
|
||||||
|
diffContent: {
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
},
|
||||||
|
diffScrollContent: {
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
diffLinesContainer: {
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
},
|
||||||
|
diffLine: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: "100%",
|
||||||
|
},
|
||||||
|
addLine: {
|
||||||
|
backgroundColor: theme.colors.palette.green[900],
|
||||||
|
color: theme.colors.palette.green[200],
|
||||||
|
},
|
||||||
|
removeLine: {
|
||||||
|
backgroundColor: theme.colors.palette.red[900],
|
||||||
|
color: theme.colors.palette.red[200],
|
||||||
|
},
|
||||||
|
headerLine: {
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
backgroundColor: theme.colors.muted,
|
||||||
|
},
|
||||||
|
contextLine: {
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -267,28 +267,24 @@ export function AgentStreamView({
|
|||||||
onMomentumScrollEnd={handleScrollEnd}
|
onMomentumScrollEnd={handleScrollEnd}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View style={stylesheet.invertedWrapper}>
|
<View style={stylesheet.emptyState}>
|
||||||
<View style={stylesheet.emptyState}>
|
<Text style={stylesheet.emptyStateText}>
|
||||||
<Text style={stylesheet.emptyStateText}>
|
Start chatting with this agent...
|
||||||
Start chatting with this agent...
|
</Text>
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
ListHeaderComponent={
|
ListHeaderComponent={
|
||||||
<View style={stylesheet.invertedWrapper}>
|
pendingPermissionItems.length > 0 ? (
|
||||||
{pendingPermissionItems.length > 0 ? (
|
<View style={stylesheet.permissionsContainer}>
|
||||||
<View style={stylesheet.permissionsContainer}>
|
{pendingPermissionItems.map((permission) => (
|
||||||
{pendingPermissionItems.map((permission) => (
|
<PermissionRequestCard
|
||||||
<PermissionRequestCard
|
key={permission.requestId}
|
||||||
key={permission.requestId}
|
permission={permission}
|
||||||
permission={permission}
|
onResponse={onPermissionResponse}
|
||||||
onResponse={onPermissionResponse}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</View>
|
||||||
</View>
|
) : null
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
}
|
}
|
||||||
extraData={pendingPermissionItems.length}
|
extraData={pendingPermissionItems.length}
|
||||||
maintainVisibleContentPosition={{
|
maintainVisibleContentPosition={{
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { ArrowLeft } from "lucide-react-native";
|
|||||||
|
|
||||||
interface BackHeaderProps {
|
interface BackHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
rightContent?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackHeader({ title }: BackHeaderProps) {
|
export function BackHeader({ title, rightContent }: BackHeaderProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -31,8 +32,10 @@ export function BackHeader({ title }: BackHeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Right side - Empty for now */}
|
{/* Right side */}
|
||||||
<View style={styles.headerRight} />
|
<View style={styles.headerRight}>
|
||||||
|
{rightContent}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ interface SessionContextValue {
|
|||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
setPendingPermissions: (perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
setPendingPermissions: (perms: Map<string, PendingPermission> | ((prev: Map<string, PendingPermission>) => Map<string, PendingPermission>)) => void;
|
||||||
|
|
||||||
|
// Git diffs
|
||||||
|
gitDiffs: Map<string, string>;
|
||||||
|
requestGitDiff: (agentId: string) => void;
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||||
@@ -167,6 +171,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
const [commands, setCommands] = useState<Map<string, Command>>(new Map());
|
const [commands, setCommands] = useState<Map<string, Command>>(new Map());
|
||||||
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
|
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
|
||||||
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
||||||
|
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// WebSocket message handlers
|
// WebSocket message handlers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -593,6 +598,21 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Git diff response handler
|
||||||
|
const unsubGitDiff = ws.on("git_diff_response", (message) => {
|
||||||
|
if (message.type !== "git_diff_response") return;
|
||||||
|
const { agentId, diff, error } = message.payload;
|
||||||
|
|
||||||
|
console.log("[Session] Git diff response for agent:", agentId, error ? `(error: ${error})` : "");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("[Session] Git diff error:", error);
|
||||||
|
setGitDiffs((prev) => new Map(prev).set(agentId, `Error: ${error}`));
|
||||||
|
} else {
|
||||||
|
setGitDiffs((prev) => new Map(prev).set(agentId, diff || ""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubSessionState();
|
unsubSessionState();
|
||||||
unsubAgentCreated();
|
unsubAgentCreated();
|
||||||
@@ -605,6 +625,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
unsubActivity();
|
unsubActivity();
|
||||||
unsubChunk();
|
unsubChunk();
|
||||||
unsubTranscription();
|
unsubTranscription();
|
||||||
|
unsubGitDiff();
|
||||||
};
|
};
|
||||||
}, [ws, audioPlayer, setIsPlayingAudio]);
|
}, [ws, audioPlayer, setIsPlayingAudio]);
|
||||||
|
|
||||||
@@ -776,6 +797,17 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
isSpeakingRef.current = isSpeaking;
|
isSpeakingRef.current = isSpeaking;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requestGitDiff = useCallback((agentId: string) => {
|
||||||
|
const msg: WSInboundMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "git_diff_request",
|
||||||
|
agentId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(msg);
|
||||||
|
}, [ws]);
|
||||||
|
|
||||||
const value: SessionContextValue = {
|
const value: SessionContextValue = {
|
||||||
ws,
|
ws,
|
||||||
audioPlayer,
|
audioPlayer,
|
||||||
@@ -797,6 +829,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
setAgentUpdates,
|
setAgentUpdates,
|
||||||
pendingPermissions,
|
pendingPermissions,
|
||||||
setPendingPermissions,
|
setPendingPermissions,
|
||||||
|
gitDiffs,
|
||||||
|
requestGitDiff,
|
||||||
initializeAgent,
|
initializeAgent,
|
||||||
sendAgentMessage,
|
sendAgentMessage,
|
||||||
sendAgentAudio,
|
sendAgentAudio,
|
||||||
|
|||||||
@@ -96,6 +96,83 @@ describe("AgentManager", () => {
|
|||||||
}
|
}
|
||||||
}, 120000);
|
}, 120000);
|
||||||
|
|
||||||
|
it("should not fail when sending '.' after plan permission request", async () => {
|
||||||
|
const manager = new AgentManager();
|
||||||
|
let permissionRequest: RequestPermissionRequest | null = null;
|
||||||
|
let requestId: string | null = null;
|
||||||
|
|
||||||
|
const agentId = await manager.createAgent({
|
||||||
|
cwd: tmpDir,
|
||||||
|
type: "claude",
|
||||||
|
initialMode: "plan",
|
||||||
|
});
|
||||||
|
createdAgents.push({ manager, agentId });
|
||||||
|
|
||||||
|
const unsubscribe = manager.subscribeToUpdates(
|
||||||
|
agentId,
|
||||||
|
(update: AgentUpdate) => {
|
||||||
|
const notification: AgentNotification = update.notification;
|
||||||
|
if (notification.type === "permission") {
|
||||||
|
permissionRequest = notification.request;
|
||||||
|
requestId = notification.requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.sendPrompt(
|
||||||
|
agentId,
|
||||||
|
"Create a file called test.txt with the content 'hello world'"
|
||||||
|
);
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
while (!permissionRequest && attempts < 40) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(permissionRequest).toBeDefined();
|
||||||
|
expect(requestId).toBeDefined();
|
||||||
|
|
||||||
|
console.log("Permission request received, now sending '.' message instead of responding");
|
||||||
|
|
||||||
|
// Instead of responding to permission, send a "." message
|
||||||
|
await manager.sendPrompt(agentId, ".");
|
||||||
|
|
||||||
|
// Wait for agent to finish processing
|
||||||
|
let status = manager.getAgentStatus(agentId);
|
||||||
|
let waitAttempts = 0;
|
||||||
|
while (status === "processing" && waitAttempts < 60) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
status = manager.getAgentStatus(agentId);
|
||||||
|
waitAttempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentInfo = manager.listAgents().find((a) => a.id === agentId);
|
||||||
|
const updates = manager.getAgentUpdates(agentId);
|
||||||
|
const sessionUpdates = updates.filter(u => u.notification.type === "session");
|
||||||
|
|
||||||
|
console.log("Agent status after '.' message:", status);
|
||||||
|
console.log("Agent error:", agentInfo?.error);
|
||||||
|
console.log("Session updates count:", sessionUpdates.length);
|
||||||
|
console.log("Last few updates:", updates.slice(-5).map(u => ({
|
||||||
|
type: u.notification.type,
|
||||||
|
timestamp: u.timestamp,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// The agent should NOT be in failed state
|
||||||
|
expect(status).not.toBe("failed");
|
||||||
|
|
||||||
|
// The agent should be in a usable state (ready, completed, or processing)
|
||||||
|
expect(["ready", "completed", "processing"]).toContain(status);
|
||||||
|
|
||||||
|
// There should be no error
|
||||||
|
expect(agentInfo?.error).toBeNull();
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
describe("persistence", () => {
|
describe("persistence", () => {
|
||||||
it("should load persisted agent and send new prompt", async () => {
|
it("should load persisted agent and send new prompt", async () => {
|
||||||
const manager = new AgentManager();
|
const manager = new AgentManager();
|
||||||
|
|||||||
@@ -416,6 +416,46 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear any pending permissions since we're starting a new turn
|
||||||
|
if (agent.pendingPermissions.size > 0) {
|
||||||
|
console.log(
|
||||||
|
`[Agent ${agentId}] Clearing ${agent.pendingPermissions.size} pending permission(s)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reject all pending permission promises with cancellation
|
||||||
|
for (const [requestId, permission] of agent.pendingPermissions) {
|
||||||
|
permission.resolve({
|
||||||
|
outcome: {
|
||||||
|
outcome: "cancelled" as const,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit permission_resolved notification so UI updates
|
||||||
|
const agentUpdate: AgentUpdate = {
|
||||||
|
agentId,
|
||||||
|
timestamp: new Date(),
|
||||||
|
notification: {
|
||||||
|
type: "permission_resolved",
|
||||||
|
requestId,
|
||||||
|
agentId,
|
||||||
|
optionId: "cancelled",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
agent.updates.push(agentUpdate);
|
||||||
|
|
||||||
|
for (const subscriber of agent.subscribers) {
|
||||||
|
try {
|
||||||
|
subscriber(agentUpdate);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Agent ${agentId}] Subscriber error:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agent.pendingPermissions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Get runtime (guaranteed to exist after ensureInitialized)
|
// Get runtime (guaranteed to exist after ensureInitialized)
|
||||||
if (
|
if (
|
||||||
agent.state.type !== "ready" &&
|
agent.state.type !== "ready" &&
|
||||||
@@ -506,14 +546,14 @@ export class AgentManager {
|
|||||||
stopReason: response.stopReason,
|
stopReason: response.stopReason,
|
||||||
};
|
};
|
||||||
} else if (response.stopReason === "refusal") {
|
} else if (response.stopReason === "refusal") {
|
||||||
console.error(
|
console.warn(
|
||||||
`[Agent ${agentId}] Agent refused to process the prompt`,
|
`[Agent ${agentId}] Agent refused to process the prompt`,
|
||||||
response
|
response
|
||||||
);
|
);
|
||||||
agent.state = {
|
agent.state = {
|
||||||
type: "failed",
|
type: "completed",
|
||||||
lastError: "Agent refused to process the prompt",
|
|
||||||
runtime: agent.state.runtime,
|
runtime: agent.state.runtime,
|
||||||
|
stopReason: response.stopReason,
|
||||||
};
|
};
|
||||||
} else if (response.stopReason === "cancelled") {
|
} else if (response.stopReason === "cancelled") {
|
||||||
agent.state = { type: "ready", runtime: agent.state.runtime };
|
agent.state = { type: "ready", runtime: agent.state.runtime };
|
||||||
|
|||||||
@@ -122,6 +122,11 @@ export const AgentPermissionResponseMessageSchema = z.object({
|
|||||||
optionId: z.string(),
|
optionId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const GitDiffRequestSchema = z.object({
|
||||||
|
type: z.literal("git_diff_request"),
|
||||||
|
agentId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
UserTextMessageSchema,
|
UserTextMessageSchema,
|
||||||
RealtimeAudioChunkMessageSchema,
|
RealtimeAudioChunkMessageSchema,
|
||||||
@@ -137,6 +142,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
InitializeAgentRequestMessageSchema,
|
InitializeAgentRequestMessageSchema,
|
||||||
SetAgentModeMessageSchema,
|
SetAgentModeMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
|
GitDiffRequestSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||||
@@ -321,6 +327,15 @@ export const AgentPermissionResolvedMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const GitDiffResponseSchema = z.object({
|
||||||
|
type: z.literal("git_diff_response"),
|
||||||
|
payload: z.object({
|
||||||
|
agentId: z.string(),
|
||||||
|
diff: z.string(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
ActivityLogMessageSchema,
|
ActivityLogMessageSchema,
|
||||||
AssistantChunkMessageSchema,
|
AssistantChunkMessageSchema,
|
||||||
@@ -338,6 +353,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
DeleteConversationResponseMessageSchema,
|
DeleteConversationResponseMessageSchema,
|
||||||
AgentPermissionRequestMessageSchema,
|
AgentPermissionRequestMessageSchema,
|
||||||
AgentPermissionResolvedMessageSchema,
|
AgentPermissionResolvedMessageSchema,
|
||||||
|
GitDiffResponseSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionOutboundMessage = z.infer<
|
export type SessionOutboundMessage = z.infer<
|
||||||
@@ -374,6 +390,8 @@ export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessage
|
|||||||
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
|
export type InitializeAgentRequestMessage = z.infer<typeof InitializeAgentRequestMessageSchema>;
|
||||||
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
||||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||||
|
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||||
|
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WebSocket Level Messages (wraps session messages)
|
// WebSocket Level Messages (wraps session messages)
|
||||||
|
|||||||
@@ -518,6 +518,10 @@ export class Session {
|
|||||||
msg.optionId
|
msg.optionId
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "git_diff_request":
|
||||||
|
await this.handleGitDiffRequest(msg.agentId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1016,6 +1020,62 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle git diff request for an agent
|
||||||
|
*/
|
||||||
|
private async handleGitDiffRequest(agentId: string): Promise<void> {
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Handling git diff request for agent ${agentId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agents = this.agentManager.listAgents();
|
||||||
|
const agent = agents.find((a) => a.id === agentId);
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
this.emit({
|
||||||
|
type: "git_diff_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
diff: "",
|
||||||
|
error: `Agent not found: ${agentId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await execAsync("git diff HEAD", {
|
||||||
|
cwd: agent.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "git_diff_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
diff: stdout,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Git diff for agent ${agentId} completed (${stdout.length} bytes)`
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] Failed to get git diff for agent ${agentId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "git_diff_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
diff: "",
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send current session state (live agents and commands) to client
|
* Send current session state (live agents and commands) to client
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,5 +6,11 @@ export default defineConfig({
|
|||||||
hookTimeout: 30000,
|
hookTimeout: 30000,
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: "node",
|
environment: "node",
|
||||||
|
pool: "threads",
|
||||||
|
poolOptions: {
|
||||||
|
threads: {
|
||||||
|
singleThread: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user