Skip to content

Commit fb143ef

Browse files
committed
fix: sidebar project icons + render error boundary + tool state extraction
Three bundled changes from upstream PR 21st-dev#192: - Sidebar: use ProjectIcon (custom project icon or FolderOpen fallback) for local chats and drafts. Remote GitHub chats keep the GitHub avatar. - Error boundary: rename ViewerErrorBoundary -> RenderErrorBoundary with reset-on-key and reload-window options. Wrap App root, all ChatViewInner panes. Add render-process-gone recovery in main process (one-shot reload). - Tool state: extract getToolStatus + getToolLifecycleState into shared agent-tool-state.ts module. Tool registry and components now share the same isInputStreaming logic.
1 parent c8ccc28 commit fb143ef

14 files changed

Lines changed: 368 additions & 260 deletions

src/main/windows/main.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }):
645645
partition: "persist:main", // Use persistent session for cookies
646646
},
647647
})
648+
let attemptedRendererRecovery = false
648649

649650
// Register window with manager and get stable ID for localStorage namespacing
650651
const stableWindowId = windowManager.register(window)
@@ -741,6 +742,22 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }):
741742
return { action: "deny" }
742743
})
743744

745+
window.webContents.on("render-process-gone", (_event, details) => {
746+
console.error("[Main] Renderer process gone in window", window.id, details)
747+
748+
if (attemptedRendererRecovery || window.isDestroyed()) {
749+
return
750+
}
751+
752+
attemptedRendererRecovery = true
753+
setTimeout(() => {
754+
if (!window.isDestroyed()) {
755+
console.log("[Main] Attempting one-shot renderer recovery in window", window.id)
756+
window.webContents.reloadIgnoringCache()
757+
}
758+
}, 150)
759+
})
760+
744761
// Prevent window close if there are active streaming sessions
745762
window.on("close", (event) => {
746763
// Skip confirmation if app quit was already confirmed by the user
@@ -808,6 +825,7 @@ export function createWindow(options?: { chatId?: string; subChatId?: string }):
808825

809826
// Log page load - traffic light visibility is managed by the renderer
810827
window.webContents.on("did-finish-load", () => {
828+
attemptedRendererRecovery = false
811829
console.log("[Main] Page finished loading in window", window.id)
812830
})
813831
window.webContents.on(

src/renderer/components/ui/error-boundary.tsx

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { Component, type ReactNode } from "react"
22
import { AlertCircle } from "lucide-react"
33
import { Button } from "./button"
44

5+
interface RenderErrorBoundaryProps {
6+
children: ReactNode
7+
title?: string
8+
description?: string
9+
resetKey?: string | number | null
10+
onReset?: () => void
11+
compact?: boolean
12+
showReload?: boolean
13+
}
14+
515
interface ErrorBoundaryProps {
616
children: ReactNode
717
viewerType?: string
@@ -13,11 +23,11 @@ interface ErrorBoundaryState {
1323
error: Error | null
1424
}
1525

16-
export class ViewerErrorBoundary extends Component<
17-
ErrorBoundaryProps,
26+
export class RenderErrorBoundary extends Component<
27+
RenderErrorBoundaryProps,
1828
ErrorBoundaryState
1929
> {
20-
constructor(props: ErrorBoundaryProps) {
30+
constructor(props: RenderErrorBoundaryProps) {
2131
super(props)
2232
this.state = { hasError: false, error: null }
2333
}
@@ -28,35 +38,87 @@ export class ViewerErrorBoundary extends Component<
2838

2939
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
3040
console.error(
31-
`[ViewerErrorBoundary] ${this.props.viewerType || "viewer"} crashed:`,
41+
`[RenderErrorBoundary] ${this.props.title || "section"} crashed:`,
3242
error,
3343
errorInfo,
3444
)
3545
}
3646

47+
componentDidUpdate(prevProps: RenderErrorBoundaryProps) {
48+
if (
49+
this.state.hasError &&
50+
prevProps.resetKey !== this.props.resetKey
51+
) {
52+
this.setState({ hasError: false, error: null })
53+
}
54+
}
55+
3756
handleReset = () => {
3857
this.setState({ hasError: false, error: null })
3958
this.props.onReset?.()
4059
}
4160

61+
handleReload = () => {
62+
window.location.reload()
63+
}
64+
4265
render() {
4366
if (this.state.hasError) {
4467
return (
45-
<div className="flex flex-col items-center justify-center h-full gap-3 p-4 text-center">
68+
<div
69+
className={
70+
this.props.compact
71+
? "flex h-full flex-col items-center justify-center gap-3 p-4 text-center"
72+
: "flex h-full min-h-0 w-full flex-col items-center justify-center gap-4 p-6 text-center"
73+
}
74+
>
4675
<AlertCircle className="h-10 w-10 text-muted-foreground" />
47-
<p className="font-medium text-foreground">
48-
Failed to render {this.props.viewerType || "file"}
49-
</p>
50-
<p className="text-sm text-muted-foreground max-w-[300px]">
51-
{this.state.error?.message || "An unexpected error occurred."}
52-
</p>
53-
<Button variant="outline" size="sm" onClick={this.handleReset}>
54-
Try again
55-
</Button>
76+
<div className="space-y-1">
77+
<p className="font-medium text-foreground">
78+
{this.props.title || "Something went wrong"}
79+
</p>
80+
<p className="text-sm text-muted-foreground max-w-[420px]">
81+
{this.props.description ||
82+
this.state.error?.message ||
83+
"An unexpected error occurred."}
84+
</p>
85+
{this.props.description && this.state.error?.message && (
86+
<p className="text-xs text-muted-foreground/70 max-w-[420px] break-words">
87+
{this.state.error.message}
88+
</p>
89+
)}
90+
</div>
91+
<div className="flex items-center gap-2">
92+
<Button variant="outline" size="sm" onClick={this.handleReset}>
93+
Try again
94+
</Button>
95+
{this.props.showReload !== false && (
96+
<Button variant="outline" size="sm" onClick={this.handleReload}>
97+
Reload window
98+
</Button>
99+
)}
100+
</div>
56101
</div>
57102
)
58103
}
59104

60105
return this.props.children
61106
}
62107
}
108+
109+
export function ViewerErrorBoundary({
110+
children,
111+
viewerType,
112+
onReset,
113+
}: ErrorBoundaryProps) {
114+
return (
115+
<RenderErrorBoundary
116+
title={`Failed to render ${viewerType || "file"}`}
117+
onReset={onReset}
118+
compact
119+
showReload={false}
120+
>
121+
{children}
122+
</RenderErrorBoundary>
123+
)
124+
}

src/renderer/features/agents/main/active-chat.tsx

Lines changed: 94 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
stripEmojis
55
} from "../../../components/chat-markdown-renderer"
66
import { Button } from "../../../components/ui/button"
7+
import { RenderErrorBoundary } from "../../../components/ui/error-boundary"
78
import {
89
AgentIcon,
910
AttachIcon,
@@ -7686,31 +7687,37 @@ Make sure to preserve all functionality from both branches when resolving confli
76867687
}
76877688
}}
76887689
>
7689-
<ChatViewInner
7690-
chat={chat}
7691-
subChatId={paneId}
7692-
parentChatId={chatId}
7693-
provider={inferProviderFromMessages(paneId)}
7694-
isFirstSubChat={isFirstSubChat}
7695-
onAutoRename={handleAutoRename}
7696-
onCreateNewSubChat={handleCreateNewSubChat}
7697-
onProviderChange={handleProviderChange}
7698-
teamId={selectedTeamId || undefined}
7699-
repository={repository}
7700-
streamId={agentChatStore.getStreamId(paneId)}
7701-
isMobile={isMobileFullscreen}
7702-
isSubChatsSidebarOpen={subChatsSidebarMode === "sidebar"}
7703-
sandboxId={sandboxId || undefined}
7704-
projectPath={worktreePath || undefined}
7705-
isArchived={isArchived}
7706-
onRestoreWorkspace={handleRestoreWorkspace}
7707-
existingPrUrl={agentChat?.prUrl}
7708-
isActive={paneId === activeSubChatId}
7709-
isSplitPane={true}
7710-
workspaceName={agentChat?.name ?? null}
7711-
workspaceBranch={agentChat?.branch ?? null}
7712-
workspaceRepoName={(agentChat as any)?.project?.gitRepo || (agentChat as any)?.project?.name || null}
7713-
/>
7690+
<RenderErrorBoundary
7691+
title="Chat pane failed to render"
7692+
description="A workspace UI error interrupted this pane. Reload the window to recover."
7693+
resetKey={paneId}
7694+
>
7695+
<ChatViewInner
7696+
chat={chat}
7697+
subChatId={paneId}
7698+
parentChatId={chatId}
7699+
provider={inferProviderFromMessages(paneId)}
7700+
isFirstSubChat={isFirstSubChat}
7701+
onAutoRename={handleAutoRename}
7702+
onCreateNewSubChat={handleCreateNewSubChat}
7703+
onProviderChange={handleProviderChange}
7704+
teamId={selectedTeamId || undefined}
7705+
repository={repository}
7706+
streamId={agentChatStore.getStreamId(paneId)}
7707+
isMobile={isMobileFullscreen}
7708+
isSubChatsSidebarOpen={subChatsSidebarMode === "sidebar"}
7709+
sandboxId={sandboxId || undefined}
7710+
projectPath={worktreePath || undefined}
7711+
isArchived={isArchived}
7712+
onRestoreWorkspace={handleRestoreWorkspace}
7713+
existingPrUrl={agentChat?.prUrl}
7714+
isActive={paneId === activeSubChatId}
7715+
isSplitPane={true}
7716+
workspaceName={agentChat?.name ?? null}
7717+
workspaceBranch={agentChat?.branch ?? null}
7718+
workspaceRepoName={(agentChat as any)?.project?.gitRepo || (agentChat as any)?.project?.name || null}
7719+
/>
7720+
</RenderErrorBoundary>
77147721
</div>
77157722
)
77167723
}]
@@ -7736,31 +7743,37 @@ Make sure to preserve all functionality from both branches when resolving confli
77367743
}}
77377744
aria-hidden
77387745
>
7739-
<ChatViewInner
7740-
chat={chat}
7741-
subChatId={subChatId}
7742-
parentChatId={chatId}
7743-
provider={inferProviderFromMessages(subChatId)}
7744-
isFirstSubChat={isFirstSubChat}
7745-
onAutoRename={handleAutoRename}
7746-
onCreateNewSubChat={handleCreateNewSubChat}
7747-
onProviderChange={handleProviderChange}
7748-
teamId={selectedTeamId || undefined}
7749-
repository={repository}
7750-
streamId={agentChatStore.getStreamId(subChatId)}
7751-
isMobile={isMobileFullscreen}
7752-
isSubChatsSidebarOpen={subChatsSidebarMode === "sidebar"}
7753-
sandboxId={sandboxId || undefined}
7754-
projectPath={worktreePath || undefined}
7755-
isArchived={isArchived}
7756-
onRestoreWorkspace={handleRestoreWorkspace}
7757-
existingPrUrl={agentChat?.prUrl}
7758-
isActive={false}
7759-
isSplitPane={false}
7760-
workspaceName={agentChat?.name ?? null}
7761-
workspaceBranch={agentChat?.branch ?? null}
7762-
workspaceRepoName={(agentChat as any)?.project?.gitRepo || (agentChat as any)?.project?.name || null}
7763-
/>
7746+
<RenderErrorBoundary
7747+
title="Chat pane failed to render"
7748+
description="A workspace UI error interrupted this pane. Reload the window to recover."
7749+
resetKey={subChatId}
7750+
>
7751+
<ChatViewInner
7752+
chat={chat}
7753+
subChatId={subChatId}
7754+
parentChatId={chatId}
7755+
provider={inferProviderFromMessages(subChatId)}
7756+
isFirstSubChat={isFirstSubChat}
7757+
onAutoRename={handleAutoRename}
7758+
onCreateNewSubChat={handleCreateNewSubChat}
7759+
onProviderChange={handleProviderChange}
7760+
teamId={selectedTeamId || undefined}
7761+
repository={repository}
7762+
streamId={agentChatStore.getStreamId(subChatId)}
7763+
isMobile={isMobileFullscreen}
7764+
isSubChatsSidebarOpen={subChatsSidebarMode === "sidebar"}
7765+
sandboxId={sandboxId || undefined}
7766+
projectPath={worktreePath || undefined}
7767+
isArchived={isArchived}
7768+
onRestoreWorkspace={handleRestoreWorkspace}
7769+
existingPrUrl={agentChat?.prUrl}
7770+
isActive={false}
7771+
isSplitPane={false}
7772+
workspaceName={agentChat?.name ?? null}
7773+
workspaceBranch={agentChat?.branch ?? null}
7774+
workspaceRepoName={(agentChat as any)?.project?.gitRepo || (agentChat as any)?.project?.name || null}
7775+
/>
7776+
</RenderErrorBoundary>
77647777
</div>
77657778
)
77667779
})}
@@ -7799,31 +7812,37 @@ Make sure to preserve all functionality from both branches when resolving confli
77997812
}}
78007813
aria-hidden={!isActive}
78017814
>
7802-
<ChatViewInner
7803-
chat={chat}
7804-
subChatId={subChatId}
7805-
parentChatId={chatId}
7806-
provider={inferProviderFromMessages(subChatId)}
7807-
isFirstSubChat={isFirstSubChat}
7808-
onAutoRename={handleAutoRename}
7809-
onCreateNewSubChat={handleCreateNewSubChat}
7810-
onProviderChange={handleProviderChange}
7811-
teamId={selectedTeamId || undefined}
7812-
repository={repository}
7813-
streamId={agentChatStore.getStreamId(subChatId)}
7814-
isMobile={isMobileFullscreen}
7815-
isSubChatsSidebarOpen={subChatsSidebarMode === "sidebar"}
7816-
sandboxId={sandboxId || undefined}
7817-
projectPath={worktreePath || undefined}
7818-
isArchived={isArchived}
7819-
onRestoreWorkspace={handleRestoreWorkspace}
7820-
existingPrUrl={agentChat?.prUrl}
7821-
isActive={isActive}
7822-
isSplitPane={false}
7823-
workspaceName={agentChat?.name ?? null}
7824-
workspaceBranch={agentChat?.branch ?? null}
7825-
workspaceRepoName={(agentChat as any)?.project?.gitRepo || (agentChat as any)?.project?.name || null}
7826-
/>
7815+
<RenderErrorBoundary
7816+
title="Chat pane failed to render"
7817+
description="A workspace UI error interrupted this pane. Reload the window to recover."
7818+
resetKey={subChatId}
7819+
>
7820+
<ChatViewInner
7821+
chat={chat}
7822+
subChatId={subChatId}
7823+
parentChatId={chatId}
7824+
provider={inferProviderFromMessages(subChatId)}
7825+
isFirstSubChat={isFirstSubChat}
7826+
onAutoRename={handleAutoRename}
7827+
onCreateNewSubChat={handleCreateNewSubChat}
7828+
onProviderChange={handleProviderChange}
7829+
teamId={selectedTeamId || undefined}
7830+
repository={repository}
7831+
streamId={agentChatStore.getStreamId(subChatId)}
7832+
isMobile={isMobileFullscreen}
7833+
isSubChatsSidebarOpen={subChatsSidebarMode === "sidebar"}
7834+
sandboxId={sandboxId || undefined}
7835+
projectPath={worktreePath || undefined}
7836+
isArchived={isArchived}
7837+
onRestoreWorkspace={handleRestoreWorkspace}
7838+
existingPrUrl={agentChat?.prUrl}
7839+
isActive={isActive}
7840+
isSplitPane={false}
7841+
workspaceName={agentChat?.name ?? null}
7842+
workspaceBranch={agentChat?.branch ?? null}
7843+
workspaceRepoName={(agentChat as any)?.project?.gitRepo || (agentChat as any)?.project?.name || null}
7844+
/>
7845+
</RenderErrorBoundary>
78277846
</div>
78287847
)
78297848
})

src/renderer/features/agents/ui/agent-bash-tool.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export const AgentBashTool = memo(function AgentBashTool({
6161
chatStatus,
6262
}: AgentBashToolProps) {
6363
const [isOutputExpanded, setIsOutputExpanded] = useState(false)
64-
const { isPending } = getToolStatus(part, chatStatus)
64+
const { isPending, isInputStreaming } = getToolStatus(part, chatStatus)
6565
const selectedProject = useAtomValue(selectedProjectAtom)
6666
const projectPath = selectedProject?.path
6767

@@ -97,12 +97,6 @@ export const AgentBashTool = memo(function AgentBashTool({
9797
[displayCommand],
9898
)
9999

100-
// Check if command input is still being streamed
101-
// Only consider streaming if chat is actively streaming (prevents hang on stop)
102-
// Include "submitted" status - this is when request was sent but streaming hasn't started yet
103-
const isActivelyStreaming = chatStatus === "streaming" || chatStatus === "submitted"
104-
const isInputStreaming = part.state === "input-streaming" && isActivelyStreaming
105-
106100
// If command is still being generated (input-streaming state), show loading state
107101
if (isInputStreaming) {
108102
return (

0 commit comments

Comments
 (0)