Files
CodePilot/src/components/chat/ChatView.tsx
T
7418andClaude Opus 4.6 ff1fb38735 feat: CodePilot - desktop GUI for Claude Code
Full-featured desktop application built with Next.js + Electron:
- Streaming chat with Claude Code via Agent SDK
- Session management with SQLite persistence
- Project file tree and file preview
- MCP server and custom skills management
- Permission controls with Code/Plan/Ask modes
- Dark/light theme, token tracking
- macOS DMG packaging with electron-builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 21:04:05 +08:00

427 lines
15 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent } 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 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]);
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) => {
if (isStreaming) return;
// Optimistic: add user message to UI immediately
const userMessage: Message = {
id: 'temp-' + Date.now(),
session_id: sessionId,
role: 'user',
content,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages((prev) => [...prev, userMessage]);
setIsStreaming(true);
setStreamingSessionId(sessionId);
setStreamingContent('');
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 }),
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;
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 'error': {
accumulated += '\n\n**Error:** ' + event.data;
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') {
// User stopped generation - still 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 {
setIsStreaming(false);
setStreamingSessionId('');
setStreamingContent('');
setToolUses([]);
setToolResults([]);
setStreamingToolOutput('');
setStatusText(undefined);
setPendingPermission(null);
setPermissionResolved(null);
setPendingApprovalSessionId('');
abortControllerRef.current = null;
}
},
[sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel]
);
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- **/help** - Show this help message\n- **/clear** - Clear conversation history\n- **/compact** - Compress conversation context\n- **/cost** - Show token usage statistics\n- **/doctor** - Check system health\n- **/init** - Initialize CLAUDE.md\n- **/review** - Start code review\n- **/terminal-setup** - Configure terminal\n\n**Tips:**\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([]);
break;
case '/cost': {
const costMessage: Message = {
id: 'cmd-' + Date.now(),
session_id: sessionId,
role: 'assistant',
content: `## Token Usage\n\nToken usage tracking is available after sending messages. Check the token count displayed at the bottom of each assistant response.`,
created_at: new Date().toISOString(),
token_usage: null,
};
setMessages(prev => [...prev, costMessage]);
break;
}
default:
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}
/>
<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>
);
}