refactor: deduplicate messages using stable IDs and add permission UI with gesture-based sidebar
```
This commit is contained in:
Mohamed Boudra
2025-10-23 08:37:53 +02:00
parent 30bf4d1b63
commit c6cdc09a0b
7 changed files with 969 additions and 71 deletions
@@ -0,0 +1,339 @@
import { View, Text, Pressable, ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Plus, X } from "lucide-react-native";
import { useEffect, useState } from "react";
import { GestureDetector, Gesture } from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
cancelAnimation,
runOnJS,
Easing,
} from "react-native-reanimated";
import type { AgentStatus } from "@server/server/acp/types";
interface Agent {
id: string;
status: AgentStatus;
title?: string;
cwd: string;
}
interface AgentSidebarProps {
isOpen: boolean;
agents: Agent[];
activeAgentId: string | null;
onClose: () => void;
onSelectAgent: (agentId: string) => void;
onNewAgent: () => void;
edgeSwipeTranslateX?: Animated.SharedValue<number> | null;
}
function getStatusColor(status: AgentStatus): string {
switch (status) {
case "initializing":
return "#FFA500";
case "ready":
return "#2563EB";
case "processing":
return "#FACC15";
case "completed":
return "#10B981";
case "failed":
return "#EF4444";
case "killed":
return "#6B7280";
default:
return "#6B7280";
}
}
export function AgentSidebar({
isOpen,
agents,
activeAgentId,
onClose,
onSelectAgent,
onNewAgent,
edgeSwipeTranslateX,
}: AgentSidebarProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const savedTranslateX = useSharedValue(0);
const [isVisible, setIsVisible] = useState(isOpen);
// Use edgeSwipeTranslateX directly since it's always provided
const translateX = edgeSwipeTranslateX || useSharedValue(-300);
useEffect(() => {
if (isOpen) {
setIsVisible(true);
// Only animate if starting from fully closed position (not mid-animation from edge swipe)
if (translateX.value === -300) {
translateX.value = withTiming(0, {
duration: 150,
easing: Easing.out(Easing.ease),
});
}
} else {
// Only animate if starting from fully open position
if (translateX.value === 0) {
translateX.value = withTiming(-300, {
duration: 150,
easing: Easing.out(Easing.ease),
}, (finished) => {
if (finished) {
runOnJS(setIsVisible)(false);
}
});
} else if (translateX.value === -300) {
// Already closed, just hide
setIsVisible(false);
}
}
}, [isOpen]);
function handleAgentSelect(agentId: string) {
onSelectAgent(agentId);
onClose();
}
// Pan gesture for closing sidebar (swipe left on the sidebar)
const closeGesture = Gesture.Pan()
.enabled(isOpen) // Only active when sidebar is open
.activeOffsetX(-10) // Require leftward swipe
.onBegin(() => {
cancelAnimation(translateX);
savedTranslateX.value = translateX.value;
})
.onChange((event) => {
// Directly update translateX - clamp between -300 and 0
const newTranslateX = Math.max(-300, Math.min(0, savedTranslateX.value + event.translationX));
translateX.value = newTranslateX;
})
.onEnd((event) => {
const shouldClose = translateX.value < -150 || event.velocityX < -500;
if (shouldClose) {
translateX.value = withTiming(-300, {
duration: 150,
easing: Easing.out(Easing.ease),
}, (finished) => {
if (finished) {
runOnJS(setIsVisible)(false);
runOnJS(onClose)();
}
});
} else {
translateX.value = withTiming(0, {
duration: 150,
easing: Easing.out(Easing.ease),
});
}
});
const animatedSidebarStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const animatedBackdropStyle = useAnimatedStyle(() => {
// Map translateX (-300 to 0) to opacity (0 to 1)
const opacity = (translateX.value + 300) / 300;
return { opacity };
});
// Don't render if not visible
if (!isVisible) {
return null;
}
return (
<>
{/* Backdrop - fade in/out with sidebar */}
<Pressable
onPress={onClose}
style={StyleSheet.absoluteFill}
pointerEvents={isOpen ? "auto" : "none"}
>
<Animated.View style={[styles.backdrop, animatedBackdropStyle]} />
</Pressable>
{/* Sidebar with close gesture */}
<GestureDetector gesture={closeGesture}>
<Animated.View
style={[
styles.sidebar,
{
paddingTop: insets.top + 16,
paddingBottom: insets.bottom + 16,
backgroundColor: theme.colors.background,
},
animatedSidebarStyle,
]}
>
{/* Header */}
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: theme.colors.foreground }]}>
Agents
</Text>
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={24} color={theme.colors.mutedForeground} />
</Pressable>
</View>
{/* New Agent Button */}
<Pressable
style={[styles.newAgentButton, { backgroundColor: theme.colors.primary }]}
onPress={() => {
onNewAgent();
onClose();
}}
>
<Plus size={20} color={theme.colors.primaryForeground} />
<Text style={[styles.newAgentText, { color: theme.colors.primaryForeground }]}>
New Agent
</Text>
</Pressable>
{/* Agent List */}
<ScrollView style={styles.agentList} showsVerticalScrollIndicator={false}>
{agents.map((agent) => {
const isActive = agent.id === activeAgentId;
const statusColor = getStatusColor(agent.status);
return (
<Pressable
key={agent.id}
style={[
styles.agentItem,
{ backgroundColor: isActive ? theme.colors.secondary : "transparent" },
]}
onPress={() => handleAgentSelect(agent.id)}
>
<View style={styles.agentInfo}>
{/* Status Indicator */}
<View
style={[styles.statusDot, { backgroundColor: statusColor }]}
/>
{/* Agent Title */}
<View style={styles.agentContent}>
<Text
style={[
styles.agentTitle,
{ color: theme.colors.foreground },
]}
numberOfLines={1}
>
{agent.title || "New Agent"}
</Text>
{/* Agent Directory */}
<Text
style={[styles.agentDirectory, { color: theme.colors.mutedForeground }]}
numberOfLines={1}
>
{agent.cwd}
</Text>
</View>
</View>
</Pressable>
);
})}
{agents.length === 0 && (
<View style={styles.emptyState}>
<Text style={[styles.emptyText, { color: theme.colors.mutedForeground }]}>
No agents yet
</Text>
</View>
)}
</ScrollView>
</Animated.View>
</GestureDetector>
</>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
zIndex: 998,
},
sidebar: {
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 300,
zIndex: 999,
paddingHorizontal: 16,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 16,
},
headerTitle: {
fontSize: 24,
fontWeight: "700",
},
closeButton: {
padding: 4,
},
newAgentButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 12,
paddingHorizontal: 16,
borderRadius: 8,
marginBottom: 16,
gap: 8,
},
newAgentText: {
fontSize: 16,
fontWeight: "600",
},
agentList: {
flex: 1,
},
agentItem: {
paddingVertical: 12,
paddingHorizontal: 12,
borderRadius: 8,
marginBottom: 4,
},
agentInfo: {
flexDirection: "row",
alignItems: "center",
gap: 12,
},
statusDot: {
width: 8,
height: 8,
borderRadius: 4,
},
agentContent: {
flex: 1,
},
agentTitle: {
fontSize: 15,
fontWeight: "500",
marginBottom: 2,
},
agentDirectory: {
fontSize: 12,
},
emptyState: {
paddingVertical: 32,
alignItems: "center",
},
emptyText: {
fontSize: 14,
},
});
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { StyleSheet } from 'react-native-unistyles';
import { StyleSheet, useUnistyles } from 'react-native-unistyles';
import type { AgentStatus } from '@server/server/acp/types';
import { AssistantMessage, UserMessage, ActivityLog, ToolCall } from './message';
import type { StreamItem } from '@/types/stream';
@@ -15,12 +15,29 @@ export interface AgentStreamViewProps {
type: 'claude';
};
streamItems: StreamItem[];
pendingPermissions: Map<
string,
{
agentId: string;
requestId: string;
sessionId: string;
toolCall: any;
options: Array<{
kind: string;
name: string;
optionId: string;
}>;
}
>;
onPermissionResponse: (requestId: string, optionId: string) => void;
}
export function AgentStreamView({
agentId,
agent,
streamItems,
pendingPermissions,
onPermissionResponse,
}: AgentStreamViewProps) {
const scrollViewRef = useRef<ScrollView>(null);
const insets = useSafeAreaInsets();
@@ -108,11 +125,141 @@ export function AgentStreamView({
}
})
)}
{/* Render pending permissions for this agent */}
{Array.from(pendingPermissions.values())
.filter((perm) => perm.agentId === agentId)
.map((permission) => (
<PermissionRequestCard
key={permission.requestId}
permission={permission}
onResponse={onPermissionResponse}
/>
))}
</ScrollView>
</View>
);
}
// Permission Request Card Component
function PermissionRequestCard({
permission,
onResponse,
}: {
permission: {
requestId: string;
toolCall: any;
options: Array<{
kind: string;
name: string;
optionId: string;
}>;
};
onResponse: (requestId: string, optionId: string) => void;
}) {
const { theme } = useUnistyles();
// Determine permission type and content based on toolCall
const getPermissionInfo = () => {
const rawInput = permission.toolCall?.rawInput || {};
const toolCallId = permission.toolCall?.toolCallId || '';
console.log('[PermissionCard] Tool call details:', {
toolCallId,
rawInputKeys: Object.keys(rawInput),
rawInput,
});
// Check if this is a plan (ExitPlanMode)
if (rawInput.plan) {
return {
title: 'Plan Ready for Review',
content: rawInput.plan,
type: 'plan' as const,
};
}
// Check if this is a file operation (Write, Edit, etc.)
if (rawInput.file_path) {
const operation = toolCallId.includes('Write') ? 'Create' : 'Edit';
const fileContent = rawInput.content || rawInput.new_string || '';
const preview = fileContent.length > 500
? fileContent.slice(0, 500) + '\n\n... (truncated)'
: fileContent;
return {
title: `${operation} File Permission`,
content: `File: ${rawInput.file_path}\n\n${preview || '(empty file)'}`,
type: 'file' as const,
};
}
// Check if this is a command (Bash)
if (rawInput.command) {
return {
title: 'Run Command Permission',
content: `Command: ${rawInput.command}\n\nDescription: ${rawInput.description || 'No description'}`,
type: 'command' as const,
};
}
// Fallback - show whatever is in rawInput
return {
title: 'Permission Required',
content: JSON.stringify(rawInput, null, 2),
type: 'unknown' as const,
};
};
const permissionInfo = getPermissionInfo();
return (
<View
style={[
permissionStyles.container,
{ backgroundColor: theme.colors.secondary, borderColor: theme.colors.border },
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>
{permissionInfo.title}
</Text>
{permissionInfo.content && (
<View style={[permissionStyles.planContainer, { backgroundColor: theme.colors.background }]}>
<Text style={[permissionStyles.planText, { color: theme.colors.foreground }]}>
{permissionInfo.content}
</Text>
</View>
)}
<Text style={[permissionStyles.question, { color: theme.colors.mutedForeground }]}>
How would you like to proceed?
</Text>
<View style={permissionStyles.optionsContainer}>
{permission.options.map((option) => (
<Pressable
key={option.optionId}
style={[
permissionStyles.optionButton,
{
backgroundColor: option.kind.includes('reject')
? theme.colors.destructive
: theme.colors.primary,
},
]}
onPress={() => onResponse(permission.requestId, option.optionId)}
>
<Text style={[permissionStyles.optionText, { color: theme.colors.primaryForeground }]}>
{option.name}
</Text>
</Pressable>
))}
</View>
</View>
);
}
const stylesheet = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -134,3 +281,42 @@ const stylesheet = StyleSheet.create((theme) => ({
textAlign: "center",
},
}));
const permissionStyles = StyleSheet.create((theme) => ({
container: {
marginVertical: theme.spacing[3],
padding: theme.spacing[4],
borderRadius: theme.spacing[2],
borderWidth: 1,
},
title: {
fontSize: 16,
fontWeight: "600",
marginBottom: theme.spacing[3],
},
planContainer: {
padding: theme.spacing[3],
borderRadius: theme.spacing[1],
marginBottom: theme.spacing[3],
},
planText: {
fontSize: 14,
lineHeight: 20,
},
question: {
fontSize: 14,
marginBottom: theme.spacing[3],
},
optionsContainer: {
gap: theme.spacing[2],
},
optionButton: {
padding: theme.spacing[3],
borderRadius: theme.spacing[1],
alignItems: "center",
},
optionText: {
fontSize: 14,
fontWeight: "600",
},
}));