mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
```
refactor: deduplicate messages using stable IDs and add permission UI with gesture-based sidebar ```
This commit is contained in:
@@ -1,17 +1,20 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { KeyboardProvider } from 'react-native-keyboard-controller';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
</Stack>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
</Stack>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Modal,
|
||||
} from "react-native";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { GestureDetector, Gesture } from "react-native-gesture-handler";
|
||||
import ReanimatedAnimated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -19,6 +20,9 @@ import ReanimatedAnimated, {
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
cancelAnimation,
|
||||
runOnJS,
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { router } from "expo-router";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
@@ -44,7 +48,7 @@ import {
|
||||
ToolCall,
|
||||
} from "@/components/message";
|
||||
import { ArtifactDrawer, type Artifact } from "@/components/artifact-drawer";
|
||||
import { ActiveProcesses } from "@/components/active-processes";
|
||||
import { AgentSidebar } from "@/components/agent-sidebar";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { ConversationSelector } from "@/components/conversation-selector";
|
||||
import { VolumeMeter } from "@/components/volume-meter";
|
||||
@@ -59,6 +63,8 @@ import {
|
||||
MicOff,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
Menu,
|
||||
Home,
|
||||
} from "lucide-react-native";
|
||||
import type {
|
||||
ActivityLogPayload,
|
||||
@@ -124,6 +130,8 @@ interface Agent {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
title?: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
@@ -338,6 +346,9 @@ export default function VoiceAssistantScreen() {
|
||||
// Agent creation modal state
|
||||
const [showCreateAgentModal, setShowCreateAgentModal] = useState(false);
|
||||
|
||||
// Agent sidebar state
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
// Mode selector modal state
|
||||
const [showModeSelector, setShowModeSelector] = useState(false);
|
||||
|
||||
@@ -347,6 +358,22 @@ export default function VoiceAssistantScreen() {
|
||||
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(
|
||||
new Map()
|
||||
);
|
||||
const [pendingPermissions, setPendingPermissions] = useState<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
agentId: string;
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
toolCall: any;
|
||||
options: Array<{
|
||||
kind: string;
|
||||
name: string;
|
||||
optionId: string;
|
||||
}>;
|
||||
}
|
||||
>
|
||||
>(new Map());
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
|
||||
@@ -417,7 +444,7 @@ export default function VoiceAssistantScreen() {
|
||||
// Agent created handler
|
||||
const unsubAgentCreated = ws.on("agent_created", (message) => {
|
||||
if (message.type !== "agent_created") return;
|
||||
const { agentId, status, type, currentModeId, availableModes } =
|
||||
const { agentId, status, type, currentModeId, availableModes, title, cwd } =
|
||||
message.payload;
|
||||
|
||||
console.log("[App] Agent created:", agentId, "currentModeId:", currentModeId, "availableModes:", availableModes);
|
||||
@@ -427,6 +454,8 @@ export default function VoiceAssistantScreen() {
|
||||
status: status as AgentStatus,
|
||||
type,
|
||||
createdAt: new Date(),
|
||||
title,
|
||||
cwd,
|
||||
currentModeId,
|
||||
availableModes,
|
||||
};
|
||||
@@ -440,6 +469,29 @@ export default function VoiceAssistantScreen() {
|
||||
});
|
||||
|
||||
// Agent update handler
|
||||
// Agent permission request handler
|
||||
const unsubAgentPermissionRequest = ws.on("agent_permission_request", (message) => {
|
||||
console.log("[App] agent_permission_request message received:", message.type);
|
||||
if (message.type !== "agent_permission_request") return;
|
||||
const { agentId, requestId, sessionId, toolCall, options } = message.payload;
|
||||
|
||||
console.log("[App] Permission request received:", requestId, "for agent:", agentId);
|
||||
console.log("[App] Permission details:", { agentId, requestId, toolCall, options });
|
||||
|
||||
// Store pending permission
|
||||
setPendingPermissions((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(requestId, {
|
||||
agentId,
|
||||
requestId,
|
||||
sessionId,
|
||||
toolCall,
|
||||
options,
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
const unsubAgentUpdate = ws.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update") return;
|
||||
const { agentId, timestamp, notification } = message.payload;
|
||||
@@ -488,6 +540,8 @@ export default function VoiceAssistantScreen() {
|
||||
error: info.error,
|
||||
currentModeId: info.currentModeId,
|
||||
availableModes: info.availableModes,
|
||||
title: info.title,
|
||||
cwd: info.cwd,
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
@@ -803,6 +857,7 @@ export default function VoiceAssistantScreen() {
|
||||
return () => {
|
||||
unsubSessionState();
|
||||
unsubAgentCreated();
|
||||
unsubAgentPermissionRequest();
|
||||
unsubAgentUpdate();
|
||||
unsubAgentStatus();
|
||||
unsubActivity();
|
||||
@@ -1002,6 +1057,36 @@ export default function VoiceAssistantScreen() {
|
||||
setShowModeSelector(false);
|
||||
}
|
||||
|
||||
// Permission response handler
|
||||
function handlePermissionResponse(requestId: string, optionId: string) {
|
||||
const permission = pendingPermissions.get(requestId);
|
||||
if (!permission) {
|
||||
console.error("[App] Permission not found:", requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[App] Responding to permission:", requestId, "with option:", optionId);
|
||||
|
||||
const message: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "agent_permission_response",
|
||||
agentId: permission.agentId,
|
||||
requestId,
|
||||
optionId,
|
||||
},
|
||||
};
|
||||
|
||||
ws.send(message);
|
||||
|
||||
// Remove from pending permissions
|
||||
setPendingPermissions((prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.delete(requestId);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
// Text message handlers
|
||||
function handleSendMessage() {
|
||||
if (!userInput.trim() || !ws.isConnected) return;
|
||||
@@ -1171,6 +1256,7 @@ export default function VoiceAssistantScreen() {
|
||||
setAgents(new Map());
|
||||
setCommands(new Map());
|
||||
setAgentUpdates(new Map());
|
||||
setPendingPermissions(new Map());
|
||||
setViewMode("orchestrator");
|
||||
setActiveAgentId(null);
|
||||
|
||||
@@ -1191,6 +1277,48 @@ export default function VoiceAssistantScreen() {
|
||||
const agent = activeAgentId ? agents.get(activeAgentId) : null;
|
||||
const streamItems = activeAgentId ? (agentStreamState.get(activeAgentId) || []) : [];
|
||||
|
||||
// Edge swipe state
|
||||
const edgeSwipeTranslateX = useSharedValue(-300);
|
||||
const isEdgeSwiping = useSharedValue(false);
|
||||
|
||||
// Edge swipe gesture to open sidebar
|
||||
const edgeSwipeGesture = Gesture.Pan()
|
||||
.enabled(!sidebarOpen)
|
||||
.activeOffsetX(10) // require a rightward intent
|
||||
.failOffsetY([-15, 15]) // ignore primarily vertical swipes
|
||||
.onStart(() => {
|
||||
isEdgeSwiping.value = true;
|
||||
cancelAnimation(edgeSwipeTranslateX);
|
||||
edgeSwipeTranslateX.value = -300;
|
||||
})
|
||||
.onChange((event) => {
|
||||
if (!isEdgeSwiping.value) {
|
||||
return;
|
||||
}
|
||||
// Move sidebar from -300 (closed) to 0 (open) as the finger moves
|
||||
const newX = -300 + event.translationX;
|
||||
edgeSwipeTranslateX.value = Math.max(-300, Math.min(0, newX));
|
||||
})
|
||||
.onEnd((event) => {
|
||||
if (!isEdgeSwiping.value) {
|
||||
edgeSwipeTranslateX.value = -300;
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldOpen = edgeSwipeTranslateX.value > -150 || event.velocityX > 500;
|
||||
|
||||
edgeSwipeTranslateX.value = withTiming(shouldOpen ? 0 : -300, {
|
||||
duration: 150,
|
||||
easing: Easing.out(Easing.ease),
|
||||
});
|
||||
|
||||
if (shouldOpen) {
|
||||
runOnJS(setSidebarOpen)(true);
|
||||
}
|
||||
|
||||
isEdgeSwiping.value = false;
|
||||
});
|
||||
|
||||
// Render main view with shared structure
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -1199,6 +1327,28 @@ export default function VoiceAssistantScreen() {
|
||||
<View style={{ paddingTop: insets.top + 16 }}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.headerLeft}>
|
||||
{/* Menu button to open sidebar */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
edgeSwipeTranslateX.value = -300;
|
||||
setSidebarOpen(true);
|
||||
}}
|
||||
style={styles.menuButton}
|
||||
>
|
||||
<Menu size={24} color="white" />
|
||||
</Pressable>
|
||||
|
||||
{/* Orchestrator button (only show in agent view) */}
|
||||
{viewMode === "agent" && (
|
||||
<Pressable
|
||||
onPress={handleBackToOrchestrator}
|
||||
style={styles.orchestratorButton}
|
||||
>
|
||||
<Home size={20} color="white" />
|
||||
<Text style={styles.orchestratorButtonText}>Orchestrator</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<ConnectionStatus isConnected={ws.isConnected} />
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
@@ -1207,16 +1357,6 @@ export default function VoiceAssistantScreen() {
|
||||
onSelectConversation={handleSelectConversation}
|
||||
websocket={ws}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => setShowCreateAgentModal(true)}
|
||||
disabled={!ws.isConnected}
|
||||
style={[
|
||||
styles.settingsButton,
|
||||
!ws.isConnected && styles.buttonDisabled,
|
||||
]}
|
||||
>
|
||||
<Plus size={20} color="white" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => router.push("/settings")}
|
||||
style={styles.settingsButton}
|
||||
@@ -1226,22 +1366,13 @@ export default function VoiceAssistantScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Active processes bar */}
|
||||
<ActiveProcesses
|
||||
agents={Array.from(agents.values())}
|
||||
commands={Array.from(commands.values())}
|
||||
viewMode={viewMode}
|
||||
activeAgentId={activeAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSelectOrchestrator={handleBackToOrchestrator}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Content Area with Keyboard Handling */}
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.contentArea, animatedKeyboardStyle]}
|
||||
>
|
||||
<GestureDetector gesture={edgeSwipeGesture}>
|
||||
<ReanimatedAnimated.View
|
||||
style={[styles.contentArea, animatedKeyboardStyle]}
|
||||
>
|
||||
{/* Conditionally render content based on view mode */}
|
||||
{viewMode === "agent" && activeAgentId && agent ? (
|
||||
// Agent view - render AgentStreamView
|
||||
@@ -1249,6 +1380,8 @@ export default function VoiceAssistantScreen() {
|
||||
agentId={activeAgentId}
|
||||
agent={agent}
|
||||
streamItems={streamItems}
|
||||
pendingPermissions={pendingPermissions}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
/>
|
||||
) : viewMode === "agent" && activeAgentId && !agent ? (
|
||||
// Agent not found
|
||||
@@ -1509,7 +1642,8 @@ export default function VoiceAssistantScreen() {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ReanimatedAnimated.View>
|
||||
</ReanimatedAnimated.View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Artifact drawer */}
|
||||
<ArtifactDrawer
|
||||
@@ -1563,6 +1697,21 @@ export default function VoiceAssistantScreen() {
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
{/* Agent sidebar */}
|
||||
<AgentSidebar
|
||||
isOpen={sidebarOpen}
|
||||
agents={Array.from(agents.values())}
|
||||
activeAgentId={activeAgentId}
|
||||
onClose={() => {
|
||||
setSidebarOpen(false);
|
||||
// Reset edge swipe position when closing
|
||||
edgeSwipeTranslateX.value = -300;
|
||||
}}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onNewAgent={() => setShowCreateAgentModal(true)}
|
||||
edgeSwipeTranslateX={edgeSwipeTranslateX}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1600,6 +1749,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
headerLeft: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
menuButton: {
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
orchestratorButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
backgroundColor: theme.colors.muted,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
orchestratorButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: "row",
|
||||
|
||||
339
packages/app/src/components/agent-sidebar.tsx
Normal file
339
packages/app/src/components/agent-sidebar.tsx
Normal file
@@ -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",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
// Only session messages trigger handlers
|
||||
if (wsMessage.type === 'session') {
|
||||
const sessionMessage = wsMessage.message;
|
||||
// console.log(`[WS] Received session message type: ${sessionMessage.type}`);
|
||||
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
|
||||
|
||||
// Track conversation ID when loaded
|
||||
if (sessionMessage.type === 'conversation_loaded') {
|
||||
|
||||
@@ -33,19 +33,27 @@ function deriveUserMessageId(text: string, timestamp: Date, messageId?: string):
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive deterministic ID for assistant message based on index in state
|
||||
* Derive deterministic ID for assistant message
|
||||
* Now uses stable messageId from server if available
|
||||
*/
|
||||
function deriveAssistantMessageId(state: StreamItem[]): string {
|
||||
const count = state.filter(s => s.kind === 'assistant_message').length;
|
||||
return `assistant_${count}`;
|
||||
function deriveAssistantMessageId(messageId: string | undefined, timestamp: Date): string {
|
||||
if (messageId) {
|
||||
return `assistant_${messageId}`;
|
||||
}
|
||||
// Fallback for messages without messageId (shouldn't happen with enriched updates)
|
||||
return `assistant_${timestamp.getTime()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive deterministic ID for thought based on index in state
|
||||
* Derive deterministic ID for thought
|
||||
* Now uses stable messageId from server if available
|
||||
*/
|
||||
function deriveThoughtId(state: StreamItem[]): string {
|
||||
const count = state.filter(s => s.kind === 'thought').length;
|
||||
return `thought_${count}`;
|
||||
function deriveThoughtId(messageId: string | undefined, timestamp: Date): string {
|
||||
if (messageId) {
|
||||
return `thought_${messageId}`;
|
||||
}
|
||||
// Fallback for thoughts without messageId (shouldn't happen with enriched updates)
|
||||
return `thought_${timestamp.getTime()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,8 +137,8 @@ export interface ArtifactItem {
|
||||
*/
|
||||
type ParsedNotification =
|
||||
| { kind: 'user_message_chunk'; text: string; messageId?: string }
|
||||
| { kind: 'agent_message_chunk'; text: string }
|
||||
| { kind: 'agent_thought_chunk'; text: string }
|
||||
| { kind: 'agent_message_chunk'; text: string; messageId?: string }
|
||||
| { kind: 'agent_thought_chunk'; text: string; messageId?: string }
|
||||
| { kind: 'tool_call'; toolCallId: string; title: string; status?: string; toolKind?: string; rawInput?: any; rawOutput?: any; content?: any[]; locations?: any[] }
|
||||
| { kind: 'tool_call_update'; toolCallId: string; title?: string | null; status?: string | null; toolKind?: string | null; rawInput?: any; rawOutput?: any; content?: any[] | null; locations?: any[] | null }
|
||||
| { kind: 'plan'; entries: Array<{ content: string; status: string; priority: string }> }
|
||||
@@ -162,12 +170,14 @@ function parseNotification(notification: SessionNotification | any): ParsedNotif
|
||||
return {
|
||||
kind: 'agent_message_chunk',
|
||||
text: update.content?.text || '',
|
||||
messageId: update.messageId,
|
||||
};
|
||||
|
||||
case 'agent_thought_chunk':
|
||||
return {
|
||||
kind: 'agent_thought_chunk',
|
||||
text: update.content?.text || '',
|
||||
messageId: update.messageId,
|
||||
};
|
||||
|
||||
case 'tool_call':
|
||||
@@ -259,21 +269,26 @@ export function reduceStreamUpdate(
|
||||
}];
|
||||
}
|
||||
|
||||
// Agent message chunks - accumulate into last assistant message or create new
|
||||
// Agent message chunks - accumulate into message with matching ID or create new
|
||||
if (parsed.kind === 'agent_message_chunk') {
|
||||
const last = state[state.length - 1];
|
||||
if (last?.kind === 'assistant_message') {
|
||||
const id = deriveAssistantMessageId(parsed.messageId, timestamp);
|
||||
|
||||
// Find existing message with this ID
|
||||
const existingIndex = state.findIndex(
|
||||
item => item.kind === 'assistant_message' && item.id === id
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Append to existing message
|
||||
return [
|
||||
...state.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
text: last.text + parsed.text,
|
||||
},
|
||||
];
|
||||
const existing = state[existingIndex] as AssistantMessageItem;
|
||||
return state.map((item, idx) =>
|
||||
idx === existingIndex
|
||||
? { ...existing, text: existing.text + parsed.text }
|
||||
: item
|
||||
);
|
||||
}
|
||||
// Create new message with deterministic ID
|
||||
const id = deriveAssistantMessageId(state);
|
||||
|
||||
// Create new message with stable ID
|
||||
return [...state, {
|
||||
kind: 'assistant_message',
|
||||
id,
|
||||
@@ -282,21 +297,26 @@ export function reduceStreamUpdate(
|
||||
}];
|
||||
}
|
||||
|
||||
// Thought chunks - accumulate into last thought or create new
|
||||
// Thought chunks - accumulate into thought with matching ID or create new
|
||||
if (parsed.kind === 'agent_thought_chunk') {
|
||||
const last = state[state.length - 1];
|
||||
if (last?.kind === 'thought') {
|
||||
const id = deriveThoughtId(parsed.messageId, timestamp);
|
||||
|
||||
// Find existing thought with this ID
|
||||
const existingIndex = state.findIndex(
|
||||
item => item.kind === 'thought' && item.id === id
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Append to existing thought
|
||||
return [
|
||||
...state.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
text: last.text + parsed.text,
|
||||
},
|
||||
];
|
||||
const existing = state[existingIndex] as ThoughtItem;
|
||||
return state.map((item, idx) =>
|
||||
idx === existingIndex
|
||||
? { ...existing, text: existing.text + parsed.text }
|
||||
: item
|
||||
);
|
||||
}
|
||||
// Create new thought with deterministic ID
|
||||
const id = deriveThoughtId(state);
|
||||
|
||||
// Create new thought with stable ID
|
||||
return [...state, {
|
||||
kind: 'thought',
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user