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

@@ -5,32 +5,12 @@ export const CLAUDE_SETTINGS_KEY = 'claude-settings';
export const safeLocalStorage = {
setItem: (key: string, value: string) => {
try {
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.length > 50) {
const truncated = parsed.slice(-50);
value = JSON.stringify(truncated);
}
} catch (parseError) {
console.warn('Could not parse chat messages for truncation:', parseError);
}
}
localStorage.setItem(key, value);
} catch (error: any) {
if (error?.name === 'QuotaExceededError') {
console.warn('localStorage quota exceeded, clearing old data');
const keys = Object.keys(localStorage);
const chatKeys = keys.filter((k) => k.startsWith('chat_messages_')).sort();
if (chatKeys.length > 3) {
chatKeys.slice(0, chatKeys.length - 3).forEach((k) => {
localStorage.removeItem(k);
});
}
const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));
draftKeys.forEach((k) => {
localStorage.removeItem(k);
@@ -40,17 +20,6 @@ export const safeLocalStorage = {
localStorage.setItem(key, value);
} catch (retryError) {
console.error('Failed to save to localStorage even after cleanup:', retryError);
if (key.startsWith('chat_messages_') && typeof value === 'string') {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed) && parsed.length > 10) {
const minimal = parsed.slice(-10);
localStorage.setItem(key, JSON.stringify(minimal));
}
} catch (finalError) {
console.error('Final save attempt failed:', finalError);
}
}
}
} else {
console.error('localStorage error:', error);

View File

@@ -1,6 +1,3 @@
import type { ChatMessage } from '../types/types';
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
export interface DiffLine {
type: 'added' | 'removed';
content: string;
@@ -9,80 +6,6 @@ export interface DiffLine {
export type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];
type CursorBlob = {
id?: string;
sequence?: number;
rowid?: number;
content?: any;
};
const asArray = <T>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
const normalizeToolInput = (value: unknown): string => {
if (value === null || value === undefined || value === '') {
return '';
}
if (typeof value === 'string') {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
};
const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
/<user_info>[\s\S]*?<\/user_info>/gi,
/<agent_skills>[\s\S]*?<\/agent_skills>/gi,
/<available_skills>[\s\S]*?<\/available_skills>/gi,
/<environment_context>[\s\S]*?<\/environment_context>/gi,
/<environment_info>[\s\S]*?<\/environment_info>/gi,
];
const extractCursorUserQuery = (rawText: string): string => {
const userQueryMatches = [...rawText.matchAll(/<user_query>([\s\S]*?)<\/user_query>/gi)];
if (userQueryMatches.length === 0) {
return '';
}
return userQueryMatches
.map((match) => (match[1] || '').trim())
.filter(Boolean)
.join('\n')
.trim();
};
const sanitizeCursorUserMessageText = (rawText: string): string => {
const decodedText = decodeHtmlEntities(rawText || '').trim();
if (!decodedText) {
return '';
}
// Cursor stores user-visible text inside <user_query> and prepends hidden context blocks
// (<user_info>, <agent_skills>, etc). We only render the actual query in chat history.
const extractedUserQuery = extractCursorUserQuery(decodedText);
if (extractedUserQuery) {
return extractedUserQuery;
}
let sanitizedText = decodedText;
CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
sanitizedText = sanitizedText.replace(pattern, '');
});
return sanitizedText.trim();
};
const toAbsolutePath = (projectPath: string, filePath?: string) => {
if (!filePath) {
return filePath;
}
return filePath.startsWith('/') ? filePath : `${projectPath}/${filePath}`;
};
export const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {
const oldLines = oldStr.split('\n');
const newLines = newStr.split('\n');
@@ -162,434 +85,3 @@ export const createCachedDiffCalculator = (): DiffCalculator => {
return calculated;
};
};
export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: string): ChatMessage[] => {
const converted: ChatMessage[] = [];
const toolUseMap: Record<string, ChatMessage> = {};
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx += 1) {
const blob = blobs[blobIdx];
const content = blob.content;
let text = '';
let role: ChatMessage['type'] = 'assistant';
let reasoningText: string | null = null;
try {
if (content?.role && content?.content) {
if (content.role === 'system') {
continue;
}
if (content.role === 'tool') {
const toolItems = asArray<any>(content.content);
for (const item of toolItems) {
if (item?.type !== 'tool-result') {
continue;
}
const toolName = item.toolName === 'ApplyPatch' ? 'Edit' : item.toolName || 'Unknown Tool';
const toolCallId = item.toolCallId || content.id;
const result = item.result || '';
if (toolCallId && toolUseMap[toolCallId]) {
toolUseMap[toolCallId].toolResult = {
content: result,
isError: false,
};
} else {
converted.push({
type: 'assistant',
content: '',
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
isToolUse: true,
toolName,
toolId: toolCallId,
toolInput: normalizeToolInput(null),
toolResult: {
content: result,
isError: false,
},
});
}
}
continue;
}
role = content.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.content)) {
const textParts: string[] = [];
for (const part of content.content) {
if (part?.type === 'text' && part?.text) {
textParts.push(decodeHtmlEntities(part.text));
continue;
}
if (part?.type === 'reasoning' && part?.text) {
reasoningText = decodeHtmlEntities(part.text);
continue;
}
if (part?.type === 'tool-call' || part?.type === 'tool_use') {
if (textParts.length > 0 || reasoningText) {
converted.push({
type: role,
content: textParts.join('\n'),
reasoning: reasoningText ?? undefined,
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
});
textParts.length = 0;
reasoningText = null;
}
const toolNameRaw = part.toolName || part.name || 'Unknown Tool';
const toolName = toolNameRaw === 'ApplyPatch' ? 'Edit' : toolNameRaw;
const toolId = part.toolCallId || part.id || `tool_${blobIdx}`;
let toolInput = part.args || part.input;
if (toolName === 'Edit' && part.args) {
if (part.args.patch) {
const patchLines = String(part.args.patch).split('\n');
const oldLines: string[] = [];
const newLines: string[] = [];
let inPatch = false;
patchLines.forEach((line) => {
if (line.startsWith('@@')) {
inPatch = true;
return;
}
if (!inPatch) {
return;
}
if (line.startsWith('-')) {
oldLines.push(line.slice(1));
} else if (line.startsWith('+')) {
newLines.push(line.slice(1));
} else if (line.startsWith(' ')) {
oldLines.push(line.slice(1));
newLines.push(line.slice(1));
}
});
toolInput = {
file_path: toAbsolutePath(projectPath, part.args.file_path),
old_string: oldLines.join('\n') || part.args.patch,
new_string: newLines.join('\n') || part.args.patch,
};
} else {
toolInput = part.args;
}
} else if (toolName === 'Read' && part.args) {
const filePath = part.args.path || part.args.file_path;
toolInput = {
file_path: toAbsolutePath(projectPath, filePath),
};
} else if (toolName === 'Write' && part.args) {
const filePath = part.args.path || part.args.file_path;
toolInput = {
file_path: toAbsolutePath(projectPath, filePath),
content: part.args.contents || part.args.content,
};
}
const toolMessage: ChatMessage = {
type: 'assistant',
content: '',
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
isToolUse: true,
toolName,
toolId,
toolInput: normalizeToolInput(toolInput),
toolResult: null,
};
converted.push(toolMessage);
toolUseMap[toolId] = toolMessage;
continue;
}
if (typeof part === 'string') {
textParts.push(part);
}
}
if (textParts.length > 0) {
text = textParts.join('\n');
if (reasoningText && !text) {
converted.push({
type: role,
content: '',
reasoning: reasoningText,
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
});
text = '';
}
} else {
text = '';
}
} else if (typeof content.content === 'string') {
text = content.content;
}
} else if (content?.message?.role && content?.message?.content) {
if (content.message.role === 'system') {
continue;
}
role = content.message.role === 'user' ? 'user' : 'assistant';
if (Array.isArray(content.message.content)) {
text = content.message.content
.map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
.filter(Boolean)
.join('\n');
} else if (typeof content.message.content === 'string') {
text = content.message.content;
}
}
} catch (error) {
console.log('Error parsing blob content:', error);
}
if (role === 'user') {
text = sanitizeCursorUserMessageText(text);
}
if (text && text.trim()) {
const message: ChatMessage = {
type: role,
content: text,
timestamp: new Date(Date.now() + blobIdx * 1000),
blobId: blob.id,
sequence: blob.sequence,
rowid: blob.rowid,
};
if (reasoningText) {
message.reasoning = reasoningText;
}
converted.push(message);
}
}
converted.sort((messageA, messageB) => {
if (messageA.sequence !== undefined && messageB.sequence !== undefined) {
return Number(messageA.sequence) - Number(messageB.sequence);
}
if (messageA.rowid !== undefined && messageB.rowid !== undefined) {
return Number(messageA.rowid) - Number(messageB.rowid);
}
return new Date(messageA.timestamp).getTime() - new Date(messageB.timestamp).getTime();
});
return converted;
};
export const convertSessionMessages = (rawMessages: any[]): ChatMessage[] => {
const converted: ChatMessage[] = [];
const toolResults = new Map<
string,
{ content: unknown; isError: boolean; timestamp: Date; toolUseResult: unknown; subagentTools?: unknown[] }
>();
rawMessages.forEach((message) => {
if (message.message?.role === 'user' && Array.isArray(message.message?.content)) {
message.message.content.forEach((part: any) => {
if (part.type !== 'tool_result') {
return;
}
toolResults.set(part.tool_use_id, {
content: part.content,
isError: Boolean(part.is_error),
timestamp: new Date(message.timestamp || Date.now()),
toolUseResult: message.toolUseResult || null,
subagentTools: message.subagentTools,
});
});
}
});
rawMessages.forEach((message) => {
if (message.message?.role === 'user' && message.message?.content) {
let content = '';
if (Array.isArray(message.message.content)) {
const textParts: string[] = [];
message.message.content.forEach((part: any) => {
if (part.type === 'text') {
textParts.push(decodeHtmlEntities(part.text));
}
});
content = textParts.join('\n');
} else if (typeof message.message.content === 'string') {
content = decodeHtmlEntities(message.message.content);
} else {
content = decodeHtmlEntities(String(message.message.content));
}
const shouldSkip =
!content ||
content.startsWith('<command-name>') ||
content.startsWith('<command-message>') ||
content.startsWith('<command-args>') ||
content.startsWith('<local-command-stdout>') ||
content.startsWith('<system-reminder>') ||
content.startsWith('Caveat:') ||
content.startsWith('This session is being continued from a previous') ||
content.startsWith('[Request interrupted');
if (!shouldSkip) {
// Parse <task-notification> blocks into compact system messages
const taskNotifRegex = /<task-notification>\s*<task-id>[^<]*<\/task-id>\s*<output-file>[^<]*<\/output-file>\s*<status>([^<]*)<\/status>\s*<summary>([^<]*)<\/summary>\s*<\/task-notification>/g;
const taskNotifMatch = taskNotifRegex.exec(content);
if (taskNotifMatch) {
const status = taskNotifMatch[1]?.trim() || 'completed';
const summary = taskNotifMatch[2]?.trim() || 'Background task finished';
converted.push({
type: 'assistant',
content: summary,
timestamp: message.timestamp || new Date().toISOString(),
isTaskNotification: true,
taskStatus: status,
});
} else {
converted.push({
type: 'user',
content: unescapeWithMathProtection(content),
timestamp: message.timestamp || new Date().toISOString(),
});
}
}
return;
}
if (message.type === 'thinking' && message.message?.content) {
converted.push({
type: 'assistant',
content: unescapeWithMathProtection(message.message.content),
timestamp: message.timestamp || new Date().toISOString(),
isThinking: true,
});
return;
}
if (message.type === 'tool_use' && message.toolName) {
converted.push({
type: 'assistant',
content: '',
timestamp: message.timestamp || new Date().toISOString(),
isToolUse: true,
toolName: message.toolName,
toolInput: normalizeToolInput(message.toolInput),
toolCallId: message.toolCallId,
});
return;
}
if (message.type === 'tool_result') {
for (let index = converted.length - 1; index >= 0; index -= 1) {
const convertedMessage = converted[index];
if (!convertedMessage.isToolUse || convertedMessage.toolResult) {
continue;
}
if (!message.toolCallId || convertedMessage.toolCallId === message.toolCallId) {
convertedMessage.toolResult = {
content: message.output || '',
isError: false,
};
break;
}
}
return;
}
if (message.message?.role === 'assistant' && message.message?.content) {
if (Array.isArray(message.message.content)) {
message.message.content.forEach((part: any) => {
if (part.type === 'text') {
let text = part.text;
if (typeof text === 'string') {
text = unescapeWithMathProtection(text);
}
converted.push({
type: 'assistant',
content: text,
timestamp: message.timestamp || new Date().toISOString(),
});
return;
}
if (part.type === 'tool_use') {
const toolResult = toolResults.get(part.id);
const isSubagentContainer = part.name === 'Task';
// Build child tools from server-provided subagentTools data
const childTools: import('../types/types').SubagentChildTool[] = [];
if (isSubagentContainer && toolResult?.subagentTools && Array.isArray(toolResult.subagentTools)) {
for (const tool of toolResult.subagentTools as any[]) {
childTools.push({
toolId: tool.toolId,
toolName: tool.toolName,
toolInput: tool.toolInput,
toolResult: tool.toolResult || null,
timestamp: new Date(tool.timestamp || Date.now()),
});
}
}
converted.push({
type: 'assistant',
content: '',
timestamp: message.timestamp || new Date().toISOString(),
isToolUse: true,
toolName: part.name,
toolInput: normalizeToolInput(part.input),
toolId: part.id,
toolResult: toolResult
? {
content:
typeof toolResult.content === 'string'
? toolResult.content
: JSON.stringify(toolResult.content),
isError: toolResult.isError,
toolUseResult: toolResult.toolUseResult,
}
: null,
toolError: toolResult?.isError || false,
toolResultTimestamp: toolResult?.timestamp || new Date(),
isSubagentContainer,
subagentState: isSubagentContainer
? {
childTools,
currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,
isComplete: Boolean(toolResult),
}
: undefined,
});
}
});
return;
}
if (typeof message.message.content === 'string') {
converted.push({
type: 'assistant',
content: unescapeWithMathProtection(message.message.content),
timestamp: message.timestamp || new Date().toISOString(),
});
}
}
});
return converted;
};