feat(chat): unify session gateway with stable IDs and a single WS protocol

The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth.
This commit is contained in:
Haileyesus
2026-06-11 18:47:19 +03:00
parent 3d948217ef
commit f5eac2ec12
40 changed files with 2451 additions and 1226 deletions

View File

@@ -1,67 +1,34 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { ServerEvent } from '../../../contexts/WebSocketContext';
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
import { playChatCompletionSound } from '../../../utils/notificationSound';
import { PENDING_SESSION_ID } from '../../../hooks/useSessionProtection';
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
import type { PendingPermissionRequest } from '../types/types';
import type { ProjectSession, LLMProvider } from '../../../types/app';
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
type LatestChatMessage = {
type?: string;
kind?: string;
data?: any;
message?: any;
delta?: string;
sessionId?: string;
session_id?: string;
requestId?: string;
toolName?: string;
input?: unknown;
context?: unknown;
error?: string;
tool?: any;
toolId?: string;
result?: any;
exitCode?: number;
isProcessing?: boolean;
actualSessionId?: string;
event?: string;
status?: any;
isNewSession?: boolean;
resultText?: string;
isError?: boolean;
success?: boolean;
reason?: string;
provider?: string;
content?: string;
text?: string;
tokens?: number;
canInterrupt?: boolean;
tokenBudget?: unknown;
newSessionId?: string;
aborted?: boolean;
[key: string]: any;
};
interface UseChatRealtimeHandlersArgs {
latestMessage: LatestChatMessage | null;
subscribe: (listener: (event: ServerEvent) => void) => () => void;
provider: LLMProvider;
selectedSession: ProjectSession | null;
currentSessionId: string | null;
setCurrentSessionId: (sessionId: string | null) => void;
setTokenBudget: (budget: Record<string, unknown> | null) => void;
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
streamTimerRef: MutableRefObject<number | null>;
accumulatedStreamRef: MutableRefObject<string>;
/** When each session's `check-session-status` was last sent; guards stale idle replies. */
/**
* Highest live `seq` observed per session. Essential for reconnect catch-up:
* `chat.subscribe` sends this value as `lastSeq` so the server replays only
* the events this client actually missed. Written here on every sequenced
* frame; read wherever a `chat.subscribe` is sent (session open, reconnect).
*/
lastSeqRef: MutableRefObject<Map<string, number>>;
/** When each session's `chat.subscribe` was last sent; guards stale idle acks. */
statusCheckSentAtRef: MutableRefObject<Map<string, number>>;
onSessionProcessing?: MarkSessionProcessing;
onSessionIdle?: MarkSessionIdle;
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
onWebSocketReconnect?: () => void;
sessionStore: SessionStore;
}
@@ -70,293 +37,259 @@ interface UseChatRealtimeHandlersArgs {
/* Hook */
/* ------------------------------------------------------------------ */
/**
* Routes server events into the session store and processing-state map.
*
* This is intentionally a thin reducer over the unified `kind`-based
* protocol: every frame is keyed by the stable app session id, so there is
* no session-id handoff, no provider branching, and no navigation here.
* Sidebar events (`session_upserted`, `loading_progress`) are handled by
* `useProjectsState`, not in this hook.
*/
export function useChatRealtimeHandlers({
latestMessage,
subscribe,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setTokenBudget,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionProcessing,
onSessionIdle,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
}: UseChatRealtimeHandlersArgs) {
const paletteOps = usePaletteOps();
const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);
useEffect(() => {
if (!latestMessage) return;
if (lastProcessedMessageRef.current === latestMessage) return;
lastProcessedMessageRef.current = latestMessage;
const handleEvent = (msg: ServerEvent) => {
if (!msg.kind) {
return;
}
const activeViewSessionId =
selectedSession?.id || currentSessionId || null;
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
/* ---------------------------------------------------------------- */
/* Legacy messages (no `kind` field) — handle and return */
/* ---------------------------------------------------------------- */
// Record replay progress for every sequenced live event.
if (sid && typeof msg.seq === 'number') {
const known = lastSeqRef.current.get(sid) ?? 0;
if (msg.seq > known) {
lastSeqRef.current.set(sid, msg.seq);
}
}
const msg = latestMessage as any;
if (!msg.kind) {
const messageType = String(msg.type || '');
switch (messageType) {
case 'websocket-reconnected':
switch (msg.kind) {
case 'websocket_reconnected':
onWebSocketReconnect?.();
return;
case 'pending-permissions-response': {
const permSessionId = msg.sessionId;
const isCurrentPermSession =
permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);
if (permSessionId && !isCurrentPermSession) return;
setPendingPermissionRequests(msg.data || []);
return;
}
case 'chat_subscribed': {
// Ack for chat.subscribe: authoritative processing state plus any
// pending tool-permission prompts for the run.
if (!sid) return;
case 'session-status': {
const statusSessionId = msg.sessionId;
if (!statusSessionId) return;
const status = msg.status;
if (status) {
onSessionProcessing?.(statusSessionId, {
statusText: status.text || null,
canInterrupt: status.can_interrupt !== false,
});
return;
}
// Reply to check-session-status (or unsolicited processing update)
if (msg.isProcessing) {
onSessionProcessing?.(statusSessionId);
return;
onSessionProcessing?.(sid);
} else {
// Idle ack: ignore it if a newer request started after the
// subscribe was sent — the ack describes the older state.
onSessionIdle?.(sid, {
ifStartedBefore: statusCheckSentAtRef.current.get(sid),
});
}
// Idle reply: ignore it if a newer request started after the check
// was sent — the reply describes the older request.
onSessionIdle?.(statusSessionId, {
ifStartedBefore: statusCheckSentAtRef.current.get(statusSessionId),
});
const isViewedSession = sid === activeViewSessionId;
if (isViewedSession && Array.isArray(msg.pendingPermissions)) {
setPendingPermissionRequests(msg.pendingPermissions as PendingPermissionRequest[]);
}
return;
}
case 'protocol_error': {
console.error('[Chat] Protocol error:', msg.code, msg.error);
if (sid) {
// Surface the failure in the conversation and stop the spinner —
// the run never started (or was rejected), so no `complete` follows.
onSessionIdle?.(sid);
sessionStore.appendRealtime(sid, {
id: `protocol_error_${Date.now()}`,
sessionId: sid,
timestamp: new Date().toISOString(),
provider,
kind: 'error',
content: String(msg.error || 'Request failed'),
} as NormalizedMessage);
}
return;
}
// Sidebar/global events — owned by useProjectsState.
case 'session_upserted':
case 'loading_progress':
return;
default:
// Unknown legacy message type — ignore
return;
break;
}
}
/* ---------------------------------------------------------------- */
/* NormalizedMessage handling (has `kind` field) */
/* ---------------------------------------------------------------- */
/* -------------------------------------------------------------- */
/* Provider NormalizedMessage handling */
/* -------------------------------------------------------------- */
const sid = msg.sessionId || activeViewSessionId;
// --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') {
const text = msg.content || '';
if (!text) return;
accumulatedStreamRef.current += text;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
if (sid) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
}, 100);
}
// Also route to store for non-active sessions
if (sid && sid !== activeViewSessionId) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
return;
}
if (msg.kind === 'stream_end') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid) {
if (accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
// --- Streaming: buffer for performance ---
if (msg.kind === 'stream_delta') {
const text = (msg.content as string) || '';
if (!text) return;
accumulatedStreamRef.current += text;
if (!streamTimerRef.current) {
streamTimerRef.current = window.setTimeout(() => {
streamTimerRef.current = null;
if (sid) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
}, 100);
}
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
return;
}
// --- All other messages: route to store ---
const shouldPersist =
msg.kind !== 'session_created'
&& msg.kind !== 'complete'
&& msg.kind !== 'status'
&& msg.kind !== 'permission_request'
&& msg.kind !== 'permission_cancelled';
if (sid && shouldPersist) {
sessionStore.appendRealtime(sid, msg as NormalizedMessage);
}
// --- UI side effects for specific kinds ---
switch (msg.kind) {
case 'session_created': {
const newSessionId = msg.newSessionId;
if (!newSessionId) break;
// We no longer synthesize client-side placeholder IDs. Until the provider
// announces `session_created`, the active id is expected to be null.
if (!currentSessionId) {
setCurrentSessionId(newSessionId);
setPendingPermissionRequests((prev) =>
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
);
// Also route to store for non-active sessions
if (sid && sid !== activeViewSessionId) {
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
}
// The in-flight request now has a concrete session id: migrate the
// processing entry from the pending placeholder.
onSessionIdle?.(PENDING_SESSION_ID);
onSessionProcessing?.(newSessionId);
onNavigateToSession?.(newSessionId);
break;
return;
}
case 'complete': {
// Flush any remaining streaming state
if (msg.kind === 'stream_end') {
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid && accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
if (sid) {
if (accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
}
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
return;
}
// `complete` is the unified terminal event — every provider run ends
// with exactly one, regardless of success, failure, or abort. The
// indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically.
onSessionIdle?.(sid);
onSessionIdle?.(PENDING_SESSION_ID);
setPendingPermissionRequests([]);
// --- All other messages: route to store ---
const shouldPersist =
msg.kind !== 'complete'
&& msg.kind !== 'status'
&& msg.kind !== 'permission_request'
&& msg.kind !== 'permission_cancelled';
if (sid && shouldPersist) {
sessionStore.appendRealtime(sid, msg as unknown as NormalizedMessage);
}
// --- UI side effects for specific kinds ---
switch (msg.kind) {
case 'complete': {
// Flush any remaining streaming state
if (streamTimerRef.current) {
clearTimeout(streamTimerRef.current);
streamTimerRef.current = null;
}
if (sid && accumulatedStreamRef.current) {
sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);
sessionStore.finalizeStreaming(sid);
}
accumulatedStreamRef.current = '';
// `complete` is the unified terminal event — every provider run ends
// with exactly one, regardless of success, failure, or abort. The
// indicator derives from the processing map, so deleting the entry
// hides it immediately and atomically.
onSessionIdle?.(sid);
setPendingPermissionRequests([]);
if (msg.aborted) {
// Abort was requested — the complete event confirms it. No
// further UI action is needed beyond clearing the entry above.
break;
}
// Celebrate only successful runs (failed runs end with success: false).
if (msg.success !== false) {
showCompletionTitleIndicator();
void playChatCompletionSound();
}
// The session id is stable for the whole conversation (allocated
// before the first send), so the only follow-up is syncing the
// viewed conversation with the now-persisted transcript.
if (sid && sid === activeViewSessionId) {
void sessionStore.refreshFromServer(sid);
}
// Handle aborted case
if (msg.aborted) {
// Abort was requested — the complete event confirms it
// No special UI action needed beyond clearing the processing entry above
// The backend already sent any abort-related messages
break;
}
// Celebrate only successful runs (failed runs end with success: false).
if (msg.success !== false) {
showCompletionTitleIndicator();
void playChatCompletionSound();
}
// 'error' is an informational message row, not a terminal event —
// providers emit it for mid-run stderr output too. Run teardown is
// always signalled by the unified 'complete' that follows.
const actualSessionId =
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
? msg.actualSessionId
: null;
const isVisibleSession =
Boolean(
sid
&& sid === activeViewSessionId,
);
if (actualSessionId && sid && actualSessionId !== sid) {
sessionStore.replaceSessionId(sid, actualSessionId);
onSessionIdle?.(actualSessionId);
if (isVisibleSession) {
setCurrentSessionId(actualSessionId);
void sessionStore.refreshFromServer(actualSessionId);
}
if (isVisibleSession) {
onNavigateToSession?.(actualSessionId, { replace: true });
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
}
break;
}
if (sid && isVisibleSession) {
void sessionStore.refreshFromServer(sid);
}
break;
}
// 'error' is an informational message row, not a terminal event —
// providers emit it for mid-run stderr output too. Run teardown is
// always signalled by the unified 'complete' that follows.
case 'permission_request': {
if (!msg.requestId) break;
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId,
toolName: msg.toolName || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
onSessionProcessing?.(sid || PENDING_SESSION_ID);
break;
}
case 'permission_cancelled': {
if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
case 'status': {
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text) {
onSessionProcessing?.(sid || PENDING_SESSION_ID, {
statusText: msg.text,
canInterrupt: msg.canInterrupt !== false,
case 'permission_request': {
if (!msg.requestId) break;
setPendingPermissionRequests((prev) => {
if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;
return [...prev, {
requestId: msg.requestId as string,
toolName: (msg.toolName as string) || 'UnknownTool',
input: msg.input,
context: msg.context,
sessionId: sid || null,
receivedAt: new Date(),
}];
});
if (sid) {
onSessionProcessing?.(sid);
}
break;
}
break;
}
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
// → already routed to store above, no UI side effects needed
default:
break;
}
case 'permission_cancelled': {
if (msg.requestId) {
setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));
}
break;
}
case 'status': {
if (msg.text === 'token_budget' && msg.tokenBudget) {
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
} else if (msg.text && sid) {
onSessionProcessing?.(sid, {
statusText: msg.text as string,
canInterrupt: msg.canInterrupt !== false,
});
}
break;
}
// text, tool_use, tool_result, thinking, interactive_prompt, task_notification
// → already routed to store above, no UI side effects needed
default:
break;
}
};
return subscribe(handleEvent);
}, [
latestMessage,
subscribe,
provider,
selectedSession,
currentSessionId,
setCurrentSessionId,
setTokenBudget,
setPendingPermissionRequests,
streamTimerRef,
accumulatedStreamRef,
lastSeqRef,
statusCheckSentAtRef,
onSessionProcessing,
onSessionIdle,
onNavigateToSession,
onWebSocketReconnect,
sessionStore,
paletteOps,
]);
}