feat: add multi-daemon support with session routing and connection management
Introduce DaemonRegistry and DaemonConnections contexts for managing multiple server connections. Add session directory for aggregating agents across daemons, with automatic routing based on agent/server context. Key changes: - Add daemon profile management (add/edit/remove/set default) - Implement connection state tracking with reconnection and exponential backoff - Create useAggregatedAgents hook for unified agent list across daemons - Add useDaemonSession and useDaemonRequest hooks for daemon-scoped operations - Update agent routes to include serverId for proper session routing - Add connection health indicators on home screen and settings - Persist session snapshots per daemon for faster hydration on reconnect - Guard screens gracefully when target daemon is offline - Add React Query for consistent loading/error state management 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Agent } from "@/contexts/session-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionDirectory } from "@/hooks/use-session-directory";
|
||||
|
||||
export interface AggregatedAgentGroup {
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
const sortAgents = (agents: Agent[]): Agent[] => {
|
||||
return [...agents].sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
const leftTime = (left.lastUserMessageAt ?? left.lastActivityAt).getTime();
|
||||
const rightTime = (right.lastUserMessageAt ?? right.lastActivityAt).getTime();
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
};
|
||||
|
||||
export function useAggregatedAgents(fallbackAgents: Map<string, Agent>): AggregatedAgentGroup[] {
|
||||
const { activeDaemonId, connectionStates } = useDaemonConnections();
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
const activeServerId = activeDaemonId ?? "default";
|
||||
|
||||
return useMemo(() => {
|
||||
const groups = new Map<string, { serverLabel: string; agents: Agent[] }>();
|
||||
const daemonOrder = new Map<string, number>();
|
||||
Array.from(connectionStates.keys()).forEach((serverId, index) => {
|
||||
daemonOrder.set(serverId, index);
|
||||
});
|
||||
|
||||
if (sessionDirectory.size > 0) {
|
||||
sessionDirectory.forEach((session, serverId) => {
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const label = connectionStates.get(serverId)?.daemon.label ?? serverId;
|
||||
const sessionAgents = Array.from(session.agents.values());
|
||||
if (sessionAgents.length === 0) {
|
||||
return;
|
||||
}
|
||||
const existing = groups.get(serverId);
|
||||
if (existing) {
|
||||
existing.agents.push(...sessionAgents);
|
||||
} else {
|
||||
groups.set(serverId, { serverLabel: label, agents: sessionAgents });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.size === 0) {
|
||||
const label = connectionStates.get(activeServerId)?.daemon.label ?? activeServerId;
|
||||
const fallbackList = Array.from(fallbackAgents.values());
|
||||
if (fallbackList.length > 0) {
|
||||
groups.set(activeServerId, { serverLabel: label, agents: fallbackList });
|
||||
}
|
||||
}
|
||||
|
||||
const aggregatedGroups = Array.from(groups.entries()).map(([serverId, { serverLabel, agents }]) => ({
|
||||
serverId,
|
||||
serverLabel,
|
||||
agents: sortAgents(agents),
|
||||
}));
|
||||
|
||||
aggregatedGroups.sort((left, right) => {
|
||||
const leftIndex = daemonOrder.get(left.serverId);
|
||||
const rightIndex = daemonOrder.get(right.serverId);
|
||||
if (leftIndex !== undefined && rightIndex !== undefined && leftIndex !== rightIndex) {
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
if (leftIndex !== undefined) {
|
||||
return -1;
|
||||
}
|
||||
if (rightIndex !== undefined) {
|
||||
return 1;
|
||||
}
|
||||
return left.serverLabel.localeCompare(right.serverLabel);
|
||||
});
|
||||
|
||||
return aggregatedGroups;
|
||||
}, [sessionDirectory, fallbackAgents, activeServerId, connectionStates]);
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
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]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useRef } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useSession } from "@/contexts/session-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { useSessionDirectory } from "./use-session-directory";
|
||||
|
||||
export class DaemonSessionUnavailableError extends Error {
|
||||
serverId: string;
|
||||
|
||||
constructor(serverId: string) {
|
||||
super(`Daemon session "${serverId}" is unavailable`);
|
||||
this.name = "DaemonSessionUnavailableError";
|
||||
this.serverId = serverId;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionForServer(
|
||||
serverId: string,
|
||||
directory: Map<string, SessionContextValue | null>
|
||||
): SessionContextValue {
|
||||
const session = directory.get(serverId) ?? null;
|
||||
if (!session) {
|
||||
throw new DaemonSessionUnavailableError(serverId);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
type UseDaemonSessionOptions = {
|
||||
suppressUnavailableAlert?: boolean;
|
||||
};
|
||||
|
||||
export function useDaemonSession(serverId?: string, options?: UseDaemonSessionOptions) {
|
||||
const activeSession = useSession();
|
||||
const sessionDirectory = useSessionDirectory();
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const alertedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const loggedDaemonsRef = useRef<Set<string>>(new Set());
|
||||
const { suppressUnavailableAlert = false } = options ?? {};
|
||||
|
||||
const targetServerId = serverId ?? activeSession.serverId;
|
||||
const isActiveSession = targetServerId === activeSession.serverId;
|
||||
|
||||
if (isActiveSession || !targetServerId) {
|
||||
return activeSession;
|
||||
}
|
||||
|
||||
try {
|
||||
return getSessionForServer(targetServerId, sessionDirectory);
|
||||
} catch (error) {
|
||||
if (error instanceof DaemonSessionUnavailableError) {
|
||||
const connection = connectionStates.get(targetServerId);
|
||||
const label = connection?.daemon.label ?? targetServerId;
|
||||
const status = connection?.status ?? "unknown";
|
||||
const lastError = connection?.lastError ? `\n${connection.lastError}` : "";
|
||||
const message = `${label} isn't connected yet (${status}). Switch to it or enable auto-connect so Paseo can reach it.${lastError}`;
|
||||
|
||||
if (!suppressUnavailableAlert && !alertedDaemonsRef.current.has(targetServerId)) {
|
||||
alertedDaemonsRef.current.add(targetServerId);
|
||||
Alert.alert("Daemon unavailable", message.trim());
|
||||
}
|
||||
|
||||
if (!loggedDaemonsRef.current.has(targetServerId)) {
|
||||
loggedDaemonsRef.current.add(targetServerId);
|
||||
console.warn(`[useDaemonSession] Session unavailable for daemon "${label}" (${status}).`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
|
||||
export function useSessionDirectory(): Map<string, SessionContextValue | null> {
|
||||
const { sessionAccessors, subscribeToSessionDirectory } = useDaemonConnections();
|
||||
const [revision, setRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToSessionDirectory(() => {
|
||||
setRevision((current) => current + 1);
|
||||
});
|
||||
}, [subscribeToSessionDirectory]);
|
||||
|
||||
return useMemo(() => {
|
||||
const entries = new Map<string, SessionContextValue | null>();
|
||||
sessionAccessors.forEach((entry, serverId) => {
|
||||
try {
|
||||
entries.set(serverId, entry.getSnapshot());
|
||||
} catch (error) {
|
||||
console.error(`[useSessionDirectory] Failed to read session accessor for "${serverId}"`, error);
|
||||
entries.set(serverId, null);
|
||||
}
|
||||
});
|
||||
return entries;
|
||||
}, [sessionAccessors, revision]);
|
||||
}
|
||||
|
||||
export function useSessionForServer(serverId: string | null): SessionContextValue | null {
|
||||
const directory = useSessionDirectory();
|
||||
if (!serverId) {
|
||||
return null;
|
||||
}
|
||||
return directory.get(serverId) ?? null;
|
||||
}
|
||||
@@ -1,78 +1,114 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const STORAGE_KEY = '@paseo:settings';
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
|
||||
|
||||
export interface Settings {
|
||||
serverUrl: string;
|
||||
export interface AppSettings {
|
||||
useSpeaker: boolean;
|
||||
keepScreenOn: boolean;
|
||||
theme: 'dark' | 'light' | 'auto';
|
||||
theme: "dark" | "light" | "auto";
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
serverUrl: 'wss://mohameds-macbook-pro.tail8fe838.ts.net/ws',
|
||||
const DEFAULT_APP_SETTINGS: AppSettings = {
|
||||
useSpeaker: true,
|
||||
keepScreenOn: true,
|
||||
theme: 'dark',
|
||||
theme: "dark",
|
||||
};
|
||||
|
||||
export interface UseSettingsReturn {
|
||||
settings: Settings;
|
||||
export interface UseAppSettingsReturn {
|
||||
settings: AppSettings;
|
||||
isLoading: boolean;
|
||||
updateSettings: (updates: Partial<Settings>) => Promise<void>;
|
||||
error: unknown | null;
|
||||
updateSettings: (updates: Partial<AppSettings>) => Promise<void>;
|
||||
resetSettings: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
export function useAppSettings(): UseAppSettingsReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isPending, error } = useQuery({
|
||||
queryKey: APP_SETTINGS_QUERY_KEY,
|
||||
queryFn: loadSettingsFromStorage,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
});
|
||||
|
||||
// Load settings from AsyncStorage on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<Settings>;
|
||||
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
|
||||
const updateSettings = useCallback(
|
||||
async (updates: Partial<AppSettings>) => {
|
||||
try {
|
||||
const prev = queryClient.getQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS;
|
||||
const next = { ...prev, ...updates };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to save settings:", err);
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to load settings:', error);
|
||||
// Continue with default settings
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const updateSettings = useCallback(async (updates: Partial<Settings>) => {
|
||||
try {
|
||||
const newSettings = { ...settings, ...updates };
|
||||
setSettings(newSettings);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to save settings:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [settings]);
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const resetSettings = useCallback(async () => {
|
||||
try {
|
||||
setSettings(DEFAULT_SETTINGS);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_SETTINGS));
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to reset settings:', error);
|
||||
throw error;
|
||||
const next = { ...DEFAULT_APP_SETTINGS };
|
||||
queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
} catch (err) {
|
||||
console.error("[AppSettings] Failed to reset settings:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
settings,
|
||||
isLoading,
|
||||
settings: data ?? DEFAULT_APP_SETTINGS,
|
||||
isLoading: isPending,
|
||||
error: error ?? null,
|
||||
updateSettings,
|
||||
resetSettings,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSettingsFromStorage(): Promise<AppSettings> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(APP_SETTINGS_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as Partial<AppSettings>;
|
||||
return { ...DEFAULT_APP_SETTINGS, ...parsed };
|
||||
}
|
||||
|
||||
const legacyStored = await AsyncStorage.getItem(LEGACY_SETTINGS_KEY);
|
||||
if (legacyStored) {
|
||||
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
|
||||
const next = {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
...pickAppSettingsFromLegacy(legacyParsed),
|
||||
} satisfies AppSettings;
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(DEFAULT_APP_SETTINGS));
|
||||
return DEFAULT_APP_SETTINGS;
|
||||
} catch (error) {
|
||||
console.error("[AppSettings] Failed to load settings:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> {
|
||||
const result: Partial<AppSettings> = {};
|
||||
if (typeof legacy.useSpeaker === "boolean") {
|
||||
result.useSpeaker = legacy.useSpeaker;
|
||||
}
|
||||
if (typeof legacy.keepScreenOn === "boolean") {
|
||||
result.keepScreenOn = legacy.keepScreenOn;
|
||||
}
|
||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
||||
result.theme = legacy.theme;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const useSettings = useAppSettings;
|
||||
|
||||
@@ -1,54 +1,123 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import type {
|
||||
WSInboundMessage,
|
||||
WSOutboundMessage,
|
||||
SessionOutboundMessage
|
||||
} from '@server/server/messages';
|
||||
SessionOutboundMessage,
|
||||
} from "@server/server/messages";
|
||||
|
||||
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;
|
||||
on: (
|
||||
type: SessionOutboundMessage["type"],
|
||||
handler: (message: SessionOutboundMessage) => void
|
||||
) => () => void;
|
||||
sendPing: () => void;
|
||||
sendUserMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
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 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 connect = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
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());
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setIsConnecting(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;
|
||||
}
|
||||
setIsConnecting(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;
|
||||
setIsConnecting(true);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WS] Connected to server');
|
||||
console.log("[WS] Connected to server");
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
setLastError(null);
|
||||
reconnectAttemptRef.current = 0;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WS] Disconnected from server');
|
||||
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"})`;
|
||||
setIsConnected(false);
|
||||
|
||||
// Attempt to reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
console.log('[WS] Attempting to reconnect...');
|
||||
connect();
|
||||
}, 3000);
|
||||
scheduleReconnect(reason);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.warn('[WS] Error:', error);
|
||||
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) => {
|
||||
@@ -56,12 +125,12 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
const wsMessage: WSOutboundMessage = JSON.parse(event.data);
|
||||
|
||||
// Only session messages trigger handlers
|
||||
if (wsMessage.type === 'session') {
|
||||
if (wsMessage.type === "session") {
|
||||
const sessionMessage = wsMessage.message;
|
||||
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
|
||||
|
||||
// Track conversation ID when loaded
|
||||
if (sessionMessage.type === 'conversation_loaded') {
|
||||
if (sessionMessage.type === "conversation_loaded") {
|
||||
setCurrentConversationId(sessionMessage.payload.conversationId);
|
||||
}
|
||||
|
||||
@@ -81,20 +150,23 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
console.log(`[WS] Received ${wsMessage.type}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WS] Failed to parse message:', err);
|
||||
console.error("[WS] Failed to parse message:", err);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current = ws;
|
||||
} catch (err) {
|
||||
console.warn('[WS] Failed to create WebSocket:', err);
|
||||
console.warn("[WS] Failed to create WebSocket:", err);
|
||||
const reason = err instanceof Error ? err.message : "Failed to create WebSocket";
|
||||
scheduleReconnect(reason);
|
||||
}
|
||||
}, [url, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
shouldReconnectRef.current = true;
|
||||
setIsConnecting(true);
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
shouldReconnectRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
@@ -108,41 +180,41 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
} else {
|
||||
console.warn('[WS] Cannot send message - not connected');
|
||||
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 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({ type: "ping" });
|
||||
}, [send]);
|
||||
|
||||
const sendUserMessage = useCallback(
|
||||
(message: string) => {
|
||||
send({
|
||||
type: 'session',
|
||||
type: "session",
|
||||
message: {
|
||||
type: 'user_text',
|
||||
type: "user_text",
|
||||
text: message,
|
||||
},
|
||||
});
|
||||
@@ -153,12 +225,14 @@ export function useWebSocket(url: string, conversationId?: string | null): UseWe
|
||||
return useMemo(
|
||||
() => ({
|
||||
isConnected,
|
||||
isConnecting,
|
||||
conversationId: currentConversationId,
|
||||
lastError,
|
||||
send,
|
||||
on,
|
||||
sendPing,
|
||||
sendUserMessage,
|
||||
}),
|
||||
[isConnected, currentConversationId, send, on, sendPing, sendUserMessage]
|
||||
[isConnected, isConnecting, currentConversationId, lastError, send, on, sendPing, sendUserMessage]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user