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
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user