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

@@ -52,8 +52,9 @@ interface UseChatComposerStateArgs {
onShowSettings?: () => void;
pendingViewSessionRef: { current: PendingViewSession | null };
scrollToBottom: () => void;
setChatMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setSessionMessages?: Dispatch<SetStateAction<any[]>>;
addMessage: (msg: ChatMessage) => void;
clearMessages: () => void;
rewindMessages: (count: number) => void;
setIsLoading: (loading: boolean) => void;
setCanAbortSession: (canAbort: boolean) => void;
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
@@ -123,8 +124,9 @@ export function useChatComposerState({
onShowSettings,
pendingViewSessionRef,
scrollToBottom,
setChatMessages,
setSessionMessages,
addMessage,
clearMessages,
rewindMessages,
setIsLoading,
setCanAbortSession,
setClaudeStatus,
@@ -155,69 +157,50 @@ export function useChatComposerState({
const { action, data } = result;
switch (action) {
case 'clear':
setChatMessages([]);
setSessionMessages?.([]);
clearMessages();
break;
case 'help':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: data.content,
timestamp: Date.now(),
},
]);
addMessage({
type: 'assistant',
content: data.content,
timestamp: Date.now(),
});
break;
case 'model':
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
timestamp: Date.now(),
},
]);
addMessage({
type: 'assistant',
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
timestamp: Date.now(),
});
break;
case 'cost': {
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
setChatMessages((previous) => [
...previous,
{ type: 'assistant', content: costMessage, timestamp: Date.now() },
]);
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
break;
}
case 'status': {
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
setChatMessages((previous) => [
...previous,
{ type: 'assistant', content: statusMessage, timestamp: Date.now() },
]);
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
break;
}
case 'memory':
if (data.error) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `⚠️ ${data.message}`,
timestamp: Date.now(),
},
]);
addMessage({
type: 'assistant',
content: `Warning: ${data.message}`,
timestamp: Date.now(),
});
} else {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
timestamp: Date.now(),
},
]);
addMessage({
type: 'assistant',
content: `${data.message}\n\nPath: \`${data.path}\``,
timestamp: Date.now(),
});
if (data.exists && onFileOpen) {
onFileOpen(data.path);
}
@@ -230,24 +213,18 @@ export function useChatComposerState({
case 'rewind':
if (data.error) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `⚠️ ${data.message}`,
timestamp: Date.now(),
},
]);
addMessage({
type: 'assistant',
content: `Warning: ${data.message}`,
timestamp: Date.now(),
});
} else {
setChatMessages((previous) => previous.slice(0, -data.steps * 2));
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `${data.message}`,
timestamp: Date.now(),
},
]);
rewindMessages(data.steps * 2);
addMessage({
type: 'assistant',
content: `Rewound ${data.steps} step(s). ${data.message}`,
timestamp: Date.now(),
});
}
break;
@@ -255,7 +232,7 @@ export function useChatComposerState({
console.warn('Unknown built-in command action:', action);
}
},
[onFileOpen, onShowSettings, setChatMessages, setSessionMessages],
[onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages],
);
const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {
@@ -266,14 +243,11 @@ export function useChatComposerState({
'This command contains bash commands that will be executed. Do you want to proceed?',
);
if (!confirmed) {
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: '❌ Command execution cancelled',
timestamp: Date.now(),
},
]);
addMessage({
type: 'assistant',
content: 'Command execution cancelled',
timestamp: Date.now(),
});
return;
}
}
@@ -288,7 +262,7 @@ export function useChatComposerState({
handleSubmitRef.current(createFakeSubmitEvent());
}
}, 0);
}, [setChatMessages]);
}, [addMessage]);
const executeCommand = useCallback(
async (command: SlashCommand, rawInput?: string) => {
@@ -346,14 +320,11 @@ export function useChatComposerState({
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Error executing command:', error);
setChatMessages((previous) => [
...previous,
{
type: 'assistant',
content: `Error executing command: ${message}`,
timestamp: Date.now(),
},
]);
addMessage({
type: 'assistant',
content: `Error executing command: ${message}`,
timestamp: Date.now(),
});
}
},
[
@@ -367,7 +338,7 @@ export function useChatComposerState({
input,
provider,
selectedProject,
setChatMessages,
addMessage,
tokenBudget,
],
);
@@ -547,18 +518,19 @@ export function useChatComposerState({
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('Image upload failed:', error);
setChatMessages((previous) => [
...previous,
{
type: 'error',
content: `Failed to upload images: ${message}`,
timestamp: new Date(),
},
]);
addMessage({
type: 'error',
content: `Failed to upload images: ${message}`,
timestamp: new Date(),
});
return;
}
}
const effectiveSessionId =
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
const userMessage: ChatMessage = {
type: 'user',
content: currentInput,
@@ -566,7 +538,7 @@ export function useChatComposerState({
timestamp: new Date(),
};
setChatMessages((previous) => [...previous, userMessage]);
addMessage(userMessage);
setIsLoading(true); // Processing banner starts
setCanAbortSession(true);
setClaudeStatus({
@@ -578,10 +550,6 @@ export function useChatComposerState({
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 100);
const effectiveSessionId =
currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
if (!effectiveSessionId && !selectedSession?.id) {
if (typeof window !== 'undefined') {
// Reset stale pending IDs from previous interrupted runs before creating a new one.
@@ -723,7 +691,7 @@ export function useChatComposerState({
selectedProject,
sendMessage,
setCanAbortSession,
setChatMessages,
addMessage,
setClaudeStatus,
setIsLoading,
setIsUserScrolledUp,