diff --git a/.gitignore b/.gitignore index 0de24f1..49e282b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ next-env.d.ts # test artifacts /playwright-report/ /test-results/ + +# user uploads (runtime data) +.codepilot-uploads/ diff --git a/package-lock.json b/package-lock.json index 9f82601..53f6a43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codepilot", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codepilot", - "version": "0.9.0", + "version": "0.10.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.33", "@hugeicons/core-free-icons": "^3.1.1", diff --git a/package.json b/package.json index 0ab5592..5eb60e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codepilot", - "version": "0.9.0", + "version": "0.10.0", "private": true, "author": { "name": "op7418", diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index d2eb8ea..75d241b 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -10,8 +10,8 @@ export const dynamic = 'force-dynamic'; export async function POST(request: NextRequest) { try { - const body: SendMessageRequest & { files?: FileAttachment[] } = await request.json(); - const { session_id, content, model, mode, files } = body; + const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number } = await request.json(); + const { session_id, content, model, mode, files, toolTimeout } = body; if (!session_id || !content) { return new Response(JSON.stringify({ error: 'session_id and content are required' }), { @@ -103,6 +103,7 @@ export async function POST(request: NextRequest) { abortController, permissionMode, files: fileAttachments, + toolTimeoutSeconds: toolTimeout || 120, }); // Tee the stream: one for client, one for collecting the response diff --git a/src/app/api/files/raw/route.ts b/src/app/api/files/raw/route.ts new file mode 100644 index 0000000..7d4bfa9 --- /dev/null +++ b/src/app/api/files/raw/route.ts @@ -0,0 +1,132 @@ +import { NextRequest } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { isPathSafe } from '@/lib/files'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const MIME_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.svg': 'image/svg+xml', + '.ico': 'image/x-icon', + '.bmp': 'image/bmp', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.md': 'text/markdown', + '.mdx': 'text/markdown', + '.json': 'application/json', + '.csv': 'text/csv', + '.xml': 'text/xml', + '.html': 'text/html', + '.htm': 'text/html', + '.css': 'text/css', + '.js': 'application/javascript', + '.ts': 'application/typescript', + '.tsx': 'application/typescript', + '.jsx': 'application/javascript', + '.py': 'text/x-python', + '.go': 'text/x-go', + '.rs': 'text/x-rust', + '.java': 'text/x-java', + '.rb': 'text/x-ruby', + '.sh': 'text/x-shellscript', + '.yaml': 'text/yaml', + '.yml': 'text/yaml', + '.toml': 'text/toml', + '.sql': 'text/x-sql', + '.swift': 'text/x-swift', + '.kt': 'text/x-kotlin', + '.c': 'text/x-c', + '.cpp': 'text/x-c++', + '.h': 'text/x-c', + '.hpp': 'text/x-c++', + '.cs': 'text/x-csharp', + '.php': 'text/x-php', + '.dart': 'text/x-dart', + '.lua': 'text/x-lua', + '.zig': 'text/x-zig', + '.vue': 'text/x-vue', + '.svelte': 'text/x-svelte', + '.graphql': 'text/x-graphql', + '.gql': 'text/x-graphql', + '.prisma': 'text/x-prisma', + '.dockerfile': 'text/x-dockerfile', + '.scss': 'text/x-scss', + '.less': 'text/x-less', + '.doc': 'application/msword', + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.zip': 'application/zip', + '.tar': 'application/x-tar', + '.gz': 'application/gzip', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.mp4': 'video/mp4', + '.mov': 'video/quicktime', + '.webm': 'video/webm', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', +}; + +/** + * Serve raw file content from the user's home directory. + * Security: only allows reading files within the user's home directory. + */ +export async function GET(request: NextRequest) { + const filePath = request.nextUrl.searchParams.get('path'); + + if (!filePath) { + return new Response(JSON.stringify({ error: 'path parameter is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const homeDir = os.homedir(); + const resolved = path.resolve(filePath); + + if (!isPathSafe(homeDir, resolved)) { + return new Response(JSON.stringify({ error: 'Access denied' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!fs.existsSync(resolved)) { + return new Response(JSON.stringify({ error: 'File not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const stat = fs.statSync(resolved); + if (!stat.isFile()) { + return new Response(JSON.stringify({ error: 'Not a file' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const buffer = fs.readFileSync(resolved); + const ext = path.extname(resolved).toLowerCase(); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + + return new Response(buffer, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `inline; filename="${path.basename(resolved)}"`, + }, + }); +} diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index 0a04dbb..1a923b9 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -13,6 +13,7 @@ import { FileIcon, FolderIcon, FolderOpenIcon, + PlusIcon, } from "lucide-react"; import { createContext, @@ -27,6 +28,7 @@ interface FileTreeContextType { togglePath: (path: string) => void; selectedPath?: string; onSelect?: (path: string) => void; + onAdd?: (path: string) => void; } // Default noop for context default value @@ -44,6 +46,7 @@ export type FileTreeProps = HTMLAttributes & { defaultExpanded?: Set; selectedPath?: string; onSelect?: (path: string) => void; + onAdd?: (path: string) => void; onExpandedChange?: (expanded: Set) => void; }; @@ -52,6 +55,7 @@ export const FileTree = ({ defaultExpanded = new Set(), selectedPath, onSelect, + onAdd, onExpandedChange, className, children, @@ -75,8 +79,8 @@ export const FileTree = ({ ); const contextValue = useMemo( - () => ({ expandedPaths, onSelect, selectedPath, togglePath }), - [expandedPaths, onSelect, selectedPath, togglePath] + () => ({ expandedPaths, onAdd, onSelect, selectedPath, togglePath }), + [expandedPaths, onAdd, onSelect, selectedPath, togglePath] ); return ( @@ -204,7 +208,7 @@ export const FileTreeFile = ({ children, ...props }: FileTreeFileProps) => { - const { selectedPath, onSelect } = useContext(FileTreeContext); + const { selectedPath, onSelect, onAdd } = useContext(FileTreeContext); const isSelected = selectedPath === path; const handleClick = useCallback(() => { @@ -220,13 +224,21 @@ export const FileTreeFile = ({ [onSelect, path] ); + const handleAdd = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onAdd?.(path); + }, + [onAdd, path] + ); + const fileContextValue = useMemo(() => ({ name, path }), [name, path]); return (
} {name} + {onAdd && ( + + )} )}
diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 4e0e56b..d5a33e8 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -37,6 +37,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo const [pendingPermission, setPendingPermission] = useState(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); @@ -68,6 +69,8 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo // 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>(undefined); // Re-sync streaming content when the window regains visibility (Electron/browser tab switch) useEffect(() => { @@ -321,6 +324,19 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo 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; @@ -353,7 +369,43 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { - // User stopped generation - still add partial content + 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(), @@ -378,6 +430,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo setMessages((prev) => [...prev, errorMessage]); } } finally { + toolTimeoutRef.current = null; setIsStreaming(false); setStreamingSessionId(''); setStreamingContent(''); @@ -395,6 +448,9 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo [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': { @@ -482,6 +538,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo pendingPermission={pendingPermission} onPermissionResponse={handlePermissionResponse} permissionResolved={permissionResolved} + onForceStop={stopStreaming} /> { + const handler = async (e: Event) => { + const customEvent = e as CustomEvent<{ path: string }>; + const filePath = customEvent.detail?.path; + if (!filePath) return; + + try { + const res = await fetch(`/api/files/raw?path=${encodeURIComponent(filePath)}`); + if (!res.ok) return; + const blob = await res.blob(); + const filename = filePath.split('/').pop() || 'file'; + const file = new File([blob], filename, { type: blob.type || 'application/octet-stream' }); + attachments.add([file]); + } catch { + // Silently fail if file fetch fails + } + }; + + window.addEventListener('attach-file-to-chat', handler); + return () => window.removeEventListener('attach-file-to-chat', handler); + }, [attachments]); + + return null; +} + /** * Capsule display for attached files, rendered inside PromptInput context. */ @@ -842,6 +874,8 @@ export function MessageInput({ multiple maxFileSize={MAX_FILE_SIZE} > + {/* Bridge: listens for file tree "+" button events */} + {/* Command badge */} {badge && (
diff --git a/src/components/chat/MessageList.tsx b/src/components/chat/MessageList.tsx index 8a4ca98..85ffaba 100644 --- a/src/components/chat/MessageList.tsx +++ b/src/components/chat/MessageList.tsx @@ -34,6 +34,7 @@ interface MessageListProps { pendingPermission?: PermissionRequestEvent | null; onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void; permissionResolved?: 'allow' | 'deny' | null; + onForceStop?: () => void; } export function MessageList({ @@ -47,6 +48,7 @@ export function MessageList({ pendingPermission, onPermissionResponse, permissionResolved, + onForceStop, }: MessageListProps) { if (messages.length === 0 && !isStreaming) { return ( @@ -78,6 +80,7 @@ export function MessageList({ pendingPermission={pendingPermission} onPermissionResponse={onPermissionResponse} permissionResolved={permissionResolved} + onForceStop={onForceStop} /> )} diff --git a/src/components/chat/StreamingMessage.tsx b/src/components/chat/StreamingMessage.tsx index b476f26..a6f1174 100644 --- a/src/components/chat/StreamingMessage.tsx +++ b/src/components/chat/StreamingMessage.tsx @@ -42,6 +42,7 @@ interface StreamingMessageProps { pendingPermission?: PermissionRequestEvent | null; onPermissionResponse?: (decision: 'allow' | 'allow_session' | 'deny') => void; permissionResolved?: 'allow' | 'deny' | null; + onForceStop?: () => void; } function ElapsedTimer() { @@ -65,16 +66,39 @@ function ElapsedTimer() { ); } -function StreamingStatusBar({ statusText }: { statusText?: string }) { +function StreamingStatusBar({ statusText, onForceStop }: { statusText?: string; onForceStop?: () => void }) { const displayText = statusText || 'Thinking'; + // Parse elapsed seconds from statusText like "Running bash... (45s)" + const elapsedMatch = statusText?.match(/\((\d+)s\)/); + const toolElapsed = elapsedMatch ? parseInt(elapsedMatch[1], 10) : 0; + const isWarning = toolElapsed >= 60; + const isCritical = toolElapsed >= 90; + return (
- {displayText} + + {displayText} + + {isWarning && !isCritical && ( + Running longer than usual + )} + {isCritical && ( + Tool may be stuck + )}
| + {isCritical && onForceStop && ( + + )}
); } @@ -89,6 +113,7 @@ export function StreamingMessage({ pendingPermission, onPermissionResponse, permissionResolved, + onForceStop, }: StreamingMessageProps) { const runningTools = toolUses.filter( (tool) => !toolResults.some((r) => r.tool_use_id === tool.id) @@ -226,7 +251,7 @@ export function StreamingMessage({ {/* Status bar during streaming */} {isStreaming && !pendingPermission && } + } onForceStop={onForceStop} />} ); diff --git a/src/components/layout/DocPreview.tsx b/src/components/layout/DocPreview.tsx index c2a6503..fc9d4f4 100644 --- a/src/components/layout/DocPreview.tsx +++ b/src/components/layout/DocPreview.tsx @@ -263,9 +263,9 @@ function RenderedView({ // Markdown / MDX return ( -
+
{content} diff --git a/src/components/layout/RightPanel.tsx b/src/components/layout/RightPanel.tsx index 849a3b5..2f0c791 100644 --- a/src/components/layout/RightPanel.tsx +++ b/src/components/layout/RightPanel.tsx @@ -66,6 +66,10 @@ export function RightPanel({ width }: RightPanelProps) { } }, [handleSaveName]); + const handleFileAdd = useCallback((path: string) => { + window.dispatchEvent(new CustomEvent('attach-file-to-chat', { detail: { path } })); + }, []); + const handleFileSelect = useCallback((path: string) => { // Only open preview for text-based files, skip images/videos/binaries const ext = path.split(".").pop()?.toLowerCase() || ""; @@ -172,6 +176,7 @@ export function RightPanel({ width }: RightPanelProps) {
diff --git a/src/components/project/FileTree.tsx b/src/components/project/FileTree.tsx index 187c345..a50626f 100644 --- a/src/components/project/FileTree.tsx +++ b/src/components/project/FileTree.tsx @@ -17,6 +17,7 @@ import type { ReactNode } from "react"; interface FileTreeProps { workingDirectory: string; onFileSelect: (path: string) => void; + onFileAdd?: (path: string) => void; } function getFileIcon(extension?: string): ReactNode { @@ -104,7 +105,7 @@ function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; search ); } -export function FileTree({ workingDirectory, onFileSelect }: FileTreeProps) { +export function FileTree({ workingDirectory, onFileSelect, onFileAdd }: FileTreeProps) { const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -186,6 +187,7 @@ export function FileTree({ workingDirectory, onFileSelect }: FileTreeProps) { defaultExpanded={defaultExpanded} // eslint-disable-next-line @typescript-eslint/no-explicit-any -- AI Elements FileTree onSelect type conflicts with HTMLAttributes.onSelect onSelect={onFileSelect as any} + onAdd={onFileAdd} className="border-0 rounded-none" > diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts index ca8dcdb..cbc0526 100644 --- a/src/lib/claude-client.ts +++ b/src/lib/claude-client.ts @@ -168,6 +168,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream({ @@ -542,6 +543,17 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream 0 && progressMsg.elapsed_time_seconds >= toolTimeoutSeconds) { + controller.enqueue(formatSSE({ + type: 'tool_timeout', + data: JSON.stringify({ + tool_name: progressMsg.tool_name, + elapsed_seconds: Math.round(progressMsg.elapsed_time_seconds), + }), + })); + abortController?.abort(); + } break; } diff --git a/src/types/index.ts b/src/types/index.ts index 63f819f..31d5b7d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -305,6 +305,7 @@ export type SSEEventType = | 'tool_use' // tool invocation info | 'tool_result' // tool execution result | 'tool_output' // streaming tool output (stderr from SDK process) + | 'tool_timeout' // tool execution timed out | 'status' // status update (compacting, etc.) | 'result' // final result with usage stats | 'error' // error occurred @@ -432,4 +433,5 @@ export interface ClaudeStreamOptions { abortController?: AbortController; permissionMode?: string; files?: FileAttachment[]; + toolTimeoutSeconds?: number; }