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:
1 parent
8747464c2f
commit
4fa26032a4
15 files changed
+314
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user