refactor(chat): split monolithic chat interface into typed modules and hooks

Replace the legacy monolithic ChatInterface.jsx implementation with a modular TypeScript architecture centered around a small orchestration component (ChatInterface.tsx).

Core architecture changes:
- Remove src/components/ChatInterface.jsx and add src/components/ChatInterface.tsx as a thin coordinator that wires provider state, session state, realtime WebSocket handlers, and composer behavior via dedicated hooks.
- Update src/components/MainContent.tsx to use typed ChatInterface directly (remove AnyChatInterface cast).

State ownership and hook extraction:
- Add src/hooks/chat/useChatProviderState.ts to centralize provider/model/permission-mode state, provider/session synchronization, cursor model bootstrap from backend config, and pending permission request scoping.
- Add src/hooks/chat/useChatSessionState.ts to own chat/session lifecycle state: session loading, cursor/claude/codex history loading, pagination, scroll restoration, visible-window slicing, token budget loading, persisted chat hydration, and processing-state restoration.
- Add src/hooks/chat/useChatRealtimeHandlers.ts to isolate WebSocket event processing for Claude/Cursor/Codex, including session filtering, streaming chunk buffering, session-created/pending-session transitions, permission request queueing/cancellation, completion/error handling, and session status updates.
- Add src/hooks/chat/useChatComposerState.ts to own composer-local state and interactions: input/draft persistence, textarea sizing and keyboard behavior, slash command execution, file mentions, image attachment/drop/paste workflow, submit/abort flows, permission decision responses, and transcript insertion.

UI modularization under src/components/chat:
- Add view/ChatMessagesPane.tsx for message list rendering, loading/empty states, pagination affordances, and thinking indicator.
- Add view/ChatComposer.tsx for composer shell layout and input area composition.
- Add view/ChatInputControls.tsx for mode toggles, token display, command launcher, clear-input, and scroll-to-bottom controls.
- Add view/PermissionRequestsBanner.tsx for explicit tool-permission review actions (allow once / allow & remember / deny).
- Add view/ProviderSelectionEmptyState.tsx for provider and model selection UX plus task starter integration.
- Add messages/MessageComponent.tsx and markdown/Markdown.tsx to isolate message rendering concerns, markdown/code rendering, and rich tool-output presentation.
- Add input/ImageAttachment.tsx for attachment previews/removal/progress/error overlay rendering.

Shared chat typing and utilities:
- Add src/components/chat/types.ts with shared types for providers, permission mode, message/tool payloads, pending permission requests, and ChatInterfaceProps.
- Add src/components/chat/utils/chatFormatting.ts for html decoding, code fence normalization, regex escaping, math-safe unescaping, and usage-limit text formatting.
- Add src/components/chat/utils/chatPermissions.ts for permission rule derivation, suggestion generation, and grant flow.
- Add src/components/chat/utils/chatStorage.ts for resilient localStorage access, quota handling, and normalized Claude settings retrieval.
- Add src/components/chat/utils/messageTransforms.ts for session message normalization (Claude/Codex/Cursor) and cached diff computation utilities.

Command/file input ergonomics:
- Add src/hooks/chat/useSlashCommands.ts for slash command fetching, usage-based ranking, fuzzy filtering, keyboard navigation, and command history persistence.
- Add src/hooks/chat/useFileMentions.tsx for project file flattening, @mention suggestions, mention highlighting, and keyboard/file insertion behavior.

TypeScript support additions:
- Add src/types/react-syntax-highlighter.d.ts module declarations to type-check markdown code highlighting imports.

Behavioral intent:
- Preserve existing chat behavior and provider flows while improving readability, separation of concerns, and future refactorability.
- Move state closer to the components/hooks that own it, reducing cross-cutting concerns in the top-level chat component.
This commit is contained in:
Haileyesus
2026-02-08 17:12:16 +03:00
parent cdc03e754f
commit 1d8b70f614
23 changed files with 6912 additions and 5698 deletions

View File

@@ -0,0 +1,86 @@
export function decodeHtmlEntities(text: string) {
if (!text) return text;
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
}
export function normalizeInlineCodeFences(text: string) {
if (!text || typeof text !== 'string') return text;
try {
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
} catch {
return text;
}
}
export function unescapeWithMathProtection(text: string) {
if (!text || typeof text !== 'string') return text;
const mathBlocks: string[] = [];
const placeholderPrefix = '__MATH_BLOCK_';
const placeholderSuffix = '__';
let processedText = text.replace(/\$\$([\s\S]*?)\$\$|\$([^\$\n]+?)\$/g, (match) => {
const index = mathBlocks.length;
mathBlocks.push(match);
return `${placeholderPrefix}${index}${placeholderSuffix}`;
});
processedText = processedText.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r');
processedText = processedText.replace(
new RegExp(`${placeholderPrefix}(\\d+)${placeholderSuffix}`, 'g'),
(match, index) => {
return mathBlocks[parseInt(index, 10)];
},
);
return processedText;
}
export function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function formatUsageLimitText(text: string) {
try {
if (typeof text !== 'string') return text;
return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => {
let timestampMs = parseInt(ts, 10);
if (!Number.isFinite(timestampMs)) return match;
if (timestampMs < 1e12) timestampMs *= 1000;
const reset = new Date(timestampMs);
const timeStr = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(reset);
const offsetMinutesLocal = -reset.getTimezoneOffset();
const sign = offsetMinutesLocal >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutesLocal);
const offH = Math.floor(abs / 60);
const offM = abs % 60;
const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;
const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
const cityRaw = tzId.split('/').pop() || '';
const city = cityRaw
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (char) => char.toUpperCase());
const tzHuman = city ? `${gmt} (${city})` : gmt;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;
return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;
});
} catch {
return text;
}
}

View File

@@ -0,0 +1,64 @@
import { safeJsonParse } from '../../../lib/utils.js';
import type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult } from '../types';
import { CLAUDE_SETTINGS_KEY, getClaudeSettings, safeLocalStorage } from './chatStorage';
export function buildClaudeToolPermissionEntry(toolName?: string, toolInput?: unknown) {
if (!toolName) return null;
if (toolName !== 'Bash') return toolName;
const parsed = safeJsonParse(toolInput);
const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';
if (!command) return toolName;
const tokens = command.split(/\s+/);
if (tokens.length === 0) return toolName;
if (tokens[0] === 'git' && tokens[1]) {
return `Bash(${tokens[0]} ${tokens[1]}:*)`;
}
return `Bash(${tokens[0]}:*)`;
}
export function formatToolInputForDisplay(input: unknown) {
if (input === undefined || input === null) return '';
if (typeof input === 'string') return input;
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
export function getClaudePermissionSuggestion(
message: ChatMessage | null | undefined,
provider: string,
): ClaudePermissionSuggestion | null {
if (provider !== 'claude') return null;
if (!message?.toolResult?.isError) return null;
const toolName = message?.toolName;
const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);
if (!entry) return null;
const settings = getClaudeSettings();
const isAllowed = settings.allowedTools.includes(entry);
return { toolName: toolName || 'UnknownTool', entry, isAllowed };
}
export function grantClaudeToolPermission(entry: string | null): PermissionGrantResult {
if (!entry) return { success: false };
const settings = getClaudeSettings();
const alreadyAllowed = settings.allowedTools.includes(entry);
const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];
const nextDisallowed = settings.disallowedTools.filter((tool) => tool !== entry);
const updatedSettings = {
...settings,
allowedTools: nextAllowed,
disallowedTools: nextDisallowed,
lastUpdated: new Date().toISOString(),
};
safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));
return { success: true, alreadyAllowed, updatedSettings };
}

View File

@@ -0,0 +1,105 @@
import type { ClaudeSettings } from '../types';
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);
});
try {
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);
}
}
},
getItem: (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch (error) {
console.error('localStorage getItem error:', error);
return null;
}
},
removeItem: (key: string) => {
try {
localStorage.removeItem(key);
} catch (error) {
console.error('localStorage removeItem error:', error);
}
},
};
export function getClaudeSettings(): ClaudeSettings {
const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);
if (!raw) {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name',
};
}
try {
const parsed = JSON.parse(raw);
return {
...parsed,
allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],
disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],
skipPermissions: Boolean(parsed.skipPermissions),
projectSortOrder: parsed.projectSortOrder || 'name',
};
} catch {
return {
allowedTools: [],
disallowedTools: [],
skipPermissions: false,
projectSortOrder: 'name',
};
}
}

View File

@@ -0,0 +1,473 @@
import type { ChatMessage } from '../types';
import { decodeHtmlEntities, unescapeWithMathProtection } from './chatFormatting';
export interface DiffLine {
type: 'added' | 'removed';
content: string;
lineNum: number;
}
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 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');
const diffLines: DiffLine[] = [];
let oldIndex = 0;
let newIndex = 0;
while (oldIndex < oldLines.length || newIndex < newLines.length) {
const oldLine = oldLines[oldIndex];
const newLine = newLines[newIndex];
if (oldIndex >= oldLines.length) {
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
newIndex += 1;
continue;
}
if (newIndex >= newLines.length) {
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
oldIndex += 1;
continue;
}
if (oldLine === newLine) {
oldIndex += 1;
newIndex += 1;
continue;
}
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
oldIndex += 1;
newIndex += 1;
}
return diffLines;
};
export const createCachedDiffCalculator = (): DiffCalculator => {
const cache = new Map<string, DiffLine[]>();
return (oldStr: string, newStr: string) => {
const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`;
const cached = cache.get(key);
if (cached) {
return cached;
}
const calculated = calculateDiff(oldStr, newStr);
cache.set(key, calculated);
if (cache.size > 100) {
const firstKey = cache.keys().next().value;
if (firstKey) {
cache.delete(firstKey);
}
}
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: 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: toolInput ? JSON.stringify(toolInput) : null,
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 (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 }
>();
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,
});
});
}
});
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) {
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: 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);
converted.push({
type: 'assistant',
content: '',
timestamp: message.timestamp || new Date().toISOString(),
isToolUse: true,
toolName: part.name,
toolInput: JSON.stringify(part.input),
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(),
});
}
});
return;
}
if (typeof message.message.content === 'string') {
converted.push({
type: 'assistant',
content: unescapeWithMathProtection(message.message.content),
timestamp: message.timestamp || new Date().toISOString(),
});
}
}
});
return converted;
};