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 -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)}"`,
},
});
}