WIP: Large refactor checkpoint
- Refactored daemon client and RPC layer - Added new client package structure in server - Removed legacy hooks (use-daemon-request, use-session-rpc, use-websocket) - Added use-daemon-client hook - Added e2e test infrastructure with Playwright - Updated agent providers and session handling - Removed codex-sdk patches
This commit is contained in:
@@ -28,7 +28,6 @@ import {
|
||||
type ImageAttachment,
|
||||
type MessageInputRef,
|
||||
} from "./message-input";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { Theme } from "@/styles/theme";
|
||||
import { CommandAutocomplete } from "./command-autocomplete";
|
||||
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
|
||||
@@ -78,7 +77,10 @@ export function AgentInputArea({
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = client?.isConnected ?? false;
|
||||
|
||||
const agent = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agents?.get(agentId)
|
||||
@@ -102,25 +104,6 @@ export function AgentInputArea({
|
||||
const noopSendAgentAudio = useCallback(async () => {}, []);
|
||||
const sendAgentAudio = methods?.sendAgentAudio ?? noopSendAgentAudio;
|
||||
|
||||
// Inert WebSocket fallback for when ws is undefined
|
||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
conversationId: null,
|
||||
lastError: null,
|
||||
send: () => {},
|
||||
on: () => () => {},
|
||||
sendPing: () => {},
|
||||
sendUserMessage: () => {},
|
||||
clearAgentAttention: () => {},
|
||||
subscribeConnectionStatus: () => () => {},
|
||||
getConnectionState: () => ({ isConnected: false, isConnecting: false }),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const wsOrInert = ws ?? inertWebSocket;
|
||||
const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime();
|
||||
|
||||
const [internalInput, setInternalInput] = useState("");
|
||||
@@ -236,9 +219,7 @@ export function AgentInputArea({
|
||||
imageAttachments?: ImageAttachment[],
|
||||
forceSend?: boolean
|
||||
) {
|
||||
const socketConnected = wsOrInert.getConnectionState
|
||||
? wsOrInert.getConnectionState().isConnected
|
||||
: wsOrInert.isConnected;
|
||||
const socketConnected = isConnected;
|
||||
if (!message.trim() || !socketConnected) return;
|
||||
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
|
||||
|
||||
@@ -297,10 +278,10 @@ export function AgentInputArea({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAgentRunning || !wsOrInert.isConnected) {
|
||||
if (!isAgentRunning || !isConnected) {
|
||||
setIsCancellingAgent(false);
|
||||
}
|
||||
}, [isAgentRunning, wsOrInert.isConnected]);
|
||||
}, [isAgentRunning, isConnected]);
|
||||
|
||||
// Hydrate draft only when switching agents
|
||||
useEffect(() => {
|
||||
@@ -351,7 +332,7 @@ export function AgentInputArea({
|
||||
if (isRealtimeMode) {
|
||||
await stopRealtime();
|
||||
} else {
|
||||
if (!wsOrInert.isConnected || !serverId) {
|
||||
if (!isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
await startRealtime(serverId);
|
||||
@@ -365,7 +346,7 @@ export function AgentInputArea({
|
||||
if (!agent || agent.status !== "running" || isCancellingAgent) {
|
||||
return;
|
||||
}
|
||||
if (!wsOrInert.isConnected || !cancelAgentRun) {
|
||||
if (!isConnected || !cancelAgentRun) {
|
||||
return;
|
||||
}
|
||||
setIsCancellingAgent(true);
|
||||
@@ -384,7 +365,7 @@ export function AgentInputArea({
|
||||
|
||||
async function handleSendQueuedNow(id: string) {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item || !wsOrInert.isConnected) return;
|
||||
if (!item || !isConnected) return;
|
||||
if (!sendAgentMessage && !onSubmitMessageRef.current) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
@@ -460,11 +441,11 @@ export function AgentInputArea({
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handleRealtimePress}
|
||||
disabled={!wsOrInert.isConnected && !isRealtimeMode}
|
||||
disabled={!isConnected && !isRealtimeMode}
|
||||
style={[
|
||||
styles.realtimeButton as any,
|
||||
(isRealtimeMode ? styles.realtimeButtonActive : undefined) as any,
|
||||
(!wsOrInert.isConnected && !isRealtimeMode
|
||||
(!isConnected && !isRealtimeMode
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
@@ -482,14 +463,14 @@ export function AgentInputArea({
|
||||
{isAgentRunning && (
|
||||
<Pressable
|
||||
onPress={handleCancelAgent}
|
||||
disabled={!wsOrInert.isConnected || isCancellingAgent}
|
||||
disabled={!isConnected || isCancellingAgent}
|
||||
accessibilityLabel={
|
||||
isCancellingAgent ? "Canceling agent" : "Stop agent"
|
||||
}
|
||||
accessibilityRole="button"
|
||||
style={[
|
||||
styles.cancelButton as any,
|
||||
(!wsOrInert.isConnected || isCancellingAgent
|
||||
(!isConnected || isCancellingAgent
|
||||
? styles.buttonDisabled
|
||||
: undefined) as any,
|
||||
]}
|
||||
@@ -581,7 +562,7 @@ export function AgentInputArea({
|
||||
images={selectedImages}
|
||||
onPickImages={handlePickImage}
|
||||
onRemoveImage={handleRemoveImage}
|
||||
ws={wsOrInert}
|
||||
client={client}
|
||||
sendAgentAudio={sendAgentAudio}
|
||||
placeholder="Message agent..."
|
||||
autoFocus={autoFocus}
|
||||
|
||||
@@ -45,8 +45,8 @@ export function AgentList({
|
||||
|
||||
// Clear attention flag when opening agent
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.ws) {
|
||||
session.ws.clearAgentAttention(agentId);
|
||||
if (session?.client) {
|
||||
session.client.clearAgentAttention(agentId);
|
||||
}
|
||||
|
||||
const navigationKey = buildAgentNavigationKey(serverId, agentId);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import Markdown from "react-native-markdown-display";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -45,9 +46,7 @@ import type { PendingPermission } from "@/types/shared";
|
||||
import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import {
|
||||
extractCommandDetails,
|
||||
extractEditEntries,
|
||||
@@ -56,14 +55,14 @@ import {
|
||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
item?.kind === "tool_call" || item?.kind === "thought";
|
||||
type PermissionResolvedMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "agent_permission_resolved" }
|
||||
>;
|
||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
@@ -88,14 +87,16 @@ export function AgentStreamView({
|
||||
const isProgrammaticScrollRef = useRef(false);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const { open: openExplorer, setActiveTab: setExplorerTab } =
|
||||
useExplorerSidebarStore();
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
|
||||
// Get ws for connection status
|
||||
const ws = useSessionStore((state) => state.sessions[resolvedServerId]?.ws);
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.client ?? null
|
||||
);
|
||||
const streamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
|
||||
);
|
||||
@@ -107,22 +108,6 @@ export function AgentStreamView({
|
||||
const requestDirectoryListing = methods?.requestDirectoryListing;
|
||||
const requestFilePreview = methods?.requestFilePreview;
|
||||
|
||||
// Create inert websocket fallback if ws is null
|
||||
const inertWebSocket = useMemo<UseWebSocketReturn>(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
conversationId: null,
|
||||
lastError: null,
|
||||
send: () => {},
|
||||
on: () => () => {},
|
||||
sendPing: () => {},
|
||||
sendUserMessage: () => {},
|
||||
clearAgentAttention: () => {},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const wsOrInert = ws ?? inertWebSocket;
|
||||
const requestDirectoryListingOrInert = requestDirectoryListing ?? (() => {});
|
||||
const requestFilePreviewOrInert = requestFilePreview ?? (() => {});
|
||||
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||
@@ -428,6 +413,68 @@ export function AgentStreamView({
|
||||
[pendingPermissions, agentId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPerfLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
const totalCount = streamItems.length;
|
||||
const prevCount = streamItemCountRef.current;
|
||||
if (totalCount === prevCount) {
|
||||
return;
|
||||
}
|
||||
const delta = Math.abs(totalCount - prevCount);
|
||||
streamItemCountRef.current = totalCount;
|
||||
if (totalCount < STREAM_ITEM_LOG_MIN_COUNT && delta < STREAM_ITEM_LOG_DELTA_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
let userCount = 0;
|
||||
let assistantCount = 0;
|
||||
let toolCallCount = 0;
|
||||
let thoughtCount = 0;
|
||||
let activityCount = 0;
|
||||
let todoCount = 0;
|
||||
for (const item of streamItems) {
|
||||
switch (item.kind) {
|
||||
case "user_message":
|
||||
userCount += 1;
|
||||
break;
|
||||
case "assistant_message":
|
||||
assistantCount += 1;
|
||||
break;
|
||||
case "tool_call":
|
||||
toolCallCount += 1;
|
||||
break;
|
||||
case "thought":
|
||||
thoughtCount += 1;
|
||||
break;
|
||||
case "activity_log":
|
||||
activityCount += 1;
|
||||
break;
|
||||
case "todo_list":
|
||||
todoCount += 1;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
const metrics = totalCount >= STREAM_ITEM_LOG_MIN_COUNT ? measurePayload(streamItems) : null;
|
||||
perfLog(AGENT_STREAM_LOG_TAG, {
|
||||
event: "stream_items",
|
||||
agentId,
|
||||
totalCount,
|
||||
userCount,
|
||||
assistantCount,
|
||||
toolCallCount,
|
||||
thoughtCount,
|
||||
activityCount,
|
||||
todoCount,
|
||||
pendingPermissionCount: pendingPermissionItems.length,
|
||||
streamHeadCount: streamHead?.length ?? 0,
|
||||
payloadApproxBytes: metrics?.approxBytes ?? 0,
|
||||
payloadFieldCount: metrics?.fieldCount ?? 0,
|
||||
});
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
|
||||
const listHeaderComponent = useMemo(() => {
|
||||
@@ -447,7 +494,7 @@ export function AgentStreamView({
|
||||
<PermissionRequestCard
|
||||
key={permission.key}
|
||||
permission={permission}
|
||||
ws={wsOrInert}
|
||||
client={client}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -475,7 +522,7 @@ export function AgentStreamView({
|
||||
}, [
|
||||
pendingPermissionItems,
|
||||
showWorkingIndicator,
|
||||
wsOrInert,
|
||||
client,
|
||||
streamHead,
|
||||
renderStreamItemContent,
|
||||
]);
|
||||
@@ -709,10 +756,10 @@ function WorkingIndicator() {
|
||||
// Permission Request Card Component
|
||||
function PermissionRequestCard({
|
||||
permission,
|
||||
ws,
|
||||
client,
|
||||
}: {
|
||||
permission: PendingPermission;
|
||||
ws: UseWebSocketReturn;
|
||||
client: DaemonClientV2 | null;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -878,51 +925,47 @@ function PermissionRequestCard({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const permissionResponse = useDaemonRequest<
|
||||
{ agentId: string; requestId: string; response: AgentPermissionResponse },
|
||||
{ agentId: string; requestId: string },
|
||||
PermissionResolvedMessage
|
||||
>({
|
||||
ws,
|
||||
responseType: "agent_permission_resolved",
|
||||
buildRequest: ({ params }) => ({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "agent_permission_response",
|
||||
agentId: params?.agentId ?? "",
|
||||
requestId: params?.requestId ?? "",
|
||||
response: params?.response ?? { behavior: "deny" },
|
||||
},
|
||||
}),
|
||||
matchResponse: (message, context) =>
|
||||
message.payload.agentId === context.params?.agentId &&
|
||||
message.payload.requestId === context.params?.requestId,
|
||||
getRequestKey: (params) =>
|
||||
params ? `${params.agentId}:${params.requestId}` : "default",
|
||||
selectData: (message) => ({
|
||||
agentId: message.payload.agentId,
|
||||
requestId: message.payload.requestId,
|
||||
}),
|
||||
timeoutMs: 15000,
|
||||
keepPreviousData: false,
|
||||
const permissionMutation = useMutation({
|
||||
mutationFn: async (input: {
|
||||
agentId: string;
|
||||
requestId: string;
|
||||
response: AgentPermissionResponse;
|
||||
}) => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
return client.respondToPermissionAndWait(
|
||||
input.agentId,
|
||||
input.requestId,
|
||||
input.response,
|
||||
15000
|
||||
);
|
||||
},
|
||||
});
|
||||
const isResponding = permissionResponse.isLoading;
|
||||
const {
|
||||
reset: resetPermissionMutation,
|
||||
mutateAsync: respondToPermission,
|
||||
isPending: isResponding,
|
||||
} = permissionMutation;
|
||||
|
||||
useEffect(() => {
|
||||
resetPermissionMutation();
|
||||
}, [permission.request.id, resetPermissionMutation]);
|
||||
const handleResponse = useCallback(
|
||||
(response: AgentPermissionResponse) => {
|
||||
permissionResponse
|
||||
.execute({
|
||||
respondToPermission({
|
||||
agentId: permission.agentId,
|
||||
requestId: permission.request.id,
|
||||
response,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[PermissionRequestCard] Failed to respond to permission:",
|
||||
error
|
||||
);
|
||||
});
|
||||
console.error(
|
||||
"[PermissionRequestCard] Failed to respond to permission:",
|
||||
error
|
||||
);
|
||||
});
|
||||
},
|
||||
[permission.agentId, permission.request.id, permissionResponse]
|
||||
[permission.agentId, permission.request.id, respondToPermission]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
@@ -13,7 +13,7 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { MessageSquare, X, Plus, Trash2 } from "lucide-react-native";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { UseWebSocketReturn } from "../hooks/use-websocket";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
|
||||
const STORAGE_KEY = "@paseo:conversation-id";
|
||||
|
||||
@@ -26,107 +26,97 @@ interface Conversation {
|
||||
interface ConversationSelectorProps {
|
||||
currentConversationId: string | null;
|
||||
onSelectConversation: (conversationId: string | null) => void;
|
||||
websocket: UseWebSocketReturn;
|
||||
client: DaemonClientV2 | null;
|
||||
}
|
||||
|
||||
export function ConversationSelector({
|
||||
currentConversationId,
|
||||
onSelectConversation,
|
||||
websocket,
|
||||
client,
|
||||
}: ConversationSelectorProps) {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Listen for conversation list responses
|
||||
useEffect(() => {
|
||||
const unsubscribe = websocket.on(
|
||||
"list_conversations_response",
|
||||
(message) => {
|
||||
if (message.type !== "list_conversations_response") return;
|
||||
setConversations(message.payload.conversations);
|
||||
setIsLoading(false);
|
||||
}
|
||||
);
|
||||
const fetchConversations = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
return unsubscribe;
|
||||
}, [websocket]);
|
||||
if (!client) {
|
||||
setIsLoading(false);
|
||||
Alert.alert("Error", "Daemon unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Listen for delete conversation responses
|
||||
useEffect(() => {
|
||||
const unsubscribe = websocket.on(
|
||||
"delete_conversation_response",
|
||||
(message) => {
|
||||
if (message.type !== "delete_conversation_response") return;
|
||||
console.log("[ConversationSelector] Delete response:", message.payload);
|
||||
if (message.payload.success) {
|
||||
// Refresh conversations list
|
||||
fetchConversations();
|
||||
|
||||
// If we deleted the current conversation, start a new one
|
||||
if (message.payload.conversationId === currentConversationId) {
|
||||
handleNewConversation();
|
||||
}
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
`Failed to delete conversation: ${message.payload.error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [websocket, currentConversationId]);
|
||||
try {
|
||||
console.log("[ConversationSelector] Fetching conversations");
|
||||
const response = await client.listConversations();
|
||||
setConversations(response.conversations);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ConversationSelector] Failed to list conversations:",
|
||||
error
|
||||
);
|
||||
Alert.alert("Error", "Failed to load conversations.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
// Fetch conversations when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchConversations();
|
||||
void fetchConversations();
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [fetchConversations, isOpen]);
|
||||
|
||||
function fetchConversations() {
|
||||
setIsLoading(true);
|
||||
console.log(
|
||||
"[ConversationSelector] Requesting conversations via WebSocket"
|
||||
);
|
||||
websocket.send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "list_conversations_request",
|
||||
},
|
||||
});
|
||||
}
|
||||
const handleDeleteConversation = useCallback(
|
||||
(id: string) => {
|
||||
Alert.alert(
|
||||
"Delete Conversation",
|
||||
"Are you sure you want to delete this conversation?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
if (!client) {
|
||||
Alert.alert("Error", "Daemon unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
function handleDeleteConversation(id: string) {
|
||||
Alert.alert(
|
||||
"Delete Conversation",
|
||||
"Are you sure you want to delete this conversation?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
console.log(
|
||||
"[ConversationSelector] Deleting conversation via WebSocket:",
|
||||
id
|
||||
);
|
||||
websocket.send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "delete_conversation_request",
|
||||
conversationId: id,
|
||||
},
|
||||
});
|
||||
console.log("[ConversationSelector] Deleting conversation:", id);
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await client.deleteConversation(id);
|
||||
if (response.success) {
|
||||
await fetchConversations();
|
||||
if (response.conversationId === currentConversationId) {
|
||||
await handleNewConversation();
|
||||
}
|
||||
} else {
|
||||
Alert.alert(
|
||||
"Error",
|
||||
`Failed to delete conversation: ${response.error}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ConversationSelector] Failed to delete conversation:",
|
||||
error
|
||||
);
|
||||
Alert.alert("Error", "Failed to delete conversation.");
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
]
|
||||
);
|
||||
},
|
||||
[currentConversationId, fetchConversations, handleNewConversation, client]
|
||||
);
|
||||
|
||||
function handleClearAll() {
|
||||
const handleClearAll = useCallback(() => {
|
||||
Alert.alert(
|
||||
"Clear All Conversations",
|
||||
"Are you sure you want to delete all conversations?",
|
||||
@@ -136,31 +126,36 @@ export function ConversationSelector({
|
||||
text: "Clear All",
|
||||
style: "destructive",
|
||||
onPress: () => {
|
||||
console.log(
|
||||
"[ConversationSelector] Clearing all conversations via WebSocket"
|
||||
);
|
||||
// Delete all conversations
|
||||
conversations.forEach((conv) => {
|
||||
websocket.send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "delete_conversation_request",
|
||||
conversationId: conv.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!client) {
|
||||
Alert.alert("Error", "Daemon unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear local state
|
||||
setConversations([]);
|
||||
|
||||
// Start new conversation
|
||||
handleNewConversation();
|
||||
setIsOpen(false);
|
||||
console.log("[ConversationSelector] Clearing all conversations");
|
||||
void (async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
for (const conv of conversations) {
|
||||
await client.deleteConversation(conv.id);
|
||||
}
|
||||
setConversations([]);
|
||||
await handleNewConversation();
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[ConversationSelector] Failed to clear conversations:",
|
||||
error
|
||||
);
|
||||
Alert.alert("Error", "Failed to clear conversations.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}, [conversations, handleNewConversation, client]);
|
||||
|
||||
async function handleSelectConversation(id: string) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useId, useRef } from "react";
|
||||
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
|
||||
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
||||
const DIFF_FILE_LOG_TAG = "[DiffFileSection]";
|
||||
const DIFF_FILE_LOG_LINE_THRESHOLD = 500;
|
||||
const DIFF_FILE_LOG_TOKEN_THRESHOLD = 5000;
|
||||
|
||||
type HighlightStyle = NonNullable<HighlightToken["style"]>;
|
||||
|
||||
@@ -81,7 +87,8 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
defaultExpanded?: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggle: (path: string) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
@@ -119,14 +126,41 @@ function DiffLineView({ line }: { line: DiffLine }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSectionProps) {
|
||||
const DiffFileSection = memo(function DiffFileSection({
|
||||
file,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
testID,
|
||||
}: DiffFileSectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
const [scrollViewWidth, setScrollViewWidth] = useState(0);
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
const scrollId = useId();
|
||||
const scrollViewRef = useRef<ScrollViewType>(null);
|
||||
const expandStartRef = useRef<number | null>(null);
|
||||
|
||||
const { hunkCount, lineCount, tokenCount } = useMemo(() => {
|
||||
let totalLines = 0;
|
||||
let totalTokens = 0;
|
||||
for (const hunk of file.hunks) {
|
||||
totalLines += hunk.lines.length;
|
||||
for (const line of hunk.lines) {
|
||||
if (line.tokens) {
|
||||
totalTokens += line.tokens.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
hunkCount: file.hunks.length,
|
||||
lineCount: totalLines,
|
||||
tokenCount: totalTokens,
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
const shouldLogFileMetrics =
|
||||
lineCount >= DIFF_FILE_LOG_LINE_THRESHOLD ||
|
||||
tokenCount >= DIFF_FILE_LOG_TOKEN_THRESHOLD;
|
||||
|
||||
// Get the close gesture ref from animation context (may not be available outside sidebar)
|
||||
let closeGestureRef: React.MutableRefObject<any> | undefined;
|
||||
@@ -138,8 +172,46 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti
|
||||
}
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
if (isPerfLoggingEnabled() && shouldLogFileMetrics) {
|
||||
expandStartRef.current = getNowMs();
|
||||
perfLog(DIFF_FILE_LOG_TAG, {
|
||||
event: "toggle",
|
||||
path: file.path,
|
||||
nextExpanded: !isExpanded,
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
});
|
||||
}
|
||||
onToggle(file.path);
|
||||
}, [file.path, onToggle, isExpanded, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPerfLoggingEnabled() || !shouldLogFileMetrics) {
|
||||
return;
|
||||
}
|
||||
const startMs = expandStartRef.current;
|
||||
if (startMs === null) {
|
||||
return;
|
||||
}
|
||||
expandStartRef.current = null;
|
||||
const logCommit = () => {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
perfLog(DIFF_FILE_LOG_TAG, {
|
||||
event: isExpanded ? "expand_commit" : "collapse_commit",
|
||||
path: file.path,
|
||||
durationMs: Math.round(durationMs),
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
});
|
||||
};
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => logCommit());
|
||||
} else {
|
||||
logCommit();
|
||||
}
|
||||
}, [isExpanded, file.path, hunkCount, lineCount, tokenCount, shouldLogFileMetrics]);
|
||||
|
||||
// Register/unregister scroll offset tracking
|
||||
useEffect(() => {
|
||||
@@ -227,7 +299,7 @@ function DiffFileSection({ file, defaultExpanded = true, testID }: DiffFileSecti
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
interface GitDiffPaneProps {
|
||||
serverId: string;
|
||||
@@ -242,12 +314,43 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
});
|
||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
|
||||
const diffMetrics = useMemo(() => {
|
||||
let hunkCount = 0;
|
||||
let lineCount = 0;
|
||||
let tokenCount = 0;
|
||||
for (const file of files) {
|
||||
hunkCount += file.hunks.length;
|
||||
for (const hunk of file.hunks) {
|
||||
lineCount += hunk.lines.length;
|
||||
for (const line of hunk.lines) {
|
||||
if (line.tokens) {
|
||||
tokenCount += line.tokens.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
fileCount: files.length,
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
};
|
||||
}, [files]);
|
||||
const lastMetricsKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleToggleExpanded = useCallback((path: string) => {
|
||||
setExpandedByPath((prev) => ({
|
||||
...prev,
|
||||
[path]: !prev[path],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Reset manual refresh flag when fetch completes
|
||||
useEffect(() => {
|
||||
if (!isFetching && isManualRefresh) {
|
||||
@@ -255,21 +358,45 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
}
|
||||
}, [isFetching, isManualRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPerfLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
const metricsKey = `${diffMetrics.fileCount}:${diffMetrics.hunkCount}:${diffMetrics.lineCount}:${diffMetrics.tokenCount}`;
|
||||
if (lastMetricsKeyRef.current === metricsKey) {
|
||||
return;
|
||||
}
|
||||
lastMetricsKeyRef.current = metricsKey;
|
||||
perfLog(DIFF_PANE_LOG_TAG, {
|
||||
event: "files_snapshot",
|
||||
serverId,
|
||||
agentId,
|
||||
fileCount: diffMetrics.fileCount,
|
||||
hunkCount: diffMetrics.hunkCount,
|
||||
lineCount: diffMetrics.lineCount,
|
||||
tokenCount: diffMetrics.tokenCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
});
|
||||
}, [agentId, diffMetrics, isFetching, isLoading, serverId]);
|
||||
|
||||
const agentExists = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agents?.has(agentId) ?? false
|
||||
);
|
||||
|
||||
const renderFileSection: ListRenderItem<ParsedDiffFile> = useCallback(
|
||||
({ item, index }) => (
|
||||
<DiffFileSection file={item} testID={`diff-file-${index}`} />
|
||||
<DiffFileSection
|
||||
file={item}
|
||||
isExpanded={expandedByPath[item.path] ?? false}
|
||||
onToggle={handleToggleExpanded}
|
||||
testID={`diff-file-${index}`}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
[expandedByPath, handleToggleExpanded]
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback(
|
||||
(item: ParsedDiffFile, index: number) => `${item.path}-${index}`,
|
||||
[]
|
||||
);
|
||||
const keyExtractor = useCallback((item: ParsedDiffFile) => item.path, []);
|
||||
|
||||
if (!agentExists) {
|
||||
return (
|
||||
@@ -312,6 +439,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
data={files}
|
||||
renderItem={renderFileSection}
|
||||
keyExtractor={keyExtractor}
|
||||
extraData={expandedByPath}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
|
||||
@@ -26,7 +26,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "./dictation-controls";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface MessageInputProps {
|
||||
images?: ImageAttachment[];
|
||||
onPickImages?: () => void;
|
||||
onRemoveImage?: (index: number) => void;
|
||||
ws: UseWebSocketReturn;
|
||||
client: DaemonClientV2 | null;
|
||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
@@ -103,7 +103,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
images = [],
|
||||
onPickImages,
|
||||
onRemoveImage,
|
||||
ws,
|
||||
client,
|
||||
sendAgentAudio,
|
||||
placeholder = "Message...",
|
||||
autoFocus = false,
|
||||
@@ -179,18 +179,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}, []);
|
||||
|
||||
const canStartDictation = useCallback(() => {
|
||||
const socketConnected = ws.getConnectionState
|
||||
? ws.getConnectionState().isConnected
|
||||
: ws.isConnected;
|
||||
const socketConnected = client?.isConnected ?? false;
|
||||
return socketConnected && !disabled;
|
||||
}, [ws, disabled]);
|
||||
}, [client, disabled]);
|
||||
|
||||
const canConfirmDictation = useCallback(() => {
|
||||
const socketConnected = ws.getConnectionState
|
||||
? ws.getConnectionState().isConnected
|
||||
: ws.isConnected;
|
||||
const socketConnected = client?.isConnected ?? false;
|
||||
return socketConnected;
|
||||
}, [ws]);
|
||||
}, [client]);
|
||||
|
||||
const {
|
||||
isRecording: isDictating,
|
||||
@@ -205,7 +201,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
discardFailedDictation,
|
||||
} = useDictation({
|
||||
sendAgentAudio,
|
||||
ws,
|
||||
client,
|
||||
mode: "transcribe_only",
|
||||
onTranscript: handleDictationTranscript,
|
||||
onError: handleDictationError,
|
||||
@@ -455,9 +451,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const hasImages = images.length > 0;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||
const shouldShowSendButton = hasSendableContent;
|
||||
const isConnected = ws.getConnectionState
|
||||
? ws.getConnectionState().isConnected
|
||||
: ws.isConnected;
|
||||
const isConnected = client?.isConnected ?? false;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -45,6 +45,7 @@ import { Colors, Fonts } from "@/constants/theme";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import type { TodoEntry, ThoughtStatus } from "@/types/stream";
|
||||
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import { resolveToolCallPreview } from "./tool-call-preview";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import {
|
||||
@@ -1307,6 +1308,8 @@ const toolKindIcons: Record<string, any> = {
|
||||
execute: SquareTerminal,
|
||||
search: Search,
|
||||
};
|
||||
const TOOL_CALL_LOG_TAG = "[ToolCall]";
|
||||
const TOOL_CALL_COMMIT_THRESHOLD_MS = 16;
|
||||
|
||||
// Derive tool kind from tool name for icon selection
|
||||
function getToolKindFromName(toolName: string): string {
|
||||
@@ -1333,6 +1336,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
}: ToolCallProps) {
|
||||
const { openToolCall } = useToolCallSheet();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const toggleStartRef = useRef<number | null>(null);
|
||||
|
||||
// Check if we're on mobile (use bottom sheet) or desktop (inline expand)
|
||||
const isMobile =
|
||||
@@ -1356,6 +1360,9 @@ export const ToolCall = memo(function ToolCall({
|
||||
const { display, errorText } = useToolCallDetails({ args, result, error });
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (!isMobile && isPerfLoggingEnabled()) {
|
||||
toggleStartRef.current = getNowMs();
|
||||
}
|
||||
if (isMobile) {
|
||||
// Mobile: open bottom sheet
|
||||
openToolCall({
|
||||
@@ -1372,6 +1379,33 @@ export const ToolCall = memo(function ToolCall({
|
||||
}
|
||||
}, [isMobile, openToolCall, toolName, kind, status, args, result, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isPerfLoggingEnabled()) {
|
||||
return;
|
||||
}
|
||||
const startMs = toggleStartRef.current;
|
||||
if (startMs === null) {
|
||||
return;
|
||||
}
|
||||
toggleStartRef.current = null;
|
||||
const logCommit = () => {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
if (durationMs >= TOOL_CALL_COMMIT_THRESHOLD_MS) {
|
||||
perfLog(TOOL_CALL_LOG_TAG, {
|
||||
event: isExpanded ? "expand_commit" : "collapse_commit",
|
||||
toolName,
|
||||
kind,
|
||||
durationMs: Math.round(durationMs),
|
||||
});
|
||||
}
|
||||
};
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => logCommit());
|
||||
} else {
|
||||
logCommit();
|
||||
}
|
||||
}, [isExpanded, isMobile, toolName, kind]);
|
||||
|
||||
// Render inline details for desktop
|
||||
const renderDetails = useCallback(() => {
|
||||
if (isMobile) return null;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { View, Text } from "react-native";
|
||||
import { ScrollView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
import {
|
||||
parseToolCallDisplay,
|
||||
buildLineDiff,
|
||||
@@ -18,8 +19,83 @@ export interface ToolCallDetailsData {
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
const TOOL_CALL_DETAILS_LOG_TAG = "[ToolCallDetails]";
|
||||
const TOOL_CALL_DETAILS_DURATION_THRESHOLD_MS = 8;
|
||||
const TOOL_CALL_DETAILS_SIZE_THRESHOLD = 20000;
|
||||
|
||||
type ToolCallDisplaySummary = {
|
||||
displayType: ToolCallDisplay["type"];
|
||||
totalChars: number;
|
||||
detail: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function summarizeToolCallDisplay(display: ToolCallDisplay): ToolCallDisplaySummary {
|
||||
switch (display.type) {
|
||||
case "shell": {
|
||||
const commandLength = display.command.length;
|
||||
const outputLength = display.output.length;
|
||||
return {
|
||||
displayType: display.type,
|
||||
totalChars: commandLength + outputLength,
|
||||
detail: {
|
||||
commandLength,
|
||||
outputLength,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "edit": {
|
||||
const oldLength = display.oldString.length;
|
||||
const newLength = display.newString.length;
|
||||
return {
|
||||
displayType: display.type,
|
||||
totalChars: oldLength + newLength,
|
||||
detail: {
|
||||
filePath: display.filePath,
|
||||
oldLength,
|
||||
newLength,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "read": {
|
||||
const contentLength = display.content.length;
|
||||
return {
|
||||
displayType: display.type,
|
||||
totalChars: contentLength,
|
||||
detail: {
|
||||
filePath: display.filePath,
|
||||
contentLength,
|
||||
offset: display.offset,
|
||||
limit: display.limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "generic": {
|
||||
const inputPairs = display.input.length;
|
||||
const outputPairs = display.output.length;
|
||||
const inputChars = display.input.reduce((sum, pair) => sum + pair.value.length, 0);
|
||||
const outputChars = display.output.reduce((sum, pair) => sum + pair.value.length, 0);
|
||||
return {
|
||||
displayType: display.type,
|
||||
totalChars: inputChars + outputChars,
|
||||
detail: {
|
||||
inputPairs,
|
||||
outputPairs,
|
||||
inputChars,
|
||||
outputChars,
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return assertNever(display);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helper ----
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unhandled tool call display: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return "";
|
||||
@@ -231,8 +307,27 @@ export function useToolCallDetails(data: ToolCallDetailsData) {
|
||||
const { args, result, error } = data;
|
||||
|
||||
return useMemo(() => {
|
||||
const shouldLog = isPerfLoggingEnabled();
|
||||
const startMs = shouldLog ? getNowMs() : 0;
|
||||
const display = parseToolCallDisplay(args, result);
|
||||
const errorText = error !== undefined ? formatValue(error) : undefined;
|
||||
if (shouldLog) {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
const summary = summarizeToolCallDisplay(display);
|
||||
if (
|
||||
durationMs >= TOOL_CALL_DETAILS_DURATION_THRESHOLD_MS ||
|
||||
summary.totalChars >= TOOL_CALL_DETAILS_SIZE_THRESHOLD
|
||||
) {
|
||||
perfLog(TOOL_CALL_DETAILS_LOG_TAG, {
|
||||
event: "parse",
|
||||
durationMs: Math.round(durationMs),
|
||||
displayType: summary.displayType,
|
||||
totalChars: summary.totalChars,
|
||||
errorLength: errorText ? errorText.length : 0,
|
||||
...summary.detail,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { display, errorText };
|
||||
}, [args, result, error]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user