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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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 { 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",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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
181
test-idempotent-stream.ts
Normal 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');
|
||||||
Reference in New Issue
Block a user