feat: tool timeout auto-retry, file tree attachment, force stop button

- Add configurable tool timeout with auto-retry: when a tool exceeds the
  timeout threshold, abort and send a follow-up message asking Claude to
  try a different approach
- Add "+" button on file tree items to attach files directly to chat
  input via new /api/files/raw endpoint and custom event bridge
- Add force stop button in streaming status bar when tool runs >90s,
  with warning indicators at 60s
- Improve doc preview markdown rendering (padding, list indentation)
- Bump version to 0.10.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-12 00:33:21 +08:00
1 parent 8747464c2f
commit 4fa26032a4
15 files changed
+314 -16

No files matched your search

+3
View File
@@ -50,3 +50,6 @@ next-env.d.ts
# test artifacts
/playwright-report/
/test-results/
# user uploads (runtime data)
.codepilot-uploads/
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codepilot",
"version": "0.9.0",
"version": "0.10.0",
"private": true,
"author": {
"name": "op7418",
+3 -2
View File
@@ -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
+132
View File
@@ -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<string, string> = {
'.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)}"`,
},
});
}
+26 -4
View File
@@ -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<HTMLDivElement> & {
defaultExpanded?: Set<string>;
selectedPath?: string;
onSelect?: (path: string) => void;
onAdd?: (path: string) => void;
onExpandedChange?: (expanded: Set<string>) => 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 (
<FileTreeFileContext.Provider value={fileContextValue}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
"group/file flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
className
)}
@@ -244,6 +256,16 @@ export const FileTreeFile = ({
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
{onAdd && (
<button
type="button"
className="ml-auto flex size-5 shrink-0 items-center justify-center rounded opacity-0 transition-opacity hover:bg-muted group-hover/file:opacity-100"
onClick={handleAdd}
title="Add to chat"
>
<PlusIcon className="size-3 text-muted-foreground" />
</button>
)}
</>
)}
</div>
+58 -1
View File
@@ -37,6 +37,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
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);
@@ -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<void>>(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}
/>
<MessageInput
onSend={sendMessage}
+34
View File
@@ -250,6 +250,38 @@ function AttachFileButton() {
);
}
/**
* Bridge component that listens for 'attach-file-to-chat' custom events
* from the file tree and adds files as attachments. Must be rendered inside PromptInput.
*/
function FileTreeAttachmentBridge() {
const attachments = usePromptInputAttachments();
useEffect(() => {
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 */}
<FileTreeAttachmentBridge />
{/* Command badge */}
{badge && (
<div className="flex w-full items-center gap-1.5 px-3 pt-2.5 pb-0 order-first">
+3
View File
@@ -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}
/>
)}
</ConversationContent>
+28 -3
View File
@@ -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 (
<div className="flex items-center gap-3 py-2 px-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Shimmer duration={1.5}>{displayText}</Shimmer>
<span className={isCritical ? 'text-red-500' : isWarning ? 'text-yellow-500' : undefined}>
<Shimmer duration={1.5}>{displayText}</Shimmer>
</span>
{isWarning && !isCritical && (
<span className="text-yellow-500 text-[10px]">Running longer than usual</span>
)}
{isCritical && (
<span className="text-red-500 text-[10px]">Tool may be stuck</span>
)}
</div>
<span className="text-muted-foreground/50">|</span>
<ElapsedTimer />
{isCritical && onForceStop && (
<button
type="button"
onClick={onForceStop}
className="ml-auto rounded-md border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium text-red-500 transition-colors hover:bg-red-500/20"
>
Force stop
</button>
)}
</div>
);
}
@@ -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 && <StreamingStatusBar statusText={
statusText || getRunningCommandSummary()
} />}
} onForceStop={onForceStop} />}
</MessageContent>
</AIMessage>
);
+2 -2
View File
@@ -263,9 +263,9 @@ function RenderedView({
// Markdown / MDX
return (
<div className="p-4 overflow-x-hidden break-words">
<div className="px-6 py-4 overflow-x-hidden break-words">
<Streamdown
className="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
className="size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_ul]:pl-6 [&_ol]:pl-6"
plugins={streamdownPlugins}
>
{content}
+5
View File
@@ -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) {
<FileTree
workingDirectory={workingDirectory}
onFileSelect={handleFileSelect}
onFileAdd={handleFileAdd}
/>
</div>
</div>
+3 -1
View File
@@ -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<FileTreeNode[]>([]);
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"
>
<RenderTreeNodes nodes={tree} searchQuery={searchQuery} />
+12
View File
@@ -168,6 +168,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
abortController,
permissionMode,
files,
toolTimeoutSeconds = 0,
} = options;
return new ReadableStream<string>({
@@ -542,6 +543,17 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
elapsed_time_seconds: progressMsg.elapsed_time_seconds,
}),
}));
// Auto-timeout: abort if tool runs longer than configured threshold
if (toolTimeoutSeconds > 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;
}
+2
View File
@@ -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;
}