From 52ef9fd0a8a382e269b00438a3cf6464323022bf Mon Sep 17 00:00:00 2001
From: iliya
Date: Mon, 2 Mar 2026 22:56:56 +0200
Subject: [PATCH] feat: add FAQ section to README and improve cross-platform
handling in code
- Introduced a comprehensive FAQ section in the README to address common user queries regarding app installation, code handling, agent communication, and project management.
- Enhanced cross-platform keyboard shortcut handling in the Electron app for better user experience on macOS and Windows/Linux.
- Updated signal handling in the standalone process to ensure proper shutdown behavior across platforms.
- Improved WSL user resolution logic to support default user retrieval for better compatibility.
- Enhanced notification handling to support cross-platform features and improve user feedback.
- Refactored SSH connection management to include additional key file types and improve authentication handling.
- Updated team management services to ensure consistent process termination across platforms.
- Improved project path handling in team provisioning to accommodate different operating systems.
- Enhanced editor components to utilize shared utility functions for path management, improving code maintainability.
---
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
{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 (