From 79f0a2bc8f86857635eff97eec594356a9b07443 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 2 Mar 2026 22:44:51 +0200 Subject: [PATCH] feat: enhance FAQ section in README and improve cross-platform handling - Added a comprehensive FAQ section to the README, addressing common user questions about installation, code handling, agent communication, and project support. - Improved cross-platform keyboard shortcut handling in the main application to unify modifier key detection for macOS and Windows/Linux. - Updated shutdown signal handling in standalone mode to ensure compatibility across platforms. - Enhanced WSL user resolution logic to better handle default user retrieval for different distributions. - Improved notification handling to support cross-platform features, including dynamic subtitle management for macOS notifications. - Refactored SSH connection management to include additional default key file types and improve key path resolution for Windows. - Introduced a new utility for killing processes in a cross-platform manner, ensuring graceful shutdowns on Unix and Windows. - Enhanced team data service to support cross-platform process termination and improved error handling in notifications. - Updated editor components to utilize new path utilities for better file handling and display consistency. --- README.md | 52 +++++++ src/main/index.ts | 9 +- src/main/ipc/config.ts | 15 +- src/main/ipc/teams.ts | 6 +- .../infrastructure/NotificationManager.ts | 12 +- .../infrastructure/SshConnectionManager.ts | 35 +++-- .../services/team/ClaudeBinaryResolver.ts | 59 ++++++-- .../services/team/TeamAgentToolsInstaller.ts | 17 ++- src/main/services/team/TeamDataService.ts | 5 +- .../services/team/TeamProvisioningService.ts | 43 ++++-- src/main/standalone.ts | 5 +- src/main/utils/processHealth.ts | 14 ++ src/main/utils/processKill.ts | 33 +++++ .../components/layout/CustomTitleBar.tsx | 8 ++ .../components/team/TeamDetailView.tsx | 34 ++--- src/renderer/components/team/TeamListView.tsx | 53 ++------ .../team/editor/EditorBinaryState.tsx | 3 +- .../team/editor/EditorBreadcrumb.tsx | 3 +- .../team/editor/EditorContextMenu.tsx | 3 +- .../components/team/editor/EditorFileTree.tsx | 7 +- .../team/editor/ProjectEditorOverlay.tsx | 9 +- .../team/editor/SearchInFilesPanel.tsx | 8 +- .../team/members/MemberStatsTab.tsx | 3 +- src/renderer/hooks/useBranchSync.ts | 128 ++++++++++++++++++ src/renderer/store/slices/editorSlice.ts | 19 +-- src/renderer/store/slices/teamSlice.ts | 19 +++ src/renderer/utils/buildSelectionAction.ts | 6 +- src/renderer/utils/chipUtils.ts | 5 +- src/renderer/utils/claudeMdTracker.ts | 30 ++-- src/renderer/utils/fileTreeBuilder.ts | 4 +- src/renderer/utils/pathDisplay.ts | 6 +- src/renderer/utils/tabLabelDisambiguation.ts | 4 +- src/renderer/utils/taskGrouping.ts | 6 +- src/shared/utils/platformPath.ts | 31 +++++ 34 files changed, 521 insertions(+), 173 deletions(-) create mode 100644 src/main/utils/processKill.ts create mode 100644 src/renderer/hooks/useBranchSync.ts create mode 100644 src/shared/utils/platformPath.ts diff --git a/README.md b/README.md index 9194a544..34d5dde8 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,58 @@ A new approach to task management with AI agents. --- --> +## FAQ + +
+Do I need to install Claude Code before using this app? +
+No. The app includes built-in installation and authentication — just launch and follow the setup wizard. +
+ +
+Does it read or upload my code? +
+No. Everything runs locally on your machine. The app reads Claude Code's session logs from ~/.claude/ — your source code is never sent anywhere. +
+ +
+Can agents communicate with each other? +
+Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated automatically through Claude Code's team protocol. +
+ +
+Is it free? +
+Yes, completely free and open source. The app itself requires no API keys or subscriptions. You only need a Claude Code plan from Anthropic to run agents. +
+ +
+Can I review code changes before they're applied? +
+Yes. Every task shows a full diff view where you can accept, reject, or comment on individual code hunks — similar to Cursor's review flow. +
+ +
+What happens if an agent gets stuck? +
+You can send a direct message to any agent at any time to course-correct, or stop and restart it from the process dashboard. If an agent needs your input, you'll get a notification and the task will be marked with a distinct badge on the board so you won't miss it. +
+ +
+Can I use it just to view past sessions without running agents? +
+Yes. The app works as a session viewer too — browse, search, and analyze any Claude Code session history. +
+ +
+Does it support multiple projects and teams? +
+Yes. Run multiple teams in one project or across different projects, even simultaneously. To avoid Git conflicts, ask agents to use git worktree in your provisioning prompt. +
+ +--- + ## Installation No prerequisites — Claude Code can be installed and configured directly from the app. diff --git a/src/main/index.ts b/src/main/index.ts index 20fc9ded..74a5e60b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -844,6 +844,9 @@ function createWindow(): void { if (!mainWindow || mainWindow.isDestroyed()) return; if (input.type !== 'keyDown') return; + // Cmd on macOS, Ctrl on Windows/Linux — unified modifier for cross-platform shortcuts + const isMod = input.meta || input.control; + // Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer // keyboard handler can use it as "Refresh Session" (fixes #58). // Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload). @@ -852,14 +855,14 @@ function createWindow(): void { return; } - // Prevent Cmd+N from opening new window; forward to renderer for review shortcuts - if (input.meta && input.key.toLowerCase() === 'n') { + // Prevent Cmd+N / Ctrl+N from opening new window; forward to renderer for review shortcuts + if (isMod && input.key.toLowerCase() === 'n') { event.preventDefault(); mainWindow.webContents.send('review:cmdN'); return; } - if (!input.meta) return; + if (!isMod) return; const currentLevel = mainWindow.webContents.getZoomLevel(); diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts index 85eb0c76..79b13a69 100644 --- a/src/main/ipc/config.ts +++ b/src/main/ipc/config.ts @@ -948,6 +948,16 @@ async function resolveWslHome(distro: string): Promise { } } +async function resolveWslDefaultUser(distro: string): Promise { + try { + const { stdout } = await runWsl(['-d', distro, '--', 'whoami'], 3000); + const user = stdout.trim(); + return user && !user.includes('/') && !user.includes('\\') ? user : null; + } catch { + return null; + } +} + /** * Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots. */ @@ -968,7 +978,10 @@ async function handleFindWslClaudeRoots( const seen = new Set(); for (const distro of distros) { const resolvedHomePath = await resolveWslHome(distro); - const fallbackHomePath = process.env.USERNAME ? `/home/${process.env.USERNAME}` : null; + // Fallback: query the default WSL user, then try Windows USERNAME + const wslUser = await resolveWslDefaultUser(distro); + const fallbackUser = wslUser || process.env.USERNAME; + const fallbackHomePath = fallbackUser ? `/home/${fallbackUser}` : null; const normalizedHome = normalizeWslHomePath(resolvedHomePath ?? '') ?? (fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f033393d..f8e0e41e 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1764,11 +1764,13 @@ export function showTeamNativeNotification(opts: { return; } + const isMac = process.platform === 'darwin'; + const truncatedBody = opts.body.slice(0, 300); const iconPath = getAppIconPath(); const notification = new Notification({ title: opts.title, - subtitle: opts.subtitle, - body: opts.body.slice(0, 300), + ...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}), + body: !isMac && opts.subtitle ? `${opts.subtitle}\n${truncatedBody}` : truncatedBody, sound: config.notifications.soundEnabled ? 'default' : undefined, ...(iconPath ? { icon: iconPath } : {}), }); diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 274df86a..6f55a4f3 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -3,7 +3,7 @@ * * Responsibilities: * - Store error history at ~/.claude/claude-devtools-notifications.json (max 100 entries) - * - Show native macOS notifications using Electron's Notification API + * - Show native notifications using Electron's Notification API (cross-platform) * - Implement throttling (5 seconds per unique error hash) * - Respect config.notifications.enabled and snoozedUntil * - Filter errors matching ignoredRegex patterns @@ -380,7 +380,9 @@ export class NotificationManager extends EventEmitter { // =========================================================================== /** - * Shows a native macOS notification for an error. + * Shows a native notification for an error. + * Note: Electron's `subtitle` option only works on macOS. + * On Windows/Linux, we prepend the subtitle to the body instead. */ private showNativeNotification(error: DetectedError): void { // Guard against standalone/Docker mode where Electron's Notification API is unavailable @@ -395,11 +397,13 @@ export class NotificationManager extends EventEmitter { const config = this.configManager.getConfig(); + const isMac = process.platform === 'darwin'; + const truncatedMessage = error.message.slice(0, 200); const iconPath = getAppIconPath(); const notification = new Notification({ title: 'Claude Code Error', - subtitle: error.context.projectName, - body: error.message.slice(0, 200), + ...(isMac ? { subtitle: error.context.projectName } : {}), + body: isMac ? truncatedMessage : `${error.context.projectName}\n${truncatedMessage}`, sound: config.notifications.soundEnabled ? 'default' : undefined, ...(iconPath ? { icon: iconPath } : {}), }); diff --git a/src/main/services/infrastructure/SshConnectionManager.ts b/src/main/services/infrastructure/SshConnectionManager.ts index b76b91b0..9f8afc30 100644 --- a/src/main/services/infrastructure/SshConnectionManager.ts +++ b/src/main/services/infrastructure/SshConnectionManager.ts @@ -396,7 +396,7 @@ export class SshConnectionManager extends EventEmitter { * Resolves authentication automatically by trying: * 1. IdentityFile from SSH config * 2. SSH agent - * 3. Default key files (id_ed25519, id_rsa) + * 3. Default key files (id_ed25519, id_rsa, id_ecdsa) */ private async resolveAutoAuth( sshConfig: SshConfigHostEntry | null @@ -407,10 +407,7 @@ export class SshConnectionManager extends EventEmitter { if (resolved) { // The config parser already told us there's an identity file. // Try common identity file locations from config - const configKeyPaths = [ - path.join(getHomeDir(), '.ssh', 'id_ed25519'), - path.join(getHomeDir(), '.ssh', 'id_rsa'), - ]; + const configKeyPaths = this.getSshKeyPaths(); for (const keyPath of configKeyPaths) { try { const keyData = await fs.promises.readFile(keyPath, 'utf8'); @@ -429,11 +426,7 @@ export class SshConnectionManager extends EventEmitter { } // Try default key files - const defaultKeys = [ - path.join(getHomeDir(), '.ssh', 'id_ed25519'), - path.join(getHomeDir(), '.ssh', 'id_rsa'), - path.join(getHomeDir(), '.ssh', 'id_ecdsa'), - ]; + const defaultKeys = this.getSshKeyPaths(); for (const keyPath of defaultKeys) { try { @@ -447,6 +440,28 @@ export class SshConnectionManager extends EventEmitter { return {}; } + /** + * Returns SSH key candidate paths for the current platform. + * On Windows, also checks %USERPROFILE%\.ssh\ (OpenSSH for Windows default). + */ + private getSshKeyPaths(): string[] { + const home = getHomeDir(); + const keyNames = ['id_ed25519', 'id_rsa', 'id_ecdsa']; + const candidates = keyNames.map((name) => path.join(home, '.ssh', name)); + + // On Windows, USERPROFILE may differ from getHomeDir() when HOME is overridden + if (process.platform === 'win32') { + const userProfile = process.env.USERPROFILE; + if (userProfile && userProfile !== home) { + for (const name of keyNames) { + candidates.push(path.join(userProfile, '.ssh', name)); + } + } + } + + return candidates; + } + private async resolveRemoteProjectsPath(username: string): Promise { // Prefer remote $HOME when available, then fall back to common paths. const remoteHome = await this.resolveRemoteHomeDirectory(); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index af7d9f7b..6df3888f 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -61,6 +61,10 @@ function expandWindowsBinaryNames(binaryName: string): string[] { } async function collectNvmCandidates(): Promise { + if (process.platform === 'win32') { + return collectNvmWindowsCandidates(); + } + const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node'); let versions: string[]; try { @@ -75,6 +79,29 @@ async function collectNvmCandidates(): Promise { .reverse(); } +/** + * Collect NVM for Windows (nvm-windows) candidates. + * nvm-windows stores Node versions under %APPDATA%\nvm\\. + */ +async function collectNvmWindowsCandidates(): Promise { + const appdata = process.env.APPDATA; + if (!appdata) return []; + + const nvmRoot = path.join(appdata, 'nvm'); + let versions: string[]; + try { + versions = await fs.promises.readdir(nvmRoot); + } catch { + return []; + } + + const exts = getWindowsExecutableExtensions(); + return versions + .flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`))) + .sort((a, b) => a.localeCompare(b)) + .reverse(); +} + async function resolveFromPathEnv(binaryName: string): Promise { const rawPath = process.env.PATH; if (!rawPath) { @@ -176,22 +203,34 @@ export class ClaudeBinaryResolver { const platformBinaryNames = process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName]; - const candidateDirs: string[] = [ - // Native binary installation path (claude install) - path.join(getHomeDir(), '.local', 'bin'), - path.join(getHomeDir(), '.npm-global', 'bin'), - path.join(getHomeDir(), '.npm', 'bin'), + const candidateDirs: string[] = process.platform === 'win32' - ? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm') - : '/usr/local/bin', - process.platform === 'win32' ? '' : '/opt/homebrew/bin', - ].filter((candidate) => candidate.length > 0); + ? [ + // Windows: npm global install + path.join(getHomeDir(), 'AppData', 'Roaming', 'npm'), + // Windows: scoop, chocolatey, and other package managers + path.join(getHomeDir(), 'scoop', 'shims'), + // Windows: Local programs + ...(process.env.LOCALAPPDATA + ? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')] + : []), + // Windows: Program Files + ...(process.env.ProgramFiles ? [path.join(process.env.ProgramFiles, 'claude')] : []), + ] + : [ + // Unix: native binary installation path (claude install) + path.join(getHomeDir(), '.local', 'bin'), + path.join(getHomeDir(), '.npm-global', 'bin'), + path.join(getHomeDir(), '.npm', 'bin'), + '/usr/local/bin', + '/opt/homebrew/bin', + ]; const candidates = candidateDirs.flatMap((dir) => platformBinaryNames.map((name) => path.join(dir, name)) ); - const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates(); + const nvmCandidates = await collectNvmCandidates(); const allCandidates = [...candidates, ...nvmCandidates]; // Check all fallback candidates in parallel for speed diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 5bc3ab57..504c7e19 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -85,7 +85,12 @@ function parseArgs(argv) { } function getHomeDir() { - return process.env.HOME || process.env.USERPROFILE || ''; + if (process.env.HOME) return process.env.HOME; + if (process.env.USERPROFILE) return process.env.USERPROFILE; + if (process.env.HOMEDRIVE && process.env.HOMEPATH) { + return process.env.HOMEDRIVE + process.env.HOMEPATH; + } + try { return require('os').homedir(); } catch { return ''; } } function getClaudeDir(flags) { @@ -98,7 +103,7 @@ function getClaudeDir(flags) { const inferred = inferClaudeDirFromScriptPath(); if (inferred) return inferred; const home = getHomeDir(); - if (!home) die('HOME is not set'); + if (!home) die('HOME/USERPROFILE is not set'); return path.join(home, '.claude'); } @@ -976,8 +981,8 @@ async function main() { parts.push( '\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)}, 'Update task status using:', - 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id), - 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id), + 'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id), + 'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id), ${JSON.stringify(AGENT_BLOCK_CLOSE)} ); sendInboxMessage(paths, teamName, { @@ -1053,8 +1058,8 @@ async function main() { parts.push( '\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)}, 'Update task status using:', - 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id), - 'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task complete ' + String(task.id), + 'node "' + __filename + '" --team ' + String(teamName) + ' task start ' + String(task.id), + 'node "' + __filename + '" --team ' + String(teamName) + ' task complete ' + String(task.id), ${JSON.stringify(AGENT_BLOCK_CLOSE)} ); sendInboxMessage(paths, teamName, { diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 3223aa54..4218ab06 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -6,6 +6,7 @@ import { getTeamsBasePath, } from '@main/utils/pathDecoder'; import { isProcessAlive } from '@main/utils/processHealth'; +import { killProcessByPid } from '@main/utils/processKill'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { getMemberColor } from '@shared/constants/memberColors'; import { createLogger } from '@shared/utils/logger'; @@ -458,9 +459,9 @@ export class TeamDataService { async killProcess(teamName: string, pid: number): Promise { const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); - // Try to kill the process + // Try to kill the process (cross-platform: SIGTERM on Unix, taskkill on Windows) try { - process.kill(pid, 'SIGTERM'); + killProcessByPid(pid); } catch (err: unknown) { // ESRCH = process not found — still mark as stopped below if ( diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 27df5d34..8c53fed3 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2700,15 +2700,19 @@ export class TeamProvisioningService { // getHomeDir() uses Electron's app.getPath('home') which handles Unicode // correctly on Windows. Prefer it over process.env which may be garbled. const electronHome = getHomeDir(); + const isWindows = process.platform === 'win32'; const home = shellEnv.HOME?.trim() || electronHome; - const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username; - const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh'; - const xdgConfigHome = - shellEnv.XDG_CONFIG_HOME?.trim() || process.env.XDG_CONFIG_HOME?.trim() || `${home}/.config`; - const xdgStateHome = - shellEnv.XDG_STATE_HOME?.trim() || - process.env.XDG_STATE_HOME?.trim() || - `${home}/.local/state`; + const user = + shellEnv.USER?.trim() || + process.env.USER?.trim() || + process.env.USERNAME?.trim() || + os.userInfo().username; + + // Shell: on Windows there is no SHELL env var; use COMSPEC (cmd.exe / powershell). + // On Unix, prefer the user's login shell from env or fall back to /bin/zsh. + const shell = isWindows + ? (process.env.COMSPEC ?? 'powershell.exe') + : shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh'; const env: NodeJS.ProcessEnv = { ...process.env, @@ -2717,16 +2721,33 @@ export class TeamProvisioningService { USERPROFILE: home, USER: user, LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, - SHELL: shell, TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color', - XDG_CONFIG_HOME: xdgConfigHome, - XDG_STATE_HOME: xdgStateHome, // Ensure CLI reads/writes from the same Claude root as the app. // This aligns teams/tasks locations when the app overrides claudeRootPath. CLAUDE_CONFIG_DIR: getClaudeBasePath(), CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', }; + // SHELL is a Unix concept — only set it on non-Windows platforms. + if (!isWindows) { + env.SHELL = shell; + } + + // XDG directories are a freedesktop.org (Linux/macOS) convention. + // On Windows, these are unused by most tools and can cause confusion. + if (!isWindows) { + const xdgConfigHome = + shellEnv.XDG_CONFIG_HOME?.trim() || + process.env.XDG_CONFIG_HOME?.trim() || + `${home}/.config`; + const xdgStateHome = + shellEnv.XDG_STATE_HOME?.trim() || + process.env.XDG_STATE_HOME?.trim() || + `${home}/.local/state`; + env.XDG_CONFIG_HOME = xdgConfigHome; + env.XDG_STATE_HOME = xdgStateHome; + } + // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { return { env, authSource: 'anthropic_api_key' }; diff --git a/src/main/standalone.ts b/src/main/standalone.ts index 66b05048..0ff2b18d 100644 --- a/src/main/standalone.ts +++ b/src/main/standalone.ts @@ -176,8 +176,11 @@ async function shutdown(): Promise { // Signal Handlers // ============================================================================= -process.on('SIGTERM', () => void shutdown()); +// SIGINT works on all platforms (Ctrl+C), but SIGTERM does not exist on Windows. process.on('SIGINT', () => void shutdown()); +if (process.platform !== 'win32') { + process.on('SIGTERM', () => void shutdown()); +} process.on('unhandledRejection', (reason) => { logger.error('Unhandled promise rejection:', reason); diff --git a/src/main/utils/processHealth.ts b/src/main/utils/processHealth.ts index 6d482a82..cd939c82 100644 --- a/src/main/utils/processHealth.ts +++ b/src/main/utils/processHealth.ts @@ -1,3 +1,17 @@ +/** + * Check whether a process with the given PID is still alive. + * + * Cross-platform notes: + * - `process.kill(pid, 0)` sends signal 0 (a no-op probe) on all platforms. + * On Windows, Node.js internally calls `OpenProcess()` which works correctly + * for same-user processes. + * - EPERM means the process exists but we lack permission to signal it — + * still counts as alive. + * - ESRCH (Unix) or ERROR_INVALID_PARAMETER (Windows, mapped to ESRCH by + * Node.js) means the process does not exist. + * - On Windows, zombie/defunct processes are not a concern because Windows + * cleans up process handles immediately upon exit (no Unix-style zombies). + */ export function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); diff --git a/src/main/utils/processKill.ts b/src/main/utils/processKill.ts new file mode 100644 index 00000000..84976248 --- /dev/null +++ b/src/main/utils/processKill.ts @@ -0,0 +1,33 @@ +import { execFile } from 'child_process'; +import path from 'path'; + +/** + * Kill a process by PID in a cross-platform manner. + * + * On Unix: sends SIGTERM, which allows the process to handle the signal gracefully. + * On Windows: uses `taskkill /T /F /PID` to kill the entire process tree. + * - `process.kill(pid, 'SIGTERM')` on Windows does NOT actually send a signal — + * it calls TerminateProcess() which is equivalent to SIGKILL (immediate, ungraceful). + * - `taskkill /T` also kills child processes, preventing orphaned process trees. + * + * Throws if the process cannot be killed (except ESRCH — process already dead). + */ +export function killProcessByPid(pid: number): void { + if (process.platform === 'win32') { + try { + const taskkillPath = path.join( + process.env.SystemRoot ?? 'C:\\Windows', + 'System32', + 'taskkill.exe' + ); + execFile(taskkillPath, ['/T', '/F', '/PID', String(pid)], () => { + // Best-effort — ignore errors (process may have already exited) + }); + } catch { + // taskkill failed to spawn, fall through to process.kill() + process.kill(pid, 'SIGTERM'); + } + } else { + process.kill(pid, 'SIGTERM'); + } +} diff --git a/src/renderer/components/layout/CustomTitleBar.tsx b/src/renderer/components/layout/CustomTitleBar.tsx index 04895818..f8b38a04 100644 --- a/src/renderer/components/layout/CustomTitleBar.tsx +++ b/src/renderer/components/layout/CustomTitleBar.tsx @@ -14,6 +14,14 @@ import { Minus, Square, X } from 'lucide-react'; const TITLE_BAR_HEIGHT = 32; +/** + * Detect whether the custom title bar should be shown. + * + * In Electron, the userAgent string reliably contains the OS name + * (e.g. "Windows NT 10.0", "Linux x86_64"), so this check works on + * all three platforms. macOS is excluded because it uses native + * traffic-light window controls instead. + */ function needsCustomTitleBar(): boolean { if (!isElectronMode()) return false; const ua = window.navigator.userAgent; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 8bede8b4..3a226ce1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -13,12 +13,13 @@ import { } from '@renderer/components/ui/dialog'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; -import { buildTaskCountsByOwner } from '@renderer/utils/pathNormalize'; +import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; import { nameColorSet } from '@renderer/utils/projectColor'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; @@ -350,27 +351,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele }; }, [projectId]); - // Resolve lead's git branch from project path - const [leadBranch, setLeadBranch] = useState(null); - useEffect(() => { - const projectPath = data?.config.projectPath?.trim(); - if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') { - setLeadBranch(null); - return; - } - let cancelled = false; - void api.teams.getProjectBranch(projectPath).then( - (branch) => { - if (!cancelled) setLeadBranch(branch); - }, - () => { - if (!cancelled) setLeadBranch(null); - } - ); - return () => { - cancelled = true; - }; - }, [data?.config.projectPath]); + // Live git branch polling for the team's project path + const teamProjectPath = data?.config.projectPath?.trim() ?? null; + const branchSyncPaths = useMemo( + () => (teamProjectPath ? [teamProjectPath] : []), + [teamProjectPath] + ); + useBranchSync(branchSyncPaths, { live: true }); + const leadBranch = useStore((s) => + teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null + ); // Filter sessions to team-only using sessionHistory + leadSessionId const teamSessions = useMemo(() => { diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index b473646f..a3b33453 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -12,6 +12,7 @@ import { TooltipTrigger, } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useBranchSync } from '@renderer/hooks/useBranchSync'; import { useStore } from '@renderer/store'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; @@ -182,7 +183,6 @@ export const TeamListView = (): React.JSX.Element => { const [searchQuery, setSearchQuery] = useState(''); const [filter, setFilter] = useState(EMPTY_TEAM_FILTER); const [aliveTeams, setAliveTeams] = useState([]); - const [branchByPath, setBranchByPath] = useState>(new Map()); const { teams, teamsLoading, @@ -337,48 +337,13 @@ export const TeamListView = (): React.JSX.Element => { leadActivityByTeam, ]); - // Live branch/worktree for team project paths (poll so it updates during process) - const projectPathsToPoll = useMemo(() => { - const byKey = new Map(); - for (const team of filteredTeams) { - const p = team.projectPath?.trim(); - if (p) { - const key = normalizePath(p); - if (!byKey.has(key)) byKey.set(key, p); - } - } - return Array.from(byKey.entries()); - }, [filteredTeams]); - - useEffect(() => { - if (!electronMode || projectPathsToPoll.length === 0) return; - let cancelled = false; - const poll = async (): Promise => { - const next = new Map(); - for (const [pathKey, actualPath] of projectPathsToPoll) { - if (cancelled) return; - try { - const branch = await api.teams.getProjectBranch(actualPath); - if (!cancelled) next.set(pathKey, branch); - } catch { - if (!cancelled) next.set(pathKey, null); - } - } - if (!cancelled && next.size > 0) { - setBranchByPath((prev) => { - const m = new Map(prev); - for (const [k, v] of next) m.set(k, v); - return m; - }); - } - }; - void poll(); - const interval = setInterval(poll, 6000); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [electronMode, projectPathsToPoll]); + // Fetch branches once for all visible team project paths (no live polling) + const teamPaths = useMemo( + () => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[], + [filteredTeams] + ); + useBranchSync(teamPaths, { live: false }); + const branchByPath = useStore((s) => s.branchByPath); const restoreTeam = useStore((s) => s.restoreTeam); const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam); @@ -748,7 +713,7 @@ export const TeamListView = (): React.JSX.Element => {

{team.projectPath && (() => { - const branch = branchByPath.get(normalizePath(team.projectPath)); + const branch = branchByPath[normalizePath(team.projectPath)]; if (!branch) return null; return ( { - const fileName = filePath.split('/').pop() ?? filePath; + const fileName = getBasename(filePath) || filePath; const previewType = getPreviewType(fileName); if (previewType === 'image' && isPreviewable(fileName, size)) { diff --git a/src/renderer/components/team/editor/EditorBreadcrumb.tsx b/src/renderer/components/team/editor/EditorBreadcrumb.tsx index d3735a55..ad5455f5 100644 --- a/src/renderer/components/team/editor/EditorBreadcrumb.tsx +++ b/src/renderer/components/team/editor/EditorBreadcrumb.tsx @@ -7,6 +7,7 @@ import { useCallback, useMemo } from 'react'; import { useStore } from '@renderer/store'; +import { splitPath } from '@shared/utils/platformPath'; import { ChevronRight } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -32,7 +33,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => { ? activeTabId.slice(projectPath.length + 1) : activeTabId; - return relativePath.split('/'); + return splitPath(relativePath); }, [activeTabId, projectPath]); const handleSegmentClick = useCallback( diff --git a/src/renderer/components/team/editor/EditorContextMenu.tsx b/src/renderer/components/team/editor/EditorContextMenu.tsx index 4f18608f..5a795ff9 100644 --- a/src/renderer/components/team/editor/EditorContextMenu.tsx +++ b/src/renderer/components/team/editor/EditorContextMenu.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useRef, useState } from 'react'; import * as ContextMenu from '@radix-ui/react-context-menu'; +import { lastSeparatorIndex } from '@shared/utils/platformPath'; import { ClipboardCopy, FilePlus, @@ -84,7 +85,7 @@ export const EditorContextMenu = ({ const parentDir = target ? target.isDir ? target.path - : target.path.substring(0, target.path.lastIndexOf('/')) + : target.path.substring(0, lastSeparatorIndex(target.path)) : null; return ( diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index 7ec14c4f..d64da1ea 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -27,6 +27,7 @@ import { } from '@renderer/components/ui/dialog'; import { useStore } from '@renderer/store'; import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; +import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath'; import { useVirtualizer } from '@tanstack/react-virtual'; import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -350,7 +351,7 @@ export const EditorFileTree = ({ const item = flatItemsByPath.get(overId); if (item) { const p = item.node.fullPath; - targetDir = p.substring(0, p.lastIndexOf('/')); + targetDir = p.substring(0, lastSeparatorIndex(p)); } } @@ -389,7 +390,7 @@ export const EditorFileTree = ({ } const destDir = dropTargetPath; - const sourceParent = sourcePath.substring(0, sourcePath.lastIndexOf('/')); + const sourceParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath)); // Validation: same folder = no-op if (sourceParent === destDir) { @@ -543,7 +544,7 @@ export const EditorFileTree = ({ Move to Trash - Move “{deleteConfirmPath?.split('/').pop() ?? ''}” to Trash? + Move “{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}” to Trash? diff --git a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx index 9d76234c..c2dd57d8 100644 --- a/src/renderer/components/team/editor/ProjectEditorOverlay.tsx +++ b/src/renderer/components/team/editor/ProjectEditorOverlay.tsx @@ -21,6 +21,7 @@ import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardSho import { useStore } from '@renderer/store'; import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction'; import { shortcutLabel } from '@renderer/utils/platformKeys'; +import { getBasename, getDirname } from '@shared/utils/platformPath'; import { AlertTriangle, HelpCircle, @@ -216,7 +217,7 @@ export const ProjectEditorOverlay = ({ const result = await promise; const ipcMs = performance.now() - t0; console.debug( - `[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${filePath.split('/').pop() ?? ''}` + `[perf] loadFileContent: IPC=${ipcMs.toFixed(1)}ms, size=${result.size}, truncated=${result.truncated}, cached=${wasCached}, file=${getBasename(filePath)}` ); setFileContent(result); @@ -519,7 +520,7 @@ export const ProjectEditorOverlay = ({ onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined, }); - const projectName = projectPath.split('/').pop() ?? projectPath; + const projectName = getBasename(projectPath) || projectPath; return (
)}
diff --git a/src/renderer/components/team/editor/SearchInFilesPanel.tsx b/src/renderer/components/team/editor/SearchInFilesPanel.tsx index 46de8da7..60f47f50 100644 --- a/src/renderer/components/team/editor/SearchInFilesPanel.tsx +++ b/src/renderer/components/team/editor/SearchInFilesPanel.tsx @@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; +import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath'; import { Loader2, Search, X } from 'lucide-react'; import { FileIcon } from './FileIcon'; @@ -263,10 +264,9 @@ const SearchFileGroup = ({ query, caseSensitive, }: SearchFileGroupProps): React.ReactElement => { - const fileName = relativePath.split('/').pop() ?? relativePath; - const dirPath = relativePath.includes('/') - ? relativePath.slice(0, relativePath.lastIndexOf('/')) - : ''; + const fileName = getBasename(relativePath) || relativePath; + const sepIdx = lastSeparatorIndex(relativePath); + const dirPath = sepIdx >= 0 ? relativePath.slice(0, sepIdx) : ''; return (