Skip to content

Commit 4fa2603

Browse files
7418claude
andcommitted
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>
1 parent 8747464 commit 4fa2603

File tree

15 files changed

+314
-16
lines changed

15 files changed

+314
-16
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,6 @@ next-env.d.ts
5050
# test artifacts
5151
/playwright-report/
5252
/test-results/
53+
54+
# user uploads (runtime data)
55+
.codepilot-uploads/

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codepilot",
3-
"version": "0.9.0",
3+
"version": "0.10.0",
44
"private": true,
55
"author": {
66
"name": "op7418",

src/app/api/chat/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ export const dynamic = 'force-dynamic';
1010

1111
export async function POST(request: NextRequest) {
1212
try {
13-
const body: SendMessageRequest & { files?: FileAttachment[] } = await request.json();
14-
const { session_id, content, model, mode, files } = body;
13+
const body: SendMessageRequest & { files?: FileAttachment[]; toolTimeout?: number } = await request.json();
14+
const { session_id, content, model, mode, files, toolTimeout } = body;
1515

1616
if (!session_id || !content) {
1717
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
@@ -103,6 +103,7 @@ export async function POST(request: NextRequest) {
103103
abortController,
104104
permissionMode,
105105
files: fileAttachments,
106+
toolTimeoutSeconds: toolTimeout || 120,
106107
});
107108

108109
// Tee the stream: one for client, one for collecting the response

src/app/api/files/raw/route.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { NextRequest } from 'next/server';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import os from 'os';
5+
import { isPathSafe } from '@/lib/files';
6+
7+
export const runtime = 'nodejs';
8+
export const dynamic = 'force-dynamic';
9+
10+
const MIME_TYPES: Record<string, string> = {
11+
'.png': 'image/png',
12+
'.jpg': 'image/jpeg',
13+
'.jpeg': 'image/jpeg',
14+
'.gif': 'image/gif',
15+
'.webp': 'image/webp',
16+
'.avif': 'image/avif',
17+
'.svg': 'image/svg+xml',
18+
'.ico': 'image/x-icon',
19+
'.bmp': 'image/bmp',
20+
'.pdf': 'application/pdf',
21+
'.txt': 'text/plain',
22+
'.md': 'text/markdown',
23+
'.mdx': 'text/markdown',
24+
'.json': 'application/json',
25+
'.csv': 'text/csv',
26+
'.xml': 'text/xml',
27+
'.html': 'text/html',
28+
'.htm': 'text/html',
29+
'.css': 'text/css',
30+
'.js': 'application/javascript',
31+
'.ts': 'application/typescript',
32+
'.tsx': 'application/typescript',
33+
'.jsx': 'application/javascript',
34+
'.py': 'text/x-python',
35+
'.go': 'text/x-go',
36+
'.rs': 'text/x-rust',
37+
'.java': 'text/x-java',
38+
'.rb': 'text/x-ruby',
39+
'.sh': 'text/x-shellscript',
40+
'.yaml': 'text/yaml',
41+
'.yml': 'text/yaml',
42+
'.toml': 'text/toml',
43+
'.sql': 'text/x-sql',
44+
'.swift': 'text/x-swift',
45+
'.kt': 'text/x-kotlin',
46+
'.c': 'text/x-c',
47+
'.cpp': 'text/x-c++',
48+
'.h': 'text/x-c',
49+
'.hpp': 'text/x-c++',
50+
'.cs': 'text/x-csharp',
51+
'.php': 'text/x-php',
52+
'.dart': 'text/x-dart',
53+
'.lua': 'text/x-lua',
54+
'.zig': 'text/x-zig',
55+
'.vue': 'text/x-vue',
56+
'.svelte': 'text/x-svelte',
57+
'.graphql': 'text/x-graphql',
58+
'.gql': 'text/x-graphql',
59+
'.prisma': 'text/x-prisma',
60+
'.dockerfile': 'text/x-dockerfile',
61+
'.scss': 'text/x-scss',
62+
'.less': 'text/x-less',
63+
'.doc': 'application/msword',
64+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
65+
'.xls': 'application/vnd.ms-excel',
66+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
67+
'.ppt': 'application/vnd.ms-powerpoint',
68+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
69+
'.zip': 'application/zip',
70+
'.tar': 'application/x-tar',
71+
'.gz': 'application/gzip',
72+
'.mp3': 'audio/mpeg',
73+
'.wav': 'audio/wav',
74+
'.mp4': 'video/mp4',
75+
'.mov': 'video/quicktime',
76+
'.webm': 'video/webm',
77+
'.woff': 'font/woff',
78+
'.woff2': 'font/woff2',
79+
'.ttf': 'font/ttf',
80+
'.otf': 'font/otf',
81+
};
82+
83+
/**
84+
* Serve raw file content from the user's home directory.
85+
* Security: only allows reading files within the user's home directory.
86+
*/
87+
export async function GET(request: NextRequest) {
88+
const filePath = request.nextUrl.searchParams.get('path');
89+
90+
if (!filePath) {
91+
return new Response(JSON.stringify({ error: 'path parameter is required' }), {
92+
status: 400,
93+
headers: { 'Content-Type': 'application/json' },
94+
});
95+
}
96+
97+
const homeDir = os.homedir();
98+
const resolved = path.resolve(filePath);
99+
100+
if (!isPathSafe(homeDir, resolved)) {
101+
return new Response(JSON.stringify({ error: 'Access denied' }), {
102+
status: 403,
103+
headers: { 'Content-Type': 'application/json' },
104+
});
105+
}
106+
107+
if (!fs.existsSync(resolved)) {
108+
return new Response(JSON.stringify({ error: 'File not found' }), {
109+
status: 404,
110+
headers: { 'Content-Type': 'application/json' },
111+
});
112+
}
113+
114+
const stat = fs.statSync(resolved);
115+
if (!stat.isFile()) {
116+
return new Response(JSON.stringify({ error: 'Not a file' }), {
117+
status: 400,
118+
headers: { 'Content-Type': 'application/json' },
119+
});
120+
}
121+
122+
const buffer = fs.readFileSync(resolved);
123+
const ext = path.extname(resolved).toLowerCase();
124+
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
125+
126+
return new Response(buffer, {
127+
headers: {
128+
'Content-Type': contentType,
129+
'Content-Disposition': `inline; filename="${path.basename(resolved)}"`,
130+
},
131+
});
132+
}

src/components/ai-elements/file-tree.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
FileIcon,
1414
FolderIcon,
1515
FolderOpenIcon,
16+
PlusIcon,
1617
} from "lucide-react";
1718
import {
1819
createContext,
@@ -27,6 +28,7 @@ interface FileTreeContextType {
2728
togglePath: (path: string) => void;
2829
selectedPath?: string;
2930
onSelect?: (path: string) => void;
31+
onAdd?: (path: string) => void;
3032
}
3133

3234
// Default noop for context default value
@@ -44,6 +46,7 @@ export type FileTreeProps = HTMLAttributes<HTMLDivElement> & {
4446
defaultExpanded?: Set<string>;
4547
selectedPath?: string;
4648
onSelect?: (path: string) => void;
49+
onAdd?: (path: string) => void;
4750
onExpandedChange?: (expanded: Set<string>) => void;
4851
};
4952

@@ -52,6 +55,7 @@ export const FileTree = ({
5255
defaultExpanded = new Set(),
5356
selectedPath,
5457
onSelect,
58+
onAdd,
5559
onExpandedChange,
5660
className,
5761
children,
@@ -75,8 +79,8 @@ export const FileTree = ({
7579
);
7680

7781
const contextValue = useMemo(
78-
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
79-
[expandedPaths, onSelect, selectedPath, togglePath]
82+
() => ({ expandedPaths, onAdd, onSelect, selectedPath, togglePath }),
83+
[expandedPaths, onAdd, onSelect, selectedPath, togglePath]
8084
);
8185

8286
return (
@@ -204,7 +208,7 @@ export const FileTreeFile = ({
204208
children,
205209
...props
206210
}: FileTreeFileProps) => {
207-
const { selectedPath, onSelect } = useContext(FileTreeContext);
211+
const { selectedPath, onSelect, onAdd } = useContext(FileTreeContext);
208212
const isSelected = selectedPath === path;
209213

210214
const handleClick = useCallback(() => {
@@ -220,13 +224,21 @@ export const FileTreeFile = ({
220224
[onSelect, path]
221225
);
222226

227+
const handleAdd = useCallback(
228+
(e: React.MouseEvent) => {
229+
e.stopPropagation();
230+
onAdd?.(path);
231+
},
232+
[onAdd, path]
233+
);
234+
223235
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
224236

225237
return (
226238
<FileTreeFileContext.Provider value={fileContextValue}>
227239
<div
228240
className={cn(
229-
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
241+
"group/file flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
230242
isSelected && "bg-muted",
231243
className
232244
)}
@@ -244,6 +256,16 @@ export const FileTreeFile = ({
244256
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
245257
</FileTreeIcon>
246258
<FileTreeName>{name}</FileTreeName>
259+
{onAdd && (
260+
<button
261+
type="button"
262+
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"
263+
onClick={handleAdd}
264+
title="Add to chat"
265+
>
266+
<PlusIcon className="size-3 text-muted-foreground" />
267+
</button>
268+
)}
247269
</>
248270
)}
249271
</div>

src/components/chat/ChatView.tsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
3737
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
3838
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
3939
const [streamingToolOutput, setStreamingToolOutput] = useState('');
40+
const toolTimeoutRef = useRef<{ toolName: string; elapsedSeconds: number } | null>(null);
4041

4142
const handleModeChange = useCallback((newMode: string) => {
4243
setMode(newMode);
@@ -68,6 +69,8 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
6869

6970
// Ref to keep accumulated streaming content in sync regardless of React batching
7071
const accumulatedRef = useRef('');
72+
// Ref for sendMessage to allow self-referencing in timeout auto-retry without circular deps
73+
const sendMessageRef = useRef<(content: string, files?: FileAttachment[]) => Promise<void>>(undefined);
7174

7275
// Re-sync streaming content when the window regains visibility (Electron/browser tab switch)
7376
useEffect(() => {
@@ -321,6 +324,19 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
321324
break;
322325
}
323326

327+
case 'tool_timeout': {
328+
try {
329+
const timeoutData = JSON.parse(event.data);
330+
toolTimeoutRef.current = {
331+
toolName: timeoutData.tool_name,
332+
elapsedSeconds: timeoutData.elapsed_seconds,
333+
};
334+
} catch {
335+
// skip malformed timeout data
336+
}
337+
break;
338+
}
339+
324340
case 'error': {
325341
accumulated += '\n\n**Error:** ' + event.data;
326342
accumulatedRef.current = accumulated;
@@ -353,7 +369,43 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
353369
}
354370
} catch (error) {
355371
if (error instanceof DOMException && error.name === 'AbortError') {
356-
// User stopped generation - still add partial content
372+
const timeoutInfo = toolTimeoutRef.current;
373+
if (timeoutInfo) {
374+
// Tool execution timed out — save partial content and auto-retry
375+
if (accumulated.trim()) {
376+
const partialMessage: Message = {
377+
id: 'temp-assistant-' + Date.now(),
378+
session_id: sessionId,
379+
role: 'assistant',
380+
content: accumulated.trim() + `\n\n*(tool ${timeoutInfo.toolName} timed out after ${timeoutInfo.elapsedSeconds}s)*`,
381+
created_at: new Date().toISOString(),
382+
token_usage: null,
383+
};
384+
setMessages((prev) => [...prev, partialMessage]);
385+
}
386+
// Clean up before auto-retry
387+
toolTimeoutRef.current = null;
388+
setIsStreaming(false);
389+
setStreamingSessionId('');
390+
setStreamingContent('');
391+
accumulatedRef.current = '';
392+
setToolUses([]);
393+
setToolResults([]);
394+
setStreamingToolOutput('');
395+
setStatusText(undefined);
396+
setPendingPermission(null);
397+
setPermissionResolved(null);
398+
setPendingApprovalSessionId('');
399+
abortControllerRef.current = null;
400+
// Auto-retry: send a follow-up message telling the model to adjust strategy
401+
setTimeout(() => {
402+
sendMessageRef.current?.(
403+
`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.`
404+
);
405+
}, 500);
406+
return; // Skip the normal finally cleanup since we did it above
407+
}
408+
// User manually stopped generation — add partial content
357409
if (accumulated.trim()) {
358410
const partialMessage: Message = {
359411
id: 'temp-assistant-' + Date.now(),
@@ -378,6 +430,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
378430
setMessages((prev) => [...prev, errorMessage]);
379431
}
380432
} finally {
433+
toolTimeoutRef.current = null;
381434
setIsStreaming(false);
382435
setStreamingSessionId('');
383436
setStreamingContent('');
@@ -395,6 +448,9 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
395448
[sessionId, isStreaming, setStreamingSessionId, setPendingApprovalSessionId, mode, currentModel]
396449
);
397450

451+
// Keep sendMessageRef in sync so timeout auto-retry can call it
452+
sendMessageRef.current = sendMessage;
453+
398454
const handleCommand = useCallback((command: string) => {
399455
switch (command) {
400456
case '/help': {
@@ -482,6 +538,7 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
482538
pendingPermission={pendingPermission}
483539
onPermissionResponse={handlePermissionResponse}
484540
permissionResolved={permissionResolved}
541+
onForceStop={stopStreaming}
485542
/>
486543
<MessageInput
487544
onSend={sendMessage}

0 commit comments

Comments
 (0)