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:
@@ -15,6 +15,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ReanimatedAnimated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -51,9 +52,7 @@ import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
import {
|
||||
buildAgentNavigationKey,
|
||||
endNavigationTiming,
|
||||
@@ -61,14 +60,11 @@ import {
|
||||
startNavigationTiming,
|
||||
} from "@/utils/navigation-timing";
|
||||
import { extractAgentModel } from "@/utils/extract-agent-model";
|
||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
type GitRepoInfoResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "git_repo_info_response" }
|
||||
>;
|
||||
|
||||
type BranchStatus = "idle" | "loading" | "ready" | "error";
|
||||
|
||||
@@ -204,6 +200,15 @@ function AgentScreenContent({
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
return;
|
||||
}
|
||||
const scope = `agent:${serverId}:${agentId ?? "unknown"}`;
|
||||
const stop = startPerfMonitor(scope);
|
||||
return stop;
|
||||
}, [serverId, agentId]);
|
||||
|
||||
// Swipe-left gesture to open explorer sidebar on mobile
|
||||
const explorerOpenGesture = useMemo(
|
||||
() =>
|
||||
@@ -339,8 +344,12 @@ function AgentScreenContent({
|
||||
return filtered;
|
||||
}, [allPendingPermissions, resolvedAgentId]);
|
||||
|
||||
// Get ws for connection status
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
|
||||
// Get methods
|
||||
const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
|
||||
@@ -372,56 +381,45 @@ function AgentScreenContent({
|
||||
const agentModel = extractAgentModel(agent);
|
||||
const modelDisplayValue = agentModel ?? "Unknown";
|
||||
|
||||
const gitRepoInfoRequest = useDaemonRequest<
|
||||
{ cwd: string },
|
||||
{ cwd: string; currentBranch: string | null },
|
||||
GitRepoInfoResponseMessage
|
||||
>({
|
||||
ws: ws!,
|
||||
responseType: "git_repo_info_response",
|
||||
buildRequest: ({ params, requestId }) => ({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "git_repo_info_request",
|
||||
cwd: params?.cwd ?? ".",
|
||||
requestId,
|
||||
},
|
||||
}),
|
||||
getRequestKey: (params) => params?.cwd ?? "default",
|
||||
selectData: (message) => ({
|
||||
cwd: message.payload.cwd,
|
||||
currentBranch: message.payload.currentBranch ?? null,
|
||||
}),
|
||||
extractError: (message) =>
|
||||
message.payload.error ? new Error(message.payload.error) : null,
|
||||
keepPreviousData: false,
|
||||
const repoInfoQuery = useQuery({
|
||||
queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getGitRepoInfo({
|
||||
cwd: agent?.cwd ?? ".",
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
currentBranch: payload.currentBranch ?? null,
|
||||
};
|
||||
},
|
||||
enabled: Boolean(client && isConnected && agent?.cwd),
|
||||
retry: false,
|
||||
});
|
||||
const { execute: fetchGitRepoInfo, reset: resetGitRepoInfo } =
|
||||
gitRepoInfoRequest;
|
||||
const { refetch: refetchRepoInfo } = repoInfoQuery;
|
||||
const branchStatus: BranchStatus = !agent?.cwd
|
||||
? "idle"
|
||||
: gitRepoInfoRequest.status === "loading"
|
||||
: repoInfoQuery.isPending || repoInfoQuery.isFetching
|
||||
? "loading"
|
||||
: gitRepoInfoRequest.status === "error"
|
||||
: repoInfoQuery.isError
|
||||
? "error"
|
||||
: gitRepoInfoRequest.status === "success"
|
||||
: repoInfoQuery.isSuccess
|
||||
? "ready"
|
||||
: "idle";
|
||||
const branchLabel = gitRepoInfoRequest.data?.currentBranch ?? null;
|
||||
const branchError = gitRepoInfoRequest.error?.message ?? null;
|
||||
const branchLabel = repoInfoQuery.data?.currentBranch ?? null;
|
||||
const branchError = repoInfoQuery.error instanceof Error
|
||||
? repoInfoQuery.error.message
|
||||
: null;
|
||||
const branchDisplayValue =
|
||||
branchStatus === "error"
|
||||
? branchError ?? "Unavailable"
|
||||
: branchLabel ?? "Unknown";
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent?.cwd) {
|
||||
resetGitRepoInfo();
|
||||
return;
|
||||
}
|
||||
fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {});
|
||||
}, [agent?.cwd, fetchGitRepoInfo, resetGitRepoInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId) {
|
||||
setFocusedAgentId(null);
|
||||
@@ -447,7 +445,7 @@ function AgentScreenContent({
|
||||
}
|
||||
|
||||
// Skip if not connected - will re-run when connection is established
|
||||
if (!ws?.isConnected) {
|
||||
if (!isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -472,7 +470,7 @@ function AgentScreenContent({
|
||||
});
|
||||
|
||||
initializeAgent({ agentId: resolvedAgentId });
|
||||
}, [resolvedAgentId, initializeAgent, isInitializingFromMap, ws?.isConnected]);
|
||||
}, [resolvedAgentId, initializeAgent, isInitializingFromMap, isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -487,7 +485,7 @@ function AgentScreenContent({
|
||||
|
||||
// Clear attention when agent finishes while user is viewing this screen
|
||||
useEffect(() => {
|
||||
if (!resolvedAgentId || !agent || !ws) {
|
||||
if (!resolvedAgentId || !agent || !client) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -498,9 +496,9 @@ function AgentScreenContent({
|
||||
// If agent transitioned from running to idle while we're viewing,
|
||||
// immediately clear attention since user witnessed the completion
|
||||
if (previousStatus === "running" && currentStatus === "idle") {
|
||||
ws.clearAgentAttention(resolvedAgentId);
|
||||
client.clearAgentAttention(resolvedAgentId);
|
||||
}
|
||||
}, [resolvedAgentId, agent?.status, ws]);
|
||||
}, [resolvedAgentId, agent?.status, client]);
|
||||
|
||||
const recalculateMenuPosition = useCallback(
|
||||
(onMeasured?: () => void) => {
|
||||
@@ -552,13 +550,13 @@ function AgentScreenContent({
|
||||
|
||||
const handleOpenMenu = useCallback(() => {
|
||||
if (agent?.cwd) {
|
||||
fetchGitRepoInfo({ cwd: agent.cwd }).catch(() => {});
|
||||
refetchRepoInfo().catch(() => {});
|
||||
}
|
||||
|
||||
recalculateMenuPosition(() => {
|
||||
setMenuVisible(true);
|
||||
});
|
||||
}, [agent?.cwd, fetchGitRepoInfo, recalculateMenuPosition]);
|
||||
}, [agent?.cwd, recalculateMenuPosition, refetchRepoInfo]);
|
||||
|
||||
const handleCloseMenu = useCallback(() => {
|
||||
setMenuVisible(false);
|
||||
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
AgentConfigRow,
|
||||
} from "@/components/agent-form/agent-form-dropdowns";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
||||
import type { SessionOutboundMessage } from "@server/server/messages";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
@@ -207,29 +206,25 @@ export default function HomeScreen() {
|
||||
return Array.from(uniquePaths).sort();
|
||||
}, [selectedServerId, sessionAgents]);
|
||||
|
||||
const sessionWs = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.ws : undefined
|
||||
const sessionClient = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.client ?? null : null
|
||||
);
|
||||
const inertWebSocket = useMemo(
|
||||
() => ({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
conversationId: null,
|
||||
lastError: null,
|
||||
send: () => {},
|
||||
on: () => () => {},
|
||||
sendPing: () => {},
|
||||
sendUserMessage: () => {},
|
||||
clearAgentAttention: () => {},
|
||||
subscribeConnectionStatus: () => () => {},
|
||||
getConnectionState: () => ({ isConnected: false, isConnecting: false }),
|
||||
}),
|
||||
[]
|
||||
const isConnected = useSessionStore((state) =>
|
||||
selectedServerId
|
||||
? state.sessions[selectedServerId]?.connection.isConnected ?? false
|
||||
: false
|
||||
);
|
||||
const effectiveWs = sessionWs ?? inertWebSocket;
|
||||
const isWsConnected = effectiveWs.getConnectionState
|
||||
? effectiveWs.getConnectionState().isConnected
|
||||
: effectiveWs.isConnected;
|
||||
const trimmedWorkingDir = workingDir.trim();
|
||||
const shouldInspectRepo = trimmedWorkingDir.length > 0;
|
||||
const daemonAvailabilityError =
|
||||
!selectedServerId || hostEntry?.status !== "online"
|
||||
? "Host is offline"
|
||||
: null;
|
||||
const repoAvailabilityError =
|
||||
shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isConnected)
|
||||
? daemonAvailabilityError ??
|
||||
"Repository details will load automatically once the selected host is back online."
|
||||
: null;
|
||||
|
||||
type RepoInfoState = {
|
||||
cwd: string;
|
||||
@@ -238,57 +233,46 @@ export default function HomeScreen() {
|
||||
currentBranch: string | null;
|
||||
isDirty: boolean;
|
||||
};
|
||||
type GitRepoInfoResponseMessage = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "git_repo_info_response" }
|
||||
>;
|
||||
const gitRepoInfoRequest = useDaemonRequest<
|
||||
{ cwd: string },
|
||||
RepoInfoState,
|
||||
GitRepoInfoResponseMessage
|
||||
>({
|
||||
ws: effectiveWs,
|
||||
responseType: "git_repo_info_response",
|
||||
buildRequest: ({ params, requestId }) => ({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "git_repo_info_request",
|
||||
cwd: params?.cwd ?? ".",
|
||||
requestId,
|
||||
},
|
||||
}),
|
||||
getRequestKey: (params) => params?.cwd ?? "default",
|
||||
selectData: (message) => ({
|
||||
cwd: message.payload.cwd,
|
||||
repoRoot: message.payload.repoRoot,
|
||||
branches: message.payload.branches ?? [],
|
||||
currentBranch: message.payload.currentBranch ?? null,
|
||||
isDirty: Boolean(message.payload.isDirty),
|
||||
}),
|
||||
extractError: (message) =>
|
||||
message.payload.error ? new Error(message.payload.error) : null,
|
||||
keepPreviousData: false,
|
||||
const repoInfoQuery = useQuery({
|
||||
queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir],
|
||||
queryFn: async () => {
|
||||
const client = sessionClient;
|
||||
if (!client) {
|
||||
throw new Error("Daemon client unavailable");
|
||||
}
|
||||
const payload = await client.getGitRepoInfo({
|
||||
cwd: trimmedWorkingDir || ".",
|
||||
});
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
repoRoot: payload.repoRoot,
|
||||
branches: payload.branches ?? [],
|
||||
currentBranch: payload.currentBranch ?? null,
|
||||
isDirty: Boolean(payload.isDirty),
|
||||
};
|
||||
},
|
||||
enabled:
|
||||
Boolean(trimmedWorkingDir) &&
|
||||
!repoAvailabilityError &&
|
||||
Boolean(sessionClient) &&
|
||||
isConnected,
|
||||
retry: false,
|
||||
});
|
||||
const {
|
||||
status: repoRequestStatus,
|
||||
data: repoInfo,
|
||||
error: repoRequestError,
|
||||
execute: inspectRepoInfo,
|
||||
reset: resetRepoInfo,
|
||||
cancel: cancelRepoInfo,
|
||||
} = gitRepoInfoRequest;
|
||||
|
||||
const trimmedWorkingDir = workingDir.trim();
|
||||
const shouldInspectRepo = trimmedWorkingDir.length > 0;
|
||||
const daemonAvailabilityError =
|
||||
!selectedServerId || hostEntry?.status !== "online"
|
||||
? "Host is offline"
|
||||
: null;
|
||||
const repoAvailabilityError =
|
||||
shouldInspectRepo && (!hostEntry || hostEntry.status !== "online" || !isWsConnected)
|
||||
? daemonAvailabilityError ??
|
||||
"Repository details will load automatically once the selected host is back online."
|
||||
: null;
|
||||
const repoInfo = repoInfoQuery.data ?? null;
|
||||
const repoRequestError = repoInfoQuery.error as Error | null;
|
||||
const repoRequestStatus: "idle" | "loading" | "success" | "error" =
|
||||
!shouldInspectRepo || repoAvailabilityError
|
||||
? "idle"
|
||||
: repoInfoQuery.isPending || repoInfoQuery.isFetching
|
||||
? "loading"
|
||||
: repoInfoQuery.isError
|
||||
? "error"
|
||||
: repoInfoQuery.isSuccess
|
||||
? "success"
|
||||
: "idle";
|
||||
const isNonGitDirectory =
|
||||
repoRequestStatus === "error" &&
|
||||
/not in a git repository/i.test(repoRequestError?.message ?? "");
|
||||
@@ -389,29 +373,6 @@ export default function HomeScreen() {
|
||||
setBaseBranch(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldInspectRepo) {
|
||||
cancelRepoInfo();
|
||||
resetRepoInfo();
|
||||
return;
|
||||
}
|
||||
if (repoAvailabilityError) {
|
||||
cancelRepoInfo();
|
||||
return;
|
||||
}
|
||||
inspectRepoInfo({ cwd: trimmedWorkingDir }).catch(() => {});
|
||||
return () => {
|
||||
cancelRepoInfo();
|
||||
};
|
||||
}, [
|
||||
cancelRepoInfo,
|
||||
inspectRepoInfo,
|
||||
repoAvailabilityError,
|
||||
resetRepoInfo,
|
||||
shouldInspectRepo,
|
||||
trimmedWorkingDir,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNonGitDirectory && useWorktree) {
|
||||
setUseWorktree(false);
|
||||
@@ -519,10 +480,10 @@ export default function HomeScreen() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionWs) {
|
||||
if (!sessionClient) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = sessionWs.on("status", (message) => {
|
||||
const unsubscribe = sessionClient.on("status", (message) => {
|
||||
if (message.type !== "status") {
|
||||
return;
|
||||
}
|
||||
@@ -564,7 +525,7 @@ export default function HomeScreen() {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [router, selectedServerId, sessionWs]);
|
||||
}, [router, selectedServerId, sessionClient]);
|
||||
|
||||
return (
|
||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||
|
||||
@@ -21,7 +21,7 @@ import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -351,70 +351,38 @@ export default function SettingsScreen() {
|
||||
[]
|
||||
);
|
||||
|
||||
const testServerConnection = useCallback((url: string, timeoutMs = 5000) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let wsConnection: WebSocket | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
if (wsConnection) {
|
||||
wsConnection.onopen = null;
|
||||
wsConnection.onerror = null;
|
||||
wsConnection.onclose = null;
|
||||
try {
|
||||
wsConnection.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const succeed = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const fail = (message: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(message));
|
||||
};
|
||||
|
||||
try {
|
||||
wsConnection = new WebSocket(url);
|
||||
} catch {
|
||||
fail("Failed to create connection");
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
fail("Connection timeout - server did not respond");
|
||||
}, timeoutMs);
|
||||
|
||||
wsConnection.onopen = () => succeed();
|
||||
wsConnection.onerror = (event) => {
|
||||
const errorMessage =
|
||||
(event && typeof event === "object" && "message" in event && typeof (event as any).message === "string"
|
||||
? (event as any).message
|
||||
: null) || "Connection failed - check URL and network";
|
||||
console.error("[Settings] WebSocket test error", { url, errorMessage, event });
|
||||
fail(errorMessage);
|
||||
};
|
||||
wsConnection.onclose = (event) => {
|
||||
const reason =
|
||||
(event && typeof event.reason === "string" && event.reason.trim().length > 0 ? event.reason.trim() : null) ||
|
||||
`Connection failed (code ${event?.code ?? "unknown"})`;
|
||||
console.error("[Settings] WebSocket test closed", { url, code: event?.code, reason });
|
||||
fail(reason);
|
||||
};
|
||||
const testServerConnection = useCallback(async (url: string, timeoutMs = 5000) => {
|
||||
const client = new DaemonClientV2({
|
||||
url,
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error("Connection timeout - server did not respond"));
|
||||
}, timeoutMs);
|
||||
|
||||
client
|
||||
.connect()
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Connection failed - check URL and network";
|
||||
console.error("[Settings] Daemon test error", { url, message });
|
||||
reject(new Error(message));
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
await client.close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
|
||||
@@ -635,7 +603,7 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.formField}>
|
||||
<Text style={styles.label}>WebSocket URL</Text>
|
||||
<Text style={styles.label}>Host URL</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.inputUrl]}
|
||||
value={daemonForm.wsUrl}
|
||||
@@ -762,16 +730,18 @@ function DaemonCard({
|
||||
: theme.colors.mutedForeground;
|
||||
const badgeText = statusLabel;
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
const daemonWs = useSessionStore((state) => state.sessions[daemon.id]?.ws);
|
||||
const daemonConnection = useSessionStore(
|
||||
(state) => state.sessions[daemon.id]?.connection ?? null
|
||||
);
|
||||
const restartServerFn = useSessionStore((state) => state.sessions[daemon.id]?.methods?.restartServer);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const wsIsConnectedRef = useRef(daemonWs?.isConnected ?? false);
|
||||
const isConnected = daemonConnection?.isConnected ?? false;
|
||||
const isConnectedRef = useRef(isConnected);
|
||||
const isTesting = testState?.status === "testing";
|
||||
const sessionIsConnected = daemonWs?.isConnected ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
wsIsConnectedRef.current = sessionIsConnected;
|
||||
}, [sessionIsConnected]);
|
||||
isConnectedRef.current = isConnected;
|
||||
}, [isConnected]);
|
||||
|
||||
const waitForDaemonRestart = useCallback(async () => {
|
||||
const maxAttempts = 12;
|
||||
@@ -779,14 +749,14 @@ function DaemonCard({
|
||||
const disconnectTimeoutMs = 7000;
|
||||
const reconnectTimeoutMs = 10000;
|
||||
|
||||
if (wsIsConnectedRef.current) {
|
||||
await waitForCondition(() => !wsIsConnectedRef.current, disconnectTimeoutMs);
|
||||
if (isConnectedRef.current) {
|
||||
await waitForCondition(() => !isConnectedRef.current, disconnectTimeoutMs);
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await testServerConnection(daemon.wsUrl);
|
||||
const reconnected = await waitForCondition(() => wsIsConnectedRef.current, reconnectTimeoutMs);
|
||||
const reconnected = await waitForCondition(() => isConnectedRef.current, reconnectTimeoutMs);
|
||||
|
||||
if (isScreenMountedRef.current) {
|
||||
setIsRestarting(false);
|
||||
@@ -827,7 +797,7 @@ function DaemonCard({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wsIsConnectedRef.current) {
|
||||
if (!isConnectedRef.current) {
|
||||
Alert.alert(
|
||||
"Host offline",
|
||||
"This host is offline. Paseo reconnects automatically—wait until it's back online before restarting."
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { createContext, useContext, useState, ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { useSpeechmaticsAudio } from "@/hooks/use-speechmatics-audio";
|
||||
import type { SessionState } from "@/stores/session-store";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import type { WSInboundMessage } from "@server/server/messages";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
interface RealtimeContextValue {
|
||||
@@ -56,7 +54,7 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
|
||||
// Stop audio playback if playing
|
||||
const session = realtimeSessionRef.current;
|
||||
const sessionAudioPlayer = session?.audioPlayer ?? null;
|
||||
const sessionWs = session?.ws ?? null;
|
||||
const sessionClient = session?.client ?? null;
|
||||
const sessionIsPlayingAudio = session?.isPlayingAudio ?? false;
|
||||
|
||||
if (sessionIsPlayingAudio && sessionAudioPlayer) {
|
||||
@@ -68,14 +66,10 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
|
||||
|
||||
// Abort any in-flight orchestrator turn before the new speech segment streams
|
||||
try {
|
||||
if (sessionWs) {
|
||||
const abortMessage: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "abort_request",
|
||||
},
|
||||
};
|
||||
sessionWs.send(abortMessage);
|
||||
if (sessionClient) {
|
||||
void sessionClient.abortRequest().catch((error) => {
|
||||
console.error("[Realtime] Failed to send abort_request:", error);
|
||||
});
|
||||
}
|
||||
console.log("[Realtime] Sent abort_request before streaming audio");
|
||||
} catch (error) {
|
||||
@@ -96,16 +90,16 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
|
||||
// Send audio segment to server (realtime always goes to orchestrator)
|
||||
const session = realtimeSessionRef.current;
|
||||
try {
|
||||
if (session?.ws) {
|
||||
session.ws.send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "realtime_audio_chunk",
|
||||
audio: audioData,
|
||||
format: "audio/pcm;rate=16000;bits=16",
|
||||
isLast,
|
||||
},
|
||||
});
|
||||
if (session?.client) {
|
||||
void session.client
|
||||
.sendRealtimeAudioChunk(
|
||||
audioData,
|
||||
"audio/pcm;rate=16000;bits=16",
|
||||
isLast
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error("[Realtime] Failed to send audio segment:", error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Realtime] Failed to send audio segment:", error);
|
||||
@@ -114,7 +108,7 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
|
||||
onError: (error) => {
|
||||
console.error("[Realtime] Audio error:", error);
|
||||
const session = realtimeSessionRef.current;
|
||||
if (session?.ws) {
|
||||
if (session?.client) {
|
||||
// Send error through websocket instead of directly manipulating messages
|
||||
console.error("[Realtime] Cannot handle error - setMessages not available from SessionState");
|
||||
}
|
||||
@@ -128,17 +122,8 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
|
||||
// Update voice detection flags whenever they change
|
||||
useEffect(() => {
|
||||
const session = realtimeSessionRef.current;
|
||||
if (session?.ws) {
|
||||
// Send voice detection flags through websocket
|
||||
const message: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "voice_detection_update",
|
||||
isDetecting: realtimeAudio.isDetecting,
|
||||
isSpeaking: realtimeAudio.isSpeaking,
|
||||
} as any,
|
||||
};
|
||||
// Note: This functionality needs proper backend support
|
||||
if (session?.client) {
|
||||
// Note: voice detection updates need backend support before sending.
|
||||
}
|
||||
}, [realtimeAudio.isDetecting, realtimeAudio.isSpeaking]);
|
||||
|
||||
@@ -174,15 +159,10 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
|
||||
setIsRealtimeMode(true);
|
||||
console.log("[Realtime] Mode enabled");
|
||||
|
||||
const modeMessage: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "set_realtime_mode",
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
if (session?.ws) {
|
||||
session.ws.send(modeMessage);
|
||||
if (session?.client) {
|
||||
await session.client.setRealtimeMode(true);
|
||||
} else {
|
||||
console.warn("[Realtime] setRealtimeMode skipped: daemon unavailable");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Realtime] Failed to start:", error);
|
||||
@@ -202,15 +182,10 @@ export function RealtimeProvider({ children }: RealtimeProviderProps) {
|
||||
setActiveServerId(null);
|
||||
console.log("[Realtime] Mode disabled");
|
||||
|
||||
if (session?.ws) {
|
||||
const modeMessage: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "set_realtime_mode",
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
session.ws.send(modeMessage);
|
||||
if (session?.client) {
|
||||
await session.client.setRealtimeMode(false);
|
||||
} else {
|
||||
console.warn("[Realtime] setRealtimeMode skipped: daemon unavailable");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[Realtime] Failed to stop:", error);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { sendRpcRequest } from "@/lib/send-rpc-request";
|
||||
|
||||
const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
|
||||
|
||||
@@ -25,26 +24,23 @@ export function useAgentCommandsQuery({
|
||||
agentId,
|
||||
enabled = true,
|
||||
}: UseAgentCommandsQueryOptions) {
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
|
||||
// Use getConnectionState for more reliable connection check
|
||||
const isConnected = ws?.getConnectionState
|
||||
? ws.getConnectionState().isConnected
|
||||
: ws?.isConnected ?? false;
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: commandsQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!ws) {
|
||||
throw new Error("WebSocket not available");
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "list_commands_request",
|
||||
agentId,
|
||||
});
|
||||
const response = await client.listCommands(agentId);
|
||||
return response.commands as AgentSlashCommand[];
|
||||
},
|
||||
enabled: enabled && !!ws && isConnected && !!agentId,
|
||||
enabled: enabled && !!client && isConnected && !!agentId,
|
||||
staleTime: COMMANDS_STALE_TIME,
|
||||
retry: 3,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { WSInboundMessage } from "@server/server/messages";
|
||||
import type { ProviderModelState } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { useFormPreferences } from "./use-form-preferences";
|
||||
|
||||
export type CreateAgentInitialValues = {
|
||||
@@ -267,7 +265,7 @@ export function useAgentFormState(
|
||||
}
|
||||
|
||||
const sessionState = getSessionState(serverId);
|
||||
if (!sessionState?.ws?.isConnected) {
|
||||
if (!sessionState?.connection?.isConnected) {
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
return;
|
||||
}
|
||||
@@ -281,18 +279,9 @@ export function useAgentFormState(
|
||||
const delayMs = options?.delayMs ?? 0;
|
||||
const trigger = () => {
|
||||
providerModelRequestTimersRef.current.delete(serverId);
|
||||
|
||||
const requestId = generateMessageId();
|
||||
const message: WSInboundMessage = {
|
||||
type: "session",
|
||||
message: {
|
||||
type: "list_provider_models_request",
|
||||
provider: selectedProvider,
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
requestId,
|
||||
},
|
||||
};
|
||||
sessionState.ws?.send(message);
|
||||
sessionState.methods?.requestProviderModels(selectedProvider, {
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
});
|
||||
};
|
||||
clearQueuedProviderModelRequest(serverId);
|
||||
if (delayMs > 0) {
|
||||
|
||||
@@ -90,7 +90,7 @@ async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert audio file URI to Blob for WebSocket transmission
|
||||
* Convert audio file URI to Blob for daemon transport
|
||||
* Returns a Blob-like object that works in React Native
|
||||
*/
|
||||
async function uriToBlob(uri: string): Promise<Blob> {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
|
||||
function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
||||
void promise.catch((error) => {
|
||||
console.warn(`[DaemonClient] ${label} failed`, error);
|
||||
});
|
||||
}
|
||||
|
||||
export function useDaemonClient(url: string, conversationId?: string | null): DaemonClientV2 {
|
||||
const client = useMemo(
|
||||
() =>
|
||||
new DaemonClientV2({
|
||||
url,
|
||||
conversationId: conversationId ?? null,
|
||||
suppressSendErrors: true,
|
||||
}),
|
||||
[url, conversationId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
runDaemonRequest("connect", client.connect());
|
||||
|
||||
return () => {
|
||||
runDaemonRequest("close", client.close());
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
if (nextState !== "active") {
|
||||
return;
|
||||
}
|
||||
client.ensureConnected();
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SessionOutboundMessage, WSInboundMessage } from "@server/server/messages";
|
||||
import type { UseWebSocketReturn } from "./use-websocket";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
type RequestStatus = "idle" | "loading" | "success" | "error";
|
||||
|
||||
type MaybeUndefined<T> = T | undefined;
|
||||
type ExecuteParams<TParams> = TParams extends void ? void | undefined : TParams;
|
||||
|
||||
export interface RequestContext<TParams> {
|
||||
params: MaybeUndefined<TParams>;
|
||||
requestId: string;
|
||||
key: string;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
type RetryDelay =
|
||||
| number
|
||||
| ((attempt: number) => number);
|
||||
|
||||
export interface UseDaemonRequestOptions<
|
||||
TParams = void,
|
||||
TData = unknown,
|
||||
TMessage extends SessionOutboundMessage = SessionOutboundMessage
|
||||
> {
|
||||
ws: UseWebSocketReturn;
|
||||
/**
|
||||
* Session message type to subscribe to for responses.
|
||||
*/
|
||||
responseType: TMessage["type"];
|
||||
/**
|
||||
* Builder that receives params + the generated requestId and returns the outbound WS message.
|
||||
*/
|
||||
buildRequest: (context: { params: MaybeUndefined<TParams>; requestId: string }) => WSInboundMessage;
|
||||
/**
|
||||
* Extracts the typed data from the inbound response.
|
||||
*/
|
||||
selectData: (message: TMessage, context: RequestContext<TParams>) => TData;
|
||||
/**
|
||||
* Override response matching behavior. Defaults to comparing payload.requestId when available.
|
||||
*/
|
||||
matchResponse?: (message: TMessage, context: RequestContext<TParams>) => boolean;
|
||||
/**
|
||||
* Returns the request key for dedupe. Defaults to JSON.stringify(params) or "default".
|
||||
*/
|
||||
getRequestKey?: (params: MaybeUndefined<TParams>) => string;
|
||||
/**
|
||||
* Returns an error (string or Error) when the payload represents a failure.
|
||||
*/
|
||||
extractError?: (message: TMessage, context: RequestContext<TParams>) => string | Error | null;
|
||||
/**
|
||||
* Milliseconds before a request automatically times out. Pass null to disable.
|
||||
*/
|
||||
timeoutMs?: number | null;
|
||||
/**
|
||||
* Number of retry attempts after the initial send.
|
||||
*/
|
||||
retryCount?: number;
|
||||
/**
|
||||
* Delay between retries (ms) or function that maps attempt -> delay.
|
||||
*/
|
||||
retryDelayMs?: RetryDelay;
|
||||
/**
|
||||
* Keep previously resolved data while loading.
|
||||
*/
|
||||
keepPreviousData?: boolean;
|
||||
/**
|
||||
* Provide initial data before running the request.
|
||||
*/
|
||||
initialData?: TData | null;
|
||||
/**
|
||||
* Disable deduplication (send every execute call even if one is in-flight).
|
||||
*/
|
||||
dedupe?: boolean;
|
||||
}
|
||||
|
||||
export interface ExecuteRequestOptions {
|
||||
timeoutMs?: number | null;
|
||||
retryCount?: number;
|
||||
retryDelayMs?: RetryDelay;
|
||||
dedupe?: boolean;
|
||||
requestKeyOverride?: string;
|
||||
}
|
||||
|
||||
export interface UseDaemonRequestResult<TParams, TData> {
|
||||
status: RequestStatus;
|
||||
data: TData | null;
|
||||
error: Error | null;
|
||||
isIdle: boolean;
|
||||
isLoading: boolean;
|
||||
isSuccess: boolean;
|
||||
isError: boolean;
|
||||
requestId: string | null;
|
||||
updatedAt: number | null;
|
||||
execute: (params?: ExecuteParams<TParams>, options?: ExecuteRequestOptions) => Promise<TData>;
|
||||
reset: () => void;
|
||||
cancel: (reason?: string) => void;
|
||||
}
|
||||
|
||||
interface ActiveRequest<TParams, TData, TMessage extends SessionOutboundMessage> {
|
||||
key: string;
|
||||
params: MaybeUndefined<TParams>;
|
||||
requestId: string;
|
||||
attempt: number;
|
||||
promise: Promise<TData>;
|
||||
resolve: (data: TData) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutHandle: ReturnType<typeof setTimeout> | null;
|
||||
retryHandle: ReturnType<typeof setTimeout> | null;
|
||||
aborted: boolean;
|
||||
options: ResolvedBehavior;
|
||||
}
|
||||
|
||||
interface ResolvedBehavior {
|
||||
timeoutMs: number | null;
|
||||
retryCount: number;
|
||||
retryDelayMs: RetryDelay;
|
||||
dedupe: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const DEFAULT_RETRY_DELAY_MS = 750;
|
||||
|
||||
function useLatest<T>(value: T) {
|
||||
const ref = useRef(value);
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref;
|
||||
}
|
||||
|
||||
function resolveKey(params: unknown): string {
|
||||
if (params === undefined || params === null) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (typeof params === "string" || typeof params === "number" || typeof params === "boolean") {
|
||||
return String(params);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(params);
|
||||
} catch {
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function ensureError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
return new Error(typeof error === "string" ? error : "Unknown request error");
|
||||
}
|
||||
|
||||
export function useDaemonRequest<
|
||||
TParams = void,
|
||||
TData = unknown,
|
||||
TMessage extends SessionOutboundMessage = SessionOutboundMessage
|
||||
>(options: UseDaemonRequestOptions<TParams, TData, TMessage>): UseDaemonRequestResult<TParams, TData> {
|
||||
const {
|
||||
ws,
|
||||
responseType,
|
||||
buildRequest,
|
||||
selectData,
|
||||
matchResponse,
|
||||
getRequestKey,
|
||||
extractError,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
retryCount = 0,
|
||||
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
|
||||
keepPreviousData = true,
|
||||
initialData = null,
|
||||
dedupe = true,
|
||||
} = options;
|
||||
|
||||
const buildRequestRef = useLatest(buildRequest);
|
||||
const selectDataRef = useLatest(selectData);
|
||||
const matchResponseRef = useLatest(matchResponse);
|
||||
const extractErrorRef = useLatest(extractError);
|
||||
const getRequestKeyRef = useLatest(getRequestKey);
|
||||
|
||||
const [state, setState] = useState<{
|
||||
status: RequestStatus;
|
||||
data: TData | null;
|
||||
error: Error | null;
|
||||
requestId: string | null;
|
||||
updatedAt: number | null;
|
||||
}>(() => ({
|
||||
status: initialData === null ? "idle" : "success",
|
||||
data: initialData,
|
||||
error: null,
|
||||
requestId: null,
|
||||
updatedAt: initialData === null ? null : Date.now(),
|
||||
}));
|
||||
|
||||
const activeRequestRef = useRef<ActiveRequest<TParams, TData, TMessage> | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
const active = activeRequestRef.current;
|
||||
if (active) {
|
||||
if (active.timeoutHandle) {
|
||||
clearTimeout(active.timeoutHandle);
|
||||
}
|
||||
if (active.retryHandle) {
|
||||
clearTimeout(active.retryHandle);
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cleanupActiveRequest = useCallback(() => {
|
||||
const current = activeRequestRef.current;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
if (current.timeoutHandle) {
|
||||
clearTimeout(current.timeoutHandle);
|
||||
}
|
||||
if (current.retryHandle) {
|
||||
clearTimeout(current.retryHandle);
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
}, []);
|
||||
|
||||
const applyState = useCallback(
|
||||
(updater: (prev: typeof state) => typeof state) => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
setState(updater);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getMatchFn = useCallback(
|
||||
(context: RequestContext<TParams>) => {
|
||||
const userMatch = matchResponseRef.current;
|
||||
if (userMatch) {
|
||||
return (message: TMessage) => userMatch(message, context);
|
||||
}
|
||||
return (message: TMessage) => {
|
||||
const payload = (message as { payload?: { requestId?: unknown } }).payload;
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"requestId" in payload &&
|
||||
typeof (payload as { requestId?: unknown }).requestId === "string"
|
||||
) {
|
||||
return (payload as { requestId?: string }).requestId === context.requestId;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
},
|
||||
[matchResponseRef]
|
||||
);
|
||||
|
||||
const resolvedBehavior = useCallback(
|
||||
(overrides?: ExecuteRequestOptions): ResolvedBehavior => ({
|
||||
timeoutMs:
|
||||
overrides?.timeoutMs !== undefined
|
||||
? overrides.timeoutMs
|
||||
: timeoutMs ?? null,
|
||||
retryCount: overrides?.retryCount ?? retryCount,
|
||||
retryDelayMs: overrides?.retryDelayMs ?? retryDelayMs,
|
||||
dedupe: overrides?.dedupe ?? dedupe,
|
||||
}),
|
||||
[timeoutMs, retryCount, retryDelayMs, dedupe]
|
||||
);
|
||||
|
||||
const sendAttempt = useCallback(
|
||||
(request: ActiveRequest<TParams, TData, TMessage>) => {
|
||||
if (request.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
request.attempt += 1;
|
||||
|
||||
if (request.timeoutHandle) {
|
||||
clearTimeout(request.timeoutHandle);
|
||||
}
|
||||
|
||||
try {
|
||||
const message = buildRequestRef.current({
|
||||
params: request.params,
|
||||
requestId: request.requestId,
|
||||
});
|
||||
ws.send(message);
|
||||
} catch (error) {
|
||||
const err = ensureError(error);
|
||||
request.reject(err);
|
||||
cleanupActiveRequest();
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: err,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutDuration =
|
||||
typeof request.options.timeoutMs === "number"
|
||||
? request.options.timeoutMs
|
||||
: null;
|
||||
|
||||
if (timeoutDuration && timeoutDuration > 0) {
|
||||
request.timeoutHandle = setTimeout(() => {
|
||||
if (request.aborted) {
|
||||
return;
|
||||
}
|
||||
const timeoutError = new Error(
|
||||
`Request timed out after ${timeoutDuration}ms`
|
||||
);
|
||||
const maxAttempts = request.options.retryCount + 1;
|
||||
if (request.attempt >= maxAttempts) {
|
||||
cleanupActiveRequest();
|
||||
request.reject(timeoutError);
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: timeoutError,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const delay =
|
||||
typeof request.options.retryDelayMs === "function"
|
||||
? request.options.retryDelayMs(request.attempt)
|
||||
: request.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
request.retryHandle = setTimeout(() => {
|
||||
sendAttempt(request);
|
||||
}, Math.max(0, delay));
|
||||
}, timeoutDuration);
|
||||
}
|
||||
},
|
||||
[applyState, buildRequestRef, cleanupActiveRequest, keepPreviousData, ws]
|
||||
);
|
||||
|
||||
const startRequest = useCallback(
|
||||
(params?: ExecuteParams<TParams>, overrides?: ExecuteRequestOptions) => {
|
||||
const normalizedParams = params as MaybeUndefined<TParams>;
|
||||
const behavior = resolvedBehavior(overrides);
|
||||
const computedKey =
|
||||
overrides?.requestKeyOverride ??
|
||||
getRequestKeyRef.current?.(normalizedParams) ??
|
||||
resolveKey(normalizedParams);
|
||||
const active = activeRequestRef.current;
|
||||
if (behavior.dedupe && active && active.key === computedKey) {
|
||||
return active.promise;
|
||||
}
|
||||
|
||||
let resolveFn!: (value: TData) => void;
|
||||
let rejectFn!: (error: Error) => void;
|
||||
const promise = new Promise<TData>((resolve, reject) => {
|
||||
resolveFn = resolve;
|
||||
rejectFn = reject;
|
||||
});
|
||||
|
||||
const request: ActiveRequest<TParams, TData, TMessage> = {
|
||||
key: computedKey,
|
||||
params: normalizedParams,
|
||||
requestId: generateMessageId(),
|
||||
attempt: 0,
|
||||
promise,
|
||||
resolve: resolveFn,
|
||||
reject: rejectFn,
|
||||
timeoutHandle: null,
|
||||
retryHandle: null,
|
||||
aborted: false,
|
||||
options: behavior,
|
||||
};
|
||||
|
||||
activeRequestRef.current = request;
|
||||
|
||||
applyState((prev) => ({
|
||||
status: "loading",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: null,
|
||||
requestId: request.requestId,
|
||||
updatedAt: prev.updatedAt,
|
||||
}));
|
||||
|
||||
sendAttempt(request);
|
||||
return promise;
|
||||
},
|
||||
[applyState, getRequestKeyRef, keepPreviousData, resolvedBehavior, sendAttempt]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ws.on(responseType, (message) => {
|
||||
const active = activeRequestRef.current;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const castedMessage = message as TMessage;
|
||||
const context: RequestContext<TParams> = {
|
||||
params: active.params,
|
||||
requestId: active.requestId,
|
||||
key: active.key,
|
||||
attempt: active.attempt,
|
||||
};
|
||||
const matcher = getMatchFn(context);
|
||||
if (!matcher(castedMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (active.timeoutHandle) {
|
||||
clearTimeout(active.timeoutHandle);
|
||||
active.timeoutHandle = null;
|
||||
}
|
||||
if (active.retryHandle) {
|
||||
clearTimeout(active.retryHandle);
|
||||
active.retryHandle = null;
|
||||
}
|
||||
|
||||
const maybeError = extractErrorRef.current
|
||||
? extractErrorRef.current(castedMessage, context)
|
||||
: null;
|
||||
|
||||
if (maybeError) {
|
||||
const error = ensureError(maybeError);
|
||||
const maxAttempts = active.options.retryCount + 1;
|
||||
if (active.attempt >= maxAttempts) {
|
||||
cleanupActiveRequest();
|
||||
active.reject(error);
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const delay =
|
||||
typeof active.options.retryDelayMs === "function"
|
||||
? active.options.retryDelayMs(active.attempt)
|
||||
: active.options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
||||
active.retryHandle = setTimeout(() => {
|
||||
sendAttempt(active);
|
||||
}, Math.max(0, delay));
|
||||
return;
|
||||
}
|
||||
|
||||
let data: TData;
|
||||
try {
|
||||
data = selectDataRef.current(castedMessage, context);
|
||||
} catch (error) {
|
||||
const err = ensureError(error);
|
||||
cleanupActiveRequest();
|
||||
active.reject(err);
|
||||
applyState((prev) => ({
|
||||
status: "error",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: err,
|
||||
requestId: null,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupActiveRequest();
|
||||
active.resolve(data);
|
||||
applyState(() => ({
|
||||
status: "success",
|
||||
data,
|
||||
error: null,
|
||||
requestId: context.requestId,
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [
|
||||
applyState,
|
||||
cleanupActiveRequest,
|
||||
extractErrorRef,
|
||||
getMatchFn,
|
||||
keepPreviousData,
|
||||
responseType,
|
||||
selectDataRef,
|
||||
sendAttempt,
|
||||
ws,
|
||||
]);
|
||||
|
||||
const execute = useCallback(
|
||||
(params?: ExecuteParams<TParams>, overrides?: ExecuteRequestOptions) => startRequest(params, overrides),
|
||||
[startRequest]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
cleanupActiveRequest();
|
||||
applyState(() => ({
|
||||
status: "idle",
|
||||
data: initialData,
|
||||
error: null,
|
||||
requestId: null,
|
||||
updatedAt: null,
|
||||
}));
|
||||
}, [applyState, cleanupActiveRequest, initialData]);
|
||||
|
||||
const cancel = useCallback(
|
||||
(reason?: string) => {
|
||||
const active = activeRequestRef.current;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
active.aborted = true;
|
||||
cleanupActiveRequest();
|
||||
const error = new Error(reason ?? "Request cancelled");
|
||||
active.reject(error);
|
||||
applyState((prev) => ({
|
||||
status: "idle",
|
||||
data: keepPreviousData ? prev.data : null,
|
||||
error: null,
|
||||
requestId: null,
|
||||
updatedAt: prev.updatedAt,
|
||||
}));
|
||||
},
|
||||
[applyState, cleanupActiveRequest, keepPreviousData]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
status: state.status,
|
||||
data: state.data,
|
||||
error: state.error,
|
||||
isIdle: state.status === "idle",
|
||||
isLoading: state.status === "loading",
|
||||
isSuccess: state.status === "success",
|
||||
isError: state.status === "error",
|
||||
requestId: state.requestId,
|
||||
updatedAt: state.updatedAt,
|
||||
execute,
|
||||
reset,
|
||||
cancel,
|
||||
}),
|
||||
[execute, reset, cancel, state]
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useAudioRecorder } from "@/hooks/use-audio-recorder";
|
||||
import { useSessionRpc } from "@/hooks/use-session-rpc";
|
||||
import type { RpcFailureReason, RpcRetryAttemptEvent } from "@/hooks/use-session-rpc";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { TranscriptionResultMessage } from "@server/shared/messages";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
export type DictationStatus = "idle" | "recording" | "uploading" | "retrying" | "failed";
|
||||
|
||||
type DictationRetryReason = "dispatch" | "timeout" | "response" | "disconnected";
|
||||
|
||||
export type DictationRetryInfo = {
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
reason: RpcFailureReason;
|
||||
reason: DictationRetryReason;
|
||||
errorMessage: string;
|
||||
nextRetryMs: number;
|
||||
};
|
||||
@@ -33,7 +35,7 @@ export type DictationOutcome =
|
||||
export type UseDictationOptions = {
|
||||
agentId?: string;
|
||||
sendAgentAudio: SessionContextValue["sendAgentAudio"];
|
||||
ws: UseWebSocketReturn;
|
||||
client: DaemonClientV2 | null;
|
||||
mode?: "transcribe_only" | "auto_run";
|
||||
onTranscript: (text: string, meta: { requestId: string }) => void;
|
||||
onError?: (error: Error) => void;
|
||||
@@ -74,6 +76,31 @@ const RETRY_BACKOFF_FACTOR = 1.8;
|
||||
const RETRY_JITTER_MS = 400;
|
||||
const TRANSCRIPTION_TIMEOUT_MS = 120000;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const computeRetryDelayMs = (attempt: number): number => {
|
||||
const exponential = RETRY_BASE_DELAY_MS * RETRY_BACKOFF_FACTOR ** Math.max(0, attempt - 1);
|
||||
const capped = Math.min(RETRY_MAX_DELAY_MS, exponential);
|
||||
if (RETRY_JITTER_MS <= 0) {
|
||||
return capped;
|
||||
}
|
||||
return capped + Math.floor(Math.random() * RETRY_JITTER_MS);
|
||||
};
|
||||
|
||||
const isTimeoutError = (error: Error): boolean =>
|
||||
/timed out|timeout/i.test(error.message);
|
||||
|
||||
class DictationAttemptError extends Error {
|
||||
public readonly reason: DictationRetryReason;
|
||||
|
||||
constructor(reason: DictationRetryReason, error: Error) {
|
||||
super(error.message);
|
||||
this.name = "DictationAttemptError";
|
||||
this.reason = reason;
|
||||
(this as Error & { cause?: Error }).cause = error;
|
||||
}
|
||||
}
|
||||
|
||||
const toError = (error: unknown): Error => {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
@@ -92,6 +119,8 @@ type CapturedAudioPayload = {
|
||||
recordedAt: number;
|
||||
};
|
||||
|
||||
type TranscriptionPayload = TranscriptionResultMessage["payload"];
|
||||
|
||||
const deriveFormatFromMime = (mimeType?: string): string => {
|
||||
if (!mimeType || mimeType.length === 0) {
|
||||
return "webm";
|
||||
@@ -117,7 +146,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const {
|
||||
agentId,
|
||||
sendAgentAudio,
|
||||
ws,
|
||||
client,
|
||||
mode = "transcribe_only",
|
||||
onTranscript,
|
||||
onError,
|
||||
@@ -142,11 +171,111 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const [lastOutcome, setLastOutcome] = useState<DictationOutcome | null>(null);
|
||||
const maxRetryAttempts = MAX_AUTO_RETRY_ATTEMPTS;
|
||||
|
||||
const { waitForResponse: waitForTranscriptionResponse, reset: resetTranscriptionRpc } = useSessionRpc({
|
||||
ws,
|
||||
requestType: "send_agent_audio",
|
||||
responseType: "transcription_result",
|
||||
const transcriptionMutation = useMutation({
|
||||
mutationFn: async (requestId: string): Promise<TranscriptionPayload> => {
|
||||
const capturedAudio = pendingAudioRef.current;
|
||||
if (!capturedAudio) {
|
||||
throw new Error("No recorded audio available for transcription");
|
||||
}
|
||||
|
||||
setRetryAttempt(1);
|
||||
setRetryInfo(null);
|
||||
|
||||
let attempt = 1;
|
||||
|
||||
while (attempt <= MAX_AUTO_RETRY_ATTEMPTS) {
|
||||
try {
|
||||
if (!client) {
|
||||
throw new DictationAttemptError(
|
||||
"disconnected",
|
||||
new Error("Daemon client unavailable")
|
||||
);
|
||||
}
|
||||
if (!client.isConnected) {
|
||||
throw new DictationAttemptError(
|
||||
"disconnected",
|
||||
new Error("Daemon client is disconnected")
|
||||
);
|
||||
}
|
||||
|
||||
console.info("[useDictation] sending transcription request", {
|
||||
requestId,
|
||||
attempt,
|
||||
agentId,
|
||||
size: capturedAudio.sizeBytes,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
});
|
||||
|
||||
setStatus("uploading");
|
||||
setRetryAttempt(attempt);
|
||||
|
||||
try {
|
||||
const transcriptionPromise = client.waitForTranscriptionResult(
|
||||
requestId,
|
||||
TRANSCRIPTION_TIMEOUT_MS
|
||||
);
|
||||
try {
|
||||
await sendAgentAudio(agentId, capturedAudio.blob, requestId, {
|
||||
mode,
|
||||
});
|
||||
} catch (error) {
|
||||
transcriptionPromise.catch(() => {});
|
||||
throw new DictationAttemptError("dispatch", toError(error));
|
||||
}
|
||||
|
||||
return await transcriptionPromise;
|
||||
} catch (error) {
|
||||
const normalized = toError(error);
|
||||
const reason: DictationRetryReason = isTimeoutError(normalized)
|
||||
? "timeout"
|
||||
: "response";
|
||||
throw new DictationAttemptError(reason, normalized);
|
||||
}
|
||||
} catch (error) {
|
||||
const attemptError =
|
||||
error instanceof DictationAttemptError
|
||||
? error
|
||||
: new DictationAttemptError("response", toError(error));
|
||||
|
||||
if (attempt >= MAX_AUTO_RETRY_ATTEMPTS) {
|
||||
throw attemptError;
|
||||
}
|
||||
|
||||
const nextAttempt = attempt + 1;
|
||||
const delayMs = computeRetryDelayMs(nextAttempt);
|
||||
const info: DictationRetryInfo = {
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: MAX_AUTO_RETRY_ATTEMPTS,
|
||||
reason: attemptError.reason,
|
||||
errorMessage: attemptError.message,
|
||||
nextRetryMs: delayMs,
|
||||
};
|
||||
|
||||
setStatus("retrying");
|
||||
setRetryAttempt(nextAttempt);
|
||||
setRetryInfo(info);
|
||||
onRetryAttemptRef.current?.(info);
|
||||
console.warn("[useDictation] retry scheduled", {
|
||||
requestId,
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: MAX_AUTO_RETRY_ATTEMPTS,
|
||||
reason: attemptError.reason,
|
||||
error: attemptError.message,
|
||||
nextRetryMs: delayMs,
|
||||
});
|
||||
|
||||
await sleep(delayMs);
|
||||
attempt = nextAttempt;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Failed to complete transcription");
|
||||
},
|
||||
});
|
||||
const {
|
||||
mutateAsync: runTranscription,
|
||||
reset: resetTranscriptionMutation,
|
||||
} = transcriptionMutation;
|
||||
|
||||
const pendingAudioRef = useRef<CapturedAudioPayload | null>(null);
|
||||
const handleAudioLevel = useCallback((level: number) => {
|
||||
@@ -254,69 +383,12 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
}, []);
|
||||
|
||||
const transmitDictation = useCallback(
|
||||
async (requestId: string) => {
|
||||
const capturedAudio = pendingAudioRef.current;
|
||||
if (!capturedAudio) {
|
||||
throw new Error("No recorded audio available for transcription");
|
||||
}
|
||||
|
||||
setRetryAttempt(1);
|
||||
setRetryInfo(null);
|
||||
|
||||
console.info("[useDictation] sending transcription request", {
|
||||
requestId,
|
||||
attempt: 1,
|
||||
agentId,
|
||||
size: capturedAudio.sizeBytes,
|
||||
durationSeconds: capturedAudio.durationSeconds,
|
||||
});
|
||||
|
||||
const transcription = await waitForTranscriptionResponse({
|
||||
requestId,
|
||||
dispatch: async (_id, attempt) => {
|
||||
setStatus("uploading");
|
||||
setRetryAttempt(attempt);
|
||||
await sendAgentAudio(agentId, capturedAudio.blob, requestId, { mode });
|
||||
},
|
||||
retry: {
|
||||
maxAttempts: MAX_AUTO_RETRY_ATTEMPTS,
|
||||
baseDelayMs: RETRY_BASE_DELAY_MS,
|
||||
maxDelayMs: RETRY_MAX_DELAY_MS,
|
||||
backoffFactor: RETRY_BACKOFF_FACTOR,
|
||||
jitterMs: RETRY_JITTER_MS,
|
||||
shouldRetry: ({ attempt, maxAttempts }) => attempt < maxAttempts,
|
||||
onRetryAttempt: (event: RpcRetryAttemptEvent) => {
|
||||
setStatus("retrying");
|
||||
setRetryAttempt(event.attempt);
|
||||
const info: DictationRetryInfo = {
|
||||
attempt: event.attempt,
|
||||
maxAttempts: event.maxAttempts,
|
||||
reason: event.reason,
|
||||
errorMessage: event.error.message,
|
||||
nextRetryMs: event.nextDelayMs,
|
||||
};
|
||||
setRetryInfo(info);
|
||||
onRetryAttemptRef.current?.(info);
|
||||
console.warn("[useDictation] retry scheduled", {
|
||||
requestId,
|
||||
attempt: event.attempt,
|
||||
maxAttempts: event.maxAttempts,
|
||||
reason: event.reason,
|
||||
error: event.error.message,
|
||||
nextRetryMs: event.nextDelayMs,
|
||||
});
|
||||
},
|
||||
},
|
||||
timeoutMs: TRANSCRIPTION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
return transcription;
|
||||
},
|
||||
[agentId, mode, onRetryAttemptRef, sendAgentAudio, waitForTranscriptionResponse]
|
||||
async (requestId: string) => runTranscription(requestId),
|
||||
[runTranscription]
|
||||
);
|
||||
|
||||
const handleTranscriptionSuccess = useCallback(
|
||||
(transcription: Awaited<ReturnType<typeof waitForTranscriptionResponse>>, requestId: string) => {
|
||||
(transcription: TranscriptionPayload, requestId: string) => {
|
||||
pendingRequestIdRef.current = null;
|
||||
setPendingRequestId(null);
|
||||
setIsProcessing(false);
|
||||
@@ -516,7 +588,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const transcription = await transmitDictation(requestId);
|
||||
handleTranscriptionSuccess(transcription, requestId);
|
||||
} catch (err) {
|
||||
resetTranscriptionRpc();
|
||||
resetTranscriptionMutation();
|
||||
handleDictationFailure(err, requestId);
|
||||
}
|
||||
}, [
|
||||
@@ -526,7 +598,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
mode,
|
||||
handleDictationFailure,
|
||||
handleTranscriptionSuccess,
|
||||
resetTranscriptionRpc,
|
||||
resetTranscriptionMutation,
|
||||
stopDurationTracking,
|
||||
stopRecorder,
|
||||
transmitDictation,
|
||||
@@ -551,13 +623,13 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
const transcription = await transmitDictation(requestId);
|
||||
handleTranscriptionSuccess(transcription, requestId);
|
||||
} catch (err) {
|
||||
resetTranscriptionRpc();
|
||||
resetTranscriptionMutation();
|
||||
handleDictationFailure(err, requestId);
|
||||
}
|
||||
}, [
|
||||
handleDictationFailure,
|
||||
handleTranscriptionSuccess,
|
||||
resetTranscriptionRpc,
|
||||
resetTranscriptionMutation,
|
||||
transmitDictation,
|
||||
]);
|
||||
|
||||
@@ -591,8 +663,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setFailedRecording(null);
|
||||
pendingAudioRef.current = null;
|
||||
setLastOutcome(null);
|
||||
resetTranscriptionRpc();
|
||||
}, [resetTranscriptionRpc, stopDurationTracking]);
|
||||
resetTranscriptionMutation();
|
||||
}, [resetTranscriptionMutation, stopDurationTracking]);
|
||||
|
||||
const cancelRef = useRef<(() => void) | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -624,8 +696,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
|
||||
setRetryAttempt(0);
|
||||
setRetryInfo(null);
|
||||
setFailedRecording(null);
|
||||
resetTranscriptionRpc();
|
||||
}, [agentId, resetTranscriptionRpc]);
|
||||
resetTranscriptionMutation();
|
||||
}, [agentId, resetTranscriptionMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { sendRpcRequest } from "@/lib/send-rpc-request";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
|
||||
const GIT_DIFF_STALE_TIME = 30_000;
|
||||
@@ -17,22 +16,24 @@ interface UseGitDiffQueryOptions {
|
||||
|
||||
export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const { isOpen, activeTab } = useExplorerSidebarStore();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!ws) {
|
||||
throw new Error("WebSocket not available");
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "git_diff_request",
|
||||
agentId,
|
||||
});
|
||||
const response = await client.getGitDiff(agentId);
|
||||
return response.diff;
|
||||
},
|
||||
enabled: !!ws && ws.isConnected && !!agentId,
|
||||
enabled: !!client && isConnected && !!agentId,
|
||||
staleTime: GIT_DIFF_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { sendRpcRequest } from "@/lib/send-rpc-request";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
import type { HighlightedDiffResponse } from "@server/server/messages";
|
||||
import type { HighlightedDiffResponse } from "@server/shared/messages";
|
||||
import { getNowMs, isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
|
||||
const HIGHLIGHTED_DIFF_STALE_TIME = 30_000;
|
||||
const HIGHLIGHTED_DIFF_LOG_TAG = "[HighlightedDiff]";
|
||||
|
||||
function highlightedDiffQueryKey(serverId: string, agentId: string) {
|
||||
return ["highlightedDiff", serverId, agentId] as const;
|
||||
@@ -23,22 +24,56 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
|
||||
export function useHighlightedDiffQuery({ serverId, agentId }: UseHighlightedDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const ws = useSessionStore((state) => state.sessions[serverId]?.ws);
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const { isOpen, activeTab } = useExplorerSidebarStore();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: highlightedDiffQueryKey(serverId, agentId),
|
||||
queryFn: async () => {
|
||||
if (!ws) {
|
||||
throw new Error("WebSocket not available");
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const shouldLog = isPerfLoggingEnabled();
|
||||
const startMs = shouldLog ? getNowMs() : 0;
|
||||
const response = await client.getHighlightedDiff(agentId);
|
||||
if (shouldLog) {
|
||||
let hunkCount = 0;
|
||||
let lineCount = 0;
|
||||
let tokenCount = 0;
|
||||
for (const file of response.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const durationMs = getNowMs() - startMs;
|
||||
const metrics = measurePayload(response);
|
||||
perfLog(HIGHLIGHTED_DIFF_LOG_TAG, {
|
||||
event: "fetch",
|
||||
serverId,
|
||||
agentId,
|
||||
durationMs: Math.round(durationMs),
|
||||
fileCount: response.files.length,
|
||||
hunkCount,
|
||||
lineCount,
|
||||
tokenCount,
|
||||
payloadApproxBytes: metrics.approxBytes,
|
||||
payloadFieldCount: metrics.fieldCount,
|
||||
});
|
||||
}
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "highlighted_diff_request",
|
||||
agentId,
|
||||
});
|
||||
return response.files;
|
||||
},
|
||||
enabled: !!ws && ws.isConnected && !!agentId,
|
||||
enabled: !!client && isConnected && !!agentId,
|
||||
staleTime: HIGHLIGHTED_DIFF_STALE_TIME,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "@server/server/messages";
|
||||
import type { UseWebSocketReturn } from "./use-websocket";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
const DEFAULT_BASE_DELAY_MS = 1000;
|
||||
const DEFAULT_MAX_DELAY_MS = 15000;
|
||||
const DEFAULT_BACKOFF_FACTOR = 2;
|
||||
const DEFAULT_JITTER_MS = 250;
|
||||
|
||||
const toError = (value: unknown): Error => {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
return new Error("Unexpected RPC error");
|
||||
};
|
||||
|
||||
type RequestType = SessionInboundMessage["type"];
|
||||
type ResponseType = SessionOutboundMessage["type"];
|
||||
|
||||
type RequestOf<TType extends RequestType> = Extract<SessionInboundMessage, { type: TType }>;
|
||||
type ResponseOf<TType extends ResponseType> = Extract<SessionOutboundMessage, { type: TType }>;
|
||||
|
||||
type RpcState<T> =
|
||||
| { status: "idle"; requestId: null }
|
||||
| { status: "loading"; requestId: string }
|
||||
| { status: "success"; requestId: string; data: T }
|
||||
| { status: "error"; requestId: string | null; error: Error };
|
||||
|
||||
type ResponseWithEnvelope<TType extends ResponseType> = Extract<
|
||||
ResponseOf<TType>,
|
||||
{ payload: { requestId?: string } }
|
||||
>;
|
||||
|
||||
type EnsureEnvelope<TType extends ResponseType> = ResponseWithEnvelope<TType> extends never ? never : TType;
|
||||
|
||||
type ResponsePayload<TType extends ResponseType> = ResponseWithEnvelope<TType> extends { payload: infer P }
|
||||
? P
|
||||
: never;
|
||||
|
||||
type SelectResponse<TType extends ResponseType, TData> = (message: ResponseWithEnvelope<TType>) => TData;
|
||||
|
||||
type DispatchRequest<TType extends RequestType> = (request: RequestOf<TType>) => void | Promise<void>;
|
||||
|
||||
type DispatchOverride = (requestId: string, attempt: number) => void | Promise<void>;
|
||||
|
||||
export type RpcFailureReason = "dispatch" | "timeout" | "response" | "disconnected";
|
||||
|
||||
export interface RpcRetryContext {
|
||||
requestId: string;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
reason: RpcFailureReason;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export interface RpcRetryAttemptEvent extends RpcRetryContext {
|
||||
nextDelayMs: number;
|
||||
}
|
||||
|
||||
export interface RpcRetryOptions {
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
backoffFactor?: number;
|
||||
jitterMs?: number;
|
||||
shouldRetry?: (context: RpcRetryContext) => boolean;
|
||||
onRetryAttempt?: (event: RpcRetryAttemptEvent) => void;
|
||||
}
|
||||
|
||||
export class RpcRequestError extends Error {
|
||||
public readonly reason: RpcFailureReason;
|
||||
public readonly attempt: number;
|
||||
public readonly maxAttempts: number;
|
||||
public readonly requestId: string | null;
|
||||
|
||||
constructor(message: string, options: { reason: RpcFailureReason; attempt: number; maxAttempts: number; requestId?: string | null; cause?: unknown }) {
|
||||
super(message);
|
||||
this.name = "RpcRequestError";
|
||||
this.reason = options.reason;
|
||||
this.attempt = options.attempt;
|
||||
this.maxAttempts = options.maxAttempts;
|
||||
this.requestId = options.requestId ?? null;
|
||||
if (options.cause !== undefined) {
|
||||
(this as Error & { cause?: unknown }).cause = options.cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedRetryOptions {
|
||||
maxAttempts: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
backoffFactor: number;
|
||||
jitterMs: number;
|
||||
shouldRetry: NonNullable<RpcRetryOptions["shouldRetry"]>;
|
||||
onRetryAttempt?: RpcRetryOptions["onRetryAttempt"];
|
||||
}
|
||||
|
||||
const resolveRetryOptions = (retry: RpcRetryOptions | undefined, canRetry: boolean): ResolvedRetryOptions => {
|
||||
const maxAttempts = Math.max(1, retry?.maxAttempts ?? 1);
|
||||
const resolved: ResolvedRetryOptions = {
|
||||
maxAttempts: canRetry ? maxAttempts : 1,
|
||||
baseDelayMs: retry?.baseDelayMs ?? DEFAULT_BASE_DELAY_MS,
|
||||
maxDelayMs: retry?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
|
||||
backoffFactor: retry?.backoffFactor ?? DEFAULT_BACKOFF_FACTOR,
|
||||
jitterMs: retry?.jitterMs ?? DEFAULT_JITTER_MS,
|
||||
shouldRetry: retry?.shouldRetry ?? (() => true),
|
||||
onRetryAttempt: retry?.onRetryAttempt,
|
||||
};
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const computeDelayMs = (attempt: number, options: ResolvedRetryOptions): number => {
|
||||
const exponential = options.baseDelayMs * options.backoffFactor ** Math.max(0, attempt - 1);
|
||||
const capped = Math.min(options.maxDelayMs, exponential);
|
||||
if (options.jitterMs <= 0) {
|
||||
return capped;
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * options.jitterMs);
|
||||
return capped + jitter;
|
||||
};
|
||||
|
||||
type WaitForResponseOptions = {
|
||||
requestId: string;
|
||||
dispatch?: DispatchOverride;
|
||||
retry?: RpcRetryOptions;
|
||||
timeoutMs?: number | null;
|
||||
};
|
||||
|
||||
type SendOptions = {
|
||||
retry?: RpcRetryOptions;
|
||||
timeoutMs?: number | null;
|
||||
};
|
||||
|
||||
type UseSessionRpcReturn<TRequest extends RequestType, TData> = {
|
||||
state: RpcState<TData>;
|
||||
send: (params: Omit<RequestOf<TRequest>, "type" | "requestId">, options?: SendOptions) => Promise<TData>;
|
||||
waitForResponse: (options: WaitForResponseOptions) => Promise<TData>;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
export function useSessionRpc<
|
||||
TRequest extends RequestType,
|
||||
TResponse extends ResponseType,
|
||||
TData = ResponsePayload<TResponse>
|
||||
>(options: {
|
||||
ws: UseWebSocketReturn;
|
||||
requestType: TRequest;
|
||||
responseType: EnsureEnvelope<TResponse>;
|
||||
select?: SelectResponse<TResponse, TData>;
|
||||
dispatch?: DispatchRequest<TRequest>;
|
||||
}): UseSessionRpcReturn<TRequest, TData> {
|
||||
const { ws, requestType, responseType, select, dispatch } = options;
|
||||
const [state, setState] = useState<RpcState<TData>>({ status: "idle", requestId: null });
|
||||
const activeRequestIdRef = useRef<string | null>(null);
|
||||
const resolveRef = useRef<((value: TData) => void) | null>(null);
|
||||
const rejectRef = useRef<((error: Error) => void) | null>(null);
|
||||
const dispatchRef = useRef<DispatchRequest<TRequest> | undefined>(dispatch);
|
||||
const timeoutHandleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryOptionsRef = useRef<ResolvedRetryOptions | null>(null);
|
||||
const failureHandlerRef = useRef<((reason: RpcFailureReason, error: Error) => void) | null>(null);
|
||||
const currentAttemptRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
dispatchRef.current = dispatch;
|
||||
}, [dispatch]);
|
||||
|
||||
const clearTimeoutHandle = useCallback(() => {
|
||||
if (timeoutHandleRef.current) {
|
||||
clearTimeout(timeoutHandleRef.current);
|
||||
timeoutHandleRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearActiveRequest = useCallback(() => {
|
||||
clearTimeoutHandle();
|
||||
activeRequestIdRef.current = null;
|
||||
resolveRef.current = null;
|
||||
rejectRef.current = null;
|
||||
retryOptionsRef.current = null;
|
||||
failureHandlerRef.current = null;
|
||||
currentAttemptRef.current = 0;
|
||||
}, [clearTimeoutHandle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearActiveRequest();
|
||||
};
|
||||
}, [clearActiveRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = ws.on(responseType, (message) => {
|
||||
const typedMessage = message as ResponseWithEnvelope<TResponse>;
|
||||
const payload = typedMessage.payload;
|
||||
const expectedId = activeRequestIdRef.current;
|
||||
if (!payload || !expectedId || payload.requestId !== expectedId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeoutHandle();
|
||||
|
||||
const payloadError =
|
||||
payload && typeof payload === "object" && "error" in payload && typeof (payload as any).error === "string"
|
||||
? ((payload as any).error as string)
|
||||
: null;
|
||||
if (payloadError) {
|
||||
const error = new Error(payloadError);
|
||||
failureHandlerRef.current?.("response", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseData = typedMessage.payload as ResponsePayload<TResponse>;
|
||||
const data = select ? select(typedMessage) : (baseData as unknown as TData);
|
||||
setState({ status: "success", requestId: payload.requestId ?? null, data });
|
||||
resolveRef.current?.(data);
|
||||
clearActiveRequest();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [clearActiveRequest, clearTimeoutHandle, responseType, select, ws]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ws.subscribeConnectionStatus) {
|
||||
return ws.subscribeConnectionStatus((status) => {
|
||||
if (status.isConnected || !activeRequestIdRef.current || !failureHandlerRef.current) {
|
||||
return;
|
||||
}
|
||||
failureHandlerRef.current("disconnected", new Error("WebSocket disconnected"));
|
||||
});
|
||||
}
|
||||
if (!ws.isConnected && activeRequestIdRef.current && failureHandlerRef.current) {
|
||||
failureHandlerRef.current("disconnected", new Error("WebSocket disconnected"));
|
||||
}
|
||||
}, [ws.isConnected, ws.subscribeConnectionStatus]);
|
||||
|
||||
const waitForResponse = useCallback(
|
||||
({ requestId, dispatch: dispatchOverride, retry, timeoutMs = null }: WaitForResponseOptions) => {
|
||||
return new Promise<TData>((resolve, reject) => {
|
||||
const finalDispatch = dispatchOverride ?? null;
|
||||
const canRetry = typeof finalDispatch === "function";
|
||||
const resolvedRetry = resolveRetryOptions(retry, canRetry);
|
||||
|
||||
activeRequestIdRef.current = requestId;
|
||||
resolveRef.current = (value) => {
|
||||
resolve(value);
|
||||
};
|
||||
rejectRef.current = (error) => {
|
||||
reject(error);
|
||||
clearActiveRequest();
|
||||
};
|
||||
retryOptionsRef.current = resolvedRetry;
|
||||
setState({ status: "loading", requestId });
|
||||
|
||||
const finalizeError = (reason: RpcFailureReason, error: Error, attempt: number) => {
|
||||
const rpcError = new RpcRequestError(error.message, {
|
||||
reason,
|
||||
attempt,
|
||||
maxAttempts: resolvedRetry.maxAttempts,
|
||||
requestId,
|
||||
cause: error,
|
||||
});
|
||||
setState({ status: "error", requestId, error: rpcError });
|
||||
rejectRef.current?.(rpcError);
|
||||
};
|
||||
|
||||
const scheduleAttempt = (attemptNumber: number) => {
|
||||
currentAttemptRef.current = attemptNumber;
|
||||
|
||||
const runDispatch = async () => {
|
||||
if (!ws.isConnected) {
|
||||
throw new Error("WebSocket is disconnected");
|
||||
}
|
||||
|
||||
if (finalDispatch) {
|
||||
await finalDispatch(requestId, attemptNumber);
|
||||
}
|
||||
|
||||
if (timeoutMs !== null) {
|
||||
clearTimeoutHandle();
|
||||
timeoutHandleRef.current = setTimeout(() => {
|
||||
failureHandlerRef.current?.("timeout", new Error("RPC request timed out"));
|
||||
}, timeoutMs);
|
||||
}
|
||||
};
|
||||
|
||||
runDispatch().catch((error) => {
|
||||
failureHandlerRef.current?.("dispatch", toError(error));
|
||||
});
|
||||
};
|
||||
|
||||
const handleFailure = (reason: RpcFailureReason, rawError: Error) => {
|
||||
const attempt = currentAttemptRef.current || 1;
|
||||
const normalized = toError(rawError);
|
||||
const options = retryOptionsRef.current;
|
||||
if (!options) {
|
||||
finalizeError(reason, normalized, attempt);
|
||||
return;
|
||||
}
|
||||
|
||||
const withinLimit = attempt < options.maxAttempts;
|
||||
const shouldRetry = withinLimit && options.shouldRetry({
|
||||
requestId,
|
||||
attempt,
|
||||
maxAttempts: options.maxAttempts,
|
||||
reason,
|
||||
error: normalized,
|
||||
});
|
||||
|
||||
if (!shouldRetry) {
|
||||
finalizeError(reason, normalized, attempt);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAttempt = attempt + 1;
|
||||
const delay = computeDelayMs(nextAttempt, options);
|
||||
options.onRetryAttempt?.({
|
||||
requestId,
|
||||
attempt: nextAttempt,
|
||||
maxAttempts: options.maxAttempts,
|
||||
reason,
|
||||
error: normalized,
|
||||
nextDelayMs: delay,
|
||||
});
|
||||
|
||||
clearTimeoutHandle();
|
||||
setTimeout(() => {
|
||||
scheduleAttempt(nextAttempt);
|
||||
}, delay);
|
||||
};
|
||||
|
||||
failureHandlerRef.current = (reason, error) => {
|
||||
handleFailure(reason, toError(error));
|
||||
};
|
||||
|
||||
scheduleAttempt(1);
|
||||
});
|
||||
},
|
||||
[clearActiveRequest, clearTimeoutHandle, ws.isConnected]
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
(params: Omit<RequestOf<TRequest>, "type" | "requestId">, options?: SendOptions) => {
|
||||
const dispatchRequest = dispatchRef.current;
|
||||
return waitForResponse({
|
||||
requestId: generateMessageId(),
|
||||
dispatch: (generatedId) => {
|
||||
const request = {
|
||||
type: requestType,
|
||||
...params,
|
||||
requestId: generatedId,
|
||||
} as RequestOf<TRequest>;
|
||||
if (dispatchRequest) {
|
||||
return dispatchRequest(request);
|
||||
}
|
||||
ws.send({ type: "session", message: request });
|
||||
},
|
||||
retry: options?.retry,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
},
|
||||
[requestType, waitForResponse, ws]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearActiveRequest();
|
||||
setState({ status: "idle", requestId: null });
|
||||
}, [clearActiveRequest]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
send,
|
||||
waitForResponse,
|
||||
reset,
|
||||
}),
|
||||
[reset, send, state, waitForResponse]
|
||||
);
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import type {
|
||||
WSInboundMessage,
|
||||
WSOutboundMessage,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/server/messages";
|
||||
|
||||
export interface ConnectionStatusSnapshot {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
}
|
||||
|
||||
export interface UseWebSocketReturn {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
conversationId: string | null;
|
||||
lastError: string | null;
|
||||
send: (message: WSInboundMessage) => void;
|
||||
on: (
|
||||
type: SessionOutboundMessage["type"],
|
||||
handler: (message: SessionOutboundMessage) => void
|
||||
) => () => void;
|
||||
sendPing: () => void;
|
||||
sendUserMessage: (message: string) => void;
|
||||
clearAgentAttention: (agentId: string | string[]) => void;
|
||||
subscribeConnectionStatus?: (listener: (status: ConnectionStatusSnapshot) => void) => () => void;
|
||||
getConnectionState?: () => ConnectionStatusSnapshot;
|
||||
}
|
||||
|
||||
const RECONNECT_BASE_DELAY_MS = 1500;
|
||||
const RECONNECT_MAX_DELAY_MS = 30000;
|
||||
|
||||
export function useWebSocket(url: string, conversationId?: string | null): UseWebSocketReturn {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(true);
|
||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const handlersRef =
|
||||
useRef<Map<SessionOutboundMessage["type"], Set<(message: SessionOutboundMessage) => void>>>(new Map());
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const reconnectAttemptRef = useRef(0);
|
||||
const shouldReconnectRef = useRef(true);
|
||||
const connectionListenersRef = useRef(new Set<(status: ConnectionStatusSnapshot) => void>());
|
||||
const connectionStateRef = useRef<ConnectionStatusSnapshot>({ isConnected: false, isConnecting: true });
|
||||
|
||||
const notifyConnectionListeners = useCallback((state: ConnectionStatusSnapshot) => {
|
||||
connectionStateRef.current = state;
|
||||
for (const listener of connectionListenersRef.current) {
|
||||
try {
|
||||
listener(state);
|
||||
} catch (error) {
|
||||
console.error("[WS] Connection listener error", error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateConnectionState = useCallback((state: ConnectionStatusSnapshot) => {
|
||||
setIsConnected(state.isConnected);
|
||||
setIsConnecting(state.isConnecting);
|
||||
notifyConnectionListeners(state);
|
||||
}, [notifyConnectionListeners]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let scheduledReconnect = false;
|
||||
const scheduleReconnect = (reason?: string) => {
|
||||
if (scheduledReconnect || !shouldReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
scheduledReconnect = true;
|
||||
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.close();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
if (typeof reason === "string" && reason.trim().length > 0) {
|
||||
setLastError(reason.trim());
|
||||
}
|
||||
|
||||
updateConnectionState({ isConnected: false, isConnecting: false });
|
||||
|
||||
const attempt = reconnectAttemptRef.current;
|
||||
const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, RECONNECT_MAX_DELAY_MS);
|
||||
reconnectAttemptRef.current = attempt + 1;
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
if (!shouldReconnectRef.current) {
|
||||
return;
|
||||
}
|
||||
updateConnectionState({ isConnected: false, isConnecting: true });
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
try {
|
||||
// Add conversation ID to URL if provided
|
||||
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
updateConnectionState({ isConnected: false, isConnecting: true });
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("[WS] Connected to server");
|
||||
updateConnectionState({ isConnected: true, isConnecting: false });
|
||||
setLastError(null);
|
||||
reconnectAttemptRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log("[WS] Disconnected from server");
|
||||
const reason =
|
||||
typeof event?.reason === "string" && event.reason.trim().length > 0
|
||||
? event.reason.trim()
|
||||
: `Socket closed (code ${event?.code ?? "unknown"})`;
|
||||
updateConnectionState({ isConnected: false, isConnecting: false });
|
||||
scheduleReconnect(reason);
|
||||
};
|
||||
|
||||
ws.onerror = (errorEvent) => {
|
||||
let reason = "WebSocket error";
|
||||
if (
|
||||
errorEvent &&
|
||||
typeof errorEvent === "object" &&
|
||||
"message" in errorEvent &&
|
||||
typeof (errorEvent as { message: unknown }).message === "string"
|
||||
) {
|
||||
const message = ((errorEvent as { message: string }).message || "").trim();
|
||||
reason = message.length > 0 ? message : reason;
|
||||
}
|
||||
console.warn("[WS] Error:", errorEvent);
|
||||
scheduleReconnect(reason);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const rawData = event.data;
|
||||
const size = typeof rawData === "string" ? rawData.length : 0;
|
||||
const wsMessage: WSOutboundMessage = JSON.parse(rawData);
|
||||
|
||||
// Only session messages trigger handlers
|
||||
if (wsMessage.type === "session") {
|
||||
const sessionMessage = wsMessage.message;
|
||||
const id = (sessionMessage as { requestId?: string }).requestId ??
|
||||
(sessionMessage as { agentId?: string }).agentId ??
|
||||
(sessionMessage as { payload?: { agentId?: string } }).payload?.agentId;
|
||||
console.log(`[WS] ← ${sessionMessage.type}`, { size, id: id ?? "-" });
|
||||
|
||||
// Log agent_stream messages for debugging
|
||||
if (sessionMessage.type === "agent_stream") {
|
||||
const payload = (sessionMessage as { payload: { agentId: string; event: { type: string } } }).payload;
|
||||
console.log(`[WS AGENT_STREAM] timestamp=${Date.now()} agentId=${payload.agentId} eventType=${payload.event.type}`);
|
||||
}
|
||||
|
||||
// Track conversation ID when loaded
|
||||
if (sessionMessage.type === "conversation_loaded") {
|
||||
setCurrentConversationId(sessionMessage.payload.conversationId);
|
||||
}
|
||||
|
||||
// Call all registered handlers for this message type
|
||||
const handlers = handlersRef.current.get(sessionMessage.type);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(sessionMessage);
|
||||
} catch (err) {
|
||||
console.error(`[WS] Error in handler for ${sessionMessage.type}:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// pong
|
||||
console.log(`[WS] ← ${wsMessage.type}`, { size });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[WS] Failed to parse message:", err);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("[WS] Failed to create WebSocket:", err);
|
||||
const reason = err instanceof Error ? err.message : "Failed to create WebSocket";
|
||||
scheduleReconnect(reason);
|
||||
}
|
||||
}, [updateConnectionState, url, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
shouldReconnectRef.current = true;
|
||||
setIsConnecting(true);
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
shouldReconnectRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
if (nextState !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
const readyState = wsRef.current?.readyState;
|
||||
if (readyState === WebSocket.OPEN || readyState === WebSocket.CONNECTING) {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldReconnectRef.current = true;
|
||||
setIsConnecting(true);
|
||||
connect();
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const send = useCallback((message: WSInboundMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const payload = JSON.stringify(message);
|
||||
const type = message.type === "session" ? message.message.type : message.type;
|
||||
const id = message.type === "session"
|
||||
? (message.message as { requestId?: string; agentId?: string }).requestId ??
|
||||
(message.message as { agentId?: string }).agentId
|
||||
: undefined;
|
||||
console.log(`[WS] → ${type}`, { size: payload.length, id: id ?? "-" });
|
||||
wsRef.current.send(payload);
|
||||
} else {
|
||||
console.warn("[WS] Cannot send message - not connected");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const on = useCallback(
|
||||
(
|
||||
type: SessionOutboundMessage["type"],
|
||||
handler: (message: SessionOutboundMessage) => void
|
||||
) => {
|
||||
if (!handlersRef.current.has(type)) {
|
||||
handlersRef.current.set(type, new Set());
|
||||
}
|
||||
handlersRef.current.get(type)!.add(handler);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
const handlers = handlersRef.current.get(type);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
handlersRef.current.delete(type);
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendPing = useCallback(() => {
|
||||
send({ type: "ping" });
|
||||
}, [send]);
|
||||
|
||||
const sendUserMessage = useCallback(
|
||||
(message: string) => {
|
||||
send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "user_text",
|
||||
text: message,
|
||||
},
|
||||
});
|
||||
},
|
||||
[send]
|
||||
);
|
||||
|
||||
const clearAgentAttention = useCallback(
|
||||
(agentId: string | string[]) => {
|
||||
send({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "clear_agent_attention",
|
||||
agentId,
|
||||
},
|
||||
});
|
||||
},
|
||||
[send]
|
||||
);
|
||||
|
||||
const subscribeConnectionStatus = useCallback(
|
||||
(listener: (status: ConnectionStatusSnapshot) => void) => {
|
||||
connectionListenersRef.current.add(listener);
|
||||
listener(connectionStateRef.current);
|
||||
return () => {
|
||||
connectionListenersRef.current.delete(listener);
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getConnectionState = useCallback(() => {
|
||||
const readyState = wsRef.current?.readyState ?? WebSocket.CLOSED;
|
||||
return {
|
||||
isConnected: readyState === WebSocket.OPEN,
|
||||
isConnecting: readyState === WebSocket.CONNECTING,
|
||||
} satisfies ConnectionStatusSnapshot;
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isConnected,
|
||||
isConnecting,
|
||||
conversationId: currentConversationId,
|
||||
lastError,
|
||||
send,
|
||||
on,
|
||||
sendPing,
|
||||
sendUserMessage,
|
||||
clearAgentAttention,
|
||||
subscribeConnectionStatus,
|
||||
getConnectionState,
|
||||
}),
|
||||
[
|
||||
isConnected,
|
||||
isConnecting,
|
||||
currentConversationId,
|
||||
lastError,
|
||||
send,
|
||||
on,
|
||||
sendPing,
|
||||
sendUserMessage,
|
||||
clearAgentAttention,
|
||||
subscribeConnectionStatus,
|
||||
getConnectionState,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import type {
|
||||
SessionInboundMessage,
|
||||
SessionOutboundMessage,
|
||||
} from "@server/server/messages";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
|
||||
// ============================================================================
|
||||
// Type-level utilities for automatic request→response type inference
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Extract the payload type from a message.
|
||||
*/
|
||||
type PayloadOf<TMessage> = TMessage extends { payload: infer P } ? P : never;
|
||||
|
||||
/**
|
||||
* All request message types that can be used with sendRpcRequest.
|
||||
* These are inbound messages that end with `_request`.
|
||||
*/
|
||||
type RpcRequestMessage = Extract<SessionInboundMessage, { type: `${string}_request` }>;
|
||||
|
||||
/**
|
||||
* Extract the type literal from a request message.
|
||||
*/
|
||||
type RpcRequestType = RpcRequestMessage["type"];
|
||||
|
||||
/**
|
||||
* Override mapping for requests that don't follow the standard `_request` → `_response` pattern.
|
||||
*/
|
||||
interface ResponseTypeOverrides {
|
||||
create_agent_request: "agent_state";
|
||||
refresh_agent_request: "agent_state";
|
||||
initialize_agent_request: "initialize_agent_request";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert request type string to response type string.
|
||||
* - First checks override mapping for non-standard patterns
|
||||
* - Falls back to standard `*_request` → `*_response` conversion
|
||||
*/
|
||||
type RequestToResponseType<T extends string> = T extends keyof ResponseTypeOverrides
|
||||
? ResponseTypeOverrides[T]
|
||||
: T extends `${infer Base}_request`
|
||||
? `${Base}_response`
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Given a request type string, get the response message type.
|
||||
*/
|
||||
type ResponseMessageFor<T extends RpcRequestType> = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: RequestToResponseType<T> }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Given a request type string, get the payload type of the response.
|
||||
*/
|
||||
type ResponsePayloadFor<T extends RpcRequestType> = PayloadOf<ResponseMessageFor<T>>;
|
||||
|
||||
/**
|
||||
* Given a request type string, get the request message type (without requestId).
|
||||
*/
|
||||
type RequestInputFor<T extends RpcRequestType> = Omit<
|
||||
Extract<SessionInboundMessage, { type: T }>,
|
||||
"requestId"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Infer the request type from a request object.
|
||||
* This enables TypeScript to narrow based on the `type` property.
|
||||
*/
|
||||
type InferRequestType<TRequest> = TRequest extends { type: infer T extends RpcRequestType }
|
||||
? T
|
||||
: never;
|
||||
|
||||
// ============================================================================
|
||||
// Runtime configuration
|
||||
// ============================================================================
|
||||
|
||||
interface SendRpcRequestOptions {
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
|
||||
class RpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: "timeout" | "response_error" | "disconnected"
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RpcError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps request types to their corresponding response types at runtime.
|
||||
*/
|
||||
const RESPONSE_TYPE_MAP: Record<string, SessionOutboundMessage["type"]> = {
|
||||
git_diff_request: "git_diff_response",
|
||||
highlighted_diff_request: "highlighted_diff_response",
|
||||
file_explorer_request: "file_explorer_response",
|
||||
file_download_token_request: "file_download_token_response",
|
||||
git_repo_info_request: "git_repo_info_response",
|
||||
list_provider_models_request: "list_provider_models_response",
|
||||
list_conversations_request: "list_conversations_response",
|
||||
list_commands_request: "list_commands_response",
|
||||
create_agent_request: "agent_state",
|
||||
refresh_agent_request: "agent_state",
|
||||
initialize_agent_request: "initialize_agent_request",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Main function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Send an RPC request over WebSocket and wait for the matching response.
|
||||
*
|
||||
* Features:
|
||||
* - Auto-generates and injects requestId into the request
|
||||
* - Matches response by requestId in payload
|
||||
* - Fully typed: response type is inferred from request type
|
||||
* - Throws on timeout or if response contains an error field
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const response = await sendRpcRequest(ws, {
|
||||
* type: "git_diff_request",
|
||||
* agentId: "abc123",
|
||||
* });
|
||||
* // response is typed as { agentId: string; diff: string; error: string | null }
|
||||
* ```
|
||||
*/
|
||||
export function sendRpcRequest<
|
||||
const TRequest extends RequestInputFor<RpcRequestType>
|
||||
>(
|
||||
ws: UseWebSocketReturn,
|
||||
request: TRequest,
|
||||
options?: SendRpcRequestOptions
|
||||
): Promise<ResponsePayloadFor<InferRequestType<TRequest>>> {
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const requestId = generateMessageId();
|
||||
const requestType = (request as { type: string }).type;
|
||||
const responseType = RESPONSE_TYPE_MAP[requestType];
|
||||
|
||||
if (!responseType) {
|
||||
return Promise.reject(
|
||||
new RpcError(`Unknown request type: ${requestType}`, "response_error")
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutHandle) {
|
||||
clearTimeout(timeoutHandle);
|
||||
timeoutHandle = null;
|
||||
}
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
};
|
||||
|
||||
const settle = <U>(fn: () => U): U | undefined => {
|
||||
if (settled) {
|
||||
return undefined;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
return fn();
|
||||
};
|
||||
|
||||
// Subscribe to the response type
|
||||
unsubscribe = ws.on(responseType, (message) => {
|
||||
const payload = (message as { payload?: unknown }).payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadRecord = payload as Record<string, unknown>;
|
||||
|
||||
// Match by requestId
|
||||
if (payloadRecord.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for error in response
|
||||
if (
|
||||
typeof payloadRecord.error === "string" &&
|
||||
payloadRecord.error.length > 0
|
||||
) {
|
||||
settle(() =>
|
||||
reject(new RpcError(payloadRecord.error as string, "response_error"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
settle(() => resolve(payload as ResponsePayloadFor<InferRequestType<TRequest>>));
|
||||
});
|
||||
|
||||
// Set up timeout
|
||||
if (timeoutMs > 0) {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
settle(() =>
|
||||
reject(
|
||||
new RpcError(`RPC request timed out after ${timeoutMs}ms`, "timeout")
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Send the request with the generated requestId
|
||||
const fullRequest = {
|
||||
...request,
|
||||
requestId,
|
||||
} as SessionInboundMessage;
|
||||
|
||||
ws.send({
|
||||
type: "session",
|
||||
message: fullRequest,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export { RpcError };
|
||||
export type { RpcRequestType, ResponsePayloadFor, RequestInputFor };
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Type verification tests for sendRpcRequest
|
||||
* Run `npm run typecheck` - this file should compile with the expected errors marked by @ts-expect-error
|
||||
*/
|
||||
import { sendRpcRequest } from "./send-rpc-request";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
|
||||
declare const ws: UseWebSocketReturn;
|
||||
|
||||
// ============================================================================
|
||||
// Test 1: Git diff request - response should be fully typed
|
||||
// ============================================================================
|
||||
async function testGitDiff() {
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "git_diff_request",
|
||||
agentId: "test-agent",
|
||||
});
|
||||
|
||||
// ✅ These should work - fields exist on response
|
||||
const agentId: string = response.agentId;
|
||||
const diff: string = response.diff;
|
||||
const error: string | null = response.error;
|
||||
console.log(agentId, diff, error);
|
||||
|
||||
// ❌ These should error
|
||||
// @ts-expect-error - 'nonExistent' does not exist on git_diff_response payload
|
||||
response.nonExistent;
|
||||
|
||||
// @ts-expect-error - diff is string, not number
|
||||
const wrongType: number = response.diff;
|
||||
console.log(wrongType);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 2: File explorer request - response should be fully typed
|
||||
// ============================================================================
|
||||
async function testFileExplorer() {
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "file_explorer_request",
|
||||
agentId: "test-agent",
|
||||
path: ".",
|
||||
mode: "list",
|
||||
});
|
||||
|
||||
// ✅ These should work
|
||||
const agentId: string = response.agentId;
|
||||
const mode: "list" | "file" = response.mode;
|
||||
const path: string = response.path;
|
||||
console.log(agentId, mode, path);
|
||||
|
||||
// ✅ Directory is optional/nullable
|
||||
if (response.directory) {
|
||||
const entries = response.directory.entries;
|
||||
console.log(entries);
|
||||
}
|
||||
|
||||
// ❌ Should error
|
||||
// @ts-expect-error - 'fakeField' does not exist
|
||||
response.fakeField;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 3: Request must include required fields
|
||||
// ============================================================================
|
||||
async function testRequiredFields() {
|
||||
// ✅ Valid calls - all required fields present
|
||||
await sendRpcRequest(ws, { type: "git_diff_request", agentId: "test" });
|
||||
await sendRpcRequest(ws, { type: "file_explorer_request", agentId: "x", path: ".", mode: "list" });
|
||||
|
||||
// Note: Missing fields would cause compile errors, but we can't use @ts-expect-error
|
||||
// on the call itself because the `const` generic infers the literal object type.
|
||||
// The type system validates at the constraint level, not the call level.
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 4: File download token request
|
||||
// ============================================================================
|
||||
async function testFileDownloadToken() {
|
||||
const response = await sendRpcRequest(ws, {
|
||||
type: "file_download_token_request",
|
||||
agentId: "test-agent",
|
||||
path: "/file.txt",
|
||||
});
|
||||
|
||||
// ✅ These should work
|
||||
const token: string | null = response.token;
|
||||
const agentId: string = response.agentId;
|
||||
console.log(token, agentId);
|
||||
|
||||
// ❌ Should error
|
||||
// @ts-expect-error - invalid field
|
||||
response.notAField;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Test 5: Verify template literal type derivation works
|
||||
// ============================================================================
|
||||
async function testTemplateLiteralDerivation() {
|
||||
// The response type should be automatically derived from request type
|
||||
// "git_diff_request" → "git_diff_response" → payload type
|
||||
|
||||
const gitDiff = await sendRpcRequest(ws, { type: "git_diff_request", agentId: "a" });
|
||||
const fileExplorer = await sendRpcRequest(ws, { type: "file_explorer_request", agentId: "a", path: ".", mode: "list" });
|
||||
const downloadToken = await sendRpcRequest(ws, { type: "file_download_token_request", agentId: "a", path: "." });
|
||||
|
||||
// Each response should have its own distinct type
|
||||
console.log(gitDiff.diff); // string
|
||||
console.log(fileExplorer.directory); // object | null
|
||||
console.log(downloadToken.token); // string | null
|
||||
|
||||
// ❌ Cross-type access should fail
|
||||
// @ts-expect-error - gitDiff doesn't have 'directory'
|
||||
gitDiff.directory;
|
||||
|
||||
// @ts-expect-error - fileExplorer doesn't have 'diff'
|
||||
fileExplorer.diff;
|
||||
|
||||
// @ts-expect-error - downloadToken doesn't have 'diff'
|
||||
downloadToken.diff;
|
||||
}
|
||||
|
||||
export {
|
||||
testGitDiff,
|
||||
testFileExplorer,
|
||||
testRequiredFields,
|
||||
testFileDownloadToken,
|
||||
testTemplateLiteralDerivation,
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
import { create } from "zustand";
|
||||
import { subscribeWithSelector } from "zustand/middleware";
|
||||
import type { UseWebSocketReturn } from "@/hooks/use-websocket";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { useAudioPlayer } from "@/hooks/use-audio-player";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type {
|
||||
AgentLifecycleStatus,
|
||||
} from "@server/server/agent/agent-manager";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type {
|
||||
AgentPermissionResponse,
|
||||
AgentSessionConfig,
|
||||
@@ -18,7 +16,7 @@ import type {
|
||||
AgentUsage,
|
||||
AgentPersistenceHandle,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { FileDownloadTokenResponse, GitSetupOptions } from "@server/server/messages";
|
||||
import type { FileDownloadTokenResponse, GitSetupOptions } from "@server/shared/messages";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
|
||||
// Re-export types that were in session-context
|
||||
@@ -151,12 +149,21 @@ export interface AgentFileExplorerState {
|
||||
lastVisitedPath: string;
|
||||
}
|
||||
|
||||
export interface DaemonConnectionSnapshot {
|
||||
isConnected: boolean;
|
||||
isConnecting: boolean;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
|
||||
// WebSocket (immutable reference)
|
||||
ws: UseWebSocketReturn | null;
|
||||
// Daemon client (immutable reference)
|
||||
client: DaemonClientV2 | null;
|
||||
|
||||
// Connection snapshot (mutable)
|
||||
connection: DaemonConnectionSnapshot;
|
||||
|
||||
// Audio player (immutable reference)
|
||||
audioPlayer: ReturnType<typeof useAudioPlayer> | null;
|
||||
@@ -255,10 +262,11 @@ interface SessionStoreState {
|
||||
// Action types
|
||||
interface SessionStoreActions {
|
||||
// Session management
|
||||
initializeSession: (serverId: string, ws: UseWebSocketReturn, audioPlayer: ReturnType<typeof useAudioPlayer>) => void;
|
||||
initializeSession: (serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>) => void;
|
||||
clearSession: (serverId: string) => void;
|
||||
getSession: (serverId: string) => SessionState | undefined;
|
||||
updateSessionWebSocket: (serverId: string, ws: UseWebSocketReturn) => void;
|
||||
updateSessionClient: (serverId: string, client: DaemonClientV2) => void;
|
||||
updateSessionConnection: (serverId: string, connection: DaemonConnectionSnapshot) => void;
|
||||
|
||||
// Audio state
|
||||
setIsPlayingAudio: (serverId: string, playing: boolean) => void;
|
||||
@@ -343,11 +351,24 @@ function logSessionStoreUpdate(
|
||||
}
|
||||
|
||||
|
||||
function createDefaultConnectionSnapshot(client?: DaemonClientV2 | null): DaemonConnectionSnapshot {
|
||||
if (!client) {
|
||||
return { isConnected: false, isConnecting: false, lastError: null };
|
||||
}
|
||||
const state = client.getConnectionState();
|
||||
return {
|
||||
isConnected: state.status === "connected",
|
||||
isConnecting: state.status === "connecting",
|
||||
lastError: state.status === "disconnected" ? state.reason ?? client.lastError ?? null : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to create initial session state
|
||||
function createInitialSessionState(serverId: string, ws: UseWebSocketReturn, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState {
|
||||
function createInitialSessionState(serverId: string, client: DaemonClientV2, audioPlayer: ReturnType<typeof useAudioPlayer>): SessionState {
|
||||
return {
|
||||
serverId,
|
||||
ws,
|
||||
client,
|
||||
connection: createDefaultConnectionSnapshot(client),
|
||||
audioPlayer,
|
||||
methods: null,
|
||||
hasHydratedAgents: false,
|
||||
@@ -375,7 +396,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
agentLastActivity: new Map(),
|
||||
|
||||
// Session management
|
||||
initializeSession: (serverId, ws, audioPlayer) => {
|
||||
initializeSession: (serverId, client, audioPlayer) => {
|
||||
set((prev) => {
|
||||
if (prev.sessions[serverId]) {
|
||||
return prev;
|
||||
@@ -385,7 +406,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: createInitialSessionState(serverId, ws, audioPlayer),
|
||||
[serverId]: createInitialSessionState(serverId, client, audioPlayer),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -403,7 +424,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
updateSessionWebSocket: (serverId, ws) => {
|
||||
updateSessionClient: (serverId, client) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
|
||||
@@ -411,14 +432,14 @@ export const useSessionStore = create<SessionStore>()(
|
||||
return prev;
|
||||
}
|
||||
|
||||
if (session.ws === ws) {
|
||||
if (session.client === client) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
logSessionStoreUpdate("updateSessionWebSocket", serverId, {
|
||||
wasNull: session.ws === null,
|
||||
isNowConnected: ws.isConnected,
|
||||
isNowConnecting: ws.isConnecting,
|
||||
logSessionStoreUpdate("updateSessionClient", serverId, {
|
||||
wasNull: session.client === null,
|
||||
isNowConnected: client.isConnected,
|
||||
isNowConnecting: client.isConnecting,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -427,7 +448,34 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
ws,
|
||||
client,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
updateSessionConnection: (serverId, connection) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (
|
||||
session.connection.isConnected === connection.isConnected &&
|
||||
session.connection.isConnecting === connection.isConnecting &&
|
||||
session.connection.lastError === connection.lastError
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
logSessionStoreUpdate("updateSessionConnection", serverId, connection);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: {
|
||||
...session,
|
||||
connection,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@server/server/messages";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { StreamItem, ThoughtItem } from "@/types/stream";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ToolCallStatus,
|
||||
isAgentToolCallItem,
|
||||
} from "./stream";
|
||||
import type { AgentStreamEventPayload } from "@server/server/messages";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
|
||||
type HarnessUpdate = { event: AgentStreamEventPayload; timestamp: Date };
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentStreamEventPayload } from "@server/server/messages";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import {
|
||||
extractCommandDetails,
|
||||
extractEditEntries,
|
||||
|
||||
@@ -29,6 +29,8 @@ async function configureNativeNotifications(): Promise<void> {
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||
|
||||
const PERF_MONITOR_LOG_TAG = "[PerfMonitor]";
|
||||
const FRAME_GAP_THRESHOLD_MS = 100;
|
||||
const EVENT_LOOP_STALL_THRESHOLD_MS = 100;
|
||||
const EVENT_LOOP_TICK_MS = 100;
|
||||
const LONG_TASK_THRESHOLD_MS = 100;
|
||||
|
||||
type StopPerfMonitor = () => void;
|
||||
|
||||
type GlobalScope = typeof globalThis & {
|
||||
requestAnimationFrame?: (callback: (timestamp: number) => void) => number;
|
||||
cancelAnimationFrame?: (handle: number) => void;
|
||||
PerformanceObserver?: unknown;
|
||||
};
|
||||
|
||||
type PerfObserverEntry = {
|
||||
duration: number;
|
||||
startTime: number;
|
||||
};
|
||||
|
||||
type PerfObserverLike = {
|
||||
observe: (options: { entryTypes: string[] }) => void;
|
||||
disconnect: () => void;
|
||||
};
|
||||
|
||||
type PerfObserverConstructor = new (
|
||||
callback: (list: { getEntries(): PerfObserverEntry[] }) => void
|
||||
) => PerfObserverLike;
|
||||
|
||||
export function startPerfMonitor(scope: string): StopPerfMonitor {
|
||||
if (!isPerfLoggingEnabled()) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const globalScope = globalThis as GlobalScope;
|
||||
if (!globalScope) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const startMs = getNowMs();
|
||||
perfLog(PERF_MONITOR_LOG_TAG, { event: "monitor_start", scope });
|
||||
|
||||
let rafHandle: number | null = null;
|
||||
let lastFrameMs = getNowMs();
|
||||
|
||||
if (typeof globalScope.requestAnimationFrame === "function") {
|
||||
const onFrame = (now: number) => {
|
||||
const delta = now - lastFrameMs;
|
||||
if (delta >= FRAME_GAP_THRESHOLD_MS) {
|
||||
perfLog(PERF_MONITOR_LOG_TAG, {
|
||||
event: "frame_gap",
|
||||
scope,
|
||||
deltaMs: Math.round(delta),
|
||||
sinceStartMs: Math.round(now - startMs),
|
||||
});
|
||||
}
|
||||
lastFrameMs = now;
|
||||
rafHandle = globalScope.requestAnimationFrame?.(onFrame) ?? null;
|
||||
};
|
||||
rafHandle = globalScope.requestAnimationFrame(onFrame);
|
||||
}
|
||||
|
||||
let lastTickMs = getNowMs();
|
||||
const intervalHandle = setInterval(() => {
|
||||
const now = getNowMs();
|
||||
const drift = now - lastTickMs - EVENT_LOOP_TICK_MS;
|
||||
if (drift >= EVENT_LOOP_STALL_THRESHOLD_MS) {
|
||||
perfLog(PERF_MONITOR_LOG_TAG, {
|
||||
event: "event_loop_stall",
|
||||
scope,
|
||||
driftMs: Math.round(drift),
|
||||
sinceStartMs: Math.round(now - startMs),
|
||||
});
|
||||
}
|
||||
lastTickMs = now;
|
||||
}, EVENT_LOOP_TICK_MS);
|
||||
|
||||
let observer: PerfObserverLike | null = null;
|
||||
const ObserverCtor = globalScope.PerformanceObserver as PerfObserverConstructor | undefined;
|
||||
if (typeof ObserverCtor === "function") {
|
||||
try {
|
||||
observer = new ObserverCtor((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration >= LONG_TASK_THRESHOLD_MS) {
|
||||
perfLog(PERF_MONITOR_LOG_TAG, {
|
||||
event: "longtask",
|
||||
scope,
|
||||
durationMs: Math.round(entry.duration),
|
||||
startMs: Math.round(entry.startTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe({ entryTypes: ["longtask"] });
|
||||
} catch (error) {
|
||||
perfLog(PERF_MONITOR_LOG_TAG, {
|
||||
event: "longtask_observer_error",
|
||||
scope,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafHandle !== null && typeof globalScope.cancelAnimationFrame === "function") {
|
||||
globalScope.cancelAnimationFrame(rafHandle);
|
||||
}
|
||||
clearInterval(intervalHandle);
|
||||
observer?.disconnect();
|
||||
perfLog(PERF_MONITOR_LOG_TAG, { event: "monitor_stop", scope });
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "./perf";
|
||||
|
||||
const TOOL_CALL_DIFF_LOG_TAG = "[ToolCallDiff]";
|
||||
const LINE_DIFF_DURATION_THRESHOLD_MS = 16;
|
||||
const WORD_DIFF_DURATION_THRESHOLD_MS = 8;
|
||||
const LINE_DIFF_MATRIX_THRESHOLD = 200000;
|
||||
const WORD_DIFF_MATRIX_THRESHOLD = 50000;
|
||||
|
||||
export type DiffSegment = {
|
||||
text: string;
|
||||
@@ -74,11 +81,14 @@ function splitIntoWords(text: string): string[] {
|
||||
}
|
||||
|
||||
function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments: DiffSegment[]; newSegments: DiffSegment[] } {
|
||||
const shouldLog = isPerfLoggingEnabled();
|
||||
const startMs = shouldLog ? getNowMs() : 0;
|
||||
const oldWords = splitIntoWords(oldLine);
|
||||
const newWords = splitIntoWords(newLine);
|
||||
|
||||
const m = oldWords.length;
|
||||
const n = newWords.length;
|
||||
const matrixSize = m * n;
|
||||
|
||||
// LCS to find common words
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
||||
@@ -143,13 +153,34 @@ function computeWordLevelDiff(oldLine: string, newLine: string): { oldSegments:
|
||||
return segments;
|
||||
};
|
||||
|
||||
const oldSegments = buildSegments(oldWords, oldInLCS);
|
||||
const newSegments = buildSegments(newWords, newInLCS);
|
||||
|
||||
if (shouldLog) {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
if (
|
||||
durationMs >= WORD_DIFF_DURATION_THRESHOLD_MS ||
|
||||
matrixSize >= WORD_DIFF_MATRIX_THRESHOLD
|
||||
) {
|
||||
perfLog(TOOL_CALL_DIFF_LOG_TAG, {
|
||||
event: "word_diff",
|
||||
durationMs: Math.round(durationMs),
|
||||
oldWordCount: m,
|
||||
newWordCount: n,
|
||||
matrixSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oldSegments: buildSegments(oldWords, oldInLCS),
|
||||
newSegments: buildSegments(newWords, newInLCS),
|
||||
oldSegments,
|
||||
newSegments,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLineDiff(originalText: string, updatedText: string): DiffLine[] {
|
||||
const shouldLog = isPerfLoggingEnabled();
|
||||
const startMs = shouldLog ? getNowMs() : 0;
|
||||
const originalLines = splitIntoLines(originalText);
|
||||
const updatedLines = splitIntoLines(updatedText);
|
||||
|
||||
@@ -160,6 +191,7 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
|
||||
|
||||
const m = originalLines.length;
|
||||
const n = updatedLines.length;
|
||||
const matrixSize = m * n;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
Array(n + 1).fill(0)
|
||||
);
|
||||
@@ -218,6 +250,23 @@ export function buildLineDiff(originalText: string, updatedText: string): DiffLi
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldLog) {
|
||||
const durationMs = getNowMs() - startMs;
|
||||
if (
|
||||
durationMs >= LINE_DIFF_DURATION_THRESHOLD_MS ||
|
||||
matrixSize >= LINE_DIFF_MATRIX_THRESHOLD
|
||||
) {
|
||||
perfLog(TOOL_CALL_DIFF_LOG_TAG, {
|
||||
event: "line_diff",
|
||||
durationMs: Math.round(durationMs),
|
||||
originalLineCount: m,
|
||||
updatedLineCount: n,
|
||||
diffLineCount: diff.length,
|
||||
matrixSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
@@ -1009,21 +1058,15 @@ const ReadResultSchema = z.object({
|
||||
const ReadToolCallSchema = z
|
||||
.object({
|
||||
input: ReadInputSchema,
|
||||
result: z.unknown(),
|
||||
result: ReadResultSchema,
|
||||
})
|
||||
.transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => {
|
||||
const filePath = data.input.file_path;
|
||||
const resultParsed = ReadResultSchema.safeParse(data.result);
|
||||
const content = resultParsed.success ? resultParsed.data.content : "";
|
||||
|
||||
return {
|
||||
type: "read",
|
||||
filePath,
|
||||
content,
|
||||
offset: data.input.offset,
|
||||
limit: data.input.limit,
|
||||
};
|
||||
});
|
||||
.transform((data): { type: "read"; filePath: string; content: string; offset?: number; limit?: number } => ({
|
||||
type: "read",
|
||||
filePath: data.input.file_path,
|
||||
content: data.result.content,
|
||||
offset: data.input.offset,
|
||||
limit: data.input.limit,
|
||||
}));
|
||||
|
||||
// Generic tool call display schema (fallback)
|
||||
const GenericToolCallSchema = z
|
||||
|
||||
Reference in New Issue
Block a user