From d4f09fb338f0e736b257084e7fbfbbf3fe23497d Mon Sep 17 00:00:00 2001 From: ashish200729 Date: Sun, 22 Mar 2026 18:32:41 +0530 Subject: [PATCH 1/2] Fix sidebar project icon rendering --- .../features/sidebar/agents-sidebar.tsx | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/src/renderer/features/sidebar/agents-sidebar.tsx b/src/renderer/features/sidebar/agents-sidebar.tsx index feb80384..cfae56e5 100644 --- a/src/renderer/features/sidebar/agents-sidebar.tsx +++ b/src/renderer/features/sidebar/agents-sidebar.tsx @@ -103,6 +103,7 @@ import { import { Logo } from "../../components/ui/logo" import { Input } from "../../components/ui/input" import { Button } from "../../components/ui/button" +import { ProjectIcon } from "../../components/ui/project-icon" import { selectedAgentChatIdAtom, selectedChatIsRemoteAtom, @@ -187,8 +188,10 @@ const ChatIcon = React.memo(function ChatIcon({ isMultiSelectMode = false, isChecked = false, onCheckboxClick, + project, gitOwner, gitProvider, + isRemote = false, showIcon = true, }: { isSelected: boolean @@ -199,23 +202,24 @@ const ChatIcon = React.memo(function ChatIcon({ isMultiSelectMode?: boolean isChecked?: boolean onCheckboxClick?: (e: React.MouseEvent) => void + project?: { + id: string + iconPath?: string | null + updatedAt?: string | Date | null + gitOwner?: string | null + gitProvider?: string | null + } | null gitOwner?: string | null gitProvider?: string | null + isRemote?: boolean showIcon?: boolean }) { - // Show GitHub avatar if available, otherwise blank project icon const renderMainIcon = () => { - if (gitOwner && gitProvider === "github") { + if (isRemote && gitOwner && gitProvider === "github") { return } - return ( - - ) + + return } // When icon is hidden and not in multi-select mode, render nothing @@ -323,8 +327,7 @@ const DraftItem = React.memo(function DraftItem({ draftId, draftText, draftUpdatedAt, - projectGitOwner, - projectGitProvider, + project, projectGitRepo, projectName, isSelected, @@ -338,8 +341,16 @@ const DraftItem = React.memo(function DraftItem({ draftId: string draftText: string draftUpdatedAt: number - projectGitOwner: string | null | undefined - projectGitProvider: string | null | undefined + project: + | { + id: string + name: string + path: string + gitOwner?: string | null + gitRepo?: string | null + gitProvider?: string | null + } + | undefined projectGitRepo: string | null | undefined projectName: string | null | undefined isSelected: boolean @@ -367,13 +378,7 @@ const DraftItem = React.memo(function DraftItem({
{showIcon && (
-
- {projectGitOwner && projectGitProvider === "github" ? ( - - ) : ( - - )} -
+
)}
@@ -438,6 +443,7 @@ const AgentChatItem = React.memo(function AgentChatItem({ displayText, gitOwner, gitProvider, + project, stats, selectedChatIdsSize, canShowPinOption, @@ -486,6 +492,16 @@ const AgentChatItem = React.memo(function AgentChatItem({ displayText: string gitOwner: string | null | undefined gitProvider: string | null | undefined + project: + | { + id: string + iconPath?: string | null + updatedAt?: string | Date | null + gitOwner?: string | null + gitProvider?: string | null + } + | null + | undefined stats: { fileCount: number; additions: number; deletions: number } | undefined selectedChatIdsSize: number canShowPinOption: boolean @@ -581,8 +597,10 @@ const AgentChatItem = React.memo(function AgentChatItem({ isMultiSelectMode={isMultiSelectMode} isChecked={isChecked} onCheckboxClick={(e) => onCheckboxClick(e, chatId)} + project={project} gitOwner={gitOwner} gitProvider={gitProvider} + isRemote={isRemote} showIcon={showIcon} />
@@ -1014,6 +1032,7 @@ const ChatListSection = React.memo(function ChatListSection({ displayText={displayText} gitOwner={gitOwner} gitProvider={gitProvider} + project={project} stats={stats ?? undefined} selectedChatIdsSize={selectedChatIds.size} canShowPinOption={canShowPinOption} @@ -3256,8 +3275,7 @@ export function AgentsSidebar({ draftId={draft.id} draftText={draft.text} draftUpdatedAt={draft.updatedAt} - projectGitOwner={draft.project?.gitOwner} - projectGitProvider={draft.project?.gitProvider} + project={draft.project} projectGitRepo={draft.project?.gitRepo} projectName={draft.project?.name} isSelected={selectedDraftId === draft.id && !selectedChatId} From d72a3ab2ae0330c1ddc3c13045ee048cef820e30 Mon Sep 17 00:00:00 2001 From: ashish200729 Date: Sun, 22 Mar 2026 21:46:04 +0530 Subject: [PATCH 2/2] Contain renderer failures and unify tool state handling --- bun.lock | 10 +- src/main/windows/main.ts | 18 ++ src/renderer/components/ui/error-boundary.tsx | 90 ++++++++-- .../features/agents/main/active-chat.tsx | 169 ++++++++++-------- .../features/agents/ui/agent-bash-tool.tsx | 8 +- .../features/agents/ui/agent-edit-tool.tsx | 7 +- .../agents/ui/agent-mcp-tool-call.tsx | 10 +- .../agents/ui/agent-plan-file-tool.tsx | 6 +- .../agents/ui/agent-thinking-tool.tsx | 8 +- .../agents/ui/agent-tool-registry.tsx | 154 ++++++---------- .../features/agents/ui/agent-tool-state.ts | 61 +++++++ .../features/agents/ui/agent-tool-utils.ts | 10 +- .../ui/agent-web-search-collapsible.tsx | 12 +- src/renderer/main.tsx | 11 +- 14 files changed, 334 insertions(+), 240 deletions(-) create mode 100644 src/renderer/features/agents/ui/agent-tool-state.ts diff --git a/bun.lock b/bun.lock index f338c9b3..7c135f7a 100644 --- a/bun.lock +++ b/bun.lock @@ -1,11 +1,12 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "21st-desktop", "dependencies": { "@ai-sdk/react": "^3.0.14", - "@anthropic-ai/claude-agent-sdk": "0.2.32", + "@anthropic-ai/claude-agent-sdk": "0.2.45", "@git-diff-view/react": "^0.0.35", "@git-diff-view/shiki": "^0.0.36", "@mcpc-tech/acp-ai-provider": "^0.2.4", @@ -42,7 +43,7 @@ "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", - "@zed-industries/codex-acp": "^0.9.3", + "@zed-industries/codex-acp": "0.9.3", "ai": "^6.0.14", "async-mutex": "^0.5.0", "better-sqlite3": "^12.6.2", @@ -90,6 +91,7 @@ "@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/utils": "^4.0.0", "@electron/rebuild": "^4.0.3", + "@tailwindcss/container-queries": "^0.1.1", "@types/better-sqlite3": "^7.6.13", "@types/diff": "^8.0.0", "@types/node": "^20.17.50", @@ -126,7 +128,7 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.32", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-8AtsSx/M9jxd0ihS08eqa7VireTEuwQy0i1+6ZJX93LECT6Svlf47dPJiAm7JB+BhVMmwTfQeS6x1akIcCfvbQ=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.45", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-linuxmusl-arm64": "^0.33.5", "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-AKH2hKoJNyjLf9ThAttKqbmCjUFg7qs/8+LR/UTVX20fCLn359YH9WrQc6dAiAfi8RYNA+mWwrNYCAq+Sdo5Ag=="], "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], @@ -648,6 +650,8 @@ "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], + "@tailwindcss/container-queries": ["@tailwindcss/container-queries@0.1.1", "", { "peerDependencies": { "tailwindcss": ">=3.2.0" } }, "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA=="], + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], diff --git a/src/main/windows/main.ts b/src/main/windows/main.ts index 15dcdd13..c646c870 100644 --- a/src/main/windows/main.ts +++ b/src/main/windows/main.ts @@ -645,6 +645,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): partition: "persist:main", // Use persistent session for cookies }, }) + let attemptedRendererRecovery = false // Register window with manager and get stable ID for localStorage namespacing const stableWindowId = windowManager.register(window) @@ -741,6 +742,22 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): return { action: "deny" } }) + window.webContents.on("render-process-gone", (_event, details) => { + console.error("[Main] Renderer process gone in window", window.id, details) + + if (attemptedRendererRecovery || window.isDestroyed()) { + return + } + + attemptedRendererRecovery = true + setTimeout(() => { + if (!window.isDestroyed()) { + console.log("[Main] Attempting one-shot renderer recovery in window", window.id) + window.webContents.reloadIgnoringCache() + } + }, 150) + }) + // Prevent window close if there are active streaming sessions window.on("close", (event) => { // Skip confirmation if app quit was already confirmed by the user @@ -835,6 +852,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }): // Log page load - traffic light visibility is managed by the renderer window.webContents.on("did-finish-load", () => { + attemptedRendererRecovery = false console.log("[Main] Page finished loading in window", window.id) }) window.webContents.on( diff --git a/src/renderer/components/ui/error-boundary.tsx b/src/renderer/components/ui/error-boundary.tsx index e5594587..216a4834 100644 --- a/src/renderer/components/ui/error-boundary.tsx +++ b/src/renderer/components/ui/error-boundary.tsx @@ -2,6 +2,16 @@ import { Component, type ReactNode } from "react" import { AlertCircle } from "lucide-react" import { Button } from "./button" +interface RenderErrorBoundaryProps { + children: ReactNode + title?: string + description?: string + resetKey?: string | number | null + onReset?: () => void + compact?: boolean + showReload?: boolean +} + interface ErrorBoundaryProps { children: ReactNode viewerType?: string @@ -13,11 +23,11 @@ interface ErrorBoundaryState { error: Error | null } -export class ViewerErrorBoundary extends Component< - ErrorBoundaryProps, +export class RenderErrorBoundary extends Component< + RenderErrorBoundaryProps, ErrorBoundaryState > { - constructor(props: ErrorBoundaryProps) { + constructor(props: RenderErrorBoundaryProps) { super(props) this.state = { hasError: false, error: null } } @@ -28,31 +38,66 @@ export class ViewerErrorBoundary extends Component< componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.error( - `[ViewerErrorBoundary] ${this.props.viewerType || "viewer"} crashed:`, + `[RenderErrorBoundary] ${this.props.title || "section"} crashed:`, error, errorInfo, ) } + componentDidUpdate(prevProps: RenderErrorBoundaryProps) { + if ( + this.state.hasError && + prevProps.resetKey !== this.props.resetKey + ) { + this.setState({ hasError: false, error: null }) + } + } + handleReset = () => { this.setState({ hasError: false, error: null }) this.props.onReset?.() } + handleReload = () => { + window.location.reload() + } + render() { if (this.state.hasError) { return ( -
+
-

- Failed to render {this.props.viewerType || "file"} -

-

- {this.state.error?.message || "An unexpected error occurred."} -

- +
+

+ {this.props.title || "Something went wrong"} +

+

+ {this.props.description || + this.state.error?.message || + "An unexpected error occurred."} +

+ {this.props.description && this.state.error?.message && ( +

+ {this.state.error.message} +

+ )} +
+
+ + {this.props.showReload !== false && ( + + )} +
) } @@ -60,3 +105,20 @@ export class ViewerErrorBoundary extends Component< return this.props.children } } + +export function ViewerErrorBoundary({ + children, + viewerType, + onReset, +}: ErrorBoundaryProps) { + return ( + + {children} + + ) +} diff --git a/src/renderer/features/agents/main/active-chat.tsx b/src/renderer/features/agents/main/active-chat.tsx index cf85ed17..8800e617 100644 --- a/src/renderer/features/agents/main/active-chat.tsx +++ b/src/renderer/features/agents/main/active-chat.tsx @@ -4,6 +4,7 @@ import { stripEmojis } from "../../../components/chat-markdown-renderer" import { Button } from "../../../components/ui/button" +import { RenderErrorBoundary } from "../../../components/ui/error-boundary" import { AgentIcon, AttachIcon, @@ -7681,31 +7682,37 @@ Make sure to preserve all functionality from both branches when resolving confli } }} > - + + +
) }] @@ -7731,31 +7738,37 @@ Make sure to preserve all functionality from both branches when resolving confli }} aria-hidden > - + + +
) })} @@ -7794,31 +7807,37 @@ Make sure to preserve all functionality from both branches when resolving confli }} aria-hidden={!isActive} > - + + + ) }) diff --git a/src/renderer/features/agents/ui/agent-bash-tool.tsx b/src/renderer/features/agents/ui/agent-bash-tool.tsx index d4e839eb..0405e735 100644 --- a/src/renderer/features/agents/ui/agent-bash-tool.tsx +++ b/src/renderer/features/agents/ui/agent-bash-tool.tsx @@ -61,7 +61,7 @@ export const AgentBashTool = memo(function AgentBashTool({ chatStatus, }: AgentBashToolProps) { const [isOutputExpanded, setIsOutputExpanded] = useState(false) - const { isPending } = getToolStatus(part, chatStatus) + const { isPending, isInputStreaming } = getToolStatus(part, chatStatus) const selectedProject = useAtomValue(selectedProjectAtom) const projectPath = selectedProject?.path @@ -97,12 +97,6 @@ export const AgentBashTool = memo(function AgentBashTool({ [displayCommand], ) - // Check if command input is still being streamed - // Only consider streaming if chat is actively streaming (prevents hang on stop) - // Include "submitted" status - this is when request was sent but streaming hasn't started yet - const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted" - const isInputStreaming = part.state === "input-streaming" && isActivelyStreaming - // If command is still being generated (input-streaming state), show loading state if (isInputStreaming) { return ( diff --git a/src/renderer/features/agents/ui/agent-edit-tool.tsx b/src/renderer/features/agents/ui/agent-edit-tool.tsx index 9b0dad56..2cd4237c 100644 --- a/src/renderer/features/agents/ui/agent-edit-tool.tsx +++ b/src/renderer/features/agents/ui/agent-edit-tool.tsx @@ -220,7 +220,7 @@ export const AgentEditTool = memo(function AgentEditTool({ chatStatus, }: AgentEditToolProps) { const [isOutputExpanded, setIsOutputExpanded] = useState(false) - const { isPending, isInterrupted } = getToolStatus(part, chatStatus) + const { isPending, isInterrupted, isInputStreaming } = getToolStatus(part, chatStatus) const codeTheme = useCodeTheme() // Atoms for opening diff sidebar and focusing on file @@ -234,11 +234,6 @@ export const AgentEditTool = memo(function AgentEditTool({ const isWriteMode = part.type === "tool-Write" const toolPrefix = isWriteMode ? "tool-Write" : "tool-Edit" - // Only consider streaming if chat is actively streaming (prevents spinner hang on stop) - // Include "submitted" status - this is when request was sent but streaming hasn't started yet - const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted" - const isInputStreaming = part.state === "input-streaming" && isActivelyStreaming - const filePath = part.input?.file_path || "" const oldString = part.input?.old_string || "" const newString = part.input?.new_string || "" diff --git a/src/renderer/features/agents/ui/agent-mcp-tool-call.tsx b/src/renderer/features/agents/ui/agent-mcp-tool-call.tsx index 25ec8ae3..7fedf454 100644 --- a/src/renderer/features/agents/ui/agent-mcp-tool-call.tsx +++ b/src/renderer/features/agents/ui/agent-mcp-tool-call.tsx @@ -205,15 +205,15 @@ export const AgentMcpToolCall = memo(function AgentMcpToolCall({ chatStatus, }: AgentMcpToolCallProps) { const [isExpanded, setIsExpanded] = useState(false) - const { isPending, isInterrupted } = getToolStatus(part, chatStatus) + const { isPending, isInterrupted, isInputStreaming } = getToolStatus(part, chatStatus) const unwrappedOutput = useMemo(() => unwrapMcpOutput(part.output), [part.output]) const title = useMemo(() => { - if (part.state === "input-streaming") return `Preparing ${mcpInfo.displayName}` + if (isInputStreaming) return `Preparing ${mcpInfo.displayName}` if (isPending) return getActiveTitle(mcpInfo) return getCompletedTitle(mcpInfo) - }, [part.state, isPending, mcpInfo]) + }, [isInputStreaming, isPending, mcpInfo]) const resultCount = useMemo(() => { if (isPending) return null @@ -221,9 +221,9 @@ export const AgentMcpToolCall = memo(function AgentMcpToolCall({ }, [isPending, unwrappedOutput]) const subtitle = useMemo(() => { - if (part.state === "input-streaming") return "" + if (isInputStreaming) return "" return formatMcpArgs(part.input) - }, [part.input, part.state]) + }, [isInputStreaming, part.input]) const displayOutput = useMemo(() => { if (!part.output) return null diff --git a/src/renderer/features/agents/ui/agent-plan-file-tool.tsx b/src/renderer/features/agents/ui/agent-plan-file-tool.tsx index 9a636cdd..d3c0642f 100644 --- a/src/renderer/features/agents/ui/agent-plan-file-tool.tsx +++ b/src/renderer/features/agents/ui/agent-plan-file-tool.tsx @@ -47,7 +47,7 @@ export const AgentPlanFileTool = memo(function AgentPlanFileTool({ }: AgentPlanFileToolProps) { const [isExpanded, setIsExpanded] = useState(false) const [copied, setCopied] = useState(false) - const { isPending } = getToolStatus(part, chatStatus) + const { isPending, isInputStreaming } = getToolStatus(part, chatStatus) const isWrite = part.type === "tool-Write" // Get mode from per-subChat atomFamily const subChatModeAtom = useMemo(() => subChatModeAtomFamily(subChatId), [subChatId]) @@ -71,10 +71,6 @@ export const AgentPlanFileTool = memo(function AgentPlanFileTool({ const [, setIsPlanSidebarOpen] = useAtom(planSidebarOpenAtom) const [, setCurrentPlanPath] = useAtom(currentPlanPathAtom) - // Only consider streaming if chat is actively streaming - const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted" - const isInputStreaming = part.state === "input-streaming" && isActivelyStreaming - // Get plan content - for Write mode it's in input.content, for Edit it's in new_string const planContent = isWrite ? (part.input?.content || "") : (part.input?.new_string || "") const filePath = part.input?.file_path || "" diff --git a/src/renderer/features/agents/ui/agent-thinking-tool.tsx b/src/renderer/features/agents/ui/agent-thinking-tool.tsx index edfda08c..864f7237 100644 --- a/src/renderer/features/agents/ui/agent-thinking-tool.tsx +++ b/src/renderer/features/agents/ui/agent-thinking-tool.tsx @@ -6,6 +6,7 @@ import { cn } from "../../../lib/utils" import { ChatMarkdownRenderer } from "../../../components/chat-markdown-renderer" import { TextShimmer } from "../../../components/ui/text-shimmer" import { AgentToolInterrupted } from "./agent-tool-interrupted" +import { getToolStatus } from "./agent-tool-registry" import { areToolPropsEqual } from "./agent-tool-utils" interface ThinkingToolPart { @@ -41,11 +42,8 @@ export const AgentThinkingTool = memo(function AgentThinkingTool({ part, chatStatus, }: AgentThinkingToolProps) { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted" - const isStreaming = isPending && isActivelyStreaming - const isInterrupted = isPending && !isActivelyStreaming && chatStatus !== undefined + const { isPending, isInterrupted } = getToolStatus(part, chatStatus) + const isStreaming = isPending // Default: expanded while streaming, collapsed when done const [isExpanded, setIsExpanded] = useState(isStreaming) diff --git a/src/renderer/features/agents/ui/agent-tool-registry.tsx b/src/renderer/features/agents/ui/agent-tool-registry.tsx index 3bf1cb98..5f24a38e 100644 --- a/src/renderer/features/agents/ui/agent-tool-registry.tsx +++ b/src/renderer/features/agents/ui/agent-tool-registry.tsx @@ -24,6 +24,11 @@ import { SparklesIcon, WriteFileIcon, } from "../../../components/ui/icons" +import { + getToolLifecycleState, +} from "./agent-tool-state" + +export { getToolStatus } from "./agent-tool-state" export type ToolVariant = "simple" | "collapsible" @@ -35,21 +40,12 @@ export interface ToolMeta { variant: ToolVariant } -export function getToolStatus(part: any, chatStatus?: string) { - const basePending = - part.state !== "output-available" && part.state !== "output-error" && part.state !== "result" - const isError = - part.state === "output-error" || - (part.state === "output-available" && part.output?.success === false) - const isSuccess = part.state === "output-available" && !isError - // Critical: if chat stopped streaming, pending tools should show as complete - // Include "submitted" status - this is when request was sent but streaming hasn't started yet - const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted" - const isPending = basePending && isActivelyStreaming - // Tool was in progress but chat stopped streaming (user interrupted) - const isInterrupted = basePending && !isActivelyStreaming && chatStatus !== undefined - - return { isPending, isError, isSuccess, isInterrupted } +function isInputStreaming(part: any) { + return getToolLifecycleState(part).isInputStreaming +} + +function isPendingState(part: any) { + return getToolLifecycleState(part).isPendingState } // Utility to get clean display path (remove sandbox/worktree/absolute prefixes) @@ -130,16 +126,13 @@ export const AgentToolRegistry: Record = { "tool-Task": { icon: SparklesIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing agent" + if (isInputStreaming(part)) return "Preparing agent" const subagentType = part.input?.subagent_type || "Agent" - return isPending ? `Running ${subagentType}` : `${subagentType} completed` + return isPendingState(part) ? `Running ${subagentType}` : `${subagentType} completed` }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const description = part.input?.description || "" return description.length > 50 ? description.slice(0, 47) + "..." @@ -151,11 +144,8 @@ export const AgentToolRegistry: Record = { "tool-Grep": { icon: SearchIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing search" - if (isPending) return "Grepping" + if (isInputStreaming(part)) return "Preparing search" + if (isPendingState(part)) return "Grepping" // Handle different output modes: // - "files_with_matches" mode: numFiles > 0, filenames is populated @@ -173,7 +163,7 @@ export const AgentToolRegistry: Record = { }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const pattern = part.input?.pattern || "" const path = part.input?.path || "" @@ -192,18 +182,15 @@ export const AgentToolRegistry: Record = { "tool-Glob": { icon: FolderSearch, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing search" - if (isPending) return "Exploring files" + if (isInputStreaming(part)) return "Preparing search" + if (isPendingState(part)) return "Exploring files" const numFiles = part.output?.numFiles || 0 return numFiles > 0 ? `Found ${numFiles} files` : "No files found" }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const pattern = part.input?.pattern || "" const targetDir = part.input?.target_directory || "" @@ -222,21 +209,18 @@ export const AgentToolRegistry: Record = { "tool-Read": { icon: EyeIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing to read" - return isPending ? "Reading" : "Read" + if (isInputStreaming(part)) return "Preparing to read" + return isPendingState(part) ? "Reading" : "Read" }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const filePath = part.input?.file_path || "" if (!filePath) return "" // Don't show "file" placeholder during streaming return filePath.split("/").pop() || "" }, tooltipContent: (part, projectPath) => { - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const filePath = part.input?.file_path || "" return getDisplayPath(filePath, projectPath) }, @@ -246,18 +230,15 @@ export const AgentToolRegistry: Record = { "tool-Edit": { icon: IconEditFile, title: (part) => { - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing edit" + if (isInputStreaming(part)) return "Preparing edit" const filePath = part.input?.file_path || "" if (!filePath) return "Edit" // Show "Edit" if no file path yet during streaming return filePath.split("/").pop() || "Edit" }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" - const isPending = - part.state !== "output-available" && part.state !== "output-error" - if (isPending) return "" + if (isInputStreaming(part)) return "" + if (isPendingState(part)) return "" const oldString = part.input?.old_string || "" const newString = part.input?.new_string || "" @@ -312,13 +293,12 @@ export const AgentToolRegistry: Record = { "tool-Write": { icon: WriteFileIcon, title: (part) => { - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing to create" + if (isInputStreaming(part)) return "Preparing to create" return "Create" }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const filePath = part.input?.file_path || "" if (!filePath) return "" // Don't show "file" placeholder during streaming return filePath.split("/").pop() || "" @@ -329,15 +309,12 @@ export const AgentToolRegistry: Record = { "tool-Bash": { icon: CustomTerminalIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Generating command" - return isPending ? "Running command" : "Ran command" + if (isInputStreaming(part)) return "Generating command" + return isPendingState(part) ? "Running command" : "Ran command" }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const command = part.input?.command || "" if (!command) return "" // Normalize line continuations, shorten absolute paths, and truncate @@ -354,15 +331,12 @@ export const AgentToolRegistry: Record = { "tool-WebFetch": { icon: GlobeIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing fetch" - return isPending ? "Fetching" : "Fetched" + if (isInputStreaming(part)) return "Preparing fetch" + return isPendingState(part) ? "Fetching" : "Fetched" }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const url = part.input?.url || "" try { return new URL(url).hostname.replace("www.", "") @@ -376,15 +350,12 @@ export const AgentToolRegistry: Record = { "tool-WebSearch": { icon: SearchIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - const isInputStreaming = part.state === "input-streaming" - if (isInputStreaming) return "Preparing search" - return isPending ? "Searching web" : "Searched web" + if (isInputStreaming(part)) return "Preparing search" + return isPendingState(part) ? "Searching web" : "Searched web" }, subtitle: (part) => { // Don't show subtitle while input is still streaming - if (part.state === "input-streaming") return "" + if (isInputStreaming(part)) return "" const query = part.input?.query || "" return query.length > 40 ? query.slice(0, 37) + "..." : query }, @@ -395,10 +366,8 @@ export const AgentToolRegistry: Record = { "tool-TodoWrite": { icon: ListTodo, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" const action = part.input?.action || "update" - if (isPending) { + if (isPendingState(part)) { return action === "add" ? "Adding todo" : "Updating todos" } return action === "add" ? "Added todo" : "Updated todos" @@ -415,9 +384,7 @@ export const AgentToolRegistry: Record = { "tool-TaskCreate": { icon: Plus, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - return isPending ? "Creating task" : "Created task" + return isPendingState(part) ? "Creating task" : "Created task" }, subtitle: (part) => { const subject = part.input?.subject || "" @@ -431,9 +398,7 @@ export const AgentToolRegistry: Record = { title: (part) => { // Status comes from INPUT (output is just confirmation string) const status = part.input?.status - const isPending = - part.state !== "output-available" && part.state !== "output-error" - if (isPending) { + if (isPendingState(part)) { if (status === "in_progress") return "Starting task" if (status === "completed") return "Completing task" if (status === "deleted") return "Deleting task" @@ -458,9 +423,7 @@ export const AgentToolRegistry: Record = { "tool-TaskGet": { icon: Eye, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - return isPending ? "Getting task" : "Got task" + return isPendingState(part) ? "Getting task" : "Got task" }, subtitle: (part) => { const subject = part.output?.task?.subject @@ -476,10 +439,8 @@ export const AgentToolRegistry: Record = { "tool-TaskList": { icon: List, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" const count = part.output?.tasks?.length - if (isPending) return "Listing tasks" + if (isPendingState(part)) return "Listing tasks" return count !== undefined ? `Listed ${count} tasks` : "Listed tasks" }, subtitle: () => "", @@ -489,11 +450,9 @@ export const AgentToolRegistry: Record = { "tool-PlanWrite": { icon: PlanningIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" const action = part.input?.action || "create" const status = part.input?.plan?.status - if (isPending) { + if (isPendingState(part)) { if (action === "create") return "Creating plan" if (action === "approve") return "Approving plan" if (action === "complete") return "Completing plan" @@ -524,8 +483,7 @@ export const AgentToolRegistry: Record = { "tool-ExitPlanMode": { icon: LogOut, title: (part) => { - const {isPending} = getToolStatus(part) - return isPending ? "Finishing plan" : "Plan complete" + return isPendingState(part) ? "Finishing plan" : "Plan complete" }, subtitle: () => "", variant: "simple", @@ -535,9 +493,7 @@ export const AgentToolRegistry: Record = { "tool-NotebookEdit": { icon: FileCode2, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - return isPending ? "Editing notebook" : "Edited notebook" + return isPendingState(part) ? "Editing notebook" : "Edited notebook" }, subtitle: (part) => { const filePath = part.input?.file_path || "" @@ -551,9 +507,7 @@ export const AgentToolRegistry: Record = { "tool-BashOutput": { icon: Terminal, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - return isPending ? "Getting output" : "Got output" + return isPendingState(part) ? "Getting output" : "Got output" }, subtitle: (part) => { const pid = part.input?.pid @@ -565,9 +519,7 @@ export const AgentToolRegistry: Record = { "tool-KillShell": { icon: XCircle, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - return isPending ? "Stopping shell" : "Stopped shell" + return isPendingState(part) ? "Stopping shell" : "Stopped shell" }, subtitle: (part) => { const pid = part.input?.pid @@ -583,11 +535,7 @@ export const AgentToolRegistry: Record = { "tool-Compact": { icon: Minimize2, title: (part) => { - const isPending = - part.state !== "output-available" && - part.state !== "output-error" && - part.state !== "result" - return isPending ? "Compacting..." : "Compacted" + return isPendingState(part) ? "Compacting..." : "Compacted" }, variant: "simple", }, @@ -596,9 +544,7 @@ export const AgentToolRegistry: Record = { "tool-Thinking": { icon: SparklesIcon, title: (part) => { - const isPending = - part.state !== "output-available" && part.state !== "output-error" - return isPending ? "Thinking..." : "Thought" + return isPendingState(part) ? "Thinking..." : "Thought" }, subtitle: (part) => { const text = part.input?.text || "" diff --git a/src/renderer/features/agents/ui/agent-tool-state.ts b/src/renderer/features/agents/ui/agent-tool-state.ts new file mode 100644 index 00000000..bdeffc37 --- /dev/null +++ b/src/renderer/features/agents/ui/agent-tool-state.ts @@ -0,0 +1,61 @@ +"use client" + +export interface ToolLifecycleState { + isInputStreaming: boolean + isTerminal: boolean + isError: boolean + hasOutput: boolean + hasResult: boolean + isPendingState: boolean +} + +export interface ToolStatus extends ToolLifecycleState { + isPending: boolean + isInterrupted: boolean + isSuccess: boolean +} + +function hasValue(value: unknown): boolean { + return value !== undefined && value !== null +} + +export function getToolLifecycleState(part: any): ToolLifecycleState { + const state = typeof part?.state === "string" ? part.state : undefined + const hasOutput = hasValue(part?.output) + const hasResult = hasValue(part?.result) + const isInputStreaming = state === "input-streaming" + const isTerminalState = + state === "output-available" || + state === "output-error" || + state === "result" || + state === "error" + const isError = + state === "output-error" || + state === "error" || + (hasOutput && part?.output?.success === false) || + (hasResult && part?.result?.success === false) + const isTerminal = isTerminalState || hasOutput || hasResult + + return { + isInputStreaming, + isTerminal, + isError, + hasOutput, + hasResult, + isPendingState: !isInputStreaming && !isTerminal, + } +} + +export function getToolStatus(part: any, chatStatus?: string): ToolStatus { + const lifecycle = getToolLifecycleState(part) + const isActivelyStreaming = + chatStatus === "streaming" || chatStatus === "submitted" + const isInFlight = lifecycle.isInputStreaming || lifecycle.isPendingState + + return { + ...lifecycle, + isPending: isInFlight && isActivelyStreaming, + isInterrupted: isInFlight && !isActivelyStreaming && chatStatus !== undefined, + isSuccess: lifecycle.isTerminal && !lifecycle.isError, + } +} diff --git a/src/renderer/features/agents/ui/agent-tool-utils.ts b/src/renderer/features/agents/ui/agent-tool-utils.ts index ea2e35e6..42f0fe3b 100644 --- a/src/renderer/features/agents/ui/agent-tool-utils.ts +++ b/src/renderer/features/agents/ui/agent-tool-utils.ts @@ -7,6 +7,8 @@ * and compare cached values, not object references. */ +import { getToolLifecycleState } from "./agent-tool-state" + // ============================================================================ // TOOL STATE CACHE // ============================================================================ @@ -90,13 +92,7 @@ function arePartsEqual(prev: any, next: any): boolean { * Completed tools don't need to react to chatStatus changes. */ function isToolCompleted(part: any): boolean { - // Has output = completed - if (part.output !== undefined && part.output !== null) return true - // Error state = completed - if (part.state === "error") return true - // Result state = completed (for some tools) - if (part.state === "result") return true - return false + return getToolLifecycleState(part).isTerminal } /** diff --git a/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx b/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx index 5c34c10a..4786e3d3 100644 --- a/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx +++ b/src/renderer/features/agents/ui/agent-web-search-collapsible.tsx @@ -6,6 +6,7 @@ import { ChevronRight } from "lucide-react" import { areToolPropsEqual } from "./agent-tool-utils" import { TextShimmer } from "../../../components/ui/text-shimmer" import { cn } from "../../../lib/utils" +import { getToolStatus } from "./agent-tool-registry" interface SearchResult { title: string @@ -23,12 +24,7 @@ export const AgentWebSearchCollapsible = memo( chatStatus, }: AgentWebSearchCollapsibleProps) { const [isExpanded, setIsExpanded] = useState(false) - - const isPending = - part.state !== "output-available" && part.state !== "output-error" - // Include "submitted" status - this is when request was sent but streaming hasn't started yet - const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted" - const isStreaming = isPending && isActivelyStreaming + const { isPending } = getToolStatus(part, chatStatus) const query = part.input?.query || "" @@ -70,7 +66,7 @@ export const AgentWebSearchCollapsible = memo(
- {isStreaming ? ( + {isPending ? ( 40 ? query.slice(0, 37) + "..." : query} {/* Result count */} - {!isStreaming && hasResults && ( + {!isPending && hasResults && ( ยท {resultCount} {resultCount === 1 ? "result" : "results"} diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 7be1072d..6e80bc03 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -10,6 +10,7 @@ if (import.meta.env.PROD) { import ReactDOM from "react-dom/client" import { App } from "./App" +import { RenderErrorBoundary } from "./components/ui/error-boundary" import "./styles/globals.css" import { preloadDiffHighlighter } from "./lib/themes/diff-view-highlighter" @@ -45,5 +46,13 @@ window.onerror = (message, source, lineno, colno, error) => { const rootElement = document.getElementById("root") if (rootElement) { - ReactDOM.createRoot(rootElement).render() + ReactDOM.createRoot(rootElement).render( + + + , + ) }