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

+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>
);