Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/main/lib/claude/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ const STRIPPED_ENV_KEYS_BASE = [
"OPENAI_API_KEY",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
// Prevent "Claude Code cannot be launched inside another session" when the
// dev build is spawned from a `claude` CLI terminal — the CLI sets these
// markers on its environment and they propagate into Electron's process.env.
// We unconditionally strip them here and then re-set CLAUDE_CODE_ENTRYPOINT
// to our own value below (order matters: strip → set).
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDECODE",
]

// In dev mode, also strip ANTHROPIC_API_KEY so OAuth token is used instead
Expand Down
94 changes: 87 additions & 7 deletions src/main/lib/trpc/routers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { observable } from "@trpc/server/observable"
import { eq } from "drizzle-orm"
import { app, BrowserWindow, safeStorage } from "electron"
import * as fs from "fs/promises"
import { existsSync } from "node:fs"
import * as os from "os"
import path from "path"
import { z } from "zod"
Expand Down Expand Up @@ -897,11 +898,34 @@ export const claudeRouter = router({
const db = getDatabase()

// 1. Get existing messages from DB
const existing = db
let existing = db
.select()
.from(subChats)
.where(eq(subChats.id, input.subChatId))
.get()

// Safety net: if the sub-chat row doesn't exist yet (renderer sent
// `send` before `createSubChat` round-tripped), backfill it instead
// of silently no-op'ing the UPDATE below.
if (!existing) {
console.warn(
`[claude] sub-chat ${input.subChatId} missing on send — auto-creating`,
)
db.insert(subChats)
.values({
id: input.subChatId,
chatId: input.chatId,
mode: input.mode,
messages: "[]",
})
.run()
existing = db
.select()
.from(subChats)
.where(eq(subChats.id, input.subChatId))
.get()
}

const existingMessages = JSON.parse(existing?.messages || "[]")
const existingSessionId = existing?.sessionId || null

Expand Down Expand Up @@ -1494,7 +1518,33 @@ export const claudeRouter = router({
}
}

const resolvedModel = finalCustomConfig?.model || input.model
const rawResolvedModel = finalCustomConfig?.model || input.model
// Opus 1M: the UI exposes `opus[1m]` as a distinct model, but the
// Claude CLI only understands the `opus` shortcut. To opt into the
// 1M context window we strip the `[1m]` suffix for the model field
// and enable the matching beta via ANTHROPIC_BETAS on the child env.
const isOpus1M = rawResolvedModel === "opus[1m]"
const resolvedModel = isOpus1M ? "opus" : rawResolvedModel
if (isOpus1M) {
const envAsRecord = finalEnv as Record<string, string>
const existingBetas = envAsRecord.ANTHROPIC_BETAS
const betaSlug = "context-1m-2025-08-07"
const merged = existingBetas
? Array.from(
new Set([
...existingBetas
.split(",")
.map((s: string) => s.trim())
.filter(Boolean),
betaSlug,
]),
).join(",")
: betaSlug
envAsRecord.ANTHROPIC_BETAS = merged
console.log(
`[claude] Opus 1M context enabled — ANTHROPIC_BETAS=${merged}`,
)
}

// DEBUG: If using Ollama, test if it's actually responding
if (isUsingOllama && finalCustomConfig) {
Expand Down Expand Up @@ -2038,6 +2088,21 @@ ${prompt}
// Plan mode: track ExitPlanMode to stop after plan is complete
let exitPlanModeToolCallId: string | null = null

// Stream wedge recovery: abort if no first chunk arrives within 90s.
// SDK hangs have been observed with no crash/no error — detect them
// instead of letting the UI sit on a pending stream forever.
let streamWedged = false
const WEDGE_TIMEOUT_MS = 90_000
const wedgeTimer = setTimeout(() => {
if (!firstMessageReceived) {
streamWedged = true
console.error(
`[claude] Stream wedged — no data in ${WEDGE_TIMEOUT_MS / 1000}s, aborting`,
)
abortController.abort()
}
}, WEDGE_TIMEOUT_MS)

if (isUsingOllama) {
console.log(`[Ollama] ===== STARTING STREAM ITERATION =====`)
console.log(`[Ollama] Model: ${finalCustomConfig?.model}`)
Expand Down Expand Up @@ -2094,6 +2159,7 @@ ${prompt}
// Warn if SDK initialization is slow (MCP delay)
if (!firstMessageReceived) {
firstMessageReceived = true
clearTimeout(wedgeTimer)
const timeToFirstMessage = Date.now() - streamIterationStart
if (isUsingOllama) {
console.log(
Expand Down Expand Up @@ -2423,6 +2489,8 @@ ${prompt}
}
}

clearTimeout(wedgeTimer)

// Warn if stream yielded no messages (offline mode issue)
const streamDuration = Date.now() - streamIterationStart
if (isUsingOllama) {
Expand Down Expand Up @@ -2469,6 +2537,7 @@ ${prompt}
}
} catch (streamError) {
// This catches errors during streaming (like process exit)
clearTimeout(wedgeTimer)
const err = streamError as Error
const stderrOutput = stderrLines.join("\n")

Expand Down Expand Up @@ -2496,7 +2565,10 @@ ${prompt}
"No conversation found with session ID",
)

if (isSessionNotFound) {
if (streamWedged) {
errorContext = `Claude stream wedged — no data received in ${WEDGE_TIMEOUT_MS / 1000}s. Try again.`
errorCategory = "STREAM_WEDGE"
} else if (isSessionNotFound) {
// Clear the invalid session ID from database so next attempt starts fresh
console.log(
`[claude] Session not found - clearing invalid sessionId from database`,
Expand All @@ -2512,8 +2584,15 @@ ${prompt}
errorContext = "Claude Code process crashed"
errorCategory = "PROCESS_CRASH"
} else if (err.message?.includes("ENOENT")) {
errorContext = "Required executable not found in PATH"
errorCategory = "EXECUTABLE_NOT_FOUND"
// If the bundled Claude binary is missing, surface a clear
// recovery path instead of a generic PATH error.
if (!existsSync(claudeBinaryPath)) {
errorContext = `Claude binary not found at ${claudeBinaryPath}. Run \`bun run claude:download\` and restart the app.`
errorCategory = "CLAUDE_BINARY_MISSING"
} else {
errorContext = "Required executable not found in PATH"
errorCategory = "EXECUTABLE_NOT_FOUND"
}
} else if (
err.message?.includes("authentication") ||
err.message?.includes("401")
Expand Down Expand Up @@ -2564,8 +2643,9 @@ ${prompt}
}
}

// Send error with stderr output to frontend (only if not aborted by user)
if (!abortController.signal.aborted) {
// Send error with stderr output to frontend (only if not aborted by user).
// Wedge-triggered aborts are NOT user aborts — surface them.
if (!abortController.signal.aborted || streamWedged) {
safeEmit({
type: "error",
errorText: stderrOutput
Expand Down
19 changes: 17 additions & 2 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,23 @@ if (process.env.NODE_ENV === "production") {
})
}

// Expose tRPC IPC bridge for type-safe communication
exposeElectronTRPC()
// Expose tRPC IPC bridge for type-safe communication.
// Guard against a race where exposeElectronTRPC throws during preload boot
// (observed as a black screen crash). Surface the error to the renderer via
// a flag the AppErrorBoundary reads on mount, so we can recover instead of
// rendering a blank window.
try {
exposeElectronTRPC()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error("[preload] exposeElectronTRPC failed:", err)
try {
contextBridge.exposeInMainWorld("__ipcBootError", message)
} catch {
// If even the contextBridge call fails, nothing more we can do here —
// the renderer will still show its error boundary on the first React crash.
}
}

// Expose webUtils for file path access in drag and drop
contextBridge.exposeInMainWorld("webUtils", {
Expand Down
41 changes: 22 additions & 19 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Provider as JotaiProvider, useAtomValue, useSetAtom } from "jotai"
import { ThemeProvider, useTheme } from "next-themes"
import { useEffect, useMemo } from "react"
import { Toaster } from "sonner"
import { AppErrorBoundary } from "./components/ui/error-boundary"
import { TooltipProvider } from "./components/ui/tooltip"
import { TRPCProvider } from "./contexts/TRPCProvider"
import { WindowProvider, getInitialWindowParams } from "./contexts/WindowContext"
Expand Down Expand Up @@ -202,24 +203,26 @@ export function App() {
}, [])

return (
<WindowProvider>
<JotaiProvider store={appStore}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<VSCodeThemeProvider>
<TooltipProvider delayDuration={100}>
<TRPCProvider>
<div
data-agents-page
className="h-screen w-screen bg-background text-foreground overflow-hidden"
>
<AppContent />
</div>
<ThemedToaster />
</TRPCProvider>
</TooltipProvider>
</VSCodeThemeProvider>
</ThemeProvider>
</JotaiProvider>
</WindowProvider>
<AppErrorBoundary>
<WindowProvider>
<JotaiProvider store={appStore}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<VSCodeThemeProvider>
<TooltipProvider delayDuration={100}>
<TRPCProvider>
<div
data-agents-page
className="h-screen w-screen bg-background text-foreground overflow-hidden"
>
<AppContent />
</div>
<ThemedToaster />
</TRPCProvider>
</TooltipProvider>
</VSCodeThemeProvider>
</ThemeProvider>
</JotaiProvider>
</WindowProvider>
</AppErrorBoundary>
)
}
94 changes: 93 additions & 1 deletion src/renderer/components/ui/error-boundary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Component, type ReactNode } from "react"
import { Component, type ErrorInfo, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { Button } from "./button"

Expand Down Expand Up @@ -60,3 +60,95 @@ export class ViewerErrorBoundary extends Component<
return this.props.children
}
}

interface AppErrorBoundaryState {
hasError: boolean
error: Error | null
ipcBootError: string | null
}

// Key used to remember a recent auto-reload so a crash loop doesn't infinite-reload.
const AUTO_RELOAD_FLAG = "app:error-boundary:auto-reloaded-at"
const AUTO_RELOAD_WINDOW_MS = 10_000

// Root-level error boundary. Catches renderer crashes that would otherwise
// leave the user on a black screen (e.g. from a throwing top-level component
// or a missing IPC bridge during preload boot). Auto-reloads once within a
// short window; subsequent crashes show a manual Reload button instead of
// looping.
export class AppErrorBoundary extends Component<
{ children: ReactNode },
AppErrorBoundaryState
> {
constructor(props: { children: ReactNode }) {
super(props)
const ipcBootError =
typeof window !== "undefined"
? ((window as unknown as { __ipcBootError?: string }).__ipcBootError ??
null)
: null
this.state = {
hasError: Boolean(ipcBootError),
error: ipcBootError ? new Error(ipcBootError) : null,
ipcBootError,
}
}

static getDerivedStateFromError(error: Error): Partial<AppErrorBoundaryState> {
return { hasError: true, error }
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("[AppErrorBoundary] Root crash:", error, errorInfo)
this.maybeAutoReload()
}

componentDidMount() {
if (this.state.ipcBootError) {
console.error(
"[AppErrorBoundary] IPC bridge failed to boot:",
this.state.ipcBootError,
)
this.maybeAutoReload()
}
}

private maybeAutoReload() {
try {
const last = Number(sessionStorage.getItem(AUTO_RELOAD_FLAG) || 0)
if (!last || Date.now() - last > AUTO_RELOAD_WINDOW_MS) {
sessionStorage.setItem(AUTO_RELOAD_FLAG, String(Date.now()))
console.warn("[AppErrorBoundary] Auto-reloading once to recover")
window.location.reload()
}
} catch {
// sessionStorage unavailable (e.g. in a sandbox) — skip auto-reload
}
}

handleReload = () => {
window.location.reload()
}

render() {
if (!this.state.hasError) return this.props.children

const message =
this.state.ipcBootError ||
this.state.error?.message ||
"An unexpected error occurred."

return (
<div className="flex flex-col items-center justify-center h-screen w-screen gap-4 p-6 text-center bg-background text-foreground">
<AlertCircle className="h-12 w-12 text-muted-foreground" />
<p className="font-medium text-lg">Something went wrong</p>
<p className="text-sm text-muted-foreground max-w-[420px] break-words">
{message}
</p>
<Button variant="outline" onClick={this.handleReload}>
Reload app
</Button>
</div>
)
}
}