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

@@ -54,25 +54,18 @@ export const api = {
projects: () => authenticatedFetch('/api/projects'),
sessions: (projectName, limit = 5, offset = 0) =>
authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),
sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => {
// Unified endpoint — all providers through one URL
unifiedSessionMessages: (sessionId, provider = 'claude', { projectName = '', projectPath = '', limit = null, offset = 0 } = {}) => {
const params = new URLSearchParams();
params.append('provider', provider);
if (projectName) params.append('projectName', projectName);
if (projectPath) params.append('projectPath', projectPath);
if (limit !== null) {
params.append('limit', limit);
params.append('offset', offset);
params.append('limit', String(limit));
params.append('offset', String(offset));
}
const queryString = params.toString();
let url;
if (provider === 'codex') {
url = `/api/codex/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
} else if (provider === 'cursor') {
url = `/api/cursor/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
} else if (provider === 'gemini') {
url = `/api/gemini/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
} else {
url = `/api/projects/${projectName}/sessions/${sessionId}/messages${queryString ? `?${queryString}` : ''}`;
}
return authenticatedFetch(url);
return authenticatedFetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`);
},
renameProject: (projectName, displayName) =>
authenticatedFetch(`/api/projects/${projectName}/rename`, {