Skip to content

Commit 93837c5

Browse files
OpenSource03claude
andcommitted
feat: claude binary resolution, chat virtualization, and panel caching
- Add claude-binary module with auto/managed/custom source, env overrides, PATH lookup, and SDK fallback - Virtualize ChatView for large conversations (300+ messages) with lazy prepend on scroll - Cache session-derived data (files, changes panels) in LRU cache to avoid recomputation - Robust TodoWrite parsing: JSON arrays, stringified JSON, and markdown checklists - Add ToolSearch tool renderer with rich query/match display - Gate CI builds on pnpm test passing - Fix notification sound URL resolution and use hasFocus() for unfocused detection - Convert session persistence to async I/O (non-blocking reads/writes) - Add Claude binary source settings in Advanced Settings UI - Bump version to 0.16.2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5df25ea commit 93837c5

32 files changed

Lines changed: 1283 additions & 103 deletions

.github/workflows/build.yml

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,33 @@ jobs:
2929
echo "No existing release for $TAG, nothing to clean"
3030
fi
3131
32+
test:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Checkout
36+
uses: actions/checkout@v4
37+
38+
- name: Setup pnpm
39+
uses: pnpm/action-setup@v4
40+
41+
- name: Setup Node.js
42+
uses: actions/setup-node@v4
43+
with:
44+
node-version: 22
45+
cache: pnpm
46+
47+
- name: Install dependencies
48+
run: pnpm install
49+
50+
- name: Run tests
51+
run: pnpm test
52+
3253
# macOS: build both arm64 + x64 in one job so electron-builder writes a single
3354
# latest-mac.yml containing both architectures. Separate jobs race and the last
3455
# one to finish overwrites the other's YAML → wrong arch served to half the users.
3556
build-mac:
36-
needs: [prepare-release]
37-
if: ${{ always() && (needs.prepare-release.result == 'success' || needs.prepare-release.result == 'skipped') }}
57+
needs: [prepare-release, test]
58+
if: ${{ always() && needs.test.result == 'success' && (needs.prepare-release.result == 'success' || needs.prepare-release.result == 'skipped') }}
3859
runs-on: macos-26
3960
steps:
4061
- name: Checkout
@@ -81,8 +102,8 @@ jobs:
81102
# Windows: same approach as macOS — one job builds both arches so electron-builder
82103
# writes a single latest.yml with both installers listed.
83104
build-win:
84-
needs: [prepare-release]
85-
if: ${{ always() && (needs.prepare-release.result == 'success' || needs.prepare-release.result == 'skipped') }}
105+
needs: [prepare-release, test]
106+
if: ${{ always() && needs.test.result == 'success' && (needs.prepare-release.result == 'success' || needs.prepare-release.result == 'skipped') }}
86107
runs-on: windows-latest
87108
steps:
88109
- name: Checkout
@@ -128,8 +149,8 @@ jobs:
128149
# access cross-arch vendor binaries that don't exist in the other arch's staging
129150
# dir). Build sequentially, then merge latest-linux.yml before publishing.
130151
build-linux:
131-
needs: [prepare-release]
132-
if: ${{ always() && (needs.prepare-release.result == 'success' || needs.prepare-release.result == 'skipped') }}
152+
needs: [prepare-release, test]
153+
if: ${{ always() && needs.test.result == 'success' && (needs.prepare-release.result == 'success' || needs.prepare-release.result == 'skipped') }}
133154
runs-on: ubuntu-22.04
134155
steps:
135156
- name: Checkout

electron/src/ipc/claude-sessions.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import os from "os";
44
import { log } from "../lib/logger";
55
import { safeSend } from "../lib/safe-send";
66
import { AsyncChannel } from "../lib/async-channel";
7-
import { getSDK, getCliPath, clientAppEnv } from "../lib/sdk";
7+
import { getSDK, clientAppEnv } from "../lib/sdk";
88
import type { QueryHandle } from "../lib/sdk";
99
import { getMcpAuthHeaders } from "../lib/mcp-oauth-flow";
1010
import { getClaudeModelsCache, setClaudeModelsCache } from "../lib/claude-model-cache";
1111
import { extractErrorMessage } from "../lib/error-utils";
12+
import { getClaudeBinaryPath, getClaudeBinaryStatus, getClaudeVersion } from "../lib/claude-binary";
1213

1314
/** SDK options for file checkpointing — enables Write/Edit/NotebookEdit revert support */
1415
function fileCheckpointOptions(): Record<string, unknown> {
@@ -234,7 +235,7 @@ async function revalidateClaudeModelsCache(cwd?: string): Promise<{ models: Arra
234235

235236
try {
236237
const query = await getSDK();
237-
const cliPath = getCliPath();
238+
const cliPath = await getClaudeBinaryPath({ installIfMissing: false, allowSdkFallback: true }).catch(() => undefined);
238239
logSdkCliPath("models-revalidate", cliPath);
239240
const queryOptions: Record<string, unknown> = {
240241
cwd: cwd?.trim() || os.homedir(),
@@ -329,7 +330,7 @@ async function restartSession(
329330
const mcpServers = mcpServersOverride ?? opts.mcpServers;
330331
const query = await getSDK();
331332
const newChannel = new AsyncChannel<unknown>();
332-
const cliPath = getCliPath();
333+
const cliPath = await getClaudeBinaryPath();
333334
logSdkCliPath(`restart session=${sessionId.slice(0, 8)}`, cliPath);
334335

335336
const newSession: SessionEntry = {
@@ -451,7 +452,7 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
451452
});
452453
};
453454

454-
const cliPath = getCliPath();
455+
const cliPath = await getClaudeBinaryPath();
455456
logSdkCliPath(`start session=${sessionId.slice(0, 8)}`, cliPath);
456457
const queryOptions: Record<string, unknown> = {
457458
cwd: options.cwd || process.cwd(),
@@ -754,6 +755,18 @@ export function register(getMainWindow: () => BrowserWindow | null): void {
754755
return revalidateClaudeModelsCache(options?.cwd);
755756
});
756757

758+
ipcMain.handle("claude:version", async () => {
759+
try {
760+
return { version: await getClaudeVersion() };
761+
} catch (err) {
762+
return { error: extractErrorMessage(err) };
763+
}
764+
});
765+
766+
ipcMain.handle("claude:binary-status", async () => {
767+
return getClaudeBinaryStatus();
768+
});
769+
757770
ipcMain.handle("claude:mcp-reconnect", async (_event, { sessionId, serverName }: { sessionId: string; serverName: string }) => {
758771
const session = sessions.get(sessionId);
759772
if (!session?.queryHandle) return { error: "No active session" };

electron/src/ipc/sessions.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function getLastUserMessageTimestamp(messages?: Array<{ role?: string; timestamp
4545
}
4646

4747
export function register(): void {
48-
ipcMain.handle("sessions:save", (_event, data: { projectId: string; id: string; createdAt?: number; messages?: Array<{ role?: string; timestamp?: number }> }) => {
48+
ipcMain.handle("sessions:save", async (_event, data: { projectId: string; id: string; createdAt?: number; messages?: Array<{ role?: string; timestamp?: number }> }) => {
4949
try {
5050
const filePath = getSessionFilePath(data.projectId, data.id);
5151
const providedLastMessageAt = (data as Record<string, unknown>).lastMessageAt;
@@ -58,42 +58,40 @@ export function register(): void {
5858
data.createdAt ??
5959
0;
6060
const enriched = { ...data, lastMessageAt };
61-
fs.writeFileSync(filePath, JSON.stringify(enriched, null, 2), "utf-8");
61+
await fs.promises.writeFile(filePath, JSON.stringify(enriched, null, 2), "utf-8");
6262
return { ok: true };
6363
} catch (err) {
6464
log("SESSIONS:SAVE_ERR", (err as Error).message);
6565
return { error: (err as Error).message };
6666
}
6767
});
6868

69-
ipcMain.handle("sessions:load", (_event, projectId: string, sessionId: string) => {
69+
ipcMain.handle("sessions:load", async (_event, projectId: string, sessionId: string) => {
7070
try {
7171
const filePath = getSessionFilePath(projectId, sessionId);
7272
if (!fs.existsSync(filePath)) return null;
73-
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
73+
return JSON.parse(await fs.promises.readFile(filePath, "utf-8"));
7474
} catch (err) {
7575
log("SESSIONS:LOAD_ERR", (err as Error).message);
7676
return null;
7777
}
7878
});
7979

80-
ipcMain.handle("sessions:list", (_event, projectId: string) => {
80+
ipcMain.handle("sessions:list", async (_event, projectId: string) => {
8181
try {
8282
const dir = getProjectSessionsDir(projectId);
83-
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
84-
const list: SessionMeta[] = [];
85-
for (const file of files) {
83+
const files = (await fs.promises.readdir(dir)).filter((f) => f.endsWith(".json"));
84+
const items = await Promise.all(files.map(async (file) => {
8685
try {
87-
const raw = fs.readFileSync(path.join(dir, file), "utf-8");
86+
const raw = await fs.promises.readFile(path.join(dir, file), "utf-8");
8887
const data = JSON.parse(raw);
89-
// Derive lastMessageAt: latest user message timestamp → stored field → createdAt
9088
const lastMessageAt: number =
9189
getLastUserMessageTimestamp(data.messages) ??
9290
(typeof data.lastMessageAt === "number" ? data.lastMessageAt : undefined) ??
9391
data.createdAt ??
9492
0;
9593

96-
list.push({
94+
const item: SessionMeta = {
9795
id: data.id,
9896
projectId: data.projectId,
9997
title: data.title || "Untitled",
@@ -104,11 +102,13 @@ export function register(): void {
104102
totalCost: data.totalCost || 0,
105103
engine: data.engine,
106104
codexThreadId: data.codexThreadId,
107-
});
105+
};
106+
return item;
108107
} catch {
109-
// Skip corrupted files
108+
return null;
110109
}
111-
}
110+
}));
111+
const list: SessionMeta[] = items.filter((item): item is SessionMeta => item !== null);
112112
// Sort by most recent user activity, not creation time.
113113
list.sort((a, b) => b.lastMessageAt - a.lastMessageAt);
114114
return list;

electron/src/ipc/title-gen.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { ipcMain } from "electron";
22
import { log } from "../lib/logger";
3-
import { getSDK, getCliPath, clientAppEnv } from "../lib/sdk";
3+
import { getSDK, clientAppEnv } from "../lib/sdk";
44
import { extractErrorMessage } from "../lib/error-utils";
55
import { gitExec } from "../lib/git-exec";
6+
import { getClaudeBinaryPath } from "../lib/claude-binary";
67

78
function firstNonEmptyLine(text: string): string | undefined {
89
for (const line of text.split(/\r?\n/g)) {
@@ -32,7 +33,7 @@ async function oneShotSdkQuery(
3233

3334
try {
3435
const query = await getSDK();
35-
const cliPath = getCliPath();
36+
const cliPath = await getClaudeBinaryPath();
3637
if (cliPath) {
3738
log("SDK_CLI_PATH", `${logLabel} path=${cliPath}`);
3839
} else {
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const {
4+
mockAccessSync,
5+
mockExecFileSync,
6+
mockGetAppSetting,
7+
mockGetCliPath,
8+
mockLog,
9+
mockSpawn,
10+
} = vi.hoisted(() => ({
11+
mockAccessSync: vi.fn(),
12+
mockExecFileSync: vi.fn(),
13+
mockGetAppSetting: vi.fn<(key: string) => string>((key: string) => {
14+
if (key === "claudeBinarySource") return "auto";
15+
if (key === "claudeCustomBinaryPath") return "";
16+
return "Harnss";
17+
}),
18+
mockGetCliPath: vi.fn(() => "/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js"),
19+
mockLog: vi.fn(),
20+
mockSpawn: vi.fn(),
21+
}));
22+
23+
vi.mock("fs", () => ({
24+
default: {
25+
accessSync: mockAccessSync,
26+
constants: { X_OK: 1 },
27+
},
28+
}));
29+
30+
vi.mock("os", () => ({
31+
default: {
32+
homedir: () => "/Users/tester",
33+
},
34+
}));
35+
36+
vi.mock("child_process", () => ({
37+
execFileSync: mockExecFileSync,
38+
spawn: mockSpawn,
39+
}));
40+
41+
vi.mock("../app-settings", () => ({
42+
getAppSetting: mockGetAppSetting,
43+
}));
44+
45+
vi.mock("../sdk", () => ({
46+
getCliPath: mockGetCliPath,
47+
}));
48+
49+
vi.mock("../logger", () => ({
50+
log: mockLog,
51+
}));
52+
53+
function allowExecutable(...filePaths: string[]): void {
54+
mockAccessSync.mockImplementation((candidate: string) => {
55+
if (filePaths.includes(candidate)) return;
56+
throw new Error("missing");
57+
});
58+
}
59+
60+
async function loadModule() {
61+
vi.resetModules();
62+
return import("../claude-binary");
63+
}
64+
65+
describe("claude binary resolution", () => {
66+
beforeEach(() => {
67+
vi.unstubAllEnvs();
68+
mockAccessSync.mockReset();
69+
mockExecFileSync.mockReset();
70+
mockGetAppSetting.mockReset();
71+
mockGetAppSetting.mockImplementation((key: string): string => {
72+
if (key === "claudeBinarySource") return "auto";
73+
if (key === "claudeCustomBinaryPath") return "";
74+
return "Harnss";
75+
});
76+
mockGetCliPath.mockReset();
77+
mockGetCliPath.mockReturnValue("/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js");
78+
mockLog.mockReset();
79+
mockSpawn.mockReset();
80+
});
81+
82+
it("uses a valid custom executable path", async () => {
83+
mockGetAppSetting.mockImplementation((key: string): string => {
84+
if (key === "claudeBinarySource") return "custom";
85+
if (key === "claudeCustomBinaryPath") return "/opt/bin/claude";
86+
return "Harnss";
87+
});
88+
allowExecutable("/opt/bin/claude");
89+
90+
const mod = await loadModule();
91+
92+
await expect(mod.getClaudeBinaryPath()).resolves.toBe("/opt/bin/claude");
93+
});
94+
95+
it("prefers the env override in auto mode", async () => {
96+
vi.stubEnv("CLAUDE_CODE_CLI_PATH", "/env/claude");
97+
allowExecutable("/env/claude");
98+
99+
const mod = await loadModule();
100+
101+
await expect(mod.getClaudeBinaryPath({ installIfMissing: false })).resolves.toBe("/env/claude");
102+
});
103+
104+
it("finds the native shim in the user local bin directory", async () => {
105+
allowExecutable("/Users/tester/.local/bin/claude");
106+
107+
const mod = await loadModule();
108+
109+
await expect(mod.getClaudeBinaryPath({ installIfMissing: false })).resolves.toBe("/Users/tester/.local/bin/claude");
110+
});
111+
112+
it("falls back to PATH lookup when the shim is missing", async () => {
113+
mockExecFileSync.mockImplementation((command: string) => {
114+
if (command === "which") return "/usr/local/bin/claude\n";
115+
throw new Error("unexpected");
116+
});
117+
allowExecutable("/usr/local/bin/claude");
118+
119+
const mod = await loadModule();
120+
121+
await expect(mod.getClaudeBinaryPath({ installIfMissing: false })).resolves.toBe("/usr/local/bin/claude");
122+
});
123+
124+
it("uses the sdk cli fallback in auto mode when native resolution fails", async () => {
125+
mockExecFileSync.mockImplementation(() => {
126+
throw new Error("missing");
127+
});
128+
allowExecutable("/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js");
129+
130+
const mod = await loadModule();
131+
132+
await expect(mod.getClaudeBinaryPath({ installIfMissing: false, allowSdkFallback: true })).resolves.toBe(
133+
"/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
134+
);
135+
expect(mockLog).toHaveBeenCalledWith(
136+
"CLAUDE_BINARY_SELECTED",
137+
"strategy=sdk-fallback path=/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js",
138+
);
139+
});
140+
141+
it("reports status without triggering install", async () => {
142+
allowExecutable("/Users/tester/.local/bin/claude");
143+
const mod = await loadModule();
144+
145+
expect(mod.getClaudeBinaryStatus()).toEqual({
146+
installed: true,
147+
installing: false,
148+
});
149+
});
150+
151+
it("returns a version when the sdk fallback path is a script", async () => {
152+
mockExecFileSync.mockImplementation((command: string, args: string[]) => {
153+
if (command === process.execPath) {
154+
expect(args).toEqual(["/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js", "--version"]);
155+
return "2.1.70\n";
156+
}
157+
throw new Error("unexpected");
158+
});
159+
allowExecutable("/app.asar.unpacked/node_modules/@anthropic-ai/claude-agent-sdk/cli.js");
160+
161+
const mod = await loadModule();
162+
163+
await expect(mod.getClaudeVersion()).resolves.toBe("2.1.70");
164+
});
165+
});

electron/src/lib/__tests__/sdk.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ describe("sdk path resolution", () => {
7676
it("prefers embed resolution when the candidate exists", async () => {
7777
const embedEntry = require.resolve("@anthropic-ai/claude-agent-sdk/embed");
7878
const embedCliPath = path.join(path.dirname(embedEntry), "cli.js");
79-
mockExistsSync.mockImplementation((candidate) => candidate === embedCliPath);
79+
mockExistsSync.mockImplementation((candidate: unknown) => candidate === embedCliPath);
8080

8181
const mod = await loadSdkModule();
8282

0 commit comments

Comments
 (0)