mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-09 22:18:19 +00:00
The frontend realtime pipeline was carrying control-plane events and unused state through message storage as if they were renderable chat content. That made the session path noisier than necessary and increased the chance of subtle drift between transport events and UI data. Why this change: - Control events like session_created, status, complete, and permission lifecycle updates drive UI side effects, not chat transcript rendering. - Persisting those events in the session store added avoidable churn, memory growth, and merge work while providing no user-visible value. - An unused streamBufferRef existed in the hot path, creating extra writes and cognitive overhead with no read consumer. - useChatRealtimeHandlers accepted selectedProject even though it was not used, which widened the hook surface and dependency noise without behavior impact. What this commit does: - Removes the write-only streamBufferRef from ChatInterface and realtime handler wiring. - Removes the unused selectedProject argument from useChatRealtimeHandlers. - Stops appending non-rendered control events to sessionStore realtime messages. These events still execute their side effects exactly as before. Net effect: The Codex/Claude/Cursor/Gemini realtime path stays behaviorally equivalent for users, but the data model now stores only message content that can actually be rendered, reducing unnecessary state traffic in the chat runtime.
412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
|
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
|
import type { PendingPermissionRequest, SessionNavigationOptions } from '../types/types';
|
|
import type { ProjectSession, LLMProvider } from '../../../types/app';
|
|
import type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';
|
|
|
|
type PendingViewSession = {
|
|
sessionId: string | null;
|
|
startedAt: number;
|
|
};
|
|
|
|
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;
|
|
provider: LLMProvider;
|
|
selectedSession: ProjectSession | null;
|
|
currentSessionId: string | null;
|
|
setCurrentSessionId: (sessionId: string | null) => void;
|
|
setIsLoading: (loading: boolean) => void;
|
|
setCanAbortSession: (canAbort: boolean) => void;
|
|
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
|
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
|
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
|
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
|
streamTimerRef: MutableRefObject<number | null>;
|
|
accumulatedStreamRef: MutableRefObject<string>;
|
|
onSessionInactive?: (sessionId?: string | null) => void;
|
|
onSessionProcessing?: (sessionId?: string | null) => void;
|
|
onSessionNotProcessing?: (sessionId?: string | null) => void;
|
|
onReplaceTemporarySession?: (sessionId?: string | null) => void;
|
|
onNavigateToSession?: (sessionId: string, options?: SessionNavigationOptions) => void;
|
|
onWebSocketReconnect?: () => void;
|
|
sessionStore: SessionStore;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Hook */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export function useChatRealtimeHandlers({
|
|
latestMessage,
|
|
provider,
|
|
selectedSession,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
setIsLoading,
|
|
setCanAbortSession,
|
|
setClaudeStatus,
|
|
setTokenBudget,
|
|
setPendingPermissionRequests,
|
|
pendingViewSessionRef,
|
|
streamTimerRef,
|
|
accumulatedStreamRef,
|
|
onSessionInactive,
|
|
onSessionProcessing,
|
|
onSessionNotProcessing,
|
|
onReplaceTemporarySession,
|
|
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 activeViewSessionId =
|
|
selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* Legacy messages (no `kind` field) — handle and return */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
const msg = latestMessage as any;
|
|
|
|
if (!msg.kind) {
|
|
const messageType = String(msg.type || '');
|
|
|
|
switch (messageType) {
|
|
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 'session-status': {
|
|
const statusSessionId = msg.sessionId;
|
|
if (!statusSessionId) return;
|
|
|
|
const status = msg.status;
|
|
if (status) {
|
|
const statusInfo = {
|
|
text: status.text || 'Working...',
|
|
tokens: status.tokens || 0,
|
|
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
|
};
|
|
setClaudeStatus(statusInfo);
|
|
setIsLoading(true);
|
|
setCanAbortSession(statusInfo.can_interrupt);
|
|
return;
|
|
}
|
|
|
|
// Legacy isProcessing format from check-session-status
|
|
const isCurrentSession =
|
|
statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);
|
|
|
|
if (msg.isProcessing) {
|
|
onSessionProcessing?.(statusSessionId);
|
|
if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }
|
|
return;
|
|
}
|
|
onSessionInactive?.(statusSessionId);
|
|
onSessionNotProcessing?.(statusSessionId);
|
|
if (isCurrentSession) {
|
|
setIsLoading(false);
|
|
setCanAbortSession(false);
|
|
setClaudeStatus(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
default:
|
|
// Unknown legacy message type — ignore
|
|
return;
|
|
}
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- */
|
|
/* NormalizedMessage handling (has `kind` field) */
|
|
/* ---------------------------------------------------------------- */
|
|
|
|
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);
|
|
}
|
|
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;
|
|
|
|
if (!currentSessionId || currentSessionId.startsWith('new-session-')) {
|
|
console.log('Session created with ID:', newSessionId);
|
|
console.log('Existing session ID:', currentSessionId);
|
|
sessionStorage.setItem('pendingSessionId', newSessionId);
|
|
if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {
|
|
pendingViewSessionRef.current.sessionId = newSessionId;
|
|
}
|
|
setCurrentSessionId(newSessionId);
|
|
onReplaceTemporarySession?.(newSessionId);
|
|
setPendingPermissionRequests((prev) =>
|
|
prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),
|
|
);
|
|
}
|
|
onNavigateToSession?.(newSessionId);
|
|
break;
|
|
}
|
|
|
|
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 = '';
|
|
|
|
setIsLoading(false);
|
|
setCanAbortSession(false);
|
|
setClaudeStatus(null);
|
|
setPendingPermissionRequests([]);
|
|
onSessionInactive?.(sid);
|
|
onSessionNotProcessing?.(sid);
|
|
|
|
// Handle aborted case
|
|
if (msg.aborted) {
|
|
// Abort was requested — the complete event confirms it
|
|
// No special UI action needed beyond clearing loading state above
|
|
// The backend already sent any abort-related messages
|
|
break;
|
|
}
|
|
|
|
const actualSessionId =
|
|
typeof msg.actualSessionId === 'string' && msg.actualSessionId.trim().length > 0
|
|
? msg.actualSessionId
|
|
: null;
|
|
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
|
const completedSuccessfully = msg.exitCode === undefined || msg.exitCode === 0;
|
|
const isVisibleSession =
|
|
Boolean(
|
|
sid
|
|
&& (
|
|
sid === activeViewSessionId
|
|
|| sid === pendingSessionId
|
|
|| pendingViewSessionRef.current?.sessionId === sid
|
|
),
|
|
);
|
|
|
|
if (actualSessionId && sid && actualSessionId !== sid) {
|
|
sessionStore.replaceSessionId(sid, actualSessionId);
|
|
|
|
if (isVisibleSession) {
|
|
setCurrentSessionId(actualSessionId);
|
|
|
|
if (pendingViewSessionRef.current) {
|
|
const pendingSession = pendingViewSessionRef.current.sessionId;
|
|
if (!pendingSession || pendingSession === sid) {
|
|
pendingViewSessionRef.current.sessionId = actualSessionId;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (completedSuccessfully && pendingSessionId === sid) {
|
|
sessionStorage.removeItem('pendingSessionId');
|
|
}
|
|
|
|
if (isVisibleSession) {
|
|
onNavigateToSession?.(actualSessionId, { replace: true });
|
|
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Clear pending session
|
|
if (pendingSessionId && !currentSessionId && completedSuccessfully) {
|
|
const resolvedSessionId = actualSessionId || pendingSessionId;
|
|
setCurrentSessionId(resolvedSessionId);
|
|
if (actualSessionId) {
|
|
onNavigateToSession?.(resolvedSessionId, { replace: true });
|
|
}
|
|
sessionStorage.removeItem('pendingSessionId');
|
|
setTimeout(() => { void paletteOps.refreshProjects(); }, 500);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'error': {
|
|
setIsLoading(false);
|
|
setCanAbortSession(false);
|
|
setClaudeStatus(null);
|
|
onSessionInactive?.(sid);
|
|
onSessionNotProcessing?.(sid);
|
|
break;
|
|
}
|
|
|
|
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(),
|
|
}];
|
|
});
|
|
setIsLoading(true);
|
|
setCanAbortSession(true);
|
|
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
|
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) {
|
|
setClaudeStatus({
|
|
text: msg.text,
|
|
tokens: msg.tokens || 0,
|
|
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
|
});
|
|
setIsLoading(true);
|
|
setCanAbortSession(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;
|
|
}
|
|
}, [
|
|
latestMessage,
|
|
provider,
|
|
selectedSession,
|
|
currentSessionId,
|
|
setCurrentSessionId,
|
|
setIsLoading,
|
|
setCanAbortSession,
|
|
setClaudeStatus,
|
|
setTokenBudget,
|
|
setPendingPermissionRequests,
|
|
pendingViewSessionRef,
|
|
streamTimerRef,
|
|
accumulatedStreamRef,
|
|
onSessionInactive,
|
|
onSessionProcessing,
|
|
onSessionNotProcessing,
|
|
onReplaceTemporarySession,
|
|
onNavigateToSession,
|
|
onWebSocketReconnect,
|
|
sessionStore,
|
|
paletteOps,
|
|
]);
|
|
}
|