WIP: Large refactor checkpoint

- Refactored daemon client and RPC layer
- Added new client package structure in server
- Removed legacy hooks (use-daemon-request, use-session-rpc, use-websocket)
- Added use-daemon-client hook
- Added e2e test infrastructure with Playwright
- Updated agent providers and session handling
- Removed codex-sdk patches
This commit is contained in:
Mohamed Boudra
2026-01-12 13:30:20 +07:00
parent 7c2e959c19
commit 868f9fb571
64 changed files with 5773 additions and 4741 deletions
@@ -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),
+4 -15
View File
@@ -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]
);
}
+150 -78
View File
@@ -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 () => {
+10 -9
View File
@@ -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,
});
-384
View File
@@ -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]
);
}
-353
View File
@@ -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,
]
);
}