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:
Mohamed Boudra
2026-01-12 13:30:20 +07:00
parent 7c2e959c19
commit 868f9fb571
64 changed files with 5773 additions and 4741 deletions
@@ -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}
+2 -2
View File
@@ -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);
+108 -65
View File
@@ -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 {
+141 -13
View File
@@ -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"
+9 -15
View File
@@ -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}>
+34
View File
@@ -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]);
}