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 { 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>
);
}

View File

@@ -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",

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

View File

@@ -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') {

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 {
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,

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');