mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-13 01:22:06 +08:00
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.
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
import { useEffect } from 'react';
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
|
|
import type { ServerEvent } from '../../../contexts/WebSocketContext';
|
|
import { showCompletionTitleIndicator } from '../../../utils/pageTitleNotification';
|
|
import { playChatCompletionSound } from '../../../utils/notificationSound';
|
|
import type { MarkSessionIdle, MarkSessionProcessing } from '../../../hooks/useSessionProtection';
|
|
import type { PendingPermissionRequest } from '../types/types';
|
|
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
|
|
|
interface UseChatRealtimeHandlersArgs {
|
|
subscribe: (listener: (event: ServerEvent) => void) => () => void;
|
|
provider: LLMProvider;
|
|
selectedSession: ProjectSession | null;
|
|
currentSessionId: string | null;
|
|
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
|
streamTimerRef: MutableRefObject<number | null>;
|
|
accumulatedStreamRef: MutableRefObject<string>;
|
|
/**
|
|
* 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;
|
|
onWebSocketReconnect?: () => void;
|
|
sessionStore: SessionStore;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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({
|
|
subscribe,
|
|
provider,
|
|
selectedSession,
|
|
currentSessionId,
|
|
setTokenBudget,
|
|
setPendingPermissionRequests,
|
|
streamTimerRef,
|
|
accumulatedStreamRef,
|
|
lastSeqRef,
|
|
statusCheckSentAtRef,
|
|
onSessionProcessing,
|
|
onSessionIdle,
|
|
onWebSocketReconnect,
|
|
sessionStore,
|
|
}: UseChatRealtimeHandlersArgs) {
|
|
useEffect(() => {
|
|
const handleEvent = (msg: ServerEvent) => {
|
|
if (!msg.kind) {
|
|
return;
|
|
}
|
|
|
|
const activeViewSessionId = selectedSession?.id || currentSessionId || null;
|
|
const sid = (typeof msg.sessionId === 'string' && msg.sessionId) || activeViewSessionId;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
switch (msg.kind) {
|
|
case 'websocket_reconnected':
|
|
onWebSocketReconnect?.();
|
|
return;
|
|
|
|
case 'chat_subscribed': {
|
|
// Ack for chat.subscribe: authoritative processing state plus any
|
|
// pending tool-permission prompts for the run.
|
|
if (!sid) return;
|
|
|
|
if (msg.isProcessing) {
|
|
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),
|
|
});
|
|
}
|
|
|
|
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:
|
|
break;
|
|
}
|
|
|
|
/* -------------------------------------------------------------- */
|
|
/* Provider NormalizedMessage handling */
|
|
/* -------------------------------------------------------------- */
|
|
|
|
// --- 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);
|
|
}
|
|
// Also route to store for non-active sessions
|
|
if (sid && sid !== activeViewSessionId) {
|
|
sessionStore.appendRealtime(sid, msg as unknown 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);
|
|
}
|
|
sessionStore.finalizeStreaming(sid);
|
|
}
|
|
accumulatedStreamRef.current = '';
|
|
return;
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
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);
|
|
}, [
|
|
subscribe,
|
|
provider,
|
|
selectedSession,
|
|
currentSessionId,
|
|
setTokenBudget,
|
|
setPendingPermissionRequests,
|
|
streamTimerRef,
|
|
accumulatedStreamRef,
|
|
lastSeqRef,
|
|
statusCheckSentAtRef,
|
|
onSessionProcessing,
|
|
onSessionIdle,
|
|
onWebSocketReconnect,
|
|
sessionStore,
|
|
]);
|
|
}
|