Files
CodePilot/src/components/chat/ChatView.tsx
T
2026-02-12 01:31:48 +08:00

562 lines
22 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent, FileAttachment } from '@/types';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { usePanel } from '@/hooks/usePanel';
interface ToolUseInfo {
id: string;
name: string;
input: unknown;
}
interface ToolResultInfo {
tool_use_id: string;
content: string;
}
interface ChatViewProps {
sessionId: string;
initialMessages?: Message[];
modelName?: string;
initialMode?: string;
}
export function ChatView({ sessionId, initialMessages = [], modelName, initialMode }: ChatViewProps) {
const { setStreamingSessionId, workingDirectory, setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [streamingContent, setStreamingContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [toolUses, setToolUses] = useState<ToolUseInfo[]>([]);
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
const [statusText, setStatusText] = useState<string | undefined>();
const [mode, setMode] = useState(initialMode || 'code');
const [currentModel, setCurrentModel] = useState(modelName || 'sonnet');
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
const [streamingToolOutput, setStreamingToolOutput] = useState('');
const toolTimeoutRef = useRef<{ toolName: string; elapsedSeconds: number } | null>(null);
const handleModeChange = useCallback((newMode: string) => {
setMode(newMode);
// Persist mode to database and notify chat list
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode }),
}).then(() => {
window.dispatchEvent(new CustomEvent('session-updated'));
}).catch(() => { /* silent */ });
}
}, [sessionId]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleWorkingDirectoryChange = useCallback((dir: string) => {
setWorkingDirectory(dir);
setPanelOpen(true);
// Persist to database
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ working_directory: dir }),
}).catch(() => { /* silent */ });
}
}, [sessionId, setWorkingDirectory, setPanelOpen]);
// Ref to keep accumulated streaming content in sync regardless of React batching
const accumulatedRef = useRef('');
// Ref for sendMessage to allow self-referencing in timeout auto-retry without circular deps
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
// Re-sync streaming content when the window regains visibility (Electron/browser tab switch)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && accumulatedRef.current) {
setStreamingContent(accumulatedRef.current);
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Also handle Electron-specific focus events
window.addEventListener('focus', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleVisibilityChange);
};
}, []);
const initializedRef = useRef(false);
useEffect(() => {
if (initialMessages.length > 0 && !initializedRef.current) {
initializedRef.current = true;
setMessages(initialMessages);
}
}, [initialMessages]);
// Sync mode when session data loads
useEffect(() => {
if (initialMode) {
setMode(initialMode);
}
}, [initialMode]);
const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
}, []);
const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny') => {
if (!pendingPermission) return;
const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[] } | { behavior: 'deny'; message?: string } } = {
permissionRequestId: pendingPermission.permissionRequestId,
decision: decision === 'deny'
? { behavior: 'deny', message: 'User denied permission' }
: {
behavior: 'allow',
...(decision === 'allow_session' && pendingPermission.suggestions
? { updatedPermissions: pendingPermission.suggestions }
: {}),
},
};
setPermissionResolved(decision === 'deny' ? 'deny' : 'allow');
setPendingApprovalSessionId('');
try {
await fetch('/api/chat/permission', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch {
// Best effort - the stream will handle timeout
}
// Clear permission state after a short delay so user sees the feedback
setTimeout(() => {
setPendingPermission(null);
setPermissionResolved(null);
}, 1000);
}, [pendingPermission, setPendingApprovalSessionId]);
const sendMessage = useCallback(
async (content: string, files?: FileAttachment[]) => {
if (isStreaming) return;
// Build display content: embed file metadata as HTML comment for MessageItem to parse
let displayContent = content;
if (files && files.length > 0) {
const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size, data: f.data }));
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
}
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content: displayContent,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, userMessage]);
setIsStreaming(true);
setStreamingSessionId(sessionId);
setStreamingContent('');
accumulatedRef.current = '';
setToolUses([]);
setToolResults([]);
setStatusText(undefined);
const controller = new AbortController();
abortControllerRef.current = controller;
let accumulated = '';
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: sessionId,
content,
mode,
model: currentModel,
...(files && files.length > 0 ? { files } : {}),
}),
signal: controller.signal,
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error || 'Failed to send message');
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response stream');
const decoder = new TextDecoder();
let tokenUsage: TokenUsage | null = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
// Keep the last potentially incomplete line in the buffer
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const event: SSEEvent = JSON.parse(line.slice(6));
switch (event.type) {
case 'text': {
accumulated += event.data;
accumulatedRef.current = accumulated;
setStreamingContent(accumulated);
break;
}
case 'tool_use': {
try {
const toolData = JSON.parse(event.data);
// Clear streaming output for new tool
setStreamingToolOutput('');
setToolUses((prev) => {
// Avoid duplicates
if (prev.some((t) => t.id === toolData.id)) return prev;
return [...prev, {
id: toolData.id,
name: toolData.name,
input: toolData.input,
}];
});
} catch {
// skip malformed tool_use data
}
break;
}
case 'tool_result': {
try {
const resultData = JSON.parse(event.data);
setStreamingToolOutput('');
setToolResults((prev) => [...prev, {
tool_use_id: resultData.tool_use_id,
content: resultData.content,
}]);
} catch {
// skip malformed tool_result data
}
break;
}
case 'tool_output': {
// Check if this is a progress heartbeat or real stderr output
try {
const parsed = JSON.parse(event.data);
if (parsed._progress) {
// SDK tool_progress event — update status with elapsed time
setStatusText(`Running ${parsed.tool_name}... (${Math.round(parsed.elapsed_time_seconds)}s)`);
break;
}
} catch {
// Not JSON — it's raw stderr output, fall through
}
// Real-time stderr output from tool execution
setStreamingToolOutput((prev) => {
const next = prev + (prev ? '\n' : '') + event.data;
return next.length > 5000 ? next.slice(-5000) : next;
});
break;
}
case 'status': {
try {
const statusData = JSON.parse(event.data);
if (statusData.session_id) {
// Init event — show briefly then clear so tool status can take over
setStatusText(`Connected (${statusData.model || 'claude'})`);
setTimeout(() => setStatusText(undefined), 2000);
} else if (statusData.notification) {
// Notification from SDK hooks — show as progress
setStatusText(statusData.message || statusData.title || undefined);
} else {
setStatusText(typeof event.data === 'string' ? event.data : undefined);
}
} catch {
setStatusText(event.data || undefined);
}
break;
}
case 'result': {
try {
const resultData = JSON.parse(event.data);
if (resultData.usage) {
tokenUsage = resultData.usage;
}
} catch {
// skip
}
setStatusText(undefined);
break;
}
case 'permission_request': {
try {
const permData: PermissionRequestEvent = JSON.parse(event.data);
setPendingPermission(permData);
setPermissionResolved(null);
setPendingApprovalSessionId(sessionId);
} catch {
// skip malformed permission_request data
}
break;
}
case 'tool_timeout': {
try {
const timeoutData = JSON.parse(event.data);
toolTimeoutRef.current = {
toolName: timeoutData.tool_name,
elapsedSeconds: timeoutData.elapsed_seconds,
};
} catch {
// skip malformed timeout data
}
break;
}
case 'error': {
accumulated += '\n\n**Error:** ' + event.data;
accumulatedRef.current = accumulated;
setStreamingContent(accumulated);
break;
}
case 'done': {
// Stream complete
break;
}
}
} catch {
// skip malformed SSE lines
}
}
}
// Add the assistant message to the list
if (accumulated.trim()) {
const assistantMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim(),
created_at: new Date().toISOString(),
token_usage: tokenUsage ? JSON.stringify(tokenUsage) : null,
};
setMessages((prev) => [...prev, assistantMessage]);
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
const timeoutInfo = toolTimeoutRef.current;
if (timeoutInfo) {
// Tool execution timed out — save partial content and auto-retry
if (accumulated.trim()) {
const partialMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim() + `\n\n*(tool ${timeoutInfo.toolName} timed out after ${timeoutInfo.elapsedSeconds}s)*`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, partialMessage]);
}
// Clean up before auto-retry
toolTimeoutRef.current = null;
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
accumulatedRef.current = '';
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
// Auto-retry: send a follow-up message telling the model to adjust strategy
setTimeout(() => {
sendMessageRef.current?.(
`The previous tool "${timeoutInfo.toolName}" timed out after ${timeoutInfo.elapsedSeconds} seconds. Please try a different approach to accomplish the task. Avoid repeating the same operation that got stuck.`
);
}, 500);
return; // Skip the normal finally cleanup since we did it above
}
// User manually stopped generation — add partial content
if (accumulated.trim()) {
const partialMessage: Message = {
id: 'temp-assistant-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: accumulated.trim() + '\n\n*(generation stopped)*',
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, partialMessage]);
}
} else {
const errMsg = error instanceof Error ? error.message : 'Unknown error';
const errorMessage: Message = {
id: 'temp-error-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `**Error:** ${errMsg}`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, errorMessage]);
}
} finally {
toolTimeoutRef.current = null;
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
accumulatedRef.current = '';
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
// Notify file tree to refresh after AI finishes
window.dispatchEvent(new CustomEvent('refresh-file-tree'));
}
},
[sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel]
);
// Keep sendMessageRef in sync so timeout auto-retry can call it
sendMessageRef.current = sendMessage;
const handleCommand = useCallback((command: string) => {
switch (command) {
case '/help': {
const helpMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Available Commands\n\n### Instant Commands\n- **/help** — Show this help message\n- **/clear** — Clear conversation history\n- **/cost** — Show token usage statistics\n\n### Prompt Commands (shown as badge, add context then send)\n- **/compact** — Compress conversation context\n- **/doctor** — Diagnose project health\n- **/init** — Initialize CLAUDE.md for project\n- **/review** — Review code quality\n- **/terminal-setup** — Configure terminal settings\n- **/memory** — Edit project memory file\n\n### Custom Skills\nSkills from \`~/.claude/commands/\` and project \`.claude/commands/\` are also available via \`/\`.\n\n**Tips:**\n- Type \`/\` to browse commands and skills\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, helpMessage]);
break;
}
case '/clear':
setMessages([]);
// Also clear database messages and reset SDK session
if (sessionId) {
fetch(`/api/chat/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear_messages: true }),
}).catch(() => { /* silent */ });
}
break;
case '/cost': {
// Aggregate token usage from all messages in this session
let totalInput = 0;
let totalOutput = 0;
let totalCacheRead = 0;
let totalCacheCreation = 0;
let totalCost = 0;
let turnCount = 0;
for (const msg of messages) {
if (msg.token_usage) {
try {
const usage = typeof msg.token_usage === 'string' ? JSON.parse(msg.token_usage) : msg.token_usage;
totalInput += usage.input_tokens || 0;
totalOutput += usage.output_tokens || 0;
totalCacheRead += usage.cache_read_input_tokens || 0;
totalCacheCreation += usage.cache_creation_input_tokens || 0;
if (usage.cost_usd) totalCost += usage.cost_usd;
turnCount++;
} catch { /* skip */ }
}
}
const totalTokens = totalInput + totalOutput;
let content: string;
if (turnCount === 0) {
content = `## Token Usage\n\nNo token usage data yet. Send a message first.`;
} else {
content = `## Token Usage\n\n| Metric | Count |\n|--------|-------|\n| Input tokens | ${totalInput.toLocaleString()} |\n| Output tokens | ${totalOutput.toLocaleString()} |\n| Cache read | ${totalCacheRead.toLocaleString()} |\n| Cache creation | ${totalCacheCreation.toLocaleString()} |\n| **Total tokens** | **${totalTokens.toLocaleString()}** |\n| Turns | ${turnCount} |${totalCost > 0 ? `\n| **Estimated cost** | **$${totalCost.toFixed(4)}** |` : ''}`;
}
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
// This shouldn't be reached since non-immediate commands are handled via badge
sendMessage(command);
}
}, [sessionId, sendMessage]);
return (
<div className="flex h-full min-h-0 flex-col">
<MessageList
messages={messages}
streamingContent={streamingContent}
isStreaming={isStreaming}
toolUses={toolUses}
toolResults={toolResults}
streamingToolOutput={streamingToolOutput}
statusText={statusText}
pendingPermission={pendingPermission}
onPermissionResponse={handlePermissionResponse}
permissionResolved={permissionResolved}
onForceStop={stopStreaming}
/>
<MessageInput
onSend={sendMessage}
onCommand={handleCommand}
onStop={stopStreaming}
disabled={false}
isStreaming={isStreaming}
sessionId={sessionId}
modelName={currentModel}
onModelChange={setCurrentModel}
workingDirectory={workingDirectory}
onWorkingDirectoryChange={handleWorkingDirectoryChange}
mode={mode}
onModeChange={handleModeChange}
/>
</div>
);
}