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

View File

@@ -1,17 +1,20 @@
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import { KeyboardProvider } from 'react-native-keyboard-controller'; import { KeyboardProvider } from 'react-native-keyboard-controller';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function RootLayout() { export default function RootLayout() {
return ( return (
<SafeAreaProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider> <SafeAreaProvider>
<Stack screenOptions={{ headerShown: false }}> <KeyboardProvider>
<Stack.Screen name="index" /> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="settings" /> <Stack.Screen name="index" />
<Stack.Screen name="audio-test" /> <Stack.Screen name="settings" />
</Stack> <Stack.Screen name="audio-test" />
</KeyboardProvider> </Stack>
</SafeAreaProvider> </KeyboardProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
); );
} }

View File

@@ -12,6 +12,7 @@ import {
Modal, Modal,
} from "react-native"; } from "react-native";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { GestureDetector, Gesture } from "react-native-gesture-handler";
import ReanimatedAnimated, { import ReanimatedAnimated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
@@ -19,6 +20,9 @@ import ReanimatedAnimated, {
withRepeat, withRepeat,
withSequence, withSequence,
withTiming, withTiming,
cancelAnimation,
runOnJS,
Easing,
} from "react-native-reanimated"; } from "react-native-reanimated";
import { router } from "expo-router"; import { router } from "expo-router";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
@@ -44,7 +48,7 @@ import {
ToolCall, ToolCall,
} from "@/components/message"; } from "@/components/message";
import { ArtifactDrawer, type Artifact } from "@/components/artifact-drawer"; 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 { AgentStreamView } from "@/components/agent-stream-view";
import { ConversationSelector } from "@/components/conversation-selector"; import { ConversationSelector } from "@/components/conversation-selector";
import { VolumeMeter } from "@/components/volume-meter"; import { VolumeMeter } from "@/components/volume-meter";
@@ -59,6 +63,8 @@ import {
MicOff, MicOff,
Plus, Plus,
ChevronDown, ChevronDown,
Menu,
Home,
} from "lucide-react-native"; } from "lucide-react-native";
import type { import type {
ActivityLogPayload, ActivityLogPayload,
@@ -124,6 +130,8 @@ interface Agent {
name: string; name: string;
description?: string | null; description?: string | null;
}>; }>;
title?: string;
cwd: string;
} }
interface Command { interface Command {
@@ -338,6 +346,9 @@ export default function VoiceAssistantScreen() {
// Agent creation modal state // Agent creation modal state
const [showCreateAgentModal, setShowCreateAgentModal] = useState(false); const [showCreateAgentModal, setShowCreateAgentModal] = useState(false);
// Agent sidebar state
const [sidebarOpen, setSidebarOpen] = useState(false);
// Mode selector modal state // Mode selector modal state
const [showModeSelector, setShowModeSelector] = useState(false); const [showModeSelector, setShowModeSelector] = useState(false);
@@ -347,6 +358,22 @@ export default function VoiceAssistantScreen() {
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>( const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(
new Map() 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); const scrollViewRef = useRef<ScrollView>(null);
@@ -417,7 +444,7 @@ export default function VoiceAssistantScreen() {
// Agent created handler // Agent created handler
const unsubAgentCreated = ws.on("agent_created", (message) => { const unsubAgentCreated = ws.on("agent_created", (message) => {
if (message.type !== "agent_created") return; if (message.type !== "agent_created") return;
const { agentId, status, type, currentModeId, availableModes } = const { agentId, status, type, currentModeId, availableModes, title, cwd } =
message.payload; message.payload;
console.log("[App] Agent created:", agentId, "currentModeId:", currentModeId, "availableModes:", availableModes); console.log("[App] Agent created:", agentId, "currentModeId:", currentModeId, "availableModes:", availableModes);
@@ -427,6 +454,8 @@ export default function VoiceAssistantScreen() {
status: status as AgentStatus, status: status as AgentStatus,
type, type,
createdAt: new Date(), createdAt: new Date(),
title,
cwd,
currentModeId, currentModeId,
availableModes, availableModes,
}; };
@@ -440,6 +469,29 @@ export default function VoiceAssistantScreen() {
}); });
// Agent update handler // 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) => { const unsubAgentUpdate = ws.on("agent_update", (message) => {
if (message.type !== "agent_update") return; if (message.type !== "agent_update") return;
const { agentId, timestamp, notification } = message.payload; const { agentId, timestamp, notification } = message.payload;
@@ -488,6 +540,8 @@ export default function VoiceAssistantScreen() {
error: info.error, error: info.error,
currentModeId: info.currentModeId, currentModeId: info.currentModeId,
availableModes: info.availableModes, availableModes: info.availableModes,
title: info.title,
cwd: info.cwd,
}); });
} }
return updated; return updated;
@@ -803,6 +857,7 @@ export default function VoiceAssistantScreen() {
return () => { return () => {
unsubSessionState(); unsubSessionState();
unsubAgentCreated(); unsubAgentCreated();
unsubAgentPermissionRequest();
unsubAgentUpdate(); unsubAgentUpdate();
unsubAgentStatus(); unsubAgentStatus();
unsubActivity(); unsubActivity();
@@ -1002,6 +1057,36 @@ export default function VoiceAssistantScreen() {
setShowModeSelector(false); 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 // Text message handlers
function handleSendMessage() { function handleSendMessage() {
if (!userInput.trim() || !ws.isConnected) return; if (!userInput.trim() || !ws.isConnected) return;
@@ -1171,6 +1256,7 @@ export default function VoiceAssistantScreen() {
setAgents(new Map()); setAgents(new Map());
setCommands(new Map()); setCommands(new Map());
setAgentUpdates(new Map()); setAgentUpdates(new Map());
setPendingPermissions(new Map());
setViewMode("orchestrator"); setViewMode("orchestrator");
setActiveAgentId(null); setActiveAgentId(null);
@@ -1191,6 +1277,48 @@ export default function VoiceAssistantScreen() {
const agent = activeAgentId ? agents.get(activeAgentId) : null; const agent = activeAgentId ? agents.get(activeAgentId) : null;
const streamItems = activeAgentId ? (agentStreamState.get(activeAgentId) || []) : []; 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 // Render main view with shared structure
return ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -1199,6 +1327,28 @@ export default function VoiceAssistantScreen() {
<View style={{ paddingTop: insets.top + 16 }}> <View style={{ paddingTop: insets.top + 16 }}>
<View style={styles.headerRow}> <View style={styles.headerRow}>
<View style={styles.headerLeft}> <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} /> <ConnectionStatus isConnected={ws.isConnected} />
</View> </View>
<View style={styles.headerRight}> <View style={styles.headerRight}>
@@ -1207,16 +1357,6 @@ export default function VoiceAssistantScreen() {
onSelectConversation={handleSelectConversation} onSelectConversation={handleSelectConversation}
websocket={ws} websocket={ws}
/> />
<Pressable
onPress={() => setShowCreateAgentModal(true)}
disabled={!ws.isConnected}
style={[
styles.settingsButton,
!ws.isConnected && styles.buttonDisabled,
]}
>
<Plus size={20} color="white" />
</Pressable>
<Pressable <Pressable
onPress={() => router.push("/settings")} onPress={() => router.push("/settings")}
style={styles.settingsButton} style={styles.settingsButton}
@@ -1226,22 +1366,13 @@ export default function VoiceAssistantScreen() {
</View> </View>
</View> </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> </View>
{/* Content Area with Keyboard Handling */} {/* Content Area with Keyboard Handling */}
<ReanimatedAnimated.View <GestureDetector gesture={edgeSwipeGesture}>
style={[styles.contentArea, animatedKeyboardStyle]} <ReanimatedAnimated.View
> style={[styles.contentArea, animatedKeyboardStyle]}
>
{/* Conditionally render content based on view mode */} {/* Conditionally render content based on view mode */}
{viewMode === "agent" && activeAgentId && agent ? ( {viewMode === "agent" && activeAgentId && agent ? (
// Agent view - render AgentStreamView // Agent view - render AgentStreamView
@@ -1249,6 +1380,8 @@ export default function VoiceAssistantScreen() {
agentId={activeAgentId} agentId={activeAgentId}
agent={agent} agent={agent}
streamItems={streamItems} streamItems={streamItems}
pendingPermissions={pendingPermissions}
onPermissionResponse={handlePermissionResponse}
/> />
) : viewMode === "agent" && activeAgentId && !agent ? ( ) : viewMode === "agent" && activeAgentId && !agent ? (
// Agent not found // Agent not found
@@ -1509,7 +1642,8 @@ export default function VoiceAssistantScreen() {
</View> </View>
)} )}
</View> </View>
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
</GestureDetector>
{/* Artifact drawer */} {/* Artifact drawer */}
<ArtifactDrawer <ArtifactDrawer
@@ -1563,6 +1697,21 @@ export default function VoiceAssistantScreen() {
</View> </View>
</Pressable> </Pressable>
</Modal> </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> </View>
); );
} }
@@ -1600,6 +1749,26 @@ const styles = StyleSheet.create((theme) => ({
}, },
headerLeft: { headerLeft: {
flex: 1, 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: { headerRight: {
flexDirection: "row", flexDirection: "row",

View 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,
},
});

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native'; import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; 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 type { AgentStatus } from '@server/server/acp/types';
import { AssistantMessage, UserMessage, ActivityLog, ToolCall } from './message'; import { AssistantMessage, UserMessage, ActivityLog, ToolCall } from './message';
import type { StreamItem } from '@/types/stream'; import type { StreamItem } from '@/types/stream';
@@ -15,12 +15,29 @@ export interface AgentStreamViewProps {
type: 'claude'; type: 'claude';
}; };
streamItems: StreamItem[]; 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({ export function AgentStreamView({
agentId, agentId,
agent, agent,
streamItems, streamItems,
pendingPermissions,
onPermissionResponse,
}: AgentStreamViewProps) { }: AgentStreamViewProps) {
const scrollViewRef = useRef<ScrollView>(null); const scrollViewRef = useRef<ScrollView>(null);
const insets = useSafeAreaInsets(); 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> </ScrollView>
</View> </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) => ({ const stylesheet = StyleSheet.create((theme) => ({
container: { container: {
flex: 1, flex: 1,
@@ -134,3 +281,42 @@ const stylesheet = StyleSheet.create((theme) => ({
textAlign: "center", 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",
},
}));

View File

@@ -58,7 +58,7 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
// Only session messages trigger handlers // Only session messages trigger handlers
if (wsMessage.type === 'session') { if (wsMessage.type === 'session') {
const sessionMessage = wsMessage.message; 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 // Track conversation ID when loaded
if (sessionMessage.type === 'conversation_loaded') { if (sessionMessage.type === 'conversation_loaded') {

View File

@@ -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 { function deriveAssistantMessageId(messageId: string | undefined, timestamp: Date): string {
const count = state.filter(s => s.kind === 'assistant_message').length; if (messageId) {
return `assistant_${count}`; 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 { function deriveThoughtId(messageId: string | undefined, timestamp: Date): string {
const count = state.filter(s => s.kind === 'thought').length; if (messageId) {
return `thought_${count}`; 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 = type ParsedNotification =
| { kind: 'user_message_chunk'; text: string; messageId?: string } | { kind: 'user_message_chunk'; text: string; messageId?: string }
| { kind: 'agent_message_chunk'; text: string } | { kind: 'agent_message_chunk'; text: string; messageId?: string }
| { kind: 'agent_thought_chunk'; text: 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'; 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: '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 }> } | { kind: 'plan'; entries: Array<{ content: string; status: string; priority: string }> }
@@ -162,12 +170,14 @@ function parseNotification(notification: SessionNotification | any): ParsedNotif
return { return {
kind: 'agent_message_chunk', kind: 'agent_message_chunk',
text: update.content?.text || '', text: update.content?.text || '',
messageId: update.messageId,
}; };
case 'agent_thought_chunk': case 'agent_thought_chunk':
return { return {
kind: 'agent_thought_chunk', kind: 'agent_thought_chunk',
text: update.content?.text || '', text: update.content?.text || '',
messageId: update.messageId,
}; };
case 'tool_call': 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') { if (parsed.kind === 'agent_message_chunk') {
const last = state[state.length - 1]; const id = deriveAssistantMessageId(parsed.messageId, timestamp);
if (last?.kind === 'assistant_message') {
// 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 // Append to existing message
return [ const existing = state[existingIndex] as AssistantMessageItem;
...state.slice(0, -1), return state.map((item, idx) =>
{ idx === existingIndex
...last, ? { ...existing, text: existing.text + parsed.text }
text: last.text + parsed.text, : item
}, );
];
} }
// Create new message with deterministic ID
const id = deriveAssistantMessageId(state); // Create new message with stable ID
return [...state, { return [...state, {
kind: 'assistant_message', kind: 'assistant_message',
id, 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') { if (parsed.kind === 'agent_thought_chunk') {
const last = state[state.length - 1]; const id = deriveThoughtId(parsed.messageId, timestamp);
if (last?.kind === 'thought') {
// Find existing thought with this ID
const existingIndex = state.findIndex(
item => item.kind === 'thought' && item.id === id
);
if (existingIndex >= 0) {
// Append to existing thought // Append to existing thought
return [ const existing = state[existingIndex] as ThoughtItem;
...state.slice(0, -1), return state.map((item, idx) =>
{ idx === existingIndex
...last, ? { ...existing, text: existing.text + parsed.text }
text: last.text + parsed.text, : item
}, );
];
} }
// Create new thought with deterministic ID
const id = deriveThoughtId(state); // Create new thought with stable ID
return [...state, { return [...state, {
kind: 'thought', kind: 'thought',
id, id,

181
test-idempotent-stream.ts Normal file
View File

@@ -0,0 +1,181 @@
/**
* Test script to verify idempotent stream reduction
*
* This tests that applying the same session updates multiple times
* or in different orders produces identical results.
*/
import { reduceStreamUpdate, hydrateStreamState, type StreamItem } from './packages/app/src/types/stream';
import type { SessionNotification } from '@agentclientprotocol/sdk';
// Helper to create enriched notifications with messageId
function createAgentMessageChunk(text: string, messageId: string): any {
return {
update: {
sessionUpdate: 'agent_message_chunk',
content: { text },
messageId,
},
};
}
function createAgentThoughtChunk(text: string, messageId: string): any {
return {
update: {
sessionUpdate: 'agent_thought_chunk',
content: { text },
messageId,
},
};
}
function createUserMessageChunk(text: string, messageId: string): any {
return {
update: {
sessionUpdate: 'user_message_chunk',
content: { text },
messageId,
},
};
}
function createToolCall(toolCallId: string, title: string): any {
return {
update: {
sessionUpdate: 'tool_call',
toolCallId,
title,
status: 'pending',
},
};
}
// Test 1: Same updates applied twice should be idempotent
function testIdempotentReduction() {
console.log('\n=== Test 1: Idempotent Reduction ===');
const timestamp1 = new Date('2025-01-01T10:00:00Z');
const timestamp2 = new Date('2025-01-01T10:00:01Z');
const timestamp3 = new Date('2025-01-01T10:00:02Z');
// Create a sequence of updates
const updates = [
{ notification: createUserMessageChunk('Hello', 'user-msg-1'), timestamp: timestamp1 },
{ notification: createAgentMessageChunk('Hello! ', 'asst-msg-1'), timestamp: timestamp2 },
{ notification: createAgentMessageChunk('How can I ', 'asst-msg-1'), timestamp: timestamp2 },
{ notification: createAgentMessageChunk('help you?', 'asst-msg-1'), timestamp: timestamp2 },
{ notification: createAgentThoughtChunk('Thinking...', 'thought-1'), timestamp: timestamp3 },
];
// Apply updates once
const state1 = hydrateStreamState(updates);
// Apply same updates again from scratch
const state2 = hydrateStreamState(updates);
// Apply updates twice (should still be same)
const state3 = updates.reduce(
(state, { notification, timestamp }) => {
const s1 = reduceStreamUpdate(state, notification, timestamp);
// Apply again
return reduceStreamUpdate(s1, notification, timestamp);
},
[] as StreamItem[]
);
console.log('State 1 (applied once):', JSON.stringify(state1, null, 2));
console.log('State 2 (applied from scratch):', JSON.stringify(state2, null, 2));
console.log('State 3 (applied twice per update):', JSON.stringify(state3, null, 2));
// Verify all states are equal
const state1Str = JSON.stringify(state1);
const state2Str = JSON.stringify(state2);
const state3Str = JSON.stringify(state3);
if (state1Str === state2Str && state2Str === state3Str) {
console.log('✅ PASS: All states are identical');
} else {
console.log('❌ FAIL: States differ');
console.log('State 1 === State 2:', state1Str === state2Str);
console.log('State 2 === State 3:', state2Str === state3Str);
}
// Verify message accumulation worked
const assistantMsg = state1.find(item => item.kind === 'assistant_message');
if (assistantMsg && assistantMsg.text === 'Hello! How can I help you?') {
console.log('✅ PASS: Message chunks accumulated correctly');
} else {
console.log('❌ FAIL: Message accumulation failed');
console.log('Expected: "Hello! How can I help you?"');
console.log('Got:', assistantMsg?.text);
}
}
// Test 2: Duplicate user messages should not create duplicates
function testUserMessageDeduplication() {
console.log('\n=== Test 2: User Message Deduplication ===');
const timestamp = new Date('2025-01-01T10:00:00Z');
// Same user message sent twice (simulating reconnection)
const updates = [
{ notification: createUserMessageChunk('Test message', 'user-msg-1'), timestamp },
{ notification: createUserMessageChunk('Test message', 'user-msg-1'), timestamp },
];
const state = hydrateStreamState(updates);
const userMessages = state.filter(item => item.kind === 'user_message');
console.log('User messages in state:', userMessages.length);
console.log('State:', JSON.stringify(state, null, 2));
if (userMessages.length === 1) {
console.log('✅ PASS: User message deduplicated correctly');
} else {
console.log('❌ FAIL: Expected 1 user message, got', userMessages.length);
}
}
// Test 3: Multiple assistant messages with different IDs
function testMultipleMessages() {
console.log('\n=== Test 3: Multiple Distinct Messages ===');
const timestamp1 = new Date('2025-01-01T10:00:00Z');
const timestamp2 = new Date('2025-01-01T10:00:05Z');
// Two separate assistant messages (new turn resets message ID)
const updates = [
{ notification: createAgentMessageChunk('First ', 'msg-1'), timestamp: timestamp1 },
{ notification: createAgentMessageChunk('message', 'msg-1'), timestamp: timestamp1 },
{ notification: createToolCall('tool-1', 'Read file'), timestamp: timestamp1 },
{ notification: createAgentMessageChunk('Second ', 'msg-2'), timestamp: timestamp2 },
{ notification: createAgentMessageChunk('message', 'msg-2'), timestamp: timestamp2 },
];
const state = hydrateStreamState(updates);
const assistantMessages = state.filter(item => item.kind === 'assistant_message');
console.log('Assistant messages:', assistantMessages.length);
console.log('State:', JSON.stringify(state, null, 2));
if (assistantMessages.length === 2 &&
assistantMessages[0].text === 'First message' &&
assistantMessages[1].text === 'Second message') {
console.log('✅ PASS: Multiple messages handled correctly');
} else {
console.log('❌ FAIL: Expected 2 distinct messages');
}
}
// Run all tests
console.log('Testing Idempotent Stream Reduction');
console.log('====================================');
testIdempotentReduction();
testUserMessageDeduplication();
testMultipleMessages();
console.log('\n====================================');
console.log('Tests complete');