feat: unified message architecture with provider adapters and session store (#558)

- Add provider adapter layer (server/providers/) with registry pattern
    - Claude, Cursor, Codex, Gemini adapters normalize native formats to NormalizedMessage
    - Shared types.js defines ProviderAdapter interface and message kinds
    - Registry enables polymorphic provider lookup

  - Add unified REST endpoint: GET /api/sessions/:id/messages?provider=...
    - Replaces four provider-specific message endpoints with one
    - Delegates to provider adapters via registry

  - Add frontend session-keyed store (useSessionStore)
    - Per-session Map with serverMessages/realtimeMessages/merged
    - Dedup by ID, stale threshold for re-fetch, background session accumulation
    - No localStorage for messages — backend JSONL is source of truth

  - Add normalizedToChatMessages converter (useChatMessages)
    - Converts NormalizedMessage[] to existing ChatMessage[] UI format

  - Wire unified store into ChatInterface, useChatSessionState, useChatRealtimeHandlers
    - Session switch uses store cache for instant render
    - Background WebSocket messages routed to correct session slot
This commit is contained in:
Simos Mikelatos
2026-03-19 14:45:06 +01:00
committed by GitHub
parent 612390db53
commit a4632dc4ce
26 changed files with 2664 additions and 2549 deletions

View File

@@ -3,10 +3,12 @@ import { useTranslation } from 'react-i18next';
import { useTasksSettings } from '../../../contexts/TasksSettingsContext';
import { QuickSettingsPanel } from '../../quick-settings-panel';
import type { ChatInterfaceProps, Provider } from '../types/types';
import type { SessionProvider } from '../../../types/app';
import { useChatProviderState } from '../hooks/useChatProviderState';
import { useChatSessionState } from '../hooks/useChatSessionState';
import { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';
import { useChatComposerState } from '../hooks/useChatComposerState';
import { useSessionStore } from '../../../stores/useSessionStore';
import ChatMessagesPane from './subcomponents/ChatMessagesPane';
import ChatComposer from './subcomponents/ChatComposer';
@@ -43,8 +45,10 @@ function ChatInterface({
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { t } = useTranslation('chat');
const sessionStore = useSessionStore();
const streamBufferRef = useRef('');
const streamTimerRef = useRef<number | null>(null);
const accumulatedStreamRef = useRef('');
const pendingViewSessionRef = useRef<PendingViewSession | null>(null);
const resetStreamingState = useCallback(() => {
@@ -53,6 +57,7 @@ function ChatInterface({
streamTimerRef.current = null;
}
streamBufferRef.current = '';
accumulatedStreamRef.current = '';
}, []);
const {
@@ -76,18 +81,17 @@ function ChatInterface({
const {
chatMessages,
setChatMessages,
addMessage,
clearMessages,
rewindMessages,
isLoading,
setIsLoading,
currentSessionId,
setCurrentSessionId,
sessionMessages,
setSessionMessages,
isLoadingSessionMessages,
isLoadingMoreMessages,
hasMoreMessages,
totalMessages,
setIsSystemSessionChange,
canAbortSession,
setCanAbortSession,
isUserScrolledUp,
@@ -109,7 +113,6 @@ function ChatInterface({
scrollToBottom,
scrollToBottomAndReset,
handleScroll,
loadSessionMessages,
} = useChatSessionState({
selectedProject,
selectedSession,
@@ -120,6 +123,7 @@ function ChatInterface({
processingSessions,
resetStreamingState,
pendingViewSessionRef,
sessionStore,
});
const {
@@ -189,8 +193,9 @@ function ChatInterface({
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
setChatMessages,
setSessionMessages,
addMessage,
clearMessages,
rewindMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
@@ -198,22 +203,19 @@ function ChatInterface({
setPendingPermissionRequests,
});
// On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
// streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
// Also reset isLoading — if the server restarted or the session died mid-stream, the client
// would be stuck in "Processing..." forever without this reset.
// On WebSocket reconnect, re-fetch the current session's messages from the server
// so missed streaming events are shown. Also reset isLoading.
const handleWebSocketReconnect = useCallback(async () => {
if (!selectedProject || !selectedSession) return;
const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
if (messages && messages.length > 0) {
setChatMessages(messages);
}
// Reset loading state — if the session is still active, new WebSocket messages will
// set it back to true. If it died, this clears the permanent frozen state.
const providerVal = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';
await sessionStore.refreshFromServer(selectedSession.id, {
provider: (selectedSession.__provider || providerVal) as SessionProvider,
projectName: selectedProject.name,
projectPath: selectedProject.fullPath || selectedProject.path || '',
});
setIsLoading(false);
setCanAbortSession(false);
}, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
}, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);
useChatRealtimeHandlers({
latestMessage,
@@ -222,22 +224,22 @@ function ChatInterface({
selectedSession,
currentSessionId,
setCurrentSessionId,
setChatMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
setTokenBudget,
setIsSystemSessionChange,
setPendingPermissionRequests,
pendingViewSessionRef,
streamBufferRef,
streamTimerRef,
accumulatedStreamRef,
onSessionInactive,
onSessionProcessing,
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
onWebSocketReconnect: handleWebSocketReconnect,
sessionStore,
});
useEffect(() => {
@@ -319,7 +321,7 @@ function ChatInterface({
isLoadingMoreMessages={isLoadingMoreMessages}
hasMoreMessages={hasMoreMessages}
totalMessages={totalMessages}
sessionMessagesCount={sessionMessages.length}
sessionMessagesCount={chatMessages.length}
visibleMessageCount={visibleMessageCount}
visibleMessages={visibleMessages}
loadEarlierMessages={loadEarlierMessages}