From 42e5a2431c9b1c7c9b82d5ea5def98560ac32adf Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 15:52:01 +0200 Subject: [PATCH 1/3] Fix Claude CLI resolution on Windows --- pnpm-workspace.yaml | 1 - .../services/team/ClaudeBinaryResolver.ts | 127 ++++++++++++++++-- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6b58ca0b..c5739b74 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,2 @@ ignoredBuiltDependencies: - - electron - esbuild diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index e05b766e..d8f6f580 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -3,6 +3,15 @@ import * as os from 'os'; import * as path from 'path'; async function isExecutable(filePath: string): Promise { + if (process.platform === 'win32') { + try { + const stat = await fs.promises.stat(filePath); + return stat.isFile(); + } catch { + return false; + } + } + try { await fs.promises.access(filePath, fs.constants.X_OK); return true; @@ -11,6 +20,46 @@ async function isExecutable(filePath: string): Promise { } } +function stripSurroundingQuotes(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function getWindowsExecutableExtensions(): string[] { + const raw = process.env.PATHEXT; + if (!raw) { + return ['.exe', '.cmd', '.bat', '.com']; + } + + const exts = raw + .split(';') + .map((ext) => ext.trim()) + .filter((ext) => ext.length > 0) + .map((ext) => (ext.startsWith('.') ? ext : `.${ext}`)) + .map((ext) => ext.toLowerCase()); + + return Array.from(new Set(exts)); +} + +function expandWindowsBinaryNames(binaryName: string): string[] { + const trimmed = binaryName.trim(); + if (!trimmed) { + return []; + } + + const ext = path.extname(trimmed); + if (ext) { + return [trimmed]; + } + + const exts = getWindowsExecutableExtensions(); + const withExt = exts.map((e) => `${trimmed}${e}`); + return [...withExt, trimmed]; +} + async function collectNvmCandidates(): Promise { const nvmNodeRoot = path.join(os.homedir(), '.nvm', 'versions', 'node'); let versions: string[]; @@ -33,16 +82,53 @@ async function resolveFromPathEnv(binaryName: string): Promise { } const pathParts = rawPath.split(path.delimiter); + const binaryNames = + process.platform === 'win32' ? expandWindowsBinaryNames(binaryName) : [binaryName]; for (const part of pathParts) { if (!part) { continue; } - const candidate = path.join(part, binaryName); + const cleanedPart = stripSurroundingQuotes(part); + if (!cleanedPart) { + continue; + } + + for (const name of binaryNames) { + const candidate = path.join(cleanedPart, name); + if (await isExecutable(candidate)) { + return candidate; + } + } + } + return null; +} + +async function resolveFromExplicitPath(inputPath: string): Promise { + const trimmed = inputPath.trim(); + if (!trimmed) { + return null; + } + + if (await isExecutable(trimmed)) { + return trimmed; + } + + if (process.platform !== 'win32') { + return null; + } + + if (path.extname(trimmed)) { + return null; + } + + for (const ext of getWindowsExecutableExtensions()) { + const candidate = `${trimmed}${ext}`; if (await isExecutable(candidate)) { return candidate; } } + return null; } @@ -52,22 +138,45 @@ export class ClaudeBinaryResolver { static async resolve(): Promise { if (cachedPath !== undefined) return cachedPath; - const platformBinaryName = process.platform === 'win32' ? 'claude.cmd' : 'claude'; - const fromPath = await resolveFromPathEnv(platformBinaryName); + const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim(); + if (overrideRaw) { + const looksLikePath = + path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/'); + const resolvedOverride = looksLikePath + ? await resolveFromExplicitPath(overrideRaw) + : await resolveFromPathEnv(overrideRaw); + + if (resolvedOverride) { + cachedPath = resolvedOverride; + return cachedPath; + } + } + + const baseBinaryName = 'claude'; + const fromPath = await resolveFromPathEnv(baseBinaryName); if (fromPath) { cachedPath = fromPath; return cachedPath; } - const candidates: string[] = [ - path.join(os.homedir(), '.npm-global', 'bin', platformBinaryName), - path.join(os.homedir(), '.npm', 'bin', platformBinaryName), + const platformBinaryNames = + process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName]; + + const candidateDirs: string[] = [ + path.join(os.homedir(), '.npm-global', 'bin'), + path.join(os.homedir(), '.npm', 'bin'), process.platform === 'win32' - ? path.join(process.env.APPDATA ?? '', 'npm', 'claude.cmd') - : '/usr/local/bin/claude', - process.platform === 'win32' ? '' : '/opt/homebrew/bin/claude', + ? process.env.APPDATA + ? path.join(process.env.APPDATA, 'npm') + : '' + : '/usr/local/bin', + process.platform === 'win32' ? '' : '/opt/homebrew/bin', ].filter((candidate) => candidate.length > 0); + const candidates = candidateDirs.flatMap((dir) => + platformBinaryNames.map((name) => path.join(dir, name)) + ); + const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates(); for (const candidate of [...candidates, ...nvmCandidates]) { if (await isExecutable(candidate)) { From fe43676c321327f622093ca40b6af063312b0dda Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 16:33:37 +0200 Subject: [PATCH 2/3] Fix CI lint errors --- .../team/activity/ActivityTimeline.tsx | 10 ++++-- .../team/dialogs/TaskDetailDialog.tsx | 2 +- src/renderer/hooks/useTeamMessagesRead.ts | 31 ++++++++----------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index f15cb7f4..00dde0b1 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -41,8 +41,14 @@ const MessageRowWithObserver = ({ const reportedRef = useRef(false); const messageRef = useRef(message); const onVisibleRef = useRef(onVisible); - messageRef.current = message; - onVisibleRef.current = onVisible; + + useEffect(() => { + messageRef.current = message; + }, [message]); + + useEffect(() => { + onVisibleRef.current = onVisible; + }, [onVisible]); useEffect(() => { if (!onVisible) return; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index b85a2567..8c89c61a 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -57,7 +57,7 @@ export const TaskDetailDialog = ({ if (comments.length === 0) return; const latest = Math.max(...comments.map((c) => new Date(c.createdAt).getTime())); if (latest > 0) markAsRead(teamName, currentTask.id, latest); - }, [open, teamName, currentTask?.id, currentTask?.comments]); + }, [open, teamName, currentTask]); const handleDependencyClick = (taskId: string): void => { onClose(); diff --git a/src/renderer/hooks/useTeamMessagesRead.ts b/src/renderer/hooks/useTeamMessagesRead.ts index 95a6eca1..df7e016a 100644 --- a/src/renderer/hooks/useTeamMessagesRead.ts +++ b/src/renderer/hooks/useTeamMessagesRead.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { getReadSet as getReadSetStorage, @@ -9,28 +9,23 @@ export function useTeamMessagesRead(teamName: string): { readSet: Set; markRead: (messageKey: string) => void; } { - const [readSet, setReadSet] = useState>(() => - teamName ? getReadSetStorage(teamName) : new Set() + const [version, setVersion] = useState(0); + const readSet = useMemo( + () => { + if (version < 0) return new Set(); + return teamName ? getReadSetStorage(teamName) : new Set(); + }, + [teamName, version] ); - useEffect(() => { - if (!teamName) { - setReadSet(new Set()); - return; - } - setReadSet(getReadSetStorage(teamName)); - }, [teamName]); - const markRead = useCallback( (messageKey: string) => { if (!teamName) return; - setReadSet((prev) => { - if (prev.has(messageKey)) return prev; - const next = new Set(prev); - next.add(messageKey); - markReadStorage(teamName, messageKey, next); - return next; - }); + const existing = getReadSetStorage(teamName); + if (existing.has(messageKey)) return; + existing.add(messageKey); + markReadStorage(teamName, messageKey, existing); + setVersion((v) => v + 1); }, [teamName] ); From 368f175db06d434f665b83be6b917f2c49866426 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 16:39:56 +0200 Subject: [PATCH 3/3] Fix readSet type on empty team --- src/renderer/hooks/useTeamMessagesRead.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/hooks/useTeamMessagesRead.ts b/src/renderer/hooks/useTeamMessagesRead.ts index df7e016a..b0f4eaf9 100644 --- a/src/renderer/hooks/useTeamMessagesRead.ts +++ b/src/renderer/hooks/useTeamMessagesRead.ts @@ -13,7 +13,7 @@ export function useTeamMessagesRead(teamName: string): { const readSet = useMemo( () => { if (version < 0) return new Set(); - return teamName ? getReadSetStorage(teamName) : new Set(); + return teamName ? getReadSetStorage(teamName) : new Set(); }, [teamName, version] );