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>
427 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|