Implement a new UI section that surfaces agents requiring user attention at the top of the agent list. This includes agents that are running, have pending permissions, or have errors. **Backend changes:** - Add `requiresAttention` field to agent projections and SDK types - Implement logic in AgentManager to detect attention states (running, pending permissions, errors) - Add WebSocket message handler to notify clients of attention state changes - Update session state to include attention tracking **Frontend changes:** - Add "REQUIRES ATTENTION" section in agent-list component with visual prominence - Update useAggregatedAgents hook to expose `requiresAttention` agents - Enhance session store to track and update attention states - Add real-time updates via WebSocket for attention state changes - Update type definitions across agent directory and stream types Verified working via Playwright MCP testing on localhost:8081. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1750 lines
54 KiB
TypeScript
1750 lines
54 KiB
TypeScript
import { createContext, useRef, ReactNode, useCallback, useEffect, useMemo } from "react";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { useWebSocket, type UseWebSocketReturn } from "@/hooks/use-websocket";
|
|
import { useDaemonRequest } from "@/hooks/use-daemon-request";
|
|
import { useSessionRpc } from "@/hooks/use-session-rpc";
|
|
import { useAudioPlayer } from "@/hooks/use-audio-player";
|
|
import { reduceStreamUpdate, generateMessageId, hydrateStreamState } from "@/types/stream";
|
|
import type {
|
|
ActivityLogPayload,
|
|
AgentSnapshotPayload,
|
|
AgentStreamEventPayload,
|
|
WSInboundMessage,
|
|
SessionOutboundMessage,
|
|
} from "@server/server/messages";
|
|
import type {
|
|
AgentLifecycleStatus,
|
|
} from "@server/server/agent/agent-manager";
|
|
import type {
|
|
AgentPermissionRequest,
|
|
} from "@server/server/agent/agent-sdk-types";
|
|
import * as FileSystem from 'expo-file-system';
|
|
import { useDaemonConnections } from "./daemon-connections-context";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
|
|
|
// Re-export types from session-store for backward compatibility
|
|
export type {
|
|
MessageEntry,
|
|
DraftInput,
|
|
ProviderModelState,
|
|
Agent,
|
|
Command,
|
|
ExplorerEntry,
|
|
ExplorerFile,
|
|
ExplorerEntryKind,
|
|
ExplorerFileKind,
|
|
ExplorerEncoding,
|
|
AgentFileExplorerState,
|
|
} from "@/stores/session-store";
|
|
|
|
const derivePendingPermissionKey = (agentId: string, request: AgentPermissionRequest) => {
|
|
const fallbackId =
|
|
request.id ||
|
|
(typeof request.metadata?.id === "string" ? request.metadata.id : undefined) ||
|
|
request.name ||
|
|
request.title ||
|
|
`${request.kind}:${JSON.stringify(request.input ?? request.metadata ?? {})}`;
|
|
|
|
return `${agentId}:${fallbackId}`;
|
|
};
|
|
|
|
type GitDiffResponseMessage = Extract<
|
|
SessionOutboundMessage,
|
|
{ type: "git_diff_response" }
|
|
>;
|
|
|
|
type FileExplorerResponseMessage = Extract<
|
|
SessionOutboundMessage,
|
|
{ type: "file_explorer_response" }
|
|
>;
|
|
|
|
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
|
|
|
|
const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:";
|
|
|
|
type PersistedSessionSnapshot = {
|
|
agents: AgentSnapshotPayload[];
|
|
commands: Array<{
|
|
id: string;
|
|
name: string;
|
|
workingDirectory: string;
|
|
currentCommand: string;
|
|
isDead: boolean;
|
|
exitCode: number | null;
|
|
}>;
|
|
savedAt: string;
|
|
};
|
|
|
|
const getSessionSnapshotStorageKey = (serverId: string): string => {
|
|
return `${SESSION_SNAPSHOT_STORAGE_PREFIX}${serverId}`;
|
|
};
|
|
|
|
async function loadPersistedSessionSnapshot(serverId: string): Promise<PersistedSessionSnapshot | null> {
|
|
try {
|
|
const raw = await AsyncStorage.getItem(getSessionSnapshotStorageKey(serverId));
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
const parsed = JSON.parse(raw) as PersistedSessionSnapshot;
|
|
if (!Array.isArray(parsed?.agents) || !Array.isArray(parsed?.commands)) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch (error) {
|
|
console.error(`[Session] Failed to load persisted snapshot for ${serverId}`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function persistSessionSnapshot(serverId: string, snapshot: { agents: AgentSnapshotPayload[]; commands: any[] }) {
|
|
try {
|
|
const payload: PersistedSessionSnapshot = {
|
|
agents: snapshot.agents,
|
|
commands: snapshot.commands,
|
|
savedAt: new Date().toISOString(),
|
|
};
|
|
await AsyncStorage.setItem(getSessionSnapshotStorageKey(serverId), JSON.stringify(payload));
|
|
} catch (error) {
|
|
console.error(`[Session] Failed to persist snapshot for ${serverId}`, error);
|
|
}
|
|
}
|
|
|
|
function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) {
|
|
const createdAt = new Date(snapshot.createdAt);
|
|
const updatedAt = new Date(snapshot.updatedAt);
|
|
const lastUserMessageAt = snapshot.lastUserMessageAt ? new Date(snapshot.lastUserMessageAt) : null;
|
|
const attentionTimestamp = snapshot.attentionTimestamp ? new Date(snapshot.attentionTimestamp) : null;
|
|
return {
|
|
serverId,
|
|
id: snapshot.id,
|
|
provider: snapshot.provider,
|
|
status: snapshot.status as AgentLifecycleStatus,
|
|
createdAt,
|
|
updatedAt,
|
|
lastUserMessageAt,
|
|
lastActivityAt: updatedAt,
|
|
sessionId: snapshot.sessionId,
|
|
capabilities: snapshot.capabilities,
|
|
currentModeId: snapshot.currentModeId,
|
|
availableModes: snapshot.availableModes ?? [],
|
|
pendingPermissions: snapshot.pendingPermissions ?? [],
|
|
persistence: snapshot.persistence ?? null,
|
|
lastUsage: snapshot.lastUsage,
|
|
lastError: snapshot.lastError ?? null,
|
|
title: snapshot.title ?? null,
|
|
cwd: snapshot.cwd,
|
|
model: snapshot.model ?? null,
|
|
requiresAttention: snapshot.requiresAttention ?? false,
|
|
attentionReason: snapshot.attentionReason ?? null,
|
|
attentionTimestamp,
|
|
};
|
|
}
|
|
|
|
const createExplorerState = () => ({
|
|
directories: new Map(),
|
|
files: new Map(),
|
|
isLoading: false,
|
|
lastError: null,
|
|
pendingRequest: null,
|
|
currentPath: ".",
|
|
history: ["."],
|
|
lastVisitedPath: ".",
|
|
});
|
|
|
|
const pushHistory = (history: string[], path: string): string[] => {
|
|
const normalizedHistory = history.length === 0 ? ["."] : history;
|
|
const last = normalizedHistory[normalizedHistory.length - 1];
|
|
if (last === path) {
|
|
return normalizedHistory;
|
|
}
|
|
return [...normalizedHistory, path];
|
|
};
|
|
|
|
// Lightweight context for imperative APIs only (no state)
|
|
export interface SessionContextValue {
|
|
serverId: string;
|
|
ws: UseWebSocketReturn;
|
|
audioPlayer: ReturnType<typeof useAudioPlayer>;
|
|
setVoiceDetectionFlags: (isDetecting: boolean, isSpeaking: boolean) => void;
|
|
requestGitDiff: (agentId: string) => void;
|
|
requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void;
|
|
requestFilePreview: (agentId: string, path: string) => void;
|
|
navigateExplorerBack: (agentId: string) => string | null;
|
|
requestProviderModels: (provider: any, options?: { cwd?: string }) => void;
|
|
restartServer: (reason?: string) => void;
|
|
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
|
refreshAgent: (params: { agentId: string; requestId?: string }) => void;
|
|
refreshSession: () => void;
|
|
cancelAgentRun: (agentId: string) => void;
|
|
sendAgentMessage: (
|
|
agentId: string,
|
|
message: string,
|
|
images?: Array<{ uri: string; mimeType?: string }>
|
|
) => Promise<void>;
|
|
sendAgentAudio: (
|
|
agentId: string,
|
|
audioBlob: Blob,
|
|
requestId?: string,
|
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
|
) => Promise<void>;
|
|
deleteAgent: (agentId: string) => void;
|
|
createAgent: (options: {
|
|
config: any;
|
|
initialPrompt: string;
|
|
git?: any;
|
|
worktreeName?: string;
|
|
requestId?: string;
|
|
}) => void;
|
|
resumeAgent: (options: { handle: any; overrides?: any; requestId?: string }) => void;
|
|
setAgentMode: (agentId: string, modeId: string) => void;
|
|
respondToPermission: (agentId: string, requestId: string, response: any) => void;
|
|
}
|
|
|
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
|
|
|
interface SessionProviderProps {
|
|
children: ReactNode;
|
|
serverUrl: string;
|
|
serverId: string;
|
|
}
|
|
|
|
// SessionProvider: Pure WebSocket message handler that updates Zustand store
|
|
export function SessionProvider({ children, serverUrl, serverId }: SessionProviderProps) {
|
|
const ws = useWebSocket(serverUrl);
|
|
const wsIsConnected = ws.isConnected;
|
|
const {
|
|
updateConnectionStatus,
|
|
} = useDaemonConnections();
|
|
|
|
// Zustand store actions
|
|
const initializeSession = useSessionStore((state) => state.initializeSession);
|
|
const clearSession = useSessionStore((state) => state.clearSession);
|
|
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
|
const setMessages = useSessionStore((state) => state.setMessages);
|
|
const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage);
|
|
const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
|
|
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
|
const setHasHydratedAgents = useSessionStore((state) => state.setHasHydratedAgents);
|
|
const setAgents = useSessionStore((state) => state.setAgents);
|
|
const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity);
|
|
const setCommands = useSessionStore((state) => state.setCommands);
|
|
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
|
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
|
|
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
|
const setProviderModels = useSessionStore((state) => state.setProviderModels);
|
|
const getDraftInput = useSessionStore((state) => state.getDraftInput);
|
|
const saveDraftInput = useSessionStore((state) => state.saveDraftInput);
|
|
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
|
const getSession = useSessionStore((state) => state.getSession);
|
|
|
|
// State for voice detection flags (will be set by RealtimeContext)
|
|
const isDetectingRef = useRef(false);
|
|
const isSpeakingRef = useRef(false);
|
|
|
|
const audioPlayer = useAudioPlayer({
|
|
isDetecting: () => isDetectingRef.current,
|
|
isSpeaking: () => isSpeakingRef.current,
|
|
});
|
|
|
|
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
|
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
|
const providerModelRequestIdsRef = useRef<Map<any, string>>(new Map());
|
|
const hasHydratedSnapshotRef = useRef(false);
|
|
const hasRequestedInitialSnapshotRef = useRef(false);
|
|
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Buffer for streaming audio chunks
|
|
interface AudioChunk {
|
|
chunkIndex: number;
|
|
audio: string; // base64
|
|
format: string;
|
|
id: string;
|
|
}
|
|
const audioChunkBuffersRef = useRef<Map<string, AudioChunk[]>>(new Map());
|
|
|
|
// Initialize session in store
|
|
useEffect(() => {
|
|
initializeSession(serverId, ws, audioPlayer);
|
|
}, [serverId, ws, audioPlayer, initializeSession]);
|
|
|
|
const updateSessionWebSocket = useSessionStore((state) => state.updateSessionWebSocket);
|
|
useEffect(() => {
|
|
updateSessionWebSocket(serverId, ws);
|
|
}, [serverId, ws, updateSessionWebSocket]);
|
|
|
|
// Connection status tracking
|
|
useEffect(() => {
|
|
if (ws.isConnected) {
|
|
updateConnectionStatus(serverId, {
|
|
status: "online",
|
|
lastOnlineAt: new Date().toISOString(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (ws.isConnecting) {
|
|
updateConnectionStatus(serverId, { status: "connecting" });
|
|
return;
|
|
}
|
|
|
|
if (ws.lastError) {
|
|
updateConnectionStatus(serverId, { status: "error", lastError: ws.lastError });
|
|
return;
|
|
}
|
|
|
|
updateConnectionStatus(serverId, { status: "offline" });
|
|
}, [serverId, updateConnectionStatus, ws.isConnected, ws.isConnecting, ws.lastError]);
|
|
|
|
// If the socket drops mid-initialization, clear pending flags
|
|
useEffect(() => {
|
|
if (!ws.isConnected) {
|
|
setInitializingAgents(serverId, new Map());
|
|
}
|
|
}, [serverId, ws.isConnected, setInitializingAgents]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
updateConnectionStatus(serverId, { status: "offline", lastError: null });
|
|
};
|
|
}, [serverId, updateConnectionStatus]);
|
|
|
|
useEffect(() => {
|
|
hasHydratedSnapshotRef.current = false;
|
|
setHasHydratedAgents(serverId, false);
|
|
}, [serverId, setHasHydratedAgents]);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const hydrateFromSnapshot = async () => {
|
|
if (hasHydratedSnapshotRef.current) {
|
|
return;
|
|
}
|
|
hasHydratedSnapshotRef.current = true;
|
|
const snapshot = await loadPersistedSessionSnapshot(serverId);
|
|
if (!snapshot || !isMounted) {
|
|
return;
|
|
}
|
|
|
|
const agents = new Map();
|
|
const pendingPermissions = new Map();
|
|
const agentLastActivity = new Map();
|
|
|
|
for (const agentSnapshot of snapshot.agents) {
|
|
const agent = normalizeAgentSnapshot(agentSnapshot, serverId);
|
|
agents.set(agent.id, agent);
|
|
agentLastActivity.set(agent.id, agent.lastActivityAt);
|
|
for (const request of agent.pendingPermissions) {
|
|
const key = derivePendingPermissionKey(agent.id, request);
|
|
pendingPermissions.set(key, { key, agentId: agent.id, request });
|
|
}
|
|
}
|
|
|
|
setAgents(serverId, (prev) => {
|
|
if (prev.size > 0) {
|
|
return prev;
|
|
}
|
|
return agents;
|
|
});
|
|
|
|
// Initialize agentLastActivity slice (top-level)
|
|
for (const [agentId, timestamp] of agentLastActivity.entries()) {
|
|
setAgentLastActivity(agentId, timestamp);
|
|
}
|
|
|
|
setPendingPermissions(serverId, pendingPermissions);
|
|
const commandEntries = snapshot.commands ?? [];
|
|
setCommands(serverId, (prev) => {
|
|
if (prev.size > 0) {
|
|
return prev;
|
|
}
|
|
return new Map(commandEntries.map((command) => [command.id, command]));
|
|
});
|
|
setHasHydratedAgents(serverId, true);
|
|
};
|
|
|
|
void hydrateFromSnapshot();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [serverId, setAgents, setCommands, setPendingPermissions, setHasHydratedAgents]);
|
|
|
|
const updateExplorerState = useCallback(
|
|
(agentId: string, updater: (state: any) => any) => {
|
|
setFileExplorer(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
const current = next.get(agentId) ?? createExplorerState();
|
|
next.set(agentId, updater(current));
|
|
return next;
|
|
});
|
|
},
|
|
[serverId, setFileExplorer]
|
|
);
|
|
|
|
const gitDiffRequest = useDaemonRequest<
|
|
{ agentId: string },
|
|
{ agentId: string; diff: string },
|
|
GitDiffResponseMessage
|
|
>({
|
|
ws,
|
|
responseType: "git_diff_response",
|
|
buildRequest: ({ params }) => ({
|
|
type: "session",
|
|
message: {
|
|
type: "git_diff_request",
|
|
agentId: params?.agentId ?? "",
|
|
},
|
|
}),
|
|
matchResponse: (message, context) =>
|
|
message.payload.agentId === context.params?.agentId,
|
|
getRequestKey: (params) => params?.agentId ?? "default",
|
|
selectData: (message) => ({
|
|
agentId: message.payload.agentId,
|
|
diff: message.payload.diff ?? "",
|
|
}),
|
|
extractError: (message) =>
|
|
message.payload.error ? new Error(message.payload.error) : null,
|
|
timeoutMs: 10000,
|
|
keepPreviousData: false,
|
|
});
|
|
|
|
const refreshAgentRequest = useDaemonRequest<
|
|
{ agentId: string },
|
|
{ agentId: string; lifecycle: AgentLifecycleStatus | undefined },
|
|
StatusMessage
|
|
>({
|
|
ws,
|
|
responseType: "status",
|
|
buildRequest: ({ params, requestId }) => ({
|
|
type: "session",
|
|
message: {
|
|
type: "refresh_agent_request",
|
|
agentId: params?.agentId ?? "",
|
|
requestId,
|
|
},
|
|
}),
|
|
matchResponse: (message, context) =>
|
|
message.payload.status === "agent_initialized" &&
|
|
(message.payload as { agentId?: string }).agentId === context.params?.agentId &&
|
|
(message.payload as { requestId?: string }).requestId === context.requestId,
|
|
getRequestKey: (params) => params?.agentId ?? "default",
|
|
selectData: (message) => ({
|
|
agentId: (message.payload as { agentId?: string }).agentId ?? "",
|
|
lifecycle: (message.payload as { agentStatus?: AgentLifecycleStatus }).agentStatus,
|
|
}),
|
|
extractError: (message) =>
|
|
message.payload.status === "error"
|
|
? new Error((message.payload as { message?: string }).message ?? "Refresh failed")
|
|
: null,
|
|
timeoutMs: 15000,
|
|
keepPreviousData: false,
|
|
});
|
|
|
|
const directoryListingRequest = useDaemonRequest<
|
|
{ agentId: string; path: string },
|
|
FileExplorerResponseMessage["payload"],
|
|
FileExplorerResponseMessage
|
|
>({
|
|
ws,
|
|
responseType: "file_explorer_response",
|
|
buildRequest: ({ params }) => ({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
agentId: params?.agentId ?? "",
|
|
path: params?.path,
|
|
mode: "list",
|
|
},
|
|
}),
|
|
matchResponse: (message, context) =>
|
|
message.payload.mode === "list" &&
|
|
message.payload.agentId === context.params?.agentId &&
|
|
message.payload.path === context.params?.path,
|
|
getRequestKey: (params) =>
|
|
params ? `${params.agentId}:list:${params.path}` : "default",
|
|
selectData: (message) => message.payload,
|
|
extractError: (message) =>
|
|
message.payload.error ? new Error(message.payload.error) : null,
|
|
timeoutMs: 10000,
|
|
keepPreviousData: false,
|
|
});
|
|
|
|
const filePreviewRequest = useDaemonRequest<
|
|
{ agentId: string; path: string },
|
|
FileExplorerResponseMessage["payload"],
|
|
FileExplorerResponseMessage
|
|
>({
|
|
ws,
|
|
responseType: "file_explorer_response",
|
|
buildRequest: ({ params }) => ({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
agentId: params?.agentId ?? "",
|
|
path: params?.path,
|
|
mode: "file",
|
|
},
|
|
}),
|
|
matchResponse: (message, context) =>
|
|
message.payload.mode === "file" &&
|
|
message.payload.agentId === context.params?.agentId &&
|
|
message.payload.path === context.params?.path,
|
|
getRequestKey: (params) =>
|
|
params ? `${params.agentId}:file:${params.path}` : "default",
|
|
selectData: (message) => message.payload,
|
|
extractError: (message) =>
|
|
message.payload.error ? new Error(message.payload.error) : null,
|
|
timeoutMs: 10000,
|
|
keepPreviousData: false,
|
|
});
|
|
|
|
const initializeAgentRpc = useSessionRpc({
|
|
ws,
|
|
requestType: "initialize_agent_request",
|
|
responseType: "initialize_agent_request",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!wsIsConnected) {
|
|
hasRequestedInitialSnapshotRef.current = false;
|
|
return;
|
|
}
|
|
if (hasRequestedInitialSnapshotRef.current) {
|
|
return;
|
|
}
|
|
hasRequestedInitialSnapshotRef.current = true;
|
|
|
|
const MAX_RETRIES = 3;
|
|
const TIMEOUT_MS = 15000;
|
|
const RETRY_DELAY_MS = 2000;
|
|
|
|
let retryCount = 0;
|
|
|
|
const requestSessionState = () => {
|
|
console.log(`[Session] Requesting session_state (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`, { serverId });
|
|
|
|
ws.send({
|
|
type: "session",
|
|
message: {
|
|
type: "load_conversation_request",
|
|
conversationId: ws.conversationId ?? "",
|
|
},
|
|
});
|
|
|
|
if (sessionStateTimeoutRef.current) {
|
|
clearTimeout(sessionStateTimeoutRef.current);
|
|
}
|
|
|
|
sessionStateTimeoutRef.current = setTimeout(() => {
|
|
if (retryCount < MAX_RETRIES) {
|
|
retryCount++;
|
|
console.warn(`[Session] session_state timeout, retrying in ${RETRY_DELAY_MS}ms`, {
|
|
serverId,
|
|
attempt: retryCount,
|
|
maxRetries: MAX_RETRIES,
|
|
});
|
|
|
|
setTimeout(() => {
|
|
requestSessionState();
|
|
}, RETRY_DELAY_MS);
|
|
} else {
|
|
console.error(`[Session] session_state failed after ${MAX_RETRIES} retries`, { serverId });
|
|
|
|
setHasHydratedAgents(serverId, true);
|
|
updateConnectionStatus(serverId, {
|
|
status: "online",
|
|
lastOnlineAt: new Date().toISOString(),
|
|
sessionReady: true
|
|
});
|
|
}
|
|
}, TIMEOUT_MS);
|
|
};
|
|
|
|
requestSessionState();
|
|
|
|
return () => {
|
|
if (sessionStateTimeoutRef.current) {
|
|
clearTimeout(sessionStateTimeoutRef.current);
|
|
sessionStateTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, [wsIsConnected, ws, serverId, setHasHydratedAgents, updateConnectionStatus]);
|
|
|
|
// WebSocket message handlers - directly update Zustand store
|
|
useEffect(() => {
|
|
console.log("[Session] Setting up session_state listener for", serverId);
|
|
|
|
const unsubSessionState = ws.on("session_state", (message) => {
|
|
if (message.type !== "session_state") return;
|
|
|
|
if (sessionStateTimeoutRef.current) {
|
|
clearTimeout(sessionStateTimeoutRef.current);
|
|
sessionStateTimeoutRef.current = null;
|
|
}
|
|
|
|
const { agents: agentsList, commands: commandsList } = message.payload;
|
|
|
|
console.log("[Session] ✅ Received session_state:", agentsList.length, "agents,", commandsList.length, "commands");
|
|
setInitializingAgents(serverId, new Map());
|
|
|
|
const agents = new Map();
|
|
const pendingPermissions = new Map();
|
|
const agentLastActivity = new Map();
|
|
|
|
for (const agentSnapshot of agentsList) {
|
|
const agent = normalizeAgentSnapshot(agentSnapshot, serverId);
|
|
agents.set(agent.id, agent);
|
|
agentLastActivity.set(agent.id, agent.lastActivityAt);
|
|
for (const request of agent.pendingPermissions) {
|
|
const key = derivePendingPermissionKey(agent.id, request);
|
|
pendingPermissions.set(key, { key, agentId: agent.id, request });
|
|
}
|
|
}
|
|
|
|
const normalizedCommands = commandsList.map((command) => command);
|
|
|
|
setAgents(serverId, agents);
|
|
|
|
// Initialize agentLastActivity slice (top-level)
|
|
for (const [agentId, timestamp] of agentLastActivity.entries()) {
|
|
setAgentLastActivity(agentId, timestamp);
|
|
}
|
|
|
|
setPendingPermissions(serverId, pendingPermissions);
|
|
setCommands(serverId, new Map(normalizedCommands.map((command: any) => [command.id, command])));
|
|
setAgentStreamState(serverId, (prev) => {
|
|
if (prev.size === 0) {
|
|
return prev;
|
|
}
|
|
|
|
const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id));
|
|
let changed = false;
|
|
const next = new Map(prev);
|
|
|
|
for (const agentId of prev.keys()) {
|
|
if (!validAgentIds.has(agentId)) {
|
|
next.delete(agentId);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? next : prev;
|
|
});
|
|
setInitializingAgents(serverId, (prev) => {
|
|
if (prev.size === 0) {
|
|
return prev;
|
|
}
|
|
|
|
const validAgentIds = new Set(agentsList.map((snapshot) => snapshot.id));
|
|
let changed = false;
|
|
const next = new Map(prev);
|
|
|
|
for (const agentId of prev.keys()) {
|
|
if (!validAgentIds.has(agentId)) {
|
|
next.delete(agentId);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return changed ? next : prev;
|
|
});
|
|
void persistSessionSnapshot(serverId, { agents: agentsList, commands: normalizedCommands });
|
|
setHasHydratedAgents(serverId, true);
|
|
updateConnectionStatus(serverId, { status: "online", lastOnlineAt: new Date().toISOString(), sessionReady: true });
|
|
});
|
|
|
|
const unsubAgentState = ws.on("agent_state", (message) => {
|
|
if (message.type !== "agent_state") return;
|
|
const snapshot = message.payload;
|
|
const agent = normalizeAgentSnapshot(snapshot, serverId);
|
|
|
|
console.log("[Session] Agent state update:", agent.id, agent.status);
|
|
|
|
setAgents(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agent.id, agent);
|
|
return next;
|
|
});
|
|
|
|
// Update agentLastActivity slice (top-level)
|
|
setAgentLastActivity(agent.id, agent.lastActivityAt);
|
|
|
|
setPendingPermissions(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
for (const [key, pending] of Array.from(next.entries())) {
|
|
if (pending.agentId === agent.id) {
|
|
next.delete(key);
|
|
}
|
|
}
|
|
for (const request of agent.pendingPermissions) {
|
|
const key = derivePendingPermissionKey(agent.id, request);
|
|
next.set(key, { key, agentId: agent.id, request });
|
|
}
|
|
return next;
|
|
});
|
|
});
|
|
|
|
const unsubAgentStream = ws.on("agent_stream", (message) => {
|
|
if (message.type !== "agent_stream") return;
|
|
const { agentId, event, timestamp } = message.payload;
|
|
const parsedTimestamp = new Date(timestamp);
|
|
|
|
console.log("[Session] agent_stream", { agentId, eventType: event.type });
|
|
|
|
setAgentStreamState(serverId, (prev) => {
|
|
const currentStream = prev.get(agentId) || [];
|
|
const lastItem = currentStream[currentStream.length - 1];
|
|
const lastItemText = lastItem && "text" in lastItem ? lastItem.text : "(no text)";
|
|
console.log(`[AGENT_STREAM BEFORE] agentId=${agentId} currentStreamLength=${currentStream.length} lastText="${lastItemText}"`);
|
|
console.log(`[AGENT_STREAM CHUNK] incoming type=${event.type}`);
|
|
|
|
const newStream = reduceStreamUpdate(
|
|
currentStream,
|
|
event as AgentStreamEventPayload,
|
|
parsedTimestamp
|
|
);
|
|
|
|
const newLastItem = newStream[newStream.length - 1];
|
|
const newLastText = newLastItem && "text" in newLastItem ? newLastItem.text : "(no text)";
|
|
console.log(`[AGENT_STREAM AFTER] newStreamLength=${newStream.length} newLastText="${newLastText}"`);
|
|
|
|
const next = new Map(prev);
|
|
next.set(agentId, newStream);
|
|
return next;
|
|
});
|
|
|
|
setInitializingAgents(serverId, (prev) => {
|
|
const currentState = prev.get(agentId);
|
|
if (currentState === false) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.set(agentId, false);
|
|
return next;
|
|
});
|
|
|
|
// NOTE: We don't update lastActivityAt on every stream event to prevent
|
|
// cascading rerenders. The agent_state handler updates agent.lastActivityAt
|
|
// on status changes, which is sufficient for sorting and display purposes.
|
|
});
|
|
|
|
const unsubAgentStreamSnapshot = ws.on("agent_stream_snapshot", (message) => {
|
|
if (message.type !== "agent_stream_snapshot") return;
|
|
const { agentId, events } = message.payload;
|
|
|
|
console.log("[Session] agent_stream_snapshot", {
|
|
agentId,
|
|
eventCount: events.length,
|
|
});
|
|
|
|
const hydrated = hydrateStreamState(
|
|
events.map(({ event, timestamp }) => ({
|
|
event: event as AgentStreamEventPayload,
|
|
timestamp: new Date(timestamp),
|
|
}))
|
|
);
|
|
|
|
setAgentStreamState(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, hydrated);
|
|
return next;
|
|
});
|
|
|
|
setInitializingAgents(serverId, (prev) => {
|
|
if (!prev.has(agentId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.set(agentId, false);
|
|
return next;
|
|
});
|
|
});
|
|
|
|
const unsubStatus = ws.on("status", (message) => {
|
|
if (message.type !== "status") return;
|
|
const status = message.payload.status;
|
|
if (status === "agent_initialized" && "agentId" in message.payload) {
|
|
console.log("[Session] status agent_initialized", {
|
|
agentId: (message.payload as any).agentId,
|
|
requestId: (message.payload as any).requestId,
|
|
});
|
|
}
|
|
if (status === "agent_initialized" && "agentId" in message.payload) {
|
|
const agentId = (message.payload as any).agentId as string | undefined;
|
|
if (agentId) {
|
|
setInitializingAgents(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, false);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
const unsubPermissionRequest = ws.on("agent_permission_request", (message) => {
|
|
if (message.type !== "agent_permission_request") return;
|
|
const { agentId, request } = message.payload;
|
|
|
|
console.log("[Session] Permission request:", request.id, "for agent:", agentId);
|
|
|
|
setPendingPermissions(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
const key = derivePendingPermissionKey(agentId, request);
|
|
next.set(key, { key, agentId, request });
|
|
return next;
|
|
});
|
|
});
|
|
|
|
const unsubPermissionResolved = ws.on("agent_permission_resolved", (message) => {
|
|
if (message.type !== "agent_permission_resolved") return;
|
|
const { requestId, agentId } = message.payload;
|
|
|
|
console.log("[Session] Permission resolved:", requestId, "for agent:", agentId);
|
|
|
|
setPendingPermissions(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
const derivedKey = `${agentId}:${requestId}`;
|
|
if (!next.delete(derivedKey)) {
|
|
for (const [key, pending] of next.entries()) {
|
|
if (pending.agentId === agentId && pending.request.id === requestId) {
|
|
next.delete(key);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
});
|
|
|
|
const unsubAudioOutput = ws.on("audio_output", async (message) => {
|
|
if (message.type !== "audio_output") return;
|
|
const data = message.payload;
|
|
const playbackGroupId = data.groupId ?? data.id;
|
|
const isFinalChunk = data.isLastChunk ?? true;
|
|
const chunkIndex = data.chunkIndex ?? 0;
|
|
|
|
activeAudioGroupsRef.current.add(playbackGroupId);
|
|
setIsPlayingAudio(serverId, true);
|
|
|
|
if (!audioChunkBuffersRef.current.has(playbackGroupId)) {
|
|
audioChunkBuffersRef.current.set(playbackGroupId, []);
|
|
}
|
|
|
|
const buffer = audioChunkBuffersRef.current.get(playbackGroupId)!;
|
|
buffer.push({
|
|
chunkIndex,
|
|
audio: data.audio,
|
|
format: data.format,
|
|
id: data.id,
|
|
});
|
|
|
|
if (!isFinalChunk) {
|
|
console.log(`[Session] Buffered chunk ${chunkIndex} for group ${playbackGroupId}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[Session] Received final chunk for group ${playbackGroupId}, total chunks: ${buffer.length}`);
|
|
buffer.sort((a, b) => a.chunkIndex - b.chunkIndex);
|
|
|
|
let playbackFailed = false;
|
|
const chunkIds = buffer.map(chunk => chunk.id);
|
|
|
|
try {
|
|
const mimeType =
|
|
data.format === "mp3" ? "audio/mpeg" : `audio/${data.format}`;
|
|
|
|
const decodedChunks: Uint8Array[] = [];
|
|
let totalSize = 0;
|
|
|
|
for (const chunk of buffer) {
|
|
const binaryString = atob(chunk.audio);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
decodedChunks.push(bytes);
|
|
totalSize += bytes.length;
|
|
}
|
|
|
|
const concatenatedBytes = new Uint8Array(totalSize);
|
|
let offset = 0;
|
|
for (const chunk of decodedChunks) {
|
|
concatenatedBytes.set(chunk, offset);
|
|
offset += chunk.length;
|
|
}
|
|
|
|
console.log(`[Session] Playing concatenated audio: ${buffer.length} chunks, ${totalSize} bytes`);
|
|
|
|
const audioBlob = {
|
|
type: mimeType,
|
|
size: totalSize,
|
|
arrayBuffer: async () => {
|
|
return concatenatedBytes.buffer;
|
|
},
|
|
} as Blob;
|
|
|
|
await audioPlayer.play(audioBlob);
|
|
|
|
for (const chunkId of chunkIds) {
|
|
const confirmMessage: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "audio_played",
|
|
id: chunkId,
|
|
},
|
|
};
|
|
ws.send(confirmMessage);
|
|
}
|
|
} catch (error: any) {
|
|
playbackFailed = true;
|
|
console.error("[Session] Audio playback error:", error);
|
|
|
|
for (const chunkId of chunkIds) {
|
|
const confirmMessage: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "audio_played",
|
|
id: chunkId,
|
|
},
|
|
};
|
|
ws.send(confirmMessage);
|
|
}
|
|
} finally {
|
|
audioChunkBuffersRef.current.delete(playbackGroupId);
|
|
activeAudioGroupsRef.current.delete(playbackGroupId);
|
|
|
|
if (activeAudioGroupsRef.current.size === 0) {
|
|
setIsPlayingAudio(serverId, false);
|
|
}
|
|
}
|
|
});
|
|
|
|
const unsubActivity = ws.on("activity_log", (message) => {
|
|
if (message.type !== "activity_log") return;
|
|
const data = message.payload;
|
|
|
|
if (data.type === "system" && data.content.includes("Transcribing")) {
|
|
return;
|
|
}
|
|
|
|
if (data.type === "tool_call" && data.metadata) {
|
|
const {
|
|
toolCallId,
|
|
toolName,
|
|
arguments: args,
|
|
} = data.metadata as {
|
|
toolCallId: string;
|
|
toolName: string;
|
|
arguments: unknown;
|
|
};
|
|
|
|
setMessages(serverId, (prev) => [
|
|
...prev,
|
|
{
|
|
type: "tool_call",
|
|
id: toolCallId,
|
|
timestamp: Date.now(),
|
|
toolName,
|
|
args,
|
|
status: "executing",
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (data.type === "tool_result" && data.metadata) {
|
|
const { toolCallId, result } = data.metadata as {
|
|
toolCallId: string;
|
|
result: unknown;
|
|
};
|
|
|
|
setMessages(serverId, (prev) =>
|
|
prev.map((msg) =>
|
|
msg.type === "tool_call" && msg.id === toolCallId
|
|
? { ...msg, result, status: "completed" as const }
|
|
: msg
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
data.type === "error" &&
|
|
data.metadata &&
|
|
"toolCallId" in data.metadata
|
|
) {
|
|
const { toolCallId, error } = data.metadata as {
|
|
toolCallId: string;
|
|
error: unknown;
|
|
};
|
|
|
|
setMessages(serverId, (prev) =>
|
|
prev.map((msg) =>
|
|
msg.type === "tool_call" && msg.id === toolCallId
|
|
? { ...msg, error, status: "failed" as const }
|
|
: msg
|
|
)
|
|
);
|
|
}
|
|
|
|
let activityType: "system" | "info" | "success" | "error" = "info";
|
|
if (data.type === "error") activityType = "error";
|
|
|
|
if (data.type === "transcript") {
|
|
setMessages(serverId, (prev) => [
|
|
...prev,
|
|
{
|
|
type: "user",
|
|
id: generateMessageId(),
|
|
timestamp: Date.now(),
|
|
message: data.content,
|
|
},
|
|
]);
|
|
return;
|
|
}
|
|
|
|
if (data.type === "assistant") {
|
|
setMessages(serverId, (prev) => [
|
|
...prev,
|
|
{
|
|
type: "assistant",
|
|
id: generateMessageId(),
|
|
timestamp: Date.now(),
|
|
message: data.content,
|
|
},
|
|
]);
|
|
setCurrentAssistantMessage(serverId, "");
|
|
return;
|
|
}
|
|
|
|
setMessages(serverId, (prev) => [
|
|
...prev,
|
|
{
|
|
type: "activity",
|
|
id: generateMessageId(),
|
|
timestamp: Date.now(),
|
|
activityType,
|
|
message: data.content,
|
|
metadata: data.metadata,
|
|
},
|
|
]);
|
|
});
|
|
|
|
const unsubChunk = ws.on("assistant_chunk", (message) => {
|
|
if (message.type !== "assistant_chunk") return;
|
|
setCurrentAssistantMessage(serverId, (prev) => prev + message.payload.chunk);
|
|
});
|
|
|
|
const unsubTranscription = ws.on("transcription_result", (message) => {
|
|
if (message.type !== "transcription_result") return;
|
|
|
|
const transcriptText = message.payload.text.trim();
|
|
|
|
if (!transcriptText) {
|
|
console.log("[Session] Empty transcription (false positive) - ignoring");
|
|
} else {
|
|
console.log("[Session] Transcription received - stopping playback");
|
|
audioPlayer.stop();
|
|
setIsPlayingAudio(serverId, false);
|
|
setCurrentAssistantMessage(serverId, "");
|
|
}
|
|
});
|
|
|
|
const unsubProviderModels = ws.on(
|
|
"list_provider_models_response",
|
|
(message) => {
|
|
if (message.type !== "list_provider_models_response") {
|
|
return;
|
|
}
|
|
const { provider, models, error, fetchedAt, requestId } = message.payload;
|
|
const latestRequestId = providerModelRequestIdsRef.current.get(provider);
|
|
if (latestRequestId && requestId && requestId !== latestRequestId) {
|
|
return;
|
|
}
|
|
if (requestId) {
|
|
providerModelRequestIdsRef.current.delete(provider);
|
|
}
|
|
setProviderModels(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(provider, {
|
|
models: models ?? null,
|
|
error: error ?? null,
|
|
fetchedAt: new Date(fetchedAt),
|
|
isLoading: false,
|
|
});
|
|
return next;
|
|
});
|
|
}
|
|
);
|
|
|
|
const unsubAgentDeleted = ws.on("agent_deleted", (message) => {
|
|
if (message.type !== "agent_deleted") {
|
|
return;
|
|
}
|
|
const { agentId } = message.payload;
|
|
console.log("[Session] Agent deleted:", agentId);
|
|
|
|
setAgents(serverId, (prev) => {
|
|
if (!prev.has(agentId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.delete(agentId);
|
|
return next;
|
|
});
|
|
|
|
// Remove from agentLastActivity slice (top-level)
|
|
useSessionStore.setState((state) => {
|
|
if (!state.agentLastActivity.has(agentId)) {
|
|
return state;
|
|
}
|
|
const nextActivity = new Map(state.agentLastActivity);
|
|
nextActivity.delete(agentId);
|
|
return {
|
|
...state,
|
|
agentLastActivity: nextActivity,
|
|
};
|
|
});
|
|
|
|
setAgentStreamState(serverId, (prev) => {
|
|
if (!prev.has(agentId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.delete(agentId);
|
|
return next;
|
|
});
|
|
|
|
// Remove draft input
|
|
saveDraftInput(agentId, { text: "", images: [] });
|
|
|
|
setPendingPermissions(serverId, (prev) => {
|
|
let changed = false;
|
|
const next = new Map(prev);
|
|
for (const [key, pending] of prev.entries()) {
|
|
if (pending.agentId === agentId) {
|
|
next.delete(key);
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? next : prev;
|
|
});
|
|
|
|
setInitializingAgents(serverId, (prev) => {
|
|
if (!prev.has(agentId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.delete(agentId);
|
|
return next;
|
|
});
|
|
|
|
setGitDiffs(serverId, (prev) => {
|
|
if (!prev.has(agentId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.delete(agentId);
|
|
return next;
|
|
});
|
|
|
|
setFileExplorer(serverId, (prev) => {
|
|
if (!prev.has(agentId)) {
|
|
return prev;
|
|
}
|
|
const next = new Map(prev);
|
|
next.delete(agentId);
|
|
return next;
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
unsubSessionState();
|
|
unsubAgentState();
|
|
unsubAgentStream();
|
|
unsubAgentStreamSnapshot();
|
|
unsubStatus();
|
|
unsubPermissionRequest();
|
|
unsubPermissionResolved();
|
|
unsubAudioOutput();
|
|
unsubActivity();
|
|
unsubChunk();
|
|
unsubTranscription();
|
|
unsubProviderModels();
|
|
unsubAgentDeleted();
|
|
};
|
|
}, [ws, audioPlayer, serverId, setIsPlayingAudio, setMessages, setCurrentAssistantMessage, setAgentStreamState, setInitializingAgents, setAgents, setAgentLastActivity, setCommands, setPendingPermissions, setGitDiffs, setFileExplorer, setProviderModels, setHasHydratedAgents, updateConnectionStatus, getSession, saveDraftInput]);
|
|
|
|
// Auto-flush queued messages when agent transitions from running -> not running
|
|
useEffect(() => {
|
|
const session = getSession(serverId);
|
|
if (!session) return;
|
|
|
|
for (const [agentId, agent] of session.agents.entries()) {
|
|
const prevStatus = previousAgentStatusRef.current.get(agentId);
|
|
if (prevStatus === "running" && agent.status !== "running") {
|
|
const queue = session.queuedMessages.get(agentId);
|
|
if (queue && queue.length > 0) {
|
|
const [next, ...rest] = queue;
|
|
void sendAgentMessage(agentId, next.text, next.images);
|
|
setQueuedMessages(serverId, (prev) => {
|
|
const updated = new Map(prev);
|
|
updated.set(agentId, rest);
|
|
return updated;
|
|
});
|
|
}
|
|
}
|
|
previousAgentStatusRef.current.set(agentId, agent.status);
|
|
}
|
|
}, [serverId, getSession, setQueuedMessages]);
|
|
|
|
const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
|
console.log("[Session] initializeAgent called", { agentId, requestId });
|
|
setInitializingAgents(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, true);
|
|
return next;
|
|
});
|
|
|
|
setAgentStreamState(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, []);
|
|
return next;
|
|
});
|
|
|
|
initializeAgentRpc
|
|
.send({ agentId })
|
|
.catch((error) => {
|
|
console.warn("[Session] initializeAgent failed", { agentId, error });
|
|
setInitializingAgents(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, false);
|
|
return next;
|
|
});
|
|
});
|
|
}, [serverId, initializeAgentRpc, setAgentStreamState, setInitializingAgents]);
|
|
|
|
const refreshAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
|
setInitializingAgents(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, true);
|
|
return next;
|
|
});
|
|
|
|
setAgentStreamState(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, []);
|
|
return next;
|
|
});
|
|
|
|
refreshAgentRequest
|
|
.execute({ agentId }, { requestKeyOverride: agentId, dedupe: false })
|
|
.catch((error) => {
|
|
console.warn("[Session] refreshAgent failed", { agentId, error });
|
|
setInitializingAgents(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
next.set(agentId, false);
|
|
return next;
|
|
});
|
|
});
|
|
}, [serverId, refreshAgentRequest, setAgentStreamState, setInitializingAgents]);
|
|
|
|
const requestProviderModels = useCallback((provider: any, options?: { cwd?: string }) => {
|
|
const requestId = generateMessageId();
|
|
providerModelRequestIdsRef.current.set(provider, requestId);
|
|
setProviderModels(serverId, (prev) => {
|
|
const next = new Map(prev);
|
|
const current =
|
|
prev.get(provider) ?? {
|
|
models: null,
|
|
fetchedAt: null,
|
|
error: null,
|
|
isLoading: false,
|
|
};
|
|
next.set(provider, {
|
|
...current,
|
|
isLoading: true,
|
|
error: null,
|
|
});
|
|
return next;
|
|
});
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "list_provider_models_request",
|
|
provider,
|
|
...(options?.cwd ? { cwd: options.cwd } : {}),
|
|
requestId,
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [serverId, ws, setProviderModels]);
|
|
|
|
const sendAgentMessage = useCallback(async (
|
|
agentId: string,
|
|
message: string,
|
|
images?: Array<{ uri: string; mimeType?: string }>
|
|
) => {
|
|
const messageId = generateMessageId();
|
|
|
|
setAgentStreamState(serverId, (prev) => {
|
|
const currentStream = prev.get(agentId) || [];
|
|
const nextItem: any = {
|
|
kind: "user_message",
|
|
id: messageId,
|
|
text: message,
|
|
timestamp: new Date(),
|
|
};
|
|
const updated = new Map(prev);
|
|
updated.set(agentId, [...currentStream, nextItem]);
|
|
return updated;
|
|
});
|
|
|
|
let imagesData: Array<{ data: string; mimeType: string }> | undefined;
|
|
if (images && images.length > 0) {
|
|
const encodedImages = await Promise.all(
|
|
images.map(async ({ uri, mimeType }) => {
|
|
try {
|
|
const data = await FileSystem.readAsStringAsync(uri, {
|
|
encoding: "base64",
|
|
});
|
|
return {
|
|
data,
|
|
mimeType: mimeType ?? "image/jpeg",
|
|
};
|
|
} catch (error) {
|
|
console.error("[Session] Failed to convert image:", error);
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
const validImages = encodedImages.filter(
|
|
(entry): entry is { data: string; mimeType: string } => entry !== null
|
|
);
|
|
if (validImages.length > 0) {
|
|
imagesData = validImages;
|
|
}
|
|
}
|
|
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "send_agent_message",
|
|
agentId,
|
|
text: message,
|
|
messageId,
|
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [serverId, ws, setAgentStreamState]);
|
|
|
|
const cancelAgentRun = useCallback((agentId: string) => {
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "cancel_agent_request",
|
|
agentId,
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [ws]);
|
|
|
|
const deleteAgent = useCallback((agentId: string) => {
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "delete_agent_request",
|
|
agentId,
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [ws]);
|
|
|
|
const restartServer = useCallback((reason?: string) => {
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "restart_server_request",
|
|
...(reason && reason.trim().length > 0 ? { reason } : {}),
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [ws]);
|
|
|
|
const sendAgentAudio = useCallback(async (
|
|
agentId: string,
|
|
audioBlob: Blob,
|
|
requestId?: string,
|
|
options?: { mode?: "transcribe_only" | "auto_run" }
|
|
) => {
|
|
try {
|
|
const isSocketConnected = ws.getConnectionState ? ws.getConnectionState().isConnected : ws.isConnected;
|
|
if (!isSocketConnected) {
|
|
throw new Error("WebSocket is disconnected");
|
|
}
|
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
const base64Audio = btoa(binary);
|
|
|
|
const deriveFormat = (mimeType: string | undefined): string => {
|
|
if (!mimeType || mimeType.length === 0) {
|
|
return "webm";
|
|
}
|
|
const slashIndex = mimeType.indexOf("/");
|
|
let formatPart = slashIndex >= 0 ? mimeType.slice(slashIndex + 1) : mimeType;
|
|
const semicolonIndex = formatPart.indexOf(";");
|
|
if (semicolonIndex >= 0) {
|
|
formatPart = formatPart.slice(0, semicolonIndex);
|
|
}
|
|
return formatPart.trim().length > 0 ? formatPart.trim() : "webm";
|
|
};
|
|
|
|
const format = deriveFormat(audioBlob.type);
|
|
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "send_agent_audio",
|
|
agentId,
|
|
audio: base64Audio,
|
|
format,
|
|
isLast: true,
|
|
requestId,
|
|
...(options?.mode ? { mode: options.mode } : {}),
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
|
|
console.log("[Session] Sent audio to agent:", agentId, format, audioBlob.size, "bytes", requestId ? `(requestId: ${requestId})` : "");
|
|
} catch (error) {
|
|
console.error("[Session] Failed to send audio:", error);
|
|
throw error;
|
|
}
|
|
}, [ws]);
|
|
|
|
const createAgent = useCallback(({ config, initialPrompt, git, worktreeName, requestId }: { config: any; initialPrompt: string; git?: any; worktreeName?: string; requestId?: string }) => {
|
|
const trimmedPrompt = initialPrompt.trim();
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "create_agent_request",
|
|
config,
|
|
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
|
...(git ? { git } : {}),
|
|
...(worktreeName ? { worktreeName } : {}),
|
|
...(requestId ? { requestId } : {}),
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [ws]);
|
|
|
|
const resumeAgent = useCallback(({ handle, overrides, requestId }: { handle: any; overrides?: any; requestId?: string }) => {
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "resume_agent_request",
|
|
handle,
|
|
...(overrides ? { overrides } : {}),
|
|
...(requestId ? { requestId } : {}),
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [ws]);
|
|
|
|
const setAgentMode = useCallback((agentId: string, modeId: string) => {
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "set_agent_mode",
|
|
agentId,
|
|
modeId,
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [ws]);
|
|
|
|
const respondToPermission = useCallback((agentId: string, requestId: string, response: any) => {
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "agent_permission_response",
|
|
agentId,
|
|
requestId,
|
|
response,
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
}, [ws]);
|
|
|
|
const setVoiceDetectionFlags = useCallback((isDetecting: boolean, isSpeaking: boolean) => {
|
|
isDetectingRef.current = isDetecting;
|
|
isSpeakingRef.current = isSpeaking;
|
|
}, []);
|
|
|
|
const requestGitDiff = useCallback((agentId: string) => {
|
|
gitDiffRequest
|
|
.execute({ agentId })
|
|
.then((result) => {
|
|
setGitDiffs(serverId, (prev) => new Map(prev).set(result.agentId, result.diff));
|
|
})
|
|
.catch((error) => {
|
|
setGitDiffs(serverId, (prev) => new Map(prev).set(agentId, `Error: ${error.message}`));
|
|
});
|
|
}, [serverId, gitDiffRequest, setGitDiffs]);
|
|
|
|
const requestDirectoryListing = useCallback((agentId: string, path: string, options?: { recordHistory?: boolean }) => {
|
|
const normalizedPath = path && path.length > 0 ? path : ".";
|
|
const shouldRecordHistory = options?.recordHistory ?? true;
|
|
|
|
updateExplorerState(agentId, (state: any) => ({
|
|
...state,
|
|
isLoading: true,
|
|
lastError: null,
|
|
pendingRequest: { path: normalizedPath, mode: "list" },
|
|
currentPath: normalizedPath,
|
|
history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history,
|
|
lastVisitedPath: normalizedPath,
|
|
}));
|
|
|
|
directoryListingRequest
|
|
.execute({ agentId, path: normalizedPath })
|
|
.then((payload) => {
|
|
updateExplorerState(agentId, (state: any) => {
|
|
const nextState: any = {
|
|
...state,
|
|
isLoading: false,
|
|
lastError: payload.error ?? null,
|
|
pendingRequest: null,
|
|
directories: state.directories,
|
|
files: state.files,
|
|
};
|
|
|
|
if (!payload.error && payload.directory) {
|
|
const directories = new Map(state.directories);
|
|
directories.set(payload.directory.path, payload.directory);
|
|
nextState.directories = directories;
|
|
}
|
|
|
|
return nextState;
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
updateExplorerState(agentId, (state: any) => ({
|
|
...state,
|
|
isLoading: false,
|
|
lastError: error.message,
|
|
pendingRequest: null,
|
|
}));
|
|
});
|
|
}, [directoryListingRequest, updateExplorerState]);
|
|
|
|
const requestFilePreview = useCallback((agentId: string, path: string) => {
|
|
const normalizedPath = path && path.length > 0 ? path : ".";
|
|
updateExplorerState(agentId, (state: any) => ({
|
|
...state,
|
|
isLoading: true,
|
|
lastError: null,
|
|
pendingRequest: { path: normalizedPath, mode: "file" },
|
|
}));
|
|
|
|
filePreviewRequest
|
|
.execute({ agentId, path: normalizedPath })
|
|
.then((payload) => {
|
|
updateExplorerState(agentId, (state: any) => {
|
|
const nextState: any = {
|
|
...state,
|
|
isLoading: false,
|
|
lastError: payload.error ?? null,
|
|
pendingRequest: null,
|
|
directories: state.directories,
|
|
files: state.files,
|
|
};
|
|
|
|
if (!payload.error && payload.file) {
|
|
const files = new Map(state.files);
|
|
files.set(payload.file.path, payload.file);
|
|
nextState.files = files;
|
|
}
|
|
|
|
return nextState;
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
updateExplorerState(agentId, (state: any) => ({
|
|
...state,
|
|
isLoading: false,
|
|
lastError: error.message,
|
|
pendingRequest: null,
|
|
}));
|
|
});
|
|
}, [filePreviewRequest, updateExplorerState]);
|
|
|
|
const navigateExplorerBack = useCallback((agentId: string) => {
|
|
let targetPath: string | null = null;
|
|
|
|
updateExplorerState(agentId, (state: any) => {
|
|
if (!state.history || state.history.length <= 1) {
|
|
return state;
|
|
}
|
|
|
|
const nextHistory = state.history.slice(0, -1);
|
|
targetPath = nextHistory[nextHistory.length - 1] ?? ".";
|
|
|
|
return {
|
|
...state,
|
|
isLoading: true,
|
|
lastError: null,
|
|
pendingRequest: { path: targetPath, mode: "list" },
|
|
currentPath: targetPath,
|
|
history: nextHistory,
|
|
};
|
|
});
|
|
|
|
if (!targetPath) {
|
|
return null;
|
|
}
|
|
|
|
const msg: WSInboundMessage = {
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
agentId,
|
|
path: targetPath,
|
|
mode: "list",
|
|
},
|
|
};
|
|
ws.send(msg);
|
|
return targetPath;
|
|
}, [updateExplorerState, ws]);
|
|
|
|
const refreshSession = useCallback(() => {
|
|
console.log(`[Session] Manual refresh requested for ${serverId}`);
|
|
ws.send({
|
|
type: "session",
|
|
message: {
|
|
type: "load_conversation_request",
|
|
conversationId: ws.conversationId ?? "",
|
|
},
|
|
});
|
|
}, [ws, serverId]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
clearSession(serverId);
|
|
};
|
|
}, [serverId, clearSession]);
|
|
|
|
const value = useMemo<SessionContextValue>(
|
|
() => ({
|
|
serverId,
|
|
ws,
|
|
audioPlayer,
|
|
setVoiceDetectionFlags,
|
|
requestGitDiff,
|
|
requestDirectoryListing,
|
|
requestFilePreview,
|
|
navigateExplorerBack,
|
|
requestProviderModels,
|
|
restartServer,
|
|
initializeAgent,
|
|
refreshAgent,
|
|
refreshSession,
|
|
cancelAgentRun,
|
|
deleteAgent,
|
|
sendAgentMessage,
|
|
sendAgentAudio,
|
|
createAgent,
|
|
resumeAgent,
|
|
setAgentMode,
|
|
respondToPermission,
|
|
}),
|
|
[
|
|
serverId,
|
|
ws,
|
|
audioPlayer,
|
|
setVoiceDetectionFlags,
|
|
requestGitDiff,
|
|
requestDirectoryListing,
|
|
requestFilePreview,
|
|
navigateExplorerBack,
|
|
requestProviderModels,
|
|
restartServer,
|
|
initializeAgent,
|
|
refreshAgent,
|
|
refreshSession,
|
|
cancelAgentRun,
|
|
deleteAgent,
|
|
sendAgentMessage,
|
|
sendAgentAudio,
|
|
createAgent,
|
|
resumeAgent,
|
|
setAgentMode,
|
|
respondToPermission,
|
|
]
|
|
);
|
|
|
|
// Sync imperative methods to Zustand store so components can access them via selectors
|
|
// Memoize the methods object to avoid infinite re-renders (object reference must be stable)
|
|
const setSessionMethods = useSessionStore((state) => state.setSessionMethods);
|
|
const methods = useMemo(() => ({
|
|
setVoiceDetectionFlags,
|
|
requestGitDiff,
|
|
requestDirectoryListing,
|
|
requestFilePreview,
|
|
navigateExplorerBack,
|
|
requestProviderModels,
|
|
restartServer,
|
|
initializeAgent,
|
|
refreshAgent,
|
|
refreshSession,
|
|
cancelAgentRun,
|
|
sendAgentMessage,
|
|
sendAgentAudio,
|
|
deleteAgent,
|
|
createAgent,
|
|
resumeAgent,
|
|
setAgentMode,
|
|
respondToPermission,
|
|
}), [
|
|
setVoiceDetectionFlags,
|
|
requestGitDiff,
|
|
requestDirectoryListing,
|
|
requestFilePreview,
|
|
navigateExplorerBack,
|
|
requestProviderModels,
|
|
restartServer,
|
|
initializeAgent,
|
|
refreshAgent,
|
|
refreshSession,
|
|
cancelAgentRun,
|
|
sendAgentMessage,
|
|
sendAgentAudio,
|
|
deleteAgent,
|
|
createAgent,
|
|
resumeAgent,
|
|
setAgentMode,
|
|
respondToPermission,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setSessionMethods(serverId, methods);
|
|
}, [serverId, setSessionMethods, methods]);
|
|
|
|
return (
|
|
<SessionContext.Provider value={value}>
|
|
{children}
|
|
</SessionContext.Provider>
|
|
);
|
|
}
|
|
|
|
// Export the context for components that need imperative APIs
|
|
export { SessionContext };
|