Skip to content

Commit 225389d

Browse files
OpenSource03claude
andcommitted
feat: bottom tool panels, PostHog error tracking, ACP agent auto-updates, and layout polish
- Add bottom tools row — right-click any tool in the picker to move it to a resizable bottom row (side ↔ bottom), with split ratios and height persistence - Add renderer-side PostHog error tracking (posthog-js) with exception autocapture, PostHogProvider wrapper, and unified reportError() helper across all IPC handlers - Add ACP agent auto-update system — checks registry on startup, periodically (4h), and on visibility change; toasts on success/failure - Add ACP draft session lifecycle — eager session start during draft phase for instant MCP probing and config options loading - Refine island layout — extract spacing/radius into CSS custom properties, tighter panel gaps, wider resize hit areas, tool picker redesign with progress ring on tasks icon - Add "avoid grouping edits" setting — Edit/Write tools render standalone instead of collapsing into groups - Remove ChangesPanel and its data layer (superseded by inline turn summaries) - Add project icon support (emoji/lucide) via sidebar right-click - Add git:diff-stat IPC for additions/deletions count - Add per-session ACP analytics properties (agent name, source, launch method) - Polish branch picker with search, terminal scrollbar auto-hide, glass border gradient refinements, dark mode tool picker button glow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 405a7c9 commit 225389d

112 files changed

Lines changed: 4160 additions & 1965 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,36 @@ Types shared between electron and renderer live in `shared/types/`. Both tsconfi
299299
- **`src/lib/file-access.ts`** — pure data transformation for file access tracking (extracted from FilesPanel)
300300
- **`src/lib/mcp-utils.ts`**`toMcpStatusState()` (moved from types/ui.ts)
301301
- **`src/lib/acp-utils.ts`**`flattenConfigOptions()` (moved from types/acp.ts)
302-
- **`electron/src/lib/error-utils.ts`**`extractErrorMessage()` (replaces 3 duplicated implementations)
302+
- **`electron/src/lib/error-utils.ts`**`extractErrorMessage()`, `reportError()` — shared error extraction and PostHog exception capture
303+
- **`src/lib/analytics.ts`**`capture()`, `captureException()`, `reportError()` — renderer-side analytics and error tracking
304+
- **`src/lib/posthog.ts`**`initPostHog()`, `syncAnalyticsSettings()` — renderer-side PostHog client (posthog-js) initialization
305+
306+
### Error Tracking (PostHog)
307+
308+
Two PostHog clients run in parallel, one per process:
309+
310+
1. **Main process** (`posthog-node` in `electron/src/lib/posthog.ts`):
311+
- `enableExceptionAutocapture: true` — auto-captures `process.on('uncaughtException')` and `process.on('unhandledRejection')`
312+
- `captureException(error, additionalProperties?)` — manual exception capture with stack trace
313+
- `captureEvent(event, properties?)` — custom analytics events
314+
- Respects `analyticsEnabled` setting, uses anonymous `analyticsUserId`
315+
316+
2. **Renderer process** (`posthog-js` + `@posthog/react` in `src/lib/posthog.ts`):
317+
- Exception autocapture via `defaults: "2026-01-30"` — auto-hooks `window.onerror` and `window.onunhandledrejection`
318+
- `PostHogProvider` wraps the app in `main.tsx`
319+
- `ErrorBoundary.componentDidCatch``posthog.captureException()` for React rendering errors
320+
- Starts opted-out (`opt_out_capturing_by_default: true`), syncs to main process settings via `syncAnalyticsSettings()`
321+
- Uses same anonymous user ID as main process for cross-process correlation
322+
323+
**Error reporting helpers:**
324+
325+
- **Main process**: `reportError(label, err, context?)` from `electron/src/lib/error-utils.ts` — combines `log()` + `captureException()` in one call, returns the error message string. Use in all IPC handler catch blocks.
326+
- **Renderer**: `reportError(label, err, context?)` from `src/lib/analytics.ts` — combines `console.error()` + `captureException()`, returns the message string. Use in hook/component catch blocks.
327+
- **Renderer**: `captureException(error, properties?)` from `src/lib/analytics.ts` — PostHog-only capture (when console logging already exists).
328+
329+
**When to use `reportError` vs leave a catch alone:**
330+
- **DO use `reportError`**: session start/stop failures, IPC handler errors, SDK/process spawn errors, OAuth failures, updater errors, file operation errors, user-visible errors
331+
- **DO NOT use `reportError`**: process kill cleanup (`/* already dead */`), JSON parse fallbacks, audio autoplay blocked, cache parse defaults, cancellation guards, analytics-internal catches (infinite recursion)
303332

304333
### Electron Session Handler Patterns
305334

@@ -323,4 +352,5 @@ The three session IPC handlers share extracted utilities:
323352
- **Memo optimization** — components use `React.memo` with custom comparators for performance
324353
- **Component decomposition** — large components are split into focused sub-components in subdirectories (git/, tool-renderers/, mcp-renderers/, sidebar/)
325354
- **Hook decomposition** — large hooks are split into focused sub-hooks (session/, useEngineBase)
326-
- **Shared components** — reusable UI patterns extracted to shared components (`TabBar`, `PanelHeader`, `SettingRow`)
355+
- **Shared components** — reusable UI patterns extracted to shared components (`TabBar`, `PanelHeader`, `SettingRow`)
356+
- **Error tracking** — all caught errors in IPC handlers and hooks must use `reportError(label, err)` (not bare `log()`). Benign/expected catches (cleanup, parse fallbacks, cancellation guards) are exempt. See "Error Tracking (PostHog)" section for details.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ Projects map to folders on disk. Spaces let you organize projects into named gro
109109

110110
Browse and install agents from the ACP community registry directly in the app. Add custom agents by specifying a command, arguments, environment variables, and an icon. All configuration is managed through Settings — no config files.
111111

112+
### Plan mode & permission control
113+
114+
Work in plan mode to have the agent draft a plan before making any changes. Three permission levels — Ask First, Accept Edits, Allow All — control how much autonomy the agent has. Switch modes at any point without interrupting context.
115+
116+
### Background task agents
117+
118+
Task agents spawned during a session continue running in the background and are tracked in a dedicated panel. Keep working in other sessions while long-running tasks complete.
119+
120+
### Image attachments & annotation
121+
122+
Attach screenshots or images directly in the chat. An built-in annotation tool lets you draw, highlight, and mark up images with freehand strokes before sending them to the agent.
123+
112124
### Voice input & notifications
113125

114126
Voice input via native macOS dictation or an on-device Whisper model (no API key required). Configurable OS notifications for plan approval requests, permission prompts, agent questions, and session completion.

electron/src/ipc/acp-sessions.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import path from "path";
66
import { log } from "../lib/logger";
77
import { safeSend } from "../lib/safe-send";
88
import { getAgent } from "../lib/agent-registry";
9+
import type { InstalledAgent } from "../lib/agent-registry";
910
import { getMcpAuthHeaders } from "../lib/mcp-oauth-flow";
10-
import { extractErrorMessage } from "../lib/error-utils";
11+
import { extractErrorMessage, reportError } from "../lib/error-utils";
1112
import { captureEvent } from "../lib/posthog";
1213

1314
// ACP SDK is ESM-only, must be async-imported
@@ -71,6 +72,7 @@ interface ACPSessionEntry {
7172
connection: ClientSideConnection;
7273
acpSessionId: string;
7374
internalId: string;
75+
analyticsProperties: AcpAnalyticsProperties;
7476
eventCounter: number;
7577
pendingPermissions: Map<string, { resolve: (response: unknown) => void }>;
7678
cwd: string;
@@ -98,6 +100,40 @@ const commandsBuffer = new Map<string, unknown[]>();
98100
// Only one start can be in-flight at a time (guarded by materializingRef in the renderer).
99101
let pendingStartProcess: { id: string; process: ChildProcess; aborted?: boolean } | null = null;
100102

103+
type AcpAnalyticsProperties = {
104+
acp_agent: string;
105+
acp_agent_source: "registry" | "custom";
106+
acp_agent_launch_method: "npx" | "binary" | "unknown";
107+
acp_agent_registry_id?: string;
108+
acp_agent_registry_version?: string;
109+
};
110+
111+
function buildAcpAnalyticsProperties(agent: InstalledAgent): AcpAnalyticsProperties {
112+
const registryId = agent.registryId?.trim();
113+
const launchMethod = agent.binary === "npx" ? "npx" : agent.binary ? "binary" : "unknown";
114+
115+
if (registryId) {
116+
return {
117+
acp_agent: registryId,
118+
acp_agent_source: "registry",
119+
acp_agent_launch_method: launchMethod,
120+
acp_agent_registry_id: registryId,
121+
...(agent.registryVersion ? { acp_agent_registry_version: agent.registryVersion } : {}),
122+
};
123+
}
124+
125+
const customHash = crypto.createHash("sha256").update(agent.id).digest("hex").slice(0, 12);
126+
return {
127+
acp_agent: `custom:${customHash}`,
128+
acp_agent_source: "custom",
129+
acp_agent_launch_method: launchMethod,
130+
};
131+
}
132+
133+
export function getAcpAnalyticsPropertiesForSession(sessionId: string): Record<string, unknown> | null {
134+
return acpSessions.get(sessionId)?.analyticsProperties ?? null;
135+
}
136+
101137
/** One-line summary for each ACP session update (mirrors summarizeEvent for Claude) */
102138
function summarizeUpdate(update: Record<string, unknown>): string {
103139
const kind = update.sessionUpdate as string;
@@ -423,6 +459,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
423459
}
424460

425461
let connResult: AcpConnectionResult | null = null;
462+
const analyticsProperties = buildAcpAnalyticsProperties(agentDef);
426463
try {
427464
connResult = await createAcpConnection(agentDef as { binary: string; args?: string[]; env?: Record<string, string>; name: string }, getMainWindow, "ACP_SPAWN");
428465
const { proc, connection, pendingPermissions, internalId, supportsLoadSession } = connResult;
@@ -444,6 +481,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
444481
connection,
445482
acpSessionId: sessionResult.sessionId,
446483
internalId,
484+
analyticsProperties,
447485
eventCounter: 0,
448486
pendingPermissions,
449487
cwd: options.cwd,
@@ -463,7 +501,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
463501
// Startup succeeded — clear the pending tracker before returning
464502
pendingStartProcess = null;
465503

466-
void captureEvent("session_created", { engine: "acp" });
504+
void captureEvent("session_created", { engine: "acp", ...analyticsProperties });
467505

468506
return {
469507
sessionId: internalId,
@@ -485,11 +523,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
485523
return { cancelled: true };
486524
}
487525

488-
const msg = extractErrorMessage(err);
489-
log("ACP_SPAWN", `ERROR: ${msg}`);
490-
if (err instanceof Error && err.stack) {
491-
log("ACP_SPAWN", `Stack: ${err.stack}`);
492-
}
526+
const msg = reportError("ACP_SPAWN", err, { engine: "acp", ...analyticsProperties });
493527
return { error: msg };
494528
}
495529
});
@@ -511,6 +545,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
511545
}
512546

513547
let connResult: AcpConnectionResult | null = null;
548+
const analyticsProperties = buildAcpAnalyticsProperties(agentDef);
514549
try {
515550
connResult = await createAcpConnection(agentDef as { binary: string; args?: string[]; env?: Record<string, string>; name: string }, getMainWindow, "ACP_REVIVE");
516551
const { proc, connection, pendingPermissions, internalId, supportsLoadSession } = connResult;
@@ -523,7 +558,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
523558

524559
if (supportsLoadSession && options.agentSessionId) {
525560
// Restore full context — suppress history replay from reaching the renderer
526-
const entry: ACPSessionEntry = { process: proc, connection, acpSessionId: options.agentSessionId, internalId, eventCounter: 0, pendingPermissions, cwd: options.cwd, supportsLoadSession, isReloading: true };
561+
const entry: ACPSessionEntry = { process: proc, connection, acpSessionId: options.agentSessionId, internalId, analyticsProperties, eventCounter: 0, pendingPermissions, cwd: options.cwd, supportsLoadSession, isReloading: true };
527562
acpSessions.set(internalId, entry);
528563
const loadResult = await connection.loadSession({ sessionId: options.agentSessionId, cwd: options.cwd, mcpServers: acpMcpServers });
529564
entry.isReloading = false;
@@ -536,14 +571,14 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
536571
// Fall back to fresh session — UI messages already restored from disk
537572
const sessionResult = await connection.newSession({ cwd: options.cwd, mcpServers: acpMcpServers });
538573
acpSessionId = sessionResult.sessionId;
539-
const entry: ACPSessionEntry = { process: proc, connection, acpSessionId, internalId, eventCounter: 0, pendingPermissions, cwd: options.cwd, supportsLoadSession, isReloading: false };
574+
const entry: ACPSessionEntry = { process: proc, connection, acpSessionId, internalId, analyticsProperties, eventCounter: 0, pendingPermissions, cwd: options.cwd, supportsLoadSession, isReloading: false };
540575
acpSessions.set(internalId, entry);
541576
configOptions = resolveConfigOptions(sessionResult, internalId, "ACP_REVIVE");
542577
log("ACP_REVIVE", `newSession fallback, session=${acpSessionId.slice(0, 12)}`);
543578
}
544579

545580
const mcpStatuses = (options.mcpServers ?? []).map(s => ({ name: s.name, status: "connected" as const }));
546-
void captureEvent("session_revived", { engine: "acp", success: true });
581+
void captureEvent("session_revived", { engine: "acp", success: true, ...analyticsProperties });
547582
return { sessionId: internalId, agentSessionId: acpSessionId, usedLoad, configOptions, mcpStatuses };
548583
} catch (err) {
549584
// Kill process and clean up any partial session entry
@@ -552,8 +587,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
552587
acpSessions.delete(connResult.internalId);
553588
configBuffer.delete(connResult.internalId);
554589
}
555-
const msg = extractErrorMessage(err);
556-
log("ACP_REVIVE", `ERROR: ${msg}`);
590+
const msg = reportError("ACP_REVIVE", err, { engine: "acp", ...analyticsProperties });
557591
return { error: msg };
558592
}
559593
});
@@ -594,7 +628,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
594628
} catch (err) {
595629
const msg = extractErrorMessage(err);
596630
const surfacedError = msg === "Internal error" && session.lastStderrError ? session.lastStderrError : msg;
597-
log("ACP_SEND", `ERROR: session=${sessionId.slice(0, 8)} ${surfacedError}`);
631+
reportError("ACP_PROMPT_ERR", err, { engine: "acp", sessionId, surfacedError });
598632
return { error: surfacedError };
599633
}
600634
});
@@ -681,8 +715,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
681715
log("ACP_RELOAD", `session=${sessionId.slice(0, 8)} loadSession OK`);
682716
return { ok: true, supportsLoad: true };
683717
} catch (err) {
684-
const msg = extractErrorMessage(err);
685-
log("ACP_RELOAD", `ERROR: session=${sessionId.slice(0, 8)} loadSession failed: ${msg}`);
718+
const msg = reportError("ACP_RELOAD_ERR", err, { engine: "acp", sessionId });
686719
return { error: msg, supportsLoad: true };
687720
}
688721
});
@@ -707,8 +740,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
707740
log("ACP_CANCEL", `session=${sessionId.slice(0, 8)} acknowledged`);
708741
return { ok: true };
709742
} catch (err) {
710-
const msg = extractErrorMessage(err);
711-
log("ACP_CANCEL", `ERROR: session=${sessionId.slice(0, 8)} ${msg}`);
743+
const msg = reportError("ACP_CANCEL_ERR", err, { engine: "acp", sessionId });
712744
return { error: msg };
713745
}
714746
});
@@ -755,8 +787,8 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
755787
throw configErr;
756788
}
757789
} catch (err) {
758-
log("ACP_CONFIG", `ERROR: session=${sessionId.slice(0, 8)} ${extractErrorMessage(err)}`);
759-
return { error: extractErrorMessage(err) };
790+
const errMsg = reportError("ACP_CONFIG_ERR", err, { engine: "acp", sessionId, configId });
791+
return { error: errMsg };
760792
}
761793
});
762794

electron/src/ipc/cc-import.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from "path";
33
import fs from "fs";
44
import crypto from "crypto";
55
import os from "os";
6-
import { log } from "../lib/logger";
6+
import { reportError } from "../lib/error-utils";
77

88
interface SessionPreview {
99
firstUserMessage: string;
@@ -243,7 +243,7 @@ export function register(): void {
243243
result.sort((a, b) => b.fileModified - a.fileModified);
244244
return result;
245245
} catch (err) {
246-
log("CC_SESSIONS:LIST_ERR", (err as Error).message);
246+
reportError("CC_SESSIONS:LIST_ERR", err);
247247
return [];
248248
}
249249
});
@@ -260,8 +260,8 @@ export function register(): void {
260260
const messages = parseJsonlToUIMessages(filePath);
261261
return { messages, ccSessionId };
262262
} catch (err) {
263-
log("CC_SESSIONS:IMPORT_ERR", (err as Error).message);
264-
return { error: (err as Error).message };
263+
const errMsg = reportError("CC_SESSIONS:IMPORT_ERR", err);
264+
return { error: errMsg };
265265
}
266266
});
267267
}

0 commit comments

Comments
 (0)