From 2151177172c1803d300e4fa96a8d17f6191f1232 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 13:40:04 -0400 Subject: [PATCH 1/6] Fix Claude runtime resolution and lane delete warnings --- .../src/tuiClient/__tests__/format.test.ts | 13 +- apps/ade-cli/src/tuiClient/format.ts | 19 ++- apps/desktop/src/main/packagedRuntimeSmoke.ts | 27 +--- .../main/services/ai/aiIntegrationService.ts | 35 +---- .../services/ai/claudeCodeExecutable.test.ts | 44 ++++++ .../main/services/ai/claudeCodeExecutable.ts | 89 ++++++++++- .../services/ai/claudeRuntimeProbe.test.ts | 1 + .../main/services/ai/claudeRuntimeProbe.ts | 14 +- .../services/chat/agentChatService.test.ts | 8 + .../main/services/chat/agentChatService.ts | 42 +++++- .../services/chat/claudeOutputStyles.test.ts | 76 ++++++++++ .../main/services/chat/claudeOutputStyles.ts | 139 +++++++++++++++++- .../main/services/lanes/laneService.test.ts | 33 +++++ .../src/main/services/lanes/laneService.ts | 29 +++- .../chat/AgentChatMessageList.test.tsx | 22 +++ .../components/chat/AgentChatMessageList.tsx | 31 +++- .../renderer/components/lanes/LanesPage.tsx | 56 +++++-- .../components/lanes/ManageLaneDialog.tsx | 18 +++ apps/desktop/src/shared/types/chat.ts | 2 + apps/desktop/src/shared/types/lanes.ts | 3 +- docs/features/chat/README.md | 16 +- docs/features/lanes/README.md | 4 +- 22 files changed, 624 insertions(+), 97 deletions(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 9880e83b0..8bdb508b8 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -511,15 +511,22 @@ describe("renderChatLines", () => { sessionId: "s1", timestamp: "2026-01-01T12:00:03.000Z", sequence: 4, - event: { type: "system_notice", noticeKind: "rate_limit", message: "rate limit hit" } as never, + event: { type: "system_notice", noticeKind: "rate_limit", severity: "error", message: "rate limit hit" } as never, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:04.000Z", + sequence: 5, + event: { type: "system_notice", noticeKind: "rate_limit", severity: "info", message: "Claude rate limit allowed" } as never, }, ], }); - expect(lines).toHaveLength(4); + expect(lines).toHaveLength(5); expect(lines[0]?.tone).toBe("error"); // error noticeKind expect(lines[1]?.tone).toBe("notice"); // warning is informational expect(lines[2]?.tone).toBe("notice"); // config is informational - expect(lines[3]?.tone).toBe("error"); // rate_limit is severity-bearing + expect(lines[3]?.tone).toBe("error"); // blocking rate_limit is severity-bearing + expect(lines[4]?.tone).toBe("notice"); // allowed rate_limit is telemetry }); it("summarizes command pass and fail counts when present", () => { diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 602ea78cd..a0d3dacdf 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -532,15 +532,18 @@ export function renderChatLines(args: { continue; } if (event.type === "system_notice") { - // Surface the severity-bearing noticeKinds with an error tone so the TUI - // colorizes them distinctively. Guardian warnings, rate limits, thread - // errors, and provider health issues map to `tone: "error"`; warnings and - // config issues keep the default notice tone. + // Surface severity-bearing notices with an error tone while keeping + // non-blocking telemetry, including allowed Claude rate-limit events, + // in the normal notice channel. const noticeKind = (event as { noticeKind?: string }).noticeKind; - const tone: "notice" | "error" = noticeKind === "error" - || noticeKind === "rate_limit" - || noticeKind === "thread_error" - || noticeKind === "provider_health" + const severity = (event as { severity?: string }).severity; + const tone: "notice" | "error" = severity === "error" + || (!severity && ( + noticeKind === "error" + || noticeKind === "thread_error" + || noticeKind === "provider_health" + || noticeKind === "rate_limit" + )) ? "error" : "notice"; lines.push({ diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 72130bd92..492bb273e 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -1,19 +1,14 @@ -import fs from "node:fs"; import os from "node:os"; -import path from "node:path"; -import { createRequire } from "node:module"; import type { Query } from "@anthropic-ai/claude-agent-sdk"; +import { resolveClaudeCodeExecutable } from "./services/ai/claudeCodeExecutable"; import { resolveCodexExecutable } from "./services/ai/codexExecutable"; import { classifyClaudeStartupFailure, - getClaudeNativeBinaryFileName, - getClaudeNativeBinaryPackageName, type ClaudeStartupProbeResult, } from "./packagedRuntimeSmokeShared"; const PTY_PROBE_TIMEOUT_MS = 4_000; const CLAUDE_PROBE_TIMEOUT_MS = 20_000; -const requireFromHere = createRequire(__filename); async function probePty(): Promise<{ ok: true; output: string }> { const pty = await import("node-pty"); @@ -56,6 +51,7 @@ async function probePty(): Promise<{ ok: true; output: string }> { async function probeClaudeStartup(): Promise { const claude = await import("@anthropic-ai/claude-agent-sdk"); + const claudeExecutable = resolveClaudeCodeExecutable(); const abortController = new AbortController(); let didTimeout = false; const timeout = setTimeout(() => { @@ -73,6 +69,7 @@ async function probeClaudeStartup(): Promise { permissionMode: "plan", tools: [], abortController, + pathToClaudeCodeExecutable: claudeExecutable.path, }, }); @@ -113,23 +110,10 @@ async function probeClaudeStartup(): Promise { } } -function resolveClaudeExecutablePath(): string | null { - const packageName = getClaudeNativeBinaryPackageName(); - if (!packageName) return null; - - try { - const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`); - const binaryPath = path.join(path.dirname(packageJsonPath), getClaudeNativeBinaryFileName()); - return fs.existsSync(binaryPath) ? binaryPath : null; - } catch { - return null; - } -} - async function main(): Promise { const pty = await import("node-pty"); const claude = await import("@anthropic-ai/claude-agent-sdk"); - const claudeExecutablePath = resolveClaudeExecutablePath(); + const claudeExecutable = resolveClaudeCodeExecutable(); const codexExecutable = resolveCodexExecutable(); const ptyProbe = await probePty(); const claudeStartup = await probeClaudeStartup(); @@ -138,7 +122,8 @@ async function main(): Promise { ok: true, nodePty: typeof pty.spawn, claudeQuery: typeof claude.query, - claudeExecutablePath, + claudeExecutablePath: claudeExecutable.path, + claudeExecutableSource: claudeExecutable.source, claudeStartup, codexExecutable: typeof resolveCodexExecutable, codexExecutablePath: codexExecutable.path, diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index 7ff30e507..6ad8d2fd6 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1,7 +1,4 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; -import { createRequire } from "node:module"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; import type { createProjectConfigService } from "../config/projectConfigService"; @@ -75,8 +72,7 @@ import { buildProviderConnections } from "./providerConnectionStatus"; import { getProviderRuntimeHealthVersion, resetProviderRuntimeHealth } from "./providerRuntimeHealth"; import { probeClaudeRuntimeHealth, resetClaudeRuntimeProbeCache } from "./claudeRuntimeProbe"; import { runProviderTask } from "./providerTaskRunner"; - -const requireFromHere = createRequire(import.meta.url); +import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; export type AiTaskType = | "planning" @@ -373,32 +369,11 @@ function detectClaudeAuthModeFromConnection( return "none"; } -function getClaudeNativeBinaryPackageName(): string | null { - const platform = process.platform; - const arch = process.arch; - if (platform === "darwin" && arch === "arm64") return "@anthropic-ai/claude-agent-sdk-darwin-arm64"; - if (platform === "darwin" && arch === "x64") return "@anthropic-ai/claude-agent-sdk-darwin-x64"; - if (platform === "linux" && arch === "arm64") return "@anthropic-ai/claude-agent-sdk-linux-arm64"; - if (platform === "linux" && arch === "x64") return "@anthropic-ai/claude-agent-sdk-linux-x64"; - if (platform === "win32" && arch === "x64") return "@anthropic-ai/claude-agent-sdk-win32-x64"; - return null; -} - function resolveBundledClaudeBinary(): Pick { - const packageName = getClaudeNativeBinaryPackageName(); - if (!packageName) { - return { present: false, source: "missing", path: null }; - } - try { - const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`); - const binaryPath = path.join(path.dirname(packageJsonPath), process.platform === "win32" ? "claude.exe" : "claude"); - if (fs.existsSync(binaryPath)) { - return { present: true, source: "bundled", path: binaryPath }; - } - } catch { - // Optional native package was not installed for this platform. - } - return { present: false, source: "missing", path: null }; + const resolved = resolveClaudeCodeExecutable({ env: { PATH: "" } }); + return resolved.source === "bundled" + ? { present: true, source: "bundled", path: resolved.path } + : { present: false, source: "missing", path: null }; } function buildClaudeAvailabilityFromConnection( diff --git a/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts b/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts index d5a640843..d4b274026 100644 --- a/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts +++ b/apps/desktop/src/main/services/ai/claudeCodeExecutable.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; describe("resolveClaudeCodeExecutable", () => { @@ -37,4 +40,45 @@ describe("resolveClaudeCodeExecutable", () => { source: "auth", }); }); + + it("prefers the packaged bundled native binary before detected auth paths", () => { + const resourcesPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-claude-bundled-")); + const binaryPath = path.join( + resourcesPath, + "app.asar.unpacked", + "node_modules", + "@anthropic-ai", + "claude-agent-sdk-darwin-arm64", + "claude", + ); + fs.mkdirSync(path.dirname(binaryPath), { recursive: true }); + fs.writeFileSync(binaryPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(binaryPath, 0o755); + try { + expect( + resolveClaudeCodeExecutable({ + auth: [ + { + type: "cli-subscription", + cli: "claude", + path: "/opt/homebrew/bin/claude", + authenticated: true, + verified: true, + }, + ], + env: { + PATH: "/usr/bin:/bin", + }, + resourcesPath, + platform: "darwin", + arch: "arm64", + }), + ).toEqual({ + path: binaryPath, + source: "bundled", + }); + } finally { + fs.rmSync(resourcesPath, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts b/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts index 5eb5ad1f7..afb40cbfa 100644 --- a/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts +++ b/apps/desktop/src/main/services/ai/claudeCodeExecutable.ts @@ -1,9 +1,16 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; import type { DetectedAuth } from "./authDetector"; import { resolveExecutableFromKnownLocations } from "./cliExecutableResolver"; +import { + getClaudeNativeBinaryFileName, + getClaudeNativeBinaryPackageName, +} from "../../packagedRuntimeSmokeShared"; export type ClaudeCodeExecutableResolution = { path: string; - source: "env" | "auth" | "path" | "common-dir" | "fallback-command"; + source: "env" | "bundled" | "auth" | "path" | "common-dir" | "fallback-command"; }; function findClaudeAuthPath(auth?: DetectedAuth[]): string | null { @@ -17,9 +24,84 @@ function findClaudeAuthPath(auth?: DetectedAuth[]): string | null { return null; } +export function isExecutablePath(candidatePath: string): boolean { + try { + const stat = fs.statSync(candidatePath); + return stat.isFile() && (process.platform === "win32" || (stat.mode & 0o111) !== 0); + } catch { + return false; + } +} + +function scopedPackagePath(packageName: string): string[] { + return packageName.split("/").filter(Boolean); +} + +function resolvePackageFromRuntimeRoots(specifier: string): string { + const roots = new Set([ + process.cwd(), + path.resolve(process.cwd(), "apps", "desktop"), + path.resolve(process.cwd(), "..", "desktop"), + path.resolve(process.cwd(), "..", "..", "apps", "desktop"), + ]); + let lastError: unknown = null; + for (const root of roots) { + try { + return createRequire(path.join(root, "package.json")).resolve(specifier); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error ? lastError : new Error(`Unable to resolve ${specifier}`); +} + +function resolveBundledClaudeCodeExecutable(args?: { + resourcesPath?: string | null; + platform?: NodeJS.Platform; + arch?: string; + packageResolver?: (specifier: string) => string; +}): string | null { + const platform = args?.platform ?? process.platform; + const arch = args?.arch ?? process.arch; + const packageName = getClaudeNativeBinaryPackageName(platform, arch); + if (!packageName) return null; + + const binaryName = getClaudeNativeBinaryFileName(platform); + const resourcesPath = args?.resourcesPath ?? (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath ?? null; + const packageParts = scopedPackagePath(packageName); + const candidates: string[] = []; + + if (resourcesPath) { + for (const unpackedName of ["app.asar.unpacked", `app-${arch}.asar.unpacked`]) { + candidates.push(path.join(resourcesPath, unpackedName, "node_modules", ...packageParts, binaryName)); + } + } + + for (const candidate of candidates) { + if (isExecutablePath(candidate)) return candidate; + } + + try { + const resolvePackage = args?.packageResolver ?? resolvePackageFromRuntimeRoots; + const packageJsonPath = resolvePackage(`${packageName}/package.json`); + const normalized = path.normalize(packageJsonPath); + const packagedNodeModule = normalized.includes(`${path.sep}app.asar.unpacked${path.sep}`) + || normalized.includes(`${path.sep}app-${arch}.asar.unpacked${path.sep}`); + if (!packagedNodeModule) return null; + const binaryPath = path.join(path.dirname(packageJsonPath), binaryName); + return isExecutablePath(binaryPath) ? binaryPath : null; + } catch { + return null; + } +} + export function resolveClaudeCodeExecutable(args?: { auth?: DetectedAuth[]; env?: NodeJS.ProcessEnv; + resourcesPath?: string | null; + platform?: NodeJS.Platform; + arch?: string; + packageResolver?: (specifier: string) => string; }): ClaudeCodeExecutableResolution { const env = args?.env ?? process.env; const envPath = env.CLAUDE_CODE_EXECUTABLE_PATH?.trim(); @@ -27,6 +109,11 @@ export function resolveClaudeCodeExecutable(args?: { return { path: envPath, source: "env" }; } + const bundledPath = resolveBundledClaudeCodeExecutable(args); + if (bundledPath) { + return { path: bundledPath, source: "bundled" }; + } + const authPath = findClaudeAuthPath(args?.auth); if (authPath) { return { path: authPath, source: "auth" }; diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts index a3be49609..ddfadf761 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.test.ts @@ -67,6 +67,7 @@ describe("claudeRuntimeProbe", () => { expect(mockState.query).toHaveBeenCalledWith(expect.objectContaining({ options: expect.objectContaining({ tools: [], + pathToClaudeCodeExecutable: expect.any(String), }), })); expect(mockState.reportProviderRuntimeAuthFailure).toHaveBeenCalledTimes(1); diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index b59eea1bb..97176fef8 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -1,6 +1,10 @@ import { query as claudeQuery, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import type { Logger } from "../logging/logger"; import { getErrorMessage } from "../shared/utils"; +import { + isExecutablePath, + resolveClaudeCodeExecutable, +} from "./claudeCodeExecutable"; import { reportProviderRuntimeAuthFailure, reportProviderRuntimeFailure, @@ -127,6 +131,7 @@ export async function probeClaudeRuntimeHealth(args: { let stream: ReturnType | null = null; try { + const claudeExecutable = resolveClaudeCodeExecutable(); stream = claudeQuery({ prompt: "System initialization check. Respond with only the word READY.", options: { @@ -134,6 +139,7 @@ export async function probeClaudeRuntimeHealth(args: { permissionMode: "plan", tools: [], abortController, + pathToClaudeCodeExecutable: claudeExecutable.path, }, }); @@ -148,11 +154,17 @@ export async function probeClaudeRuntimeHealth(args: { message: DEFAULT_RUNTIME_FAILURE, }); } catch (error) { + const claudeExecutable = resolveClaudeCodeExecutable(); + const missingMessageIsFalseNegative = claudeExecutable.source !== "fallback-command" + && isExecutablePath(claudeExecutable.path) + && normalizeErrorMessage(error).toLowerCase().includes("native binary not found"); const result = isClaudeRuntimeAuthError(error) ? { state: "auth-failed", message: CLAUDE_RUNTIME_AUTH_ERROR } satisfies ClaudeRuntimeProbeResult : { state: "runtime-failed", - message: normalizeErrorMessage(error), + message: missingMessageIsFalseNegative + ? `Claude runtime failed to spawn resolved executable ${claudeExecutable.path}: ${normalizeErrorMessage(error)}` + : normalizeErrorMessage(error), } satisfies ClaudeRuntimeProbeResult; return cacheResult(projectRoot, result); } finally { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 32bd6d7eb..d614e4179 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { query, startup } from "@anthropic-ai/claude-agent-sdk"; +import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { buildOpenCodePromptParts, startOpenCodeSession } from "../opencode/openCodeRuntime"; import { clearOpenCodeInventoryCache, @@ -1296,6 +1297,8 @@ beforeEach(() => { vi.mocked(query).mockReset(); vi.mocked(startup).mockReset(); installClaudeSdkCompatMocks(); + vi.mocked(resolveClaudeCodeExecutable).mockClear(); + vi.mocked(resolveClaudeCodeExecutable).mockReturnValue({ path: "/usr/local/bin/claude", source: "path" }); vi.mocked(detectAllAuth).mockResolvedValue([]); vi.mocked(parseAgentChatTranscript).mockReturnValue([]); vi.mocked(clearOpenCodeInventoryCache).mockClear(); @@ -3524,10 +3527,15 @@ describe("createAgentChatService", () => { expect(rateLimitNotices[0].event).toMatchObject({ type: "system_notice", noticeKind: "rate_limit", + severity: "warning", + status: "allowed_warning", message: "Claude rate limit allowed warning", }); expect(rateLimitNotices[0].event.detail).toContain("82% utilized"); expect(rateLimitNotices[0].event.detail).toContain("resets"); + expect(claudeSdkCreateSessionCompat.mock.calls.some(([options]) => + options?.pathToClaudeCodeExecutable === "/usr/local/bin/claude", + )).toBe(true); }); it("registers a PreCompact hook on non-lightweight Claude sessions", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 6ab1903ba..130f79686 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -207,6 +207,10 @@ import { createLinearTools } from "../ai/tools/linearTools"; import { createCtoOperatorTools, type CtoOperatorToolDeps } from "../ai/tools/ctoOperatorTools"; import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { resolveClaudeCliModel } from "../ai/claudeModelUtils"; +import { + isExecutablePath, + resolveClaudeCodeExecutable, +} from "../ai/claudeCodeExecutable"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import { getProviderRuntimeHealth, @@ -7004,6 +7008,18 @@ export function createAgentChatService(args: { const match = classifyAgentCliError(`${event.message}\n${event.detail ?? ""}`, managed.session.provider); if (!match) return event; + if (managed.session.provider === "claude" && match.category === "missing") { + const resolved = resolveClaudeCodeExecutable(); + if (resolved.source !== "fallback-command" && isExecutablePath(resolved.path)) { + logger.warn("agent_chat.claude_missing_cli_error_suppressed", { + sessionId: managed.session.id, + resolvedPath: resolved.path, + resolvedSource: resolved.source, + message: event.message, + }); + return event; + } + } return { ...event, @@ -8934,7 +8950,14 @@ export function createAgentChatService(args: { if (msg.type === "rate_limit_event") { const rateMsg = msg as any; const info = rateMsg.rate_limit_info ?? {}; - const status = typeof info.status === "string" ? info.status.replace(/_/g, " ") : "updated"; + const rawStatus = typeof info.status === "string" ? info.status : "updated"; + const status = rawStatus.replace(/_/g, " "); + const severity: "info" | "warning" | "error" = + rawStatus === "allowed" + ? "info" + : rawStatus === "allowed_warning" + ? "warning" + : "error"; const details: string[] = []; if (typeof info.utilization === "number") { const percent = info.utilization <= 1 @@ -8950,6 +8973,8 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "system_notice", noticeKind: "rate_limit", + severity, + status: rawStatus, message: `Claude rate limit ${status}`, detail: details.length ? details.join(" | ") : undefined, turnId, @@ -9417,6 +9442,7 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "system_notice", noticeKind: "rate_limit", + severity: "error", message: `Rate limited${rlMsg.retry_after ? `. Retrying in ${rlMsg.retry_after}s...` : ". Retrying..."}`, turnId, }); @@ -9602,7 +9628,11 @@ export function createAgentChatService(args: { // If resume failed, clear sessionId and the caller can retry fresh const isStaleSessionError = (err: unknown): boolean => { const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); - return msg.includes("session not found") || msg.includes("invalid session") || msg.includes("stale session") || msg.includes("session expired"); + return msg.includes("session not found") + || msg.includes("no conversation found with session id") + || msg.includes("invalid session") + || msg.includes("stale session") + || msg.includes("session expired"); }; if (runtime.sdkSessionId && isStaleSessionError(effectiveError)) { logger.warn("agent_chat.claude_sdk_session_error", { @@ -11703,6 +11733,7 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "system_notice", noticeKind: "rate_limit", + severity: "warning", message: `Codex rate limit: ${rateLimits.remaining}/${rateLimits.limit} remaining${rateLimits.resetAt ? ` (resets ${rateLimits.resetAt})` : ""}`, turnId: typeof params.turnId === "string" ? params.turnId : undefined, }); @@ -12452,6 +12483,7 @@ export function createAgentChatService(args: { managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; const lightweight = isLightweightSession(managed.session); const claudeEnv = buildAgentRuntimeEnv(managed); + const claudeExecutable = resolveClaudeCodeExecutable({ env: claudeEnv }); const outputStyle = resolveManagedClaudeOutputStyle(managed); const pluginPaths = discoverClaudePluginPaths(managed.laneWorktreePath); const mcpServers = lightweight @@ -12465,6 +12497,7 @@ export function createAgentChatService(args: { const opts: ClaudeSDKOptions = { cwd: managed.laneWorktreePath, env: claudeEnv, + pathToClaudeCodeExecutable: claudeExecutable.path, settings: { outputStyle }, ...(pluginPaths.length ? { plugins: pluginPaths.map((pluginPath) => ({ type: "local" as const, path: pluginPath })) } : {}), ...(Object.keys(mcpServers).length ? { mcpServers } : {}), @@ -12485,6 +12518,11 @@ export function createAgentChatService(args: { cwd: managed.laneWorktreePath, }), }; + logger.debug("agent_chat.claude_executable_resolved", { + sessionId: managed.session.id, + source: claudeExecutable.source, + path: claudeExecutable.path, + }); if (!lightweight) { opts.toolConfig = { askUserQuestion: { diff --git a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts index 2a9a9a068..d24667bb7 100644 --- a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts +++ b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts @@ -110,4 +110,80 @@ describe("discoverClaudePlugins", () => { }, ]); }); + + it("ignores plugins that are only present in the user marketplace cache", () => { + const pluginRoot = path.join( + homeRoot, + ".claude", + "plugins", + "marketplaces", + "claude-plugins-official", + "external_plugins", + "serena", + ); + fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "serena", + description: "Marketplace source copy", + })); + + expect(discoverClaudePlugins(tmpRoot)).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "serena" }), + ])); + }); + + it("loads installed user plugins only when Claude settings enable them", () => { + const enabledRoot = path.join( + homeRoot, + ".claude", + "plugins", + "cache", + "claude-plugins-official", + "code-simplifier", + "1.0.0", + ); + const disabledRoot = path.join( + homeRoot, + ".claude", + "plugins", + "cache", + "claude-plugins-official", + "serena", + "1.0.0", + ); + fs.mkdirSync(path.join(enabledRoot, ".claude-plugin"), { recursive: true }); + fs.mkdirSync(path.join(disabledRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(enabledRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "code-simplifier", + })); + fs.writeFileSync(path.join(disabledRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "serena", + })); + fs.mkdirSync(path.join(homeRoot, ".claude", "plugins"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "settings.json"), JSON.stringify({ + enabledPlugins: { + "code-simplifier@claude-plugins-official": true, + "serena@claude-plugins-official": false, + }, + })); + fs.writeFileSync(path.join(homeRoot, ".claude", "plugins", "installed_plugins.json"), JSON.stringify({ + version: 2, + plugins: { + "code-simplifier@claude-plugins-official": [ + { scope: "user", installPath: enabledRoot }, + ], + "serena@claude-plugins-official": [ + { scope: "user", installPath: disabledRoot }, + ], + }, + })); + + expect(discoverClaudePlugins(tmpRoot)).toEqual([ + { + name: "code-simplifier", + path: fs.realpathSync(enabledRoot), + source: "local", + }, + ]); + }); }); diff --git a/apps/desktop/src/main/services/chat/claudeOutputStyles.ts b/apps/desktop/src/main/services/chat/claudeOutputStyles.ts index d48872eb0..5dc015259 100644 --- a/apps/desktop/src/main/services/chat/claudeOutputStyles.ts +++ b/apps/desktop/src/main/services/chat/claudeOutputStyles.ts @@ -7,6 +7,7 @@ import { writeTextAtomic } from "../shared/utils"; const MAX_ANCESTOR_DEPTH = 25; const MAX_PLUGIN_DEPTH = 6; +const CLAUDE_MANAGED_PLUGIN_DIRS = new Set(["cache", "marketplaces"]); export const CLAUDE_BUILT_IN_OUTPUT_STYLES: AgentChatClaudeOutputStyle[] = [ { @@ -43,9 +44,17 @@ type ClaudePluginManifest = { }; type ClaudeSettingsLocal = Record & { + enabledPlugins?: unknown; outputStyle?: unknown; }; +type ClaudeInstalledPluginEntry = Record & { + installPath?: unknown; + path?: unknown; + projectPath?: unknown; + scope?: unknown; +}; + function readFrontmatter(markdown: string): Record { if (!markdown.startsWith("---")) return {}; const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); @@ -64,6 +73,21 @@ function maybeString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } +function maybeRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function readJsonObject(filePath: string): Record { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + return maybeRecord(parsed) ?? {}; + } catch { + return {}; + } +} + function styleKey(value: string): string { return value.trim().toLowerCase(); } @@ -81,6 +105,17 @@ function realpathOrResolve(targetPath: string): string { } } +function isSamePathOrDescendant(candidatePath: string, ancestorPath: string): boolean { + const relative = path.relative(ancestorPath, candidatePath); + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function pathsOverlap(firstPath: string, secondPath: string): boolean { + const first = realpathOrResolve(firstPath); + const second = realpathOrResolve(secondPath); + return isSamePathOrDescendant(first, second) || isSamePathOrDescendant(second, first); +} + function ancestorClaudeRoots(cwd: string): string[] { const roots: string[] = []; const seen = new Set(); @@ -179,6 +214,7 @@ function discoverPluginRoots(pluginsDir: string): string[] { for (const entry of entries) { if (!entry.isDirectory()) continue; + if (depth === 0 && CLAUDE_MANAGED_PLUGIN_DIRS.has(entry.name)) continue; visit(path.join(dir, entry.name), depth + 1); } }; @@ -187,18 +223,113 @@ function discoverPluginRoots(pluginsDir: string): string[] { return roots; } +function isEnabledPluginSetting(value: unknown): boolean { + if (value === false || value === null || value === undefined) return false; + if (maybeRecord(value)?.enabled === false) return false; + return true; +} + +function claudeSettingsFilesByPrecedence(cwd: string): string[] { + const homeClaudeRoot = path.resolve(os.homedir(), ".claude"); + const projectRoots = ancestorClaudeRoots(cwd) + .map((root) => path.resolve(root)) + .filter((root) => root !== homeClaudeRoot) + .reverse(); + + return [ + path.join(homeClaudeRoot, "settings.json"), + ...projectRoots.flatMap((root) => [ + path.join(root, "settings.json"), + path.join(root, "settings.local.json"), + ]), + ]; +} + +function discoverEnabledClaudePluginIds(cwd: string): Set { + const enabledByPluginId = new Map(); + for (const settingsPath of claudeSettingsFilesByPrecedence(cwd)) { + const enabledPlugins = maybeRecord(readJsonObject(settingsPath).enabledPlugins); + if (!enabledPlugins) continue; + for (const [pluginId, enabled] of Object.entries(enabledPlugins)) { + enabledByPluginId.set(pluginId, isEnabledPluginSetting(enabled)); + } + } + return new Set( + [...enabledByPluginId.entries()] + .filter(([, enabled]) => enabled) + .map(([pluginId]) => pluginId), + ); +} + +function readClaudeInstalledPluginEntries(homeClaudeRoot: string): Map { + const registry = readJsonObject(path.join(homeClaudeRoot, "plugins", "installed_plugins.json")); + const rawPlugins = maybeRecord(registry.plugins); + const entriesByPluginId = new Map(); + if (!rawPlugins) return entriesByPluginId; + + for (const [pluginId, rawEntries] of Object.entries(rawPlugins)) { + const rawEntryList = Array.isArray(rawEntries) ? rawEntries : [rawEntries]; + const entries = rawEntryList + .map((entry) => maybeRecord(entry)) + .filter((entry): entry is ClaudeInstalledPluginEntry => !!entry); + if (entries.length) entriesByPluginId.set(pluginId, entries); + } + + return entriesByPluginId; +} + +function installedPluginEntryAppliesToCwd(entry: ClaudeInstalledPluginEntry, cwd: string): boolean { + const scope = maybeString(entry.scope); + if (!scope || scope === "user" || scope === "global") return true; + + const projectPath = maybeString(entry.projectPath); + if (!projectPath) return false; + return pathsOverlap(cwd, projectPath); +} + +function discoverEnabledInstalledPluginPaths(cwd: string): string[] { + const enabledPluginIds = discoverEnabledClaudePluginIds(cwd); + if (!enabledPluginIds.size) return []; + + const homeClaudeRoot = path.resolve(os.homedir(), ".claude"); + const installedPluginEntries = readClaudeInstalledPluginEntries(homeClaudeRoot); + const pluginPaths: string[] = []; + const seen = new Set(); + + for (const pluginId of enabledPluginIds) { + for (const entry of installedPluginEntries.get(pluginId) ?? []) { + if (!installedPluginEntryAppliesToCwd(entry, cwd)) continue; + const installPath = maybeString(entry.installPath) ?? maybeString(entry.path); + if (!installPath || !fs.existsSync(path.join(installPath, ".claude-plugin", "plugin.json"))) continue; + const resolved = realpathOrResolve(installPath); + if (seen.has(resolved)) continue; + seen.add(resolved); + pluginPaths.push(resolved); + } + } + + return pluginPaths; +} + export function discoverClaudePluginPaths(cwd: string): string[] { const roots = claudeRootsByPrecedence(cwd); + const homeClaudeRoot = path.resolve(os.homedir(), ".claude"); const pluginPaths: string[] = []; const seen = new Set(); + const addPluginPath = (pluginRoot: string): void => { + const resolved = realpathOrResolve(pluginRoot); + if (seen.has(resolved)) return; + seen.add(resolved); + pluginPaths.push(resolved); + }; + for (const root of roots) { + if (path.resolve(root) === homeClaudeRoot) continue; for (const pluginRoot of discoverPluginRoots(path.join(root, "plugins"))) { - const resolved = realpathOrResolve(pluginRoot); - if (seen.has(resolved)) continue; - seen.add(resolved); - pluginPaths.push(resolved); + addPluginPath(pluginRoot); } } + for (const pluginRoot of discoverEnabledInstalledPluginPaths(cwd)) addPluginPath(pluginRoot); return pluginPaths; } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index d7899dc69..d099db621 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -2717,6 +2717,39 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(wtStep?.status).toBe("completed"); }); + it("deletes the lane locally when optional remote branch cleanup fails", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service, db } = await setupWithLane({ teardown: fake, events, createWorktree: false }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "show-ref") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "remote" && args[1] === "get-url") return { exitCode: 0, stdout: "git@example.test/repo.git\n", stderr: "" } as any; + if (args[0] === "ls-remote") return { exitCode: 0, stdout: "abc\trefs/heads/feature/child\n", stderr: "" } as any; + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockImplementation(async (args: string[]) => { + if (args[0] === "push") throw new Error("remote rejected delete"); + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + + await service.delete({ + laneId: "lane-child", + deleteBranch: true, + deleteRemoteBranch: true, + remoteName: "origin", + }); + + const last = events[events.length - 1]; + expect(last.progress.overallStatus).toBe("completed_with_warnings"); + expect(last.progress.steps.find((s: any) => s.name === "git_branch_delete")?.status).toBe("completed"); + const remoteStep = last.progress.steps.find((s: any) => s.name === "git_remote_branch_delete"); + expect(remoteStep?.status).toBe("warning"); + expect(remoteStep?.errorMessage).toContain("remote rejected delete"); + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])).toBeNull(); + }); + it("cleans lane-owned database state when deleting a lane", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 56cad9dd0..9c962198c 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3543,13 +3543,16 @@ export function createLaneService({ const token: DeleteToken = { cancelled: false, cancellable: true }; deleteTokens.set(laneId, token); + const nonFatalFailures: Array<{ step: LaneDeleteStepName; message: string }> = []; const runStep = async ( name: LaneDeleteStepName, - work: () => Promise<{ detail?: string } | void> + work: () => Promise<{ detail?: string } | void>, + options?: { fatal?: boolean }, ): Promise => { const step = findStep(name); if (!step) return; + const fatal = options?.fatal !== false; step.status = "running"; step.startedAt = new Date().toISOString(); broadcastDeleteEvent(progress); @@ -3560,12 +3563,18 @@ export function createLaneService({ step.detail = result?.detail; step.status = "completed"; } catch (error) { - step.status = "failed"; - step.errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error); + step.status = fatal ? "failed" : "warning"; + step.errorMessage = errorMessage; step.completedAt = new Date().toISOString(); step.durationMs = Date.now() - t0; + if (!fatal) { + nonFatalFailures.push({ step: name, message: errorMessage }); + logger.warn("lane.delete.non_fatal_step_failed", { laneId, step: name, error: errorMessage }); + } broadcastDeleteEvent(progress); - throw error; + if (fatal) throw error; + return; } step.completedAt = new Date().toISOString(); step.durationMs = Date.now() - t0; @@ -3576,6 +3585,11 @@ export function createLaneService({ }; const finalize = (status: LaneDeleteProgress["overallStatus"]): void => { + if (status === "failed" || status === "cancelled") { + for (const step of progress.steps) { + if (step.status === "pending") step.status = "skipped"; + } + } progress.overallStatus = status; progress.completedAt = new Date().toISOString(); progress.cancellable = false; @@ -3713,7 +3727,7 @@ export function createLaneService({ if (refCheck.exitCode !== 0) return { detail: "ref not found" }; await runGitOrThrow(["branch", "-D", row.branch_ref], { cwd: projectRoot, timeoutMs: 30_000 }); return { detail: row.branch_ref }; - }); + }, { fatal: false }); } if (deleteRemoteBranch && row.branch_ref) { @@ -3732,7 +3746,7 @@ export function createLaneService({ } await runGitOrThrow(["push", remote, "--delete", row.branch_ref], { cwd: projectRoot, timeoutMs: 45_000 }); return { detail: `${remote}/${row.branch_ref}` }; - }); + }, { fatal: false }); } await runStep("pack_dir_remove", async () => { @@ -3767,7 +3781,7 @@ export function createLaneService({ }); invalidateLaneListCache(); - finalize("completed"); + finalize(nonFatalFailures.length > 0 ? "completed_with_warnings" : "completed"); const totalMs = Date.now() - new Date(progress.startedAt).getTime(); if (totalMs >= 1_000) { logger.info("lane.delete.completed", { @@ -3776,6 +3790,7 @@ export function createLaneService({ deleteBranch, deleteRemoteBranch, force, + warnings: nonFatalFailures, durationMs: totalMs }); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 7b8f7e7a8..e6040b186 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -541,6 +541,28 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByText("Codex session is missing thread id")).toBeTruthy(); }); + it("renders allowed rate-limit telemetry as a compact non-error notice", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "system_notice", + noticeKind: "rate_limit", + severity: "info", + status: "allowed", + message: "Claude rate limit allowed", + detail: "resets 2026-05-12T20:30:00.000Z", + }, + }, + ]); + + expect(screen.getByText("rate limit")).toBeTruthy(); + expect(screen.getByText("Claude rate limit allowed")).toBeTruthy(); + expect(screen.getByText("resets 2026-05-12T20:30:00.000Z")).toBeTruthy(); + expect(screen.queryByRole("button")).toBeNull(); + }); + // Work-log grouping, file-change grouping, and overflow-expand tests // removed: they tested old ChatWorkLogBlock rendering (Show N earlier, // specific label text) which changes with every UI iteration. diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index dffcaed62..53dffc4c8 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2533,6 +2533,11 @@ function renderEvent( ); } + const inferredSeverity = event.severity + ?? (event.noticeKind === "rate_limit" && /^Claude rate limit allowed warning/i.test(event.message) ? "warning" as const : undefined) + ?? (event.noticeKind === "rate_limit" && /^Claude rate limit allowed/i.test(event.message) ? "info" as const : undefined) + ?? (event.noticeKind === "rate_limit" || event.noticeKind === "error" || event.noticeKind === "thread_error" ? "error" as const : undefined) + ?? (event.noticeKind === "warning" ? "warning" as const : "info" as const); const kindStyles: Record = { auth: { border: "border-amber-500/18", bg: "bg-amber-500/[0.06]", text: "text-amber-300", icon: Warning }, rate_limit: { border: "border-red-500/18", bg: "bg-red-500/[0.06]", text: "text-red-300", icon: Warning }, @@ -2544,7 +2549,10 @@ function renderEvent( error: { border: "border-red-500/18", bg: "bg-red-500/[0.06]", text: "text-red-300", icon: Warning }, config: { border: "border-border/14", bg: "bg-surface-recessed/70", text: "text-muted-fg/55", icon: Note }, }; - const style = kindStyles[event.noticeKind] ?? kindStyles.info!; + const styleKey = event.noticeKind === "rate_limit" && inferredSeverity !== "error" + ? inferredSeverity + : event.noticeKind; + const style = kindStyles[styleKey] ?? kindStyles.info!; const NoticeIcon = style.icon; const hasDetail = hasNoticeDetail(event.detail); @@ -2552,6 +2560,27 @@ function renderEvent( return ; } + if (hasDetail && inferredSeverity !== "error") { + const detail = typeof event.detail === "string" + ? event.detail + : typeof event.detail === "object" && event.detail && "summary" in event.detail && typeof event.detail.summary === "string" + ? event.detail.summary + : null; + return ( +
+ + {event.noticeKind.replace("_", " ")} + {event.message} + {detail ? {detail} : null} +
+ ); + } + if (hasDetail) { return ( = { + git_status: "dirty-state check", + cancel_auto_rebase: "auto-rebase cancellation", + stop_processes: "process shutdown", + stop_ptys: "terminal shutdown", + stop_watchers: "file watcher shutdown", + cleanup_env: "environment cleanup", + git_worktree_remove: "worktree removal", + git_branch_delete: "local branch delete", + git_remote_branch_delete: "remote branch delete", + pack_dir_remove: "pack folder cleanup", + database_cleanup: "database cleanup", +}; + +function formatLaneDeleteProgressError(progress: LaneDeleteProgress, laneName: string): string { + const failedStep = progress.steps.find((step) => step.status === "failed"); + const warningSteps = progress.steps.filter((step) => step.status === "warning"); + if (failedStep) { + const label = LANE_DELETE_STEP_LABELS[failedStep.name] ?? failedStep.name; + const detail = failedStep.errorMessage ? `: ${failedStep.errorMessage}` : ""; + return `${laneName} delete failed during ${label}${detail}`; + } + if (warningSteps.length > 0) { + const first = warningSteps[0]!; + const label = LANE_DELETE_STEP_LABELS[first.name] ?? first.name; + const detail = first.errorMessage ? `: ${first.errorMessage}` : ""; + const extra = warningSteps.length > 1 ? ` (+${warningSteps.length - 1} more)` : ""; + return `${laneName} was deleted, but ${label} needs attention${detail}${extra}`; + } + return `${laneName} delete failed.`; +} + function laneTilingLayoutIds(laneId: string): string[] { return [ `lanes:tiling:${LANES_TILING_LAYOUT_VERSION}:${laneId}`, @@ -756,17 +791,15 @@ export function LanesPage() { }); if (overallStatus === "failed" || overallStatus === "cancelled") { completedLaneDeleteRefreshesRef.current.delete(laneId); - const failedStep = event.progress.steps.find((step) => step.status === "failed"); const laneName = lanesByIdRef.current?.get(laneId)?.name ?? laneId; - const detail = failedStep?.errorMessage ? `: ${failedStep.errorMessage}` : ""; setLaneActionError( overallStatus === "cancelled" ? `${laneName} delete was cancelled.` - : `${laneName} delete failed${detail}`, + : formatLaneDeleteProgressError(event.progress, laneName), ); return; } - if (overallStatus !== "completed") return; + if (overallStatus !== "completed" && overallStatus !== "completed_with_warnings") return; if (completedLaneDeleteRefreshesRef.current.has(laneId)) return; completedLaneDeleteRefreshesRef.current.add(laneId); @@ -780,7 +813,12 @@ export function LanesPage() { }); setManagedLaneIds((prev) => prev.filter((id) => id !== laneId)); clearLaneInspectorTab(laneId); - setLaneActionError(null); + if (overallStatus === "completed_with_warnings") { + const laneName = lanesByIdRef.current?.get(laneId)?.name ?? laneId; + setLaneActionError(formatLaneDeleteProgressError(event.progress, laneName)); + } else { + setLaneActionError(null); + } pendingLaneDeleteRefreshIdsRef.current.add(laneId); scheduleLaneDeleteRefresh(); }); @@ -1403,7 +1441,7 @@ export function LanesPage() { } } if (errors.length > 0) { - setLaneActionError(errors.join("\n")); + setLaneActionError((current) => current ?? errors.join("\n")); } })(); }; @@ -2612,7 +2650,7 @@ export function LanesPage() { {laneActionError ? (
- Lane action failed + {laneActionError.split(/\r?\n/)[0]?.trim() || "Lane action failed"}
) : null} + {progress.overallStatus === "completed_with_warnings" ? ( +
Lane deleted. Some branch cleanup needs attention.
+ ) : null} ); } @@ -540,6 +546,10 @@ function ProgressStepRow({ step }: { step: LaneDeleteStep }) { icon = ; textTone = "text-fg/85"; break; + case "warning": + icon = ; + textTone = "text-amber-300"; + break; case "failed": icon = ; textTone = "text-red-300"; @@ -562,6 +572,14 @@ function ProgressStepRow({ step }: { step: LaneDeleteStep }) {
{icon} {label} + {step.errorMessage ? ( + + {step.errorMessage} + + ) : null} {step.detail ? {step.detail} : null} {duration ? {duration} : null}
diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 17d5555f4..a7024c2d3 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -531,6 +531,8 @@ export type AgentChatEvent = | { type: "system_notice"; noticeKind: "auth" | "rate_limit" | "hook" | "file_persist" | "info" | "memory" | "provider_health" | "thread_error" | "warning" | "error" | "config"; + severity?: "info" | "warning" | "error"; + status?: string; message: string; detail?: string | AgentChatNoticeDetail; steerId?: string; diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index 1e5c06ab9..bbf72684d 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -300,6 +300,7 @@ export type LaneDeleteStepStatus = | "pending" | "running" | "completed" + | "warning" | "failed" | "skipped"; @@ -313,7 +314,7 @@ export type LaneDeleteStep = { errorMessage?: string; }; -export type LaneDeleteOverallStatus = "running" | "completed" | "failed" | "cancelled"; +export type LaneDeleteOverallStatus = "running" | "completed" | "completed_with_warnings" | "failed" | "cancelled"; export type LaneDeleteProgress = { laneId: string; diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 7fef91a47..b212a81d4 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -11,7 +11,7 @@ machinery layered on top. | Path | Role | |---|---| -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for parallel launch. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Large orchestrator file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, and prompt-derived lane-name suggestions for parallel launch. Tracks Codex Fast Mode (`codexFastMode: boolean`) per session and forwards it as `serviceTier: "fast" \| null` on every Codex `thread/start` and `turn/start` JSON-RPC call (see [Agent Routing](agent-routing.md#codex-service-tiers-fast-mode)). Spawns Claude/Codex agent runtimes with `buildAgentRuntimeEnv(managed)` so every agent process inherits `ADE_CHAT_SESSION_ID`, `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT` (used by the agent guidance to call `ade --socket app-control logs` / `terminal read --chat-session "$ADE_CHAT_SESSION_ID"` without resolving the chat ID itself). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks. Large orchestrator file. | | `apps/desktop/src/main/services/chat/runtimeEvents.ts` | Canonical cross-runtime event vocabulary (`turn.*`, `content.delta`, `tool.*`, `subagent.*`, teammate/task events, compaction boundaries) plus shims between legacy `AgentChatEvent` rows and the canonical runtime envelope. Claude emits canonical subagent events alongside the legacy rows while the other adapters migrate. | | `apps/ade-cli/src/tuiClient/` | Terminal **Work** chat TUI (Ink + React): same action/RPC contracts as desktop, **attached** (socket) or **embedded** (headless runtime via `ade-cli`). See [ADE Code](../ade-code/README.md). | | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Spoofs a desktop Chrome `User-Agent` and the matching `Sec-CH-UA*` client hints on every request through `webRequest.onBeforeSendHeaders` so external sign-in flows (Google, etc.) treat the embedded view as a normal desktop Chrome instead of refusing to load — the previous "open Google sign-in in the system browser" branch was removed because the spoofed UA stops Google from blocking the page in the first place. Window-open requests are forwarded into a fresh tab with `openPanel: true` so the Work sidebar Browser tab pops automatically. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | @@ -20,7 +20,7 @@ machinery layered on top. | `apps/desktop/src/main/services/chat/claudeInputPump.ts` | Async iterable input pump that feeds live user turns into the Claude Agent SDK `query()` stream. | | `apps/desktop/src/main/services/chat/claudeSubprocessReaper.ts` | Tracks Claude SDK subprocesses and tears them down on runtime shutdown. | | `apps/desktop/src/main/services/chat/claudeMcpServers.ts` | Builds upfront Claude MCP server configuration from ADE built-ins and `.mcp.json`. | -| `apps/desktop/src/main/services/chat/claudeOutputStyles.ts` | Discovers Claude output styles and local plugins from user/project/plugin roots. | +| `apps/desktop/src/main/services/chat/claudeOutputStyles.ts` | Discovers Claude output styles and plugins from project/user roots. Project roots are walked directly, while user-installed marketplace plugins are loaded only from Claude's installed-plugin registry when enabled in settings, so cache/source copies do not leak into ADE sessions. | | `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers project and user Claude slash surfaces by walking ancestor `.claude` roots, reading `.claude/commands/**/*.md`, `~/.claude/commands/**/*.md`, and `.claude/skills/*/SKILL.md` / `~/.claude/skills/*/SKILL.md` entries with command frontmatter. Consumed by `agentChatService` to enrich both the `chat.slashCommands` response and Claude system prompt with local command/skill metadata. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. | | `apps/desktop/src/main/services/chat/sessionRecovery.ts` | Version-2 persisted-state reconstruction when sessions resume from disk. | @@ -77,11 +77,13 @@ render them, but neither one *runs* them. and plugins are discovered by `claudeOutputStyles.ts`, slash commands by `claudeSlashCommandDiscovery.ts`, and every spawned subprocess is tracked by `claudeSubprocessReaper.ts` so runtime shutdown reaps - child processes. The SDK's bundled Claude Code binary is trusted — - `pathToClaudeCodeExecutable` is no longer threaded through. Context - usage, rewindFiles, forkSession, MCP toggling, and output-style - selection all run through the SDK control channel surfaced on the - active `Query` handle. + child processes. Claude executable resolution prefers an explicit + `CLAUDE_CODE_EXECUTABLE_PATH`, then the packaged bundled native + binary, then detected auth/PATH/common locations, and the resolved + path is passed through `pathToClaudeCodeExecutable`. Context usage, + rewindFiles, forkSession, MCP toggling, and output-style selection + all run through the SDK control channel surfaced on the active + `Query` handle. - **Provider-agnostic sessions.** `AgentChatProvider` is one of `claude`, `codex`, `opencode`, `cursor`, or a free-form string reserved for local providers. The service owns a pluggable adapter per provider (Claude Agent diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 3bacd6d40..8feda23a2 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -69,7 +69,7 @@ Renderer components: | File | Responsibility | |------|---------------| -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar "Lane action failed" chip surfaces any failure or cancellation through `laneActionError`. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures, cancellations, and non-fatal cleanup warnings through `laneActionError`. | | `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, ADE-vs-GitHub PR tag precedence, deep-link lane selection, create-lane request normalization, and delete-start selection fallback. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated 12-swatch lane color palette (`LANE_COLOR_PALETTE`) plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | @@ -88,7 +88,7 @@ Renderer components: | `renderer/components/lanes/LinearIssuePicker.tsx` | Filterable Linear issue picker rendered inside `CreateLaneDialog`. Loads project / state / assignee filters from `ade.cto.getLinearIssuePickerData` and pages issues through `ade.cto.searchLinearIssues`. Shared row + label helpers (`LinearIssueRow`, `linearPriorityLabel`, `issueProjectLabel`, `issueUpdatedLabel`, `toLaneLinearIssue`, `branchExistsForLinearIssue`) are reused by `LinearIssueBrowser` (top-bar quick view) and the chat composer's Linear context dialog. Also exports a `LinearIssueSummaryCard` used by the dialog's "currently connected" state. | | `renderer/components/lanes/LinearIssueBadge.tsx` | Compact lane-list badge that surfaces the lane's connected Linear issue (identifier + state + priority); clicking opens the issue in a new chat with the issue pre-attached as context, falling back to opening the issue in Linear when chat is unavailable. | | `renderer/components/lanes/linearBrand.tsx` | Linear brand tokens (`LINEAR_BRAND` colour palette) plus the icon family used everywhere ADE references Linear: `LinearMark`, `LinearStateIcon`, `LinearPriorityIcon`. | -| `renderer/components/lanes/ManageLaneDialog.tsx` | Unified delete / archive / adopt-attached dialog. Supports single-lane and batch (multi-select) modes, three delete scopes (`worktree`, `local_branch`, `remote_branch`), a typed confirmation phrase, remote-branch name input, dirty-state warnings, and a live multi-step progress strip wired to `lanes.delete.event` (`stop_processes` / `stop_ptys` / `stop_watchers` / `cancel_auto_rebase` / `cleanup_env` / `git_status` / `git_worktree_remove` / `git_branch_delete` / `git_remote_branch_delete` / `pack_dir_remove` / `database_cleanup`). The dialog calls `lanes.getDeleteRisk` on open to surface dirty state, unpushed commits, running processes / PTYs / watchers, and remote-branch existence before the user confirms; while a delete is running, the user can cancel each lane through `lanes.cancelDelete` until the irreversible filesystem step (`git_worktree_remove`) starts. | +| `renderer/components/lanes/ManageLaneDialog.tsx` | Unified delete / archive / adopt-attached dialog. Supports single-lane and batch (multi-select) modes, three delete scopes (`worktree`, `local_branch`, `remote_branch`), a typed confirmation phrase, remote-branch name input, dirty-state warnings, and a live multi-step progress strip wired to `lanes.delete.event` (`stop_processes` / `stop_ptys` / `stop_watchers` / `cancel_auto_rebase` / `cleanup_env` / `git_status` / `git_worktree_remove` / `git_branch_delete` / `git_remote_branch_delete` / `pack_dir_remove` / `database_cleanup`). Optional branch cleanup steps can finish as warnings, allowing lane-owned worktree/database cleanup to complete while still showing the branch cleanup error inline. The dialog calls `lanes.getDeleteRisk` on open to surface dirty state, unpushed commits, running processes / PTYs / watchers, and remote-branch existence before the user confirms; while a delete is running, the user can cancel each lane through `lanes.cancelDelete` until the irreversible filesystem step (`git_worktree_remove`) starts. | | `renderer/components/lanes/MonacoDiffView.tsx` | Monaco diff editor used for editable working-tree views (invoked from `AdeDiffViewer`) | | `renderer/components/run/LaneRuntimeBar.tsx` | Compact lane runtime status bar (health, preview, port, proxy, oauth) | | `renderer/components/run/RunPage.tsx`, `RunNetworkPanel.tsx` | Runtime dashboards that consume lane runtime services | From 868fd602d2f5d27bf8d1005f7312cf9d26fbd06b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 13:55:14 -0400 Subject: [PATCH 2/6] Fix Claude plugin test fixture isolation --- .../src/main/services/chat/agentChatService.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d614e4179..ed9b29a30 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -603,6 +603,7 @@ import { // Helpers // --------------------------------------------------------------------------- +let tmpHomeRoot: string; let tmpRoot: string; function makeDefaultClaudeSession() { @@ -1255,14 +1256,16 @@ function makeLaneLinearIssue(overrides: Partial = {}): LaneLine // --------------------------------------------------------------------------- beforeEach(() => { - tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-chat-svc-test-")); + tmpHomeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-chat-svc-home-")); + tmpRoot = path.join(tmpHomeRoot, "project"); + fs.mkdirSync(tmpRoot, { recursive: true }); // Ensure .ade directories exist fs.mkdirSync(path.join(tmpRoot, ".ade", "cache", "chat-sessions"), { recursive: true }); fs.mkdirSync(path.join(tmpRoot, ".ade", "transcripts", "chat"), { recursive: true }); - // Pin os.homedir() to tmpRoot so user-scope slash command discovery + // Pin os.homedir() to an isolated temp root so user-scope slash command discovery // (~/.claude/commands, ~/.codex/prompts) doesn't leak the developer's real - // home dir into tests. - vi.spyOn(os, "homedir").mockReturnValue(tmpRoot); + // home dir into tests, while project-local .claude roots remain distinct. + vi.spyOn(os, "homedir").mockReturnValue(tmpHomeRoot); mockState.sessions.clear(); mockState.uuidCounter = 0; mockState.codexThreadCounter = 0; @@ -1322,7 +1325,7 @@ afterEach(() => { process.env.CURSOR_API_KEY = ORIGINAL_CURSOR_API_KEY; } try { - fs.rmSync(tmpRoot, { recursive: true, force: true }); + fs.rmSync(tmpHomeRoot, { recursive: true, force: true }); } catch { /* ignore */ } }); From 8815923cc3a809933bac52b9bafeabcf5876e12a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 14:01:24 -0400 Subject: [PATCH 3/6] Address Claude plugin and notice review feedback --- .../services/chat/claudeOutputStyles.test.ts | 18 ++++++++++++++++++ .../main/services/chat/claudeOutputStyles.ts | 2 -- .../chat/AgentChatMessageList.test.tsx | 1 + .../components/chat/AgentChatMessageList.tsx | 9 ++++++++- .../renderer/components/lanes/LanesPage.tsx | 2 +- 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts index d24667bb7..81b583f7a 100644 --- a/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts +++ b/apps/desktop/src/main/services/chat/claudeOutputStyles.test.ts @@ -132,6 +132,24 @@ describe("discoverClaudePlugins", () => { ])); }); + it("loads manually placed user plugins outside managed Claude plugin dirs", () => { + const pluginRoot = path.join(homeRoot, ".claude", "plugins", "personal-review-plugin"); + fs.mkdirSync(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); + fs.writeFileSync(path.join(pluginRoot, ".claude-plugin", "plugin.json"), JSON.stringify({ + name: "personal-review-plugin", + description: "Local user helper", + })); + + expect(discoverClaudePlugins(tmpRoot)).toEqual([ + { + name: "personal-review-plugin", + description: "Local user helper", + path: fs.realpathSync(pluginRoot), + source: "local", + }, + ]); + }); + it("loads installed user plugins only when Claude settings enable them", () => { const enabledRoot = path.join( homeRoot, diff --git a/apps/desktop/src/main/services/chat/claudeOutputStyles.ts b/apps/desktop/src/main/services/chat/claudeOutputStyles.ts index 5dc015259..df56e9c11 100644 --- a/apps/desktop/src/main/services/chat/claudeOutputStyles.ts +++ b/apps/desktop/src/main/services/chat/claudeOutputStyles.ts @@ -313,7 +313,6 @@ function discoverEnabledInstalledPluginPaths(cwd: string): string[] { export function discoverClaudePluginPaths(cwd: string): string[] { const roots = claudeRootsByPrecedence(cwd); - const homeClaudeRoot = path.resolve(os.homedir(), ".claude"); const pluginPaths: string[] = []; const seen = new Set(); const addPluginPath = (pluginRoot: string): void => { @@ -324,7 +323,6 @@ export function discoverClaudePluginPaths(cwd: string): string[] { }; for (const root of roots) { - if (path.resolve(root) === homeClaudeRoot) continue; for (const pluginRoot of discoverPluginRoots(path.join(root, "plugins"))) { addPluginPath(pluginRoot); } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index e6040b186..f36d13e73 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -539,6 +539,7 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.getByText("thread error")).toBeTruthy(); expect(screen.getByText("Claude is taking longer than usual")).toBeTruthy(); expect(screen.getByText("Codex session is missing thread id")).toBeTruthy(); + expect(screen.getAllByRole("button")).toHaveLength(2); }); it("renders allowed rate-limit telemetry as a compact non-error notice", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 53dffc4c8..81b7c9a20 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2536,7 +2536,14 @@ function renderEvent( const inferredSeverity = event.severity ?? (event.noticeKind === "rate_limit" && /^Claude rate limit allowed warning/i.test(event.message) ? "warning" as const : undefined) ?? (event.noticeKind === "rate_limit" && /^Claude rate limit allowed/i.test(event.message) ? "info" as const : undefined) - ?? (event.noticeKind === "rate_limit" || event.noticeKind === "error" || event.noticeKind === "thread_error" ? "error" as const : undefined) + ?? ( + event.noticeKind === "rate_limit" + || event.noticeKind === "error" + || event.noticeKind === "thread_error" + || event.noticeKind === "provider_health" + ? "error" as const + : undefined + ) ?? (event.noticeKind === "warning" ? "warning" as const : "info" as const); const kindStyles: Record = { auth: { border: "border-amber-500/18", bg: "bg-amber-500/[0.06]", text: "text-amber-300", icon: Warning }, diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 6aac51cee..9393b3697 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -1441,7 +1441,7 @@ export function LanesPage() { } } if (errors.length > 0) { - setLaneActionError((current) => current ?? errors.join("\n")); + setLaneActionError(errors.join("\n")); } })(); }; From 0dab41921918814240702babc044752a00039cf6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 14:02:43 -0400 Subject: [PATCH 4/6] Keep non-rate-limit notices collapsible --- .../chat/AgentChatMessageList.test.tsx | 19 +++++++++++++++++++ .../components/chat/AgentChatMessageList.tsx | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index f36d13e73..c0ba3d7b9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -564,6 +564,25 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.queryByRole("button")).toBeNull(); }); + it("keeps non-rate-limit notice details in collapsible cards", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "system_notice", + noticeKind: "warning", + message: "Hook stderr captured", + detail: "Long hook output remains behind a disclosure.", + }, + }, + ]); + + expect(screen.getByText("warning")).toBeTruthy(); + expect(screen.getByText("Hook stderr captured")).toBeTruthy(); + expect(screen.getByRole("button")).toBeTruthy(); + }); + // Work-log grouping, file-change grouping, and overflow-expand tests // removed: they tested old ChatWorkLogBlock rendering (Show N earlier, // specific label text) which changes with every UI iteration. diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 81b7c9a20..d2b9f9aa9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -2567,7 +2567,7 @@ function renderEvent( return ; } - if (hasDetail && inferredSeverity !== "error") { + if (hasDetail && event.noticeKind === "rate_limit" && inferredSeverity !== "error") { const detail = typeof event.detail === "string" ? event.detail : typeof event.detail === "object" && event.detail && "summary" in event.detail && typeof event.detail.summary === "string" From 1556ba323caeaf514afcfd499664cee2c4a31866 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 14:27:30 -0400 Subject: [PATCH 5/6] Preserve lane delete warnings across batch completions --- apps/desktop/src/main/services/chat/agentChatService.test.ts | 1 + apps/desktop/src/renderer/components/lanes/LanesPage.tsx | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index ed9b29a30..8c8f52f2b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -422,6 +422,7 @@ vi.mock("../ai/claudeRuntimeProbe", () => ({ })); vi.mock("../ai/claudeCodeExecutable", () => ({ + isExecutablePath: vi.fn(() => true), resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/usr/local/bin/claude", source: "path" })), })); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 9393b3697..d62248b69 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -816,8 +816,6 @@ export function LanesPage() { if (overallStatus === "completed_with_warnings") { const laneName = lanesByIdRef.current?.get(laneId)?.name ?? laneId; setLaneActionError(formatLaneDeleteProgressError(event.progress, laneName)); - } else { - setLaneActionError(null); } pendingLaneDeleteRefreshIdsRef.current.add(laneId); scheduleLaneDeleteRefresh(); From b991737ba244c3dd05c705b845335f28813f2ed7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 12 May 2026 14:50:47 -0400 Subject: [PATCH 6/6] Stabilize lane delete warning state --- .../main/services/ai/claudeRuntimeProbe.ts | 3 +-- .../renderer/components/lanes/LanesPage.tsx | 20 +++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 97176fef8..8c856791d 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -129,9 +129,9 @@ export async function probeClaudeRuntimeHealth(args: { const abortController = new AbortController(); const timeout = setTimeout(() => abortController.abort(), PROBE_TIMEOUT_MS); let stream: ReturnType | null = null; + const claudeExecutable = resolveClaudeCodeExecutable(); try { - const claudeExecutable = resolveClaudeCodeExecutable(); stream = claudeQuery({ prompt: "System initialization check. Respond with only the word READY.", options: { @@ -154,7 +154,6 @@ export async function probeClaudeRuntimeHealth(args: { message: DEFAULT_RUNTIME_FAILURE, }); } catch (error) { - const claudeExecutable = resolveClaudeCodeExecutable(); const missingMessageIsFalseNegative = claudeExecutable.source !== "fallback-command" && isExecutablePath(claudeExecutable.path) && normalizeErrorMessage(error).toLowerCase().includes("native binary not found"); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index d62248b69..af2cd1ca4 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -221,6 +221,11 @@ function formatLaneDeleteProgressError(progress: LaneDeleteProgress, laneName: s return `${laneName} delete failed.`; } +function formatLaneDeleteWarningMessages(messagesByLaneId: Map): string | null { + const messages = [...messagesByLaneId.values()]; + return messages.length > 0 ? messages.join("\n") : null; +} + function laneTilingLayoutIds(laneId: string): string[] { return [ `lanes:tiling:${LANES_TILING_LAYOUT_VERSION}:${laneId}`, @@ -323,6 +328,7 @@ export function LanesPage() { const [laneActionError, setLaneActionError] = useState(null); const [laneActionKind, setLaneActionKind] = useState<"delete" | "archive" | "adopt" | null>(null); const [deleteProgressByLaneId, setDeleteProgressByLaneId] = useState>({}); + const laneDeleteWarningMessagesRef = useRef>(new Map()); const [managedLaneIds, setManagedLaneIds] = useState([]); const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); const chipTimersRef = useRef>(new Map()); @@ -790,6 +796,7 @@ export function LanesPage() { return next; }); if (overallStatus === "failed" || overallStatus === "cancelled") { + laneDeleteWarningMessagesRef.current.delete(laneId); completedLaneDeleteRefreshesRef.current.delete(laneId); const laneName = lanesByIdRef.current?.get(laneId)?.name ?? laneId; setLaneActionError( @@ -815,7 +822,12 @@ export function LanesPage() { clearLaneInspectorTab(laneId); if (overallStatus === "completed_with_warnings") { const laneName = lanesByIdRef.current?.get(laneId)?.name ?? laneId; - setLaneActionError(formatLaneDeleteProgressError(event.progress, laneName)); + laneDeleteWarningMessagesRef.current.set(laneId, formatLaneDeleteProgressError(event.progress, laneName)); + setLaneActionError(formatLaneDeleteWarningMessages(laneDeleteWarningMessagesRef.current)); + } else { + laneDeleteWarningMessagesRef.current.delete(laneId); + const remainingWarnings = formatLaneDeleteWarningMessages(laneDeleteWarningMessagesRef.current); + setLaneActionError((current) => remainingWarnings ?? (current && /\bdelet(?:e|ed|ing)\b/i.test(current) ? null : current)); } pendingLaneDeleteRefreshIdsRef.current.add(laneId); scheduleLaneDeleteRefresh(); @@ -1418,6 +1430,7 @@ export function LanesPage() { setLaneActionBusy(false); setLaneActionStatus(null); setLaneActionKind(null); + laneDeleteWarningMessagesRef.current.clear(); setLaneActionError(null); setDeleteConfirmText(""); moveAwayFromDeletingLanes(laneIds); @@ -2663,7 +2676,10 @@ export function LanesPage() { type="button" className="shrink-0" style={{ background: "transparent", border: "none", color: COLORS.danger, cursor: "pointer", padding: 0 }} - onClick={() => setLaneActionError(null)} + onClick={() => { + laneDeleteWarningMessagesRef.current.clear(); + setLaneActionError(null); + }} title="Dismiss" >