```
refactor: deduplicate messages using stable IDs and add permission UI with gesture-based sidebar ```
This commit is contained in:
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user