Merge pull request #13 from 777genius/improvements

feat: enhance FAQ section in README and improve cross-platform handling
This commit is contained in:
Илия 2026-03-02 23:11:04 +02:00 committed by GitHub
commit 9319189ff9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 552 additions and 174 deletions

View file

@ -60,6 +60,58 @@ A new approach to task management with AI agents.
--- ---
--> -->
## FAQ
<details>
<summary><strong>Do I need to install Claude Code before using this app?</strong></summary>
<br />
No. The app includes built-in installation and authentication — just launch and follow the setup wizard.
</details>
<details>
<summary><strong>Does it read or upload my code?</strong></summary>
<br />
No. Everything runs locally on your machine. The app reads Claude Code's session logs from <code>~/.claude/</code> — your source code is never sent anywhere.
</details>
<details>
<summary><strong>Can agents communicate with each other?</strong></summary>
<br />
Yes. Agents send direct messages, create shared tasks, and leave comments — all coordinated automatically through Claude Code's team protocol.
</details>
<details>
<summary><strong>Is it free?</strong></summary>
<br />
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.
</details>
<details>
<summary><strong>Can I review code changes before they're applied?</strong></summary>
<br />
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.
</details>
<details>
<summary><strong>What happens if an agent gets stuck?</strong></summary>
<br />
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.
</details>
<details>
<summary><strong>Can I use it just to view past sessions without running agents?</strong></summary>
<br />
Yes. The app works as a session viewer too — browse, search, and analyze any Claude Code session history.
</details>
<details>
<summary><strong>Does it support multiple projects and teams?</strong></summary>
<br />
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.
</details>
---
## Installation ## Installation
No prerequisites — Claude Code can be installed and configured directly from the app. No prerequisites — Claude Code can be installed and configured directly from the app.

View file

@ -844,6 +844,9 @@ function createWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
if (input.type !== 'keyDown') 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 // Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
// keyboard handler can use it as "Refresh Session" (fixes #58). // keyboard handler can use it as "Refresh Session" (fixes #58).
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload). // Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
@ -852,14 +855,14 @@ function createWindow(): void {
return; return;
} }
// Prevent Cmd+N from opening new window; forward to renderer for review shortcuts // Prevent Cmd+N / Ctrl+N from opening new window; forward to renderer for review shortcuts
if (input.meta && input.key.toLowerCase() === 'n') { if (isMod && input.key.toLowerCase() === 'n') {
event.preventDefault(); event.preventDefault();
mainWindow.webContents.send('review:cmdN'); mainWindow.webContents.send('review:cmdN');
return; return;
} }
if (!input.meta) return; if (!isMod) return;
const currentLevel = mainWindow.webContents.getZoomLevel(); const currentLevel = mainWindow.webContents.getZoomLevel();

View file

@ -948,6 +948,16 @@ async function resolveWslHome(distro: string): Promise<string | null> {
} }
} }
async function resolveWslDefaultUser(distro: string): Promise<string | null> {
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. * Handler for 'config:findWslClaudeRoots' - Find Windows UNC candidates for WSL Claude roots.
*/ */
@ -968,7 +978,14 @@ async function handleFindWslClaudeRoots(
const seen = new Set<string>(); const seen = new Set<string>();
for (const distro of distros) { for (const distro of distros) {
const resolvedHomePath = await resolveWslHome(distro); 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
? fallbackUser === 'root'
? '/root'
: `/home/${fallbackUser}`
: null;
const normalizedHome = const normalizedHome =
normalizeWslHomePath(resolvedHomePath ?? '') ?? normalizeWslHomePath(resolvedHomePath ?? '') ??
(fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null); (fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);

View file

@ -1764,11 +1764,13 @@ export function showTeamNativeNotification(opts: {
return; return;
} }
const isMac = process.platform === 'darwin';
const truncatedBody = opts.body.slice(0, 300);
const iconPath = getAppIconPath(); const iconPath = getAppIconPath();
const notification = new Notification({ const notification = new Notification({
title: opts.title, title: opts.title,
subtitle: opts.subtitle, ...(isMac && opts.subtitle ? { subtitle: opts.subtitle } : {}),
body: opts.body.slice(0, 300), body: !isMac && opts.subtitle ? `${opts.subtitle}\n${truncatedBody}` : truncatedBody,
sound: config.notifications.soundEnabled ? 'default' : undefined, sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}), ...(iconPath ? { icon: iconPath } : {}),
}); });

View file

@ -3,7 +3,7 @@
* *
* Responsibilities: * Responsibilities:
* - Store error history at ~/.claude/claude-devtools-notifications.json (max 100 entries) * - 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) * - Implement throttling (5 seconds per unique error hash)
* - Respect config.notifications.enabled and snoozedUntil * - Respect config.notifications.enabled and snoozedUntil
* - Filter errors matching ignoredRegex patterns * - 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 { private showNativeNotification(error: DetectedError): void {
// Guard against standalone/Docker mode where Electron's Notification API is unavailable // 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 config = this.configManager.getConfig();
const isMac = process.platform === 'darwin';
const truncatedMessage = error.message.slice(0, 200);
const iconPath = getAppIconPath(); const iconPath = getAppIconPath();
const notification = new Notification({ const notification = new Notification({
title: 'Claude Code Error', title: 'Claude Code Error',
subtitle: error.context.projectName, ...(isMac ? { subtitle: error.context.projectName } : {}),
body: error.message.slice(0, 200), body: isMac ? truncatedMessage : `${error.context.projectName}\n${truncatedMessage}`,
sound: config.notifications.soundEnabled ? 'default' : undefined, sound: config.notifications.soundEnabled ? 'default' : undefined,
...(iconPath ? { icon: iconPath } : {}), ...(iconPath ? { icon: iconPath } : {}),
}); });

View file

@ -396,7 +396,7 @@ export class SshConnectionManager extends EventEmitter {
* Resolves authentication automatically by trying: * Resolves authentication automatically by trying:
* 1. IdentityFile from SSH config * 1. IdentityFile from SSH config
* 2. SSH agent * 2. SSH agent
* 3. Default key files (id_ed25519, id_rsa) * 3. Default key files (id_ed25519, id_rsa, id_ecdsa)
*/ */
private async resolveAutoAuth( private async resolveAutoAuth(
sshConfig: SshConfigHostEntry | null sshConfig: SshConfigHostEntry | null
@ -407,10 +407,7 @@ export class SshConnectionManager extends EventEmitter {
if (resolved) { if (resolved) {
// The config parser already told us there's an identity file. // The config parser already told us there's an identity file.
// Try common identity file locations from config // Try common identity file locations from config
const configKeyPaths = [ const configKeyPaths = this.getSshKeyPaths();
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
path.join(getHomeDir(), '.ssh', 'id_rsa'),
];
for (const keyPath of configKeyPaths) { for (const keyPath of configKeyPaths) {
try { try {
const keyData = await fs.promises.readFile(keyPath, 'utf8'); const keyData = await fs.promises.readFile(keyPath, 'utf8');
@ -429,11 +426,7 @@ export class SshConnectionManager extends EventEmitter {
} }
// Try default key files // Try default key files
const defaultKeys = [ const defaultKeys = this.getSshKeyPaths();
path.join(getHomeDir(), '.ssh', 'id_ed25519'),
path.join(getHomeDir(), '.ssh', 'id_rsa'),
path.join(getHomeDir(), '.ssh', 'id_ecdsa'),
];
for (const keyPath of defaultKeys) { for (const keyPath of defaultKeys) {
try { try {
@ -447,6 +440,28 @@ export class SshConnectionManager extends EventEmitter {
return {}; 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<string> { private async resolveRemoteProjectsPath(username: string): Promise<string> {
// Prefer remote $HOME when available, then fall back to common paths. // Prefer remote $HOME when available, then fall back to common paths.
const remoteHome = await this.resolveRemoteHomeDirectory(); const remoteHome = await this.resolveRemoteHomeDirectory();

View file

@ -61,6 +61,10 @@ function expandWindowsBinaryNames(binaryName: string): string[] {
} }
async function collectNvmCandidates(): Promise<string[]> { async function collectNvmCandidates(): Promise<string[]> {
if (process.platform === 'win32') {
return collectNvmWindowsCandidates();
}
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node'); const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
let versions: string[]; let versions: string[];
try { try {
@ -75,6 +79,28 @@ async function collectNvmCandidates(): Promise<string[]> {
.reverse(); .reverse();
} }
/**
* Collect NVM for Windows (nvm-windows) candidates.
* nvm-windows stores Node versions under %APPDATA%\nvm\<version>\.
*/
async function collectNvmWindowsCandidates(): Promise<string[]> {
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
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }))
.flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`)));
}
async function resolveFromPathEnv(binaryName: string): Promise<string | null> { async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
const rawPath = process.env.PATH; const rawPath = process.env.PATH;
if (!rawPath) { if (!rawPath) {
@ -176,22 +202,34 @@ export class ClaudeBinaryResolver {
const platformBinaryNames = const platformBinaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName]; process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
const candidateDirs: string[] = [ 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'),
process.platform === 'win32' process.platform === 'win32'
? path.join(getHomeDir(), 'AppData', 'Roaming', 'npm') ? [
: '/usr/local/bin', // Windows: npm global install
process.platform === 'win32' ? '' : '/opt/homebrew/bin', path.join(getHomeDir(), 'AppData', 'Roaming', 'npm'),
].filter((candidate) => candidate.length > 0); // 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) => const candidates = candidateDirs.flatMap((dir) =>
platformBinaryNames.map((name) => path.join(dir, name)) platformBinaryNames.map((name) => path.join(dir, name))
); );
const nvmCandidates = process.platform === 'win32' ? [] : await collectNvmCandidates(); const nvmCandidates = await collectNvmCandidates();
const allCandidates = [...candidates, ...nvmCandidates]; const allCandidates = [...candidates, ...nvmCandidates];
// Check all fallback candidates in parallel for speed // Check all fallback candidates in parallel for speed

View file

@ -85,7 +85,12 @@ function parseArgs(argv) {
} }
function getHomeDir() { 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) { function getClaudeDir(flags) {
@ -98,7 +103,7 @@ function getClaudeDir(flags) {
const inferred = inferClaudeDirFromScriptPath(); const inferred = inferClaudeDirFromScriptPath();
if (inferred) return inferred; if (inferred) return inferred;
const home = getHomeDir(); const home = getHomeDir();
if (!home) die('HOME is not set'); if (!home) die('HOME/USERPROFILE is not set');
return path.join(home, '.claude'); return path.join(home, '.claude');
} }
@ -148,7 +153,26 @@ function atomicWrite(filePath, data) {
'.' + '.' +
String(Math.random().toString(16).slice(2)); String(Math.random().toString(16).slice(2));
fs.writeFileSync(tmp, data, 'utf8'); fs.writeFileSync(tmp, data, 'utf8');
fs.renameSync(tmp, filePath); // On Windows, fs.renameSync can throw EPERM/EACCES when another process
// is concurrently renaming to the same target. Retry with backoff.
const maxRetries = process.platform === 'win32' ? 5 : 1;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
fs.renameSync(tmp, filePath);
return;
} catch (e) {
if (attempt < maxRetries - 1 && e && (e.code === 'EPERM' || e.code === 'EACCES')) {
// Busy wait — small random delay to reduce contention
const ms = Math.floor(Math.random() * 50) + 10;
const end = Date.now() + ms;
while (Date.now() < end) { /* spin */ }
continue;
}
// Clean up temp file on final failure
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
throw e;
}
}
} }
function normalizeStatus(value) { function normalizeStatus(value) {
@ -976,8 +1000,8 @@ async function main() {
parts.push( parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)}, '\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:', 'Update task status using:',
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id), 'node "' + __filename + '" --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 complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)} ${JSON.stringify(AGENT_BLOCK_CLOSE)}
); );
sendInboxMessage(paths, teamName, { sendInboxMessage(paths, teamName, {
@ -1053,8 +1077,8 @@ async function main() {
parts.push( parts.push(
'\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)}, '\n' + ${JSON.stringify(AGENT_BLOCK_OPEN)},
'Update task status using:', 'Update task status using:',
'node "$HOME/.claude/tools/${TOOL_FILE_NAME}" --team ' + String(teamName) + ' task start ' + String(task.id), 'node "' + __filename + '" --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 complete ' + String(task.id),
${JSON.stringify(AGENT_BLOCK_CLOSE)} ${JSON.stringify(AGENT_BLOCK_CLOSE)}
); );
sendInboxMessage(paths, teamName, { sendInboxMessage(paths, teamName, {

View file

@ -6,6 +6,7 @@ import {
getTeamsBasePath, getTeamsBasePath,
} from '@main/utils/pathDecoder'; } from '@main/utils/pathDecoder';
import { isProcessAlive } from '@main/utils/processHealth'; import { isProcessAlive } from '@main/utils/processHealth';
import { killProcessByPid } from '@main/utils/processKill';
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks'; import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
import { getMemberColor } from '@shared/constants/memberColors'; import { getMemberColor } from '@shared/constants/memberColors';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
@ -458,9 +459,9 @@ export class TeamDataService {
async killProcess(teamName: string, pid: number): Promise<void> { async killProcess(teamName: string, pid: number): Promise<void> {
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); 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 { try {
process.kill(pid, 'SIGTERM'); killProcessByPid(pid);
} catch (err: unknown) { } catch (err: unknown) {
// ESRCH = process not found — still mark as stopped below // ESRCH = process not found — still mark as stopped below
if ( if (

View file

@ -2700,15 +2700,26 @@ export class TeamProvisioningService {
// getHomeDir() uses Electron's app.getPath('home') which handles Unicode // getHomeDir() uses Electron's app.getPath('home') which handles Unicode
// correctly on Windows. Prefer it over process.env which may be garbled. // correctly on Windows. Prefer it over process.env which may be garbled.
const electronHome = getHomeDir(); const electronHome = getHomeDir();
const isWindows = process.platform === 'win32';
const home = shellEnv.HOME?.trim() || electronHome; const home = shellEnv.HOME?.trim() || electronHome;
const user = shellEnv.USER?.trim() || process.env.USER?.trim() || os.userInfo().username; let osUsername = '';
const shell = shellEnv.SHELL?.trim() || process.env.SHELL?.trim() || '/bin/zsh'; try {
const xdgConfigHome = osUsername = os.userInfo().username;
shellEnv.XDG_CONFIG_HOME?.trim() || process.env.XDG_CONFIG_HOME?.trim() || `${home}/.config`; } catch {
const xdgStateHome = // os.userInfo() can throw SystemError in restricted environments (no passwd entry, Docker, etc.)
shellEnv.XDG_STATE_HOME?.trim() || }
process.env.XDG_STATE_HOME?.trim() || const user =
`${home}/.local/state`; shellEnv.USER?.trim() ||
process.env.USER?.trim() ||
process.env.USERNAME?.trim() ||
osUsername ||
'unknown';
// 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 = { const env: NodeJS.ProcessEnv = {
...process.env, ...process.env,
@ -2717,16 +2728,33 @@ export class TeamProvisioningService {
USERPROFILE: home, USERPROFILE: home,
USER: user, USER: user,
LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, LOGNAME: shellEnv.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user,
SHELL: shell,
TERM: shellEnv.TERM?.trim() || process.env.TERM?.trim() || 'xterm-256color', 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. // Ensure CLI reads/writes from the same Claude root as the app.
// This aligns teams/tasks locations when the app overrides claudeRootPath. // This aligns teams/tasks locations when the app overrides claudeRootPath.
CLAUDE_CONFIG_DIR: getClaudeBasePath(), CLAUDE_CONFIG_DIR: getClaudeBasePath(),
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', 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 // 1. Explicit ANTHROPIC_API_KEY — works with `-p` mode directly
if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) { if (typeof env.ANTHROPIC_API_KEY === 'string' && env.ANTHROPIC_API_KEY.trim().length > 0) {
return { env, authSource: 'anthropic_api_key' }; return { env, authSource: 'anthropic_api_key' };

View file

@ -176,8 +176,11 @@ async function shutdown(): Promise<void> {
// Signal Handlers // 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()); process.on('SIGINT', () => void shutdown());
if (process.platform !== 'win32') {
process.on('SIGTERM', () => void shutdown());
}
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection:', reason); logger.error('Unhandled promise rejection:', reason);

View file

@ -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 { export function isProcessAlive(pid: number): boolean {
try { try {
process.kill(pid, 0); process.kill(pid, 0);

View file

@ -0,0 +1,34 @@
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.
*
* On Unix, throws if the process cannot be killed (except ESRCH process already dead).
* On Windows, taskkill is best-effort (async fire-and-forget) to match killProcessTree() semantics.
*/
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');
}
}

View file

@ -14,6 +14,14 @@ import { Minus, Square, X } from 'lucide-react';
const TITLE_BAR_HEIGHT = 32; 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 { function needsCustomTitleBar(): boolean {
if (!isElectronMode()) return false; if (!isElectronMode()) return false;
const ua = window.navigator.userAgent; const ua = window.navigator.userAgent;

View file

@ -13,12 +13,13 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatProjectPath } from '@renderer/utils/pathDisplay'; 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 { nameColorSet } from '@renderer/utils/projectColor';
import { resolveProjectIdByPath } from '@renderer/utils/projectLookup'; import { resolveProjectIdByPath } from '@renderer/utils/projectLookup';
import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { toMessageKey } from '@renderer/utils/teamMessageKey';
@ -350,27 +351,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}; };
}, [projectId]); }, [projectId]);
// Resolve lead's git branch from project path // Live git branch polling for the team's project path
const [leadBranch, setLeadBranch] = useState<string | null>(null); const teamProjectPath = data?.config.projectPath?.trim() ?? null;
useEffect(() => { const branchSyncPaths = useMemo(
const projectPath = data?.config.projectPath?.trim(); () => (teamProjectPath ? [teamProjectPath] : []),
if (!projectPath || typeof api.teams?.getProjectBranch !== 'function') { [teamProjectPath]
setLeadBranch(null); );
return; useBranchSync(branchSyncPaths, { live: true });
} const leadBranch = useStore((s) =>
let cancelled = false; teamProjectPath ? (s.branchByPath[normalizePath(teamProjectPath)] ?? null) : null
void api.teams.getProjectBranch(projectPath).then( );
(branch) => {
if (!cancelled) setLeadBranch(branch);
},
() => {
if (!cancelled) setLeadBranch(null);
}
);
return () => {
cancelled = true;
};
}, [data?.config.projectPath]);
// Filter sessions to team-only using sessionHistory + leadSessionId // Filter sessions to team-only using sessionHistory + leadSessionId
const teamSessions = useMemo(() => { const teamSessions = useMemo(() => {

View file

@ -12,6 +12,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@renderer/components/ui/tooltip'; } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskCountsByTeam, normalizePath } from '@renderer/utils/pathNormalize';
@ -182,7 +183,6 @@ export const TeamListView = (): React.JSX.Element => {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER); const [filter, setFilter] = useState<TeamListFilterState>(EMPTY_TEAM_FILTER);
const [aliveTeams, setAliveTeams] = useState<string[]>([]); const [aliveTeams, setAliveTeams] = useState<string[]>([]);
const [branchByPath, setBranchByPath] = useState<Map<string, string | null>>(new Map());
const { const {
teams, teams,
teamsLoading, teamsLoading,
@ -337,48 +337,13 @@ export const TeamListView = (): React.JSX.Element => {
leadActivityByTeam, leadActivityByTeam,
]); ]);
// Live branch/worktree for team project paths (poll so it updates during process) // Fetch branches once for all visible team project paths (no live polling)
const projectPathsToPoll = useMemo(() => { const teamPaths = useMemo(
const byKey = new Map<string, string>(); () => filteredTeams.map((t) => t.projectPath?.trim()).filter(Boolean) as string[],
for (const team of filteredTeams) { [filteredTeams]
const p = team.projectPath?.trim(); );
if (p) { useBranchSync(teamPaths, { live: false });
const key = normalizePath(p); const branchByPath = useStore((s) => s.branchByPath);
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<void> => {
const next = new Map<string, string | null>();
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]);
const restoreTeam = useStore((s) => s.restoreTeam); const restoreTeam = useStore((s) => s.restoreTeam);
const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam); const permanentlyDeleteTeam = useStore((s) => s.permanentlyDeleteTeam);
@ -748,7 +713,7 @@ export const TeamListView = (): React.JSX.Element => {
</p> </p>
{team.projectPath && {team.projectPath &&
(() => { (() => {
const branch = branchByPath.get(normalizePath(team.projectPath)); const branch = branchByPath[normalizePath(team.projectPath)];
if (!branch) return null; if (!branch) return null;
return ( return (
<span <span

View file

@ -4,6 +4,7 @@
*/ */
import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry'; import { getPreviewType, isPreviewable } from '@renderer/utils/previewRegistry';
import { getBasename } from '@shared/utils/platformPath';
import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder'; import { EditorBinaryPlaceholder } from './EditorBinaryPlaceholder';
import { EditorImagePreview } from './EditorImagePreview'; import { EditorImagePreview } from './EditorImagePreview';
@ -17,7 +18,7 @@ export const EditorBinaryState = ({
filePath, filePath,
size, size,
}: EditorBinaryStateProps): React.ReactElement => { }: EditorBinaryStateProps): React.ReactElement => {
const fileName = filePath.split('/').pop() ?? filePath; const fileName = getBasename(filePath) || filePath;
const previewType = getPreviewType(fileName); const previewType = getPreviewType(fileName);
if (previewType === 'image' && isPreviewable(fileName, size)) { if (previewType === 'image' && isPreviewable(fileName, size)) {

View file

@ -7,6 +7,7 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { splitPath } from '@shared/utils/platformPath';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -32,7 +33,7 @@ export const EditorBreadcrumb = (): React.ReactElement | null => {
? activeTabId.slice(projectPath.length + 1) ? activeTabId.slice(projectPath.length + 1)
: activeTabId; : activeTabId;
return relativePath.split('/'); return splitPath(relativePath);
}, [activeTabId, projectPath]); }, [activeTabId, projectPath]);
const handleSegmentClick = useCallback( const handleSegmentClick = useCallback(

View file

@ -9,6 +9,7 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import * as ContextMenu from '@radix-ui/react-context-menu'; import * as ContextMenu from '@radix-ui/react-context-menu';
import { lastSeparatorIndex } from '@shared/utils/platformPath';
import { import {
ClipboardCopy, ClipboardCopy,
FilePlus, FilePlus,
@ -84,7 +85,7 @@ export const EditorContextMenu = ({
const parentDir = target const parentDir = target
? target.isDir ? target.isDir
? target.path ? target.path
: target.path.substring(0, target.path.lastIndexOf('/')) : target.path.substring(0, lastSeparatorIndex(target.path))
: null; : null;
return ( return (

View file

@ -27,6 +27,7 @@ import {
} from '@renderer/components/ui/dialog'; } from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder'; import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react'; import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -350,7 +351,7 @@ export const EditorFileTree = ({
const item = flatItemsByPath.get(overId); const item = flatItemsByPath.get(overId);
if (item) { if (item) {
const p = item.node.fullPath; 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 destDir = dropTargetPath;
const sourceParent = sourcePath.substring(0, sourcePath.lastIndexOf('/')); const sourceParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
// Validation: same folder = no-op // Validation: same folder = no-op
if (sourceParent === destDir) { if (sourceParent === destDir) {
@ -543,7 +544,7 @@ export const EditorFileTree = ({
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm">Move to Trash</DialogTitle> <DialogTitle className="text-sm">Move to Trash</DialogTitle>
<DialogDescription> <DialogDescription>
Move &ldquo;{deleteConfirmPath?.split('/').pop() ?? ''}&rdquo; to Trash? Move &ldquo;{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}&rdquo; to Trash?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View file

@ -21,6 +21,7 @@ import { useEditorKeyboardShortcuts } from '@renderer/hooks/useEditorKeyboardSho
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction'; import { buildFileAction, buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { shortcutLabel } from '@renderer/utils/platformKeys'; import { shortcutLabel } from '@renderer/utils/platformKeys';
import { getBasename, getDirname } from '@shared/utils/platformPath';
import { import {
AlertTriangle, AlertTriangle,
HelpCircle, HelpCircle,
@ -216,7 +217,7 @@ export const ProjectEditorOverlay = ({
const result = await promise; const result = await promise;
const ipcMs = performance.now() - t0; const ipcMs = performance.now() - t0;
console.debug( 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); setFileContent(result);
@ -519,7 +520,7 @@ export const ProjectEditorOverlay = ({
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined, onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
}); });
const projectName = projectPath.split('/').pop() ?? projectPath; const projectName = getBasename(projectPath) || projectPath;
return ( return (
<div <div
@ -785,7 +786,7 @@ export const ProjectEditorOverlay = ({
key={`${activeTabId}-${editorResetKey}`} key={`${activeTabId}-${editorResetKey}`}
filePath={activeTabId} filePath={activeTabId}
content={fileContent.content} content={fileContent.content}
fileName={activeTabId.split('/').pop() ?? 'file'} fileName={getBasename(activeTabId) || 'file'}
mtimeMs={fileContent.mtimeMs} mtimeMs={fileContent.mtimeMs}
onCursorChange={handleCursorChange} onCursorChange={handleCursorChange}
onDraftRecovered={handleDraftRecovered} onDraftRecovered={handleDraftRecovered}
@ -803,7 +804,7 @@ export const ProjectEditorOverlay = ({
splitRatio={splitRatio} splitRatio={splitRatio}
onSplitRatioChange={handleSplitRatioChange} onSplitRatioChange={handleSplitRatioChange}
viewKey={activeTabId} viewKey={activeTabId}
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))} baseDir={activeTabId ? getDirname(activeTabId) : undefined}
/> />
)} )}
</div> </div>

View file

@ -10,6 +10,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button'; import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
import { Loader2, Search, X } from 'lucide-react'; import { Loader2, Search, X } from 'lucide-react';
import { FileIcon } from './FileIcon'; import { FileIcon } from './FileIcon';
@ -263,10 +264,9 @@ const SearchFileGroup = ({
query, query,
caseSensitive, caseSensitive,
}: SearchFileGroupProps): React.ReactElement => { }: SearchFileGroupProps): React.ReactElement => {
const fileName = relativePath.split('/').pop() ?? relativePath; const fileName = getBasename(relativePath) || relativePath;
const dirPath = relativePath.includes('/') const sepIdx = lastSeparatorIndex(relativePath);
? relativePath.slice(0, relativePath.lastIndexOf('/')) const dirPath = sepIdx >= 0 ? relativePath.slice(0, sepIdx) : '';
: '';
return ( return (
<div className="border-border/50 border-b"> <div className="border-border/50 border-b">
<button <button

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { api } from '@renderer/api'; import { api } from '@renderer/api';
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { formatRelativeTime } from '@renderer/utils/formatters'; import { formatRelativeTime } from '@renderer/utils/formatters';
import { getBasename } from '@shared/utils/platformPath';
import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { formatTokensCompact } from '@shared/utils/tokenFormatting';
import { import {
AlertCircle, AlertCircle,
@ -247,7 +248,7 @@ const FilesTouchedSection = ({
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{visibleFiles.map((filePath) => { {visibleFiles.map((filePath) => {
const basename = filePath.split('/').pop() ?? filePath; const basename = getBasename(filePath) || filePath;
const fStats = fileStats?.[filePath]; const fStats = fileStats?.[filePath];
return ( return (
<button <button

View file

@ -0,0 +1,128 @@
/**
* Centralized git branch polling hook.
*
* Provides two modes:
* - `live: false` (default) one-shot fetch on mount / path change
* - `live: true` continuous polling with ref-counted shared timer
*
* Data is stored in the Zustand store (`branchByPath`) so any component
* can read it via `useStore(s => s.branchByPath)`.
*
* The module-level polling manager guarantees:
* - A single shared `setInterval` across all live subscribers
* - Deduplication: N components subscribing to the same path = 1 poll
* - Automatic cleanup: timer stops when all subscribers unmount
*/
import { useEffect, useMemo } from 'react';
import { useStore } from '@renderer/store';
import { normalizePath } from '@renderer/utils/pathNormalize';
// =============================================================================
// Constants
// =============================================================================
const POLL_INTERVAL_MS = 6_000;
// =============================================================================
// Module-level polling manager (singleton, outside React lifecycle)
// =============================================================================
const livePaths = new Map<string, { actualPath: string; refCount: number }>();
let pollTimer: ReturnType<typeof setInterval> | null = null;
function startPollingIfNeeded(): void {
if (pollTimer || livePaths.size === 0) return;
pollTimer = setInterval(() => {
const paths = Array.from(livePaths.values()).map((v) => v.actualPath);
void useStore.getState().fetchBranches(paths);
}, POLL_INTERVAL_MS);
}
function stopPollingIfEmpty(): void {
if (pollTimer && livePaths.size === 0) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function subscribe(normalizedKey: string, actualPath: string): void {
const entry = livePaths.get(normalizedKey);
if (entry) {
entry.refCount++;
} else {
livePaths.set(normalizedKey, { actualPath, refCount: 1 });
}
startPollingIfNeeded();
}
function unsubscribe(normalizedKey: string): void {
const entry = livePaths.get(normalizedKey);
if (!entry) return;
entry.refCount--;
if (entry.refCount <= 0) {
livePaths.delete(normalizedKey);
}
stopPollingIfEmpty();
}
// =============================================================================
// Hook
// =============================================================================
/**
* Sync git branch data for the given project paths into the store.
*
* @param paths - Raw project paths to resolve branches for
* @param options.live - When true, keeps polling every 6s while mounted
*/
export function useBranchSync(paths: string[], options?: { live?: boolean }): void {
const live = options?.live ?? false;
const fetchBranches = useStore((s) => s.fetchBranches);
// Deduplicate and normalize paths into [normalizedKey, actualPath] entries.
// `paths` identity should be stabilized by the caller via useMemo.
const pathEntries = useMemo(() => {
const map = new Map<string, string>();
for (const p of paths) {
const trimmed = p.trim();
if (trimmed) {
const key = normalizePath(trimmed);
if (!map.has(key)) map.set(key, trimmed);
}
}
return Array.from(map.entries());
}, [paths]);
// Stable string key for useEffect deps — avoids re-running on same set of paths
const pathsKey = useMemo(
() =>
pathEntries
.map(([k]) => k)
.sort((a, b) => a.localeCompare(b))
.join('\n'),
[pathEntries]
);
// Initial fetch on mount and whenever paths change (both live and one-shot modes)
useEffect(() => {
if (pathEntries.length === 0) return;
void fetchBranches(pathEntries.map(([, actual]) => actual));
// eslint-disable-next-line react-hooks/exhaustive-deps -- pathsKey is a stable string derived from pathEntries, avoids re-fetching on array identity change
}, [pathsKey, fetchBranches]);
// Live subscription: register paths with the ref-counted polling manager
useEffect(() => {
if (!live || pathEntries.length === 0) return;
for (const [key, actual] of pathEntries) {
subscribe(key, actual);
}
return () => {
for (const [key] of pathEntries) {
unsubscribe(key);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- pathsKey is a stable string key; pathEntries excluded to prevent re-subscribing on array identity change
}, [live, pathsKey]);
}

View file

@ -13,6 +13,7 @@ import { editorBridge } from '@renderer/utils/editorBridge';
import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache'; import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache';
import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation'; import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getBasename, lastSeparatorIndex, splitPath } from '@shared/utils/platformPath';
import type { AppState } from '../types'; import type { AppState } from '../types';
import type { import type {
@ -330,7 +331,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
: null; : null;
if (relative) { if (relative) {
const segments = relative.split('/'); const segments = splitPath(relative);
// Expand each parent directory sequentially (root → child → grandchild). // Expand each parent directory sequentially (root → child → grandchild).
// Skip the last segment (the file name itself). // Skip the last segment (the file name itself).
// Each expandDirectory call is awaited so that its children are merged // Each expandDirectory call is awaited so that its children are merged
@ -577,7 +578,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
return; return;
} }
const fileName = filePath.split('/').pop() ?? 'file'; const fileName = getBasename(filePath) || 'file';
const language = getLanguageFromFileName(fileName); const language = getLanguageFromFileName(fileName);
const tab: EditorFileTab = { const tab: EditorFileTab = {
@ -881,7 +882,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
} }
// Refresh parent directory // Refresh parent directory
const parentDir = filePath.substring(0, filePath.lastIndexOf('/')); const parentDir = filePath.substring(0, lastSeparatorIndex(filePath));
if (parentDir) { if (parentDir) {
await refreshDirectory(get, set, parentDir); await refreshDirectory(get, set, parentDir);
} }
@ -906,7 +907,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
try { try {
const result = await api.editor.moveFile(sourcePath, destDir); const result = await api.editor.moveFile(sourcePath, destDir);
const { newPath, isDirectory } = result; const { newPath, isDirectory } = result;
const oldParent = sourcePath.substring(0, sourcePath.lastIndexOf('/')); const oldParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
// Record move timestamps for watcher cooldown // Record move timestamps for watcher cooldown
recentMoveTimestamps.set(sourcePath, Date.now()); recentMoveTimestamps.set(sourcePath, Date.now());
@ -917,7 +918,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const tabs = s.editorOpenTabs.map((tab) => { const tabs = s.editorOpenTabs.map((tab) => {
const remapped = remapPath(tab.filePath, sourcePath, newPath); const remapped = remapPath(tab.filePath, sourcePath, newPath);
if (remapped === tab.filePath) return tab; if (remapped === tab.filePath) return tab;
const fileName = remapped.split('/').pop() ?? 'file'; const fileName = getBasename(remapped) || 'file';
return { return {
...tab, ...tab,
id: remapped, id: remapped,
@ -1009,7 +1010,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
try { try {
const result = await api.editor.renameFile(sourcePath, newName); const result = await api.editor.renameFile(sourcePath, newName);
const { newPath, isDirectory } = result; const { newPath, isDirectory } = result;
const parentDir = sourcePath.substring(0, sourcePath.lastIndexOf('/')); const parentDir = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
recentMoveTimestamps.set(sourcePath, Date.now()); recentMoveTimestamps.set(sourcePath, Date.now());
recentMoveTimestamps.set(newPath, Date.now()); recentMoveTimestamps.set(newPath, Date.now());
@ -1018,7 +1019,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const tabs = s.editorOpenTabs.map((tab) => { const tabs = s.editorOpenTabs.map((tab) => {
const remapped = remapPath(tab.filePath, sourcePath, newPath); const remapped = remapPath(tab.filePath, sourcePath, newPath);
if (remapped === tab.filePath) return tab; if (remapped === tab.filePath) return tab;
const fileName = remapped.split('/').pop() ?? 'file'; const fileName = getBasename(remapped) || 'file';
return { return {
...tab, ...tab,
id: remapped, id: remapped,
@ -1208,7 +1209,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// Refresh parent directory in tree for create/delete // Refresh parent directory in tree for create/delete
if (event.type === 'create' || event.type === 'delete') { if (event.type === 'create' || event.type === 'delete') {
invalidateQuickOpenCache(); invalidateQuickOpenCache();
const parentDir = event.path.substring(0, event.path.lastIndexOf('/')); const parentDir = event.path.substring(0, lastSeparatorIndex(event.path));
if (parentDir && editorProjectPath) { if (parentDir && editorProjectPath) {
const existing = dirRefreshDebounceTimers.get(parentDir); const existing = dirRefreshDebounceTimers.get(parentDir);
if (existing) clearTimeout(existing); if (existing) clearTimeout(existing);
@ -1295,7 +1296,7 @@ async function refreshDirectory(
const t0 = performance.now(); const t0 = performance.now();
const result = await api.editor.readDir(dirPath); const result = await api.editor.readDir(dirPath);
log.info( log.info(
`[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${dirPath.split('/').pop() ?? ''}` `[perf] refreshDirectory: IPC=${(performance.now() - t0).toFixed(1)}ms, entries=${result.entries.length}, dir=${getBasename(dirPath)}`
); );
const currentTree = get().editorFileTree; const currentTree = get().editorFileTree;
if (!currentTree) return; if (!currentTree) return;

View file

@ -116,6 +116,8 @@ export interface TeamSlice {
teamByName: Record<string, TeamSummary>; teamByName: Record<string, TeamSummary>;
/** O(1) lookup: sessionId -> owning team (lead + history) */ /** O(1) lookup: sessionId -> owning team (lead + history) */
teamBySessionId: Record<string, TeamSummary>; teamBySessionId: Record<string, TeamSummary>;
/** Centralized git branch cache: normalizedPath → branch name | null */
branchByPath: Record<string, string | null>;
teamsLoading: boolean; teamsLoading: boolean;
teamsError: string | null; teamsError: string | null;
globalTasks: GlobalTask[]; globalTasks: GlobalTask[];
@ -141,6 +143,7 @@ export interface TeamSlice {
provisioningError: string | null; provisioningError: string | null;
kanbanFilterQuery: string | null; kanbanFilterQuery: string | null;
provisioningProgressUnsubscribe: (() => void) | null; provisioningProgressUnsubscribe: (() => void) | null;
fetchBranches: (paths: string[]) => Promise<void>;
fetchTeams: () => Promise<void>; fetchTeams: () => Promise<void>;
fetchAllTasks: () => Promise<void>; fetchAllTasks: () => Promise<void>;
openTeamsTab: () => void; openTeamsTab: () => void;
@ -213,6 +216,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
teams: [], teams: [],
teamByName: {}, teamByName: {},
teamBySessionId: {}, teamBySessionId: {},
branchByPath: {},
teamsLoading: false, teamsLoading: false,
teamsError: null, teamsError: null,
globalTasks: [], globalTasks: [],
@ -244,6 +248,21 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deletedTasks: [], deletedTasks: [],
deletedTasksLoading: false, deletedTasksLoading: false,
fetchBranches: async (paths: string[]) => {
const results: Record<string, string | null> = {};
for (const p of paths) {
try {
const branch = await api.teams.getProjectBranch(p);
results[normalizePath(p)] = branch;
} catch {
results[normalizePath(p)] = null;
}
}
if (Object.keys(results).length > 0) {
set((state) => ({ branchByPath: { ...state.branchByPath, ...results } }));
}
},
fetchTeams: async () => { fetchTeams: async () => {
// Guard: prevent concurrent fetches (component mount + centralized init chain). // Guard: prevent concurrent fetches (component mount + centralized init chain).
// Only effective during initial load (when teamsLoading is set to true below). // Only effective during initial load (when teamsLoading is set to true below).

View file

@ -5,6 +5,8 @@
* without pulling in CodeMirror dependencies. * without pulling in CodeMirror dependencies.
*/ */
import { getBasename } from '@shared/utils/platformPath';
import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor'; import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor';
// ============================================================================= // =============================================================================
@ -66,7 +68,7 @@ export function buildFileAction(
filePath: string, filePath: string,
projectPath?: string | null projectPath?: string | null
): EditorSelectionAction { ): EditorSelectionAction {
const fileName = filePath.split('/').pop() ?? 'file'; const fileName = getBasename(filePath) || 'file';
const displayPath = const displayPath =
projectPath && filePath.startsWith(projectPath + '/') projectPath && filePath.startsWith(projectPath + '/')
? filePath.slice(projectPath.length + 1) ? filePath.slice(projectPath.length + 1)
@ -87,7 +89,7 @@ export function buildSelectionAction(
type: EditorSelectionAction['type'], type: EditorSelectionAction['type'],
info: EditorSelectionInfo info: EditorSelectionInfo
): EditorSelectionAction { ): EditorSelectionAction {
const fileName = info.filePath.split('/').pop() ?? 'file'; const fileName = getBasename(info.filePath) || 'file';
const lang = getCodeFenceLanguage(fileName); const lang = getCodeFenceLanguage(fileName);
const lineRef = const lineRef =
info.fromLine === info.toLine info.fromLine === info.toLine

View file

@ -4,6 +4,7 @@
import { chipToken } from '@renderer/types/inlineChip'; import { chipToken } from '@renderer/types/inlineChip';
import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction'; import { getCodeFenceLanguage } from '@renderer/utils/buildSelectionAction';
import { getBasename } from '@shared/utils/platformPath';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { EditorSelectionAction } from '@shared/types/editor'; import type { EditorSelectionAction } from '@shared/types/editor';
@ -31,7 +32,7 @@ export function createChipFromSelection(
); );
if (isDuplicate) return null; if (isDuplicate) return null;
const fileName = action.filePath.split('/').pop() ?? 'file'; const fileName = getBasename(action.filePath) || 'file';
return { return {
id: `chip-${++chipCounter}-${Date.now()}`, id: `chip-${++chipCounter}-${Date.now()}`,
filePath: action.filePath, filePath: action.filePath,
@ -51,7 +52,7 @@ export function createChipFromSelection(
); );
if (isDuplicate) return null; if (isDuplicate) return null;
const fileName = action.filePath.split('/').pop() ?? 'file'; const fileName = getBasename(action.filePath) || 'file';
const language = getCodeFenceLanguage(fileName); const language = getCodeFenceLanguage(fileName);
return { return {

View file

@ -7,6 +7,11 @@
* - Directory-specific CLAUDE.md files (detected from Read tool calls and @ mentions) * - Directory-specific CLAUDE.md files (detected from Read tool calls and @ mentions)
*/ */
import {
lastSeparatorIndex,
splitPath as splitPathCrossPlatform,
} from '@shared/utils/platformPath';
import { extractFileReferences } from './groupTransformer'; import { extractFileReferences } from './groupTransformer';
import type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd'; import type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd';
@ -147,26 +152,9 @@ function normalizeSeparators(input: string, separator: '/' | '\\'): string {
return output; return output;
} }
/** Local alias — delegates to the shared cross-platform splitPath. */
function splitPath(input: string): string[] { function splitPath(input: string): string[] {
const parts: string[] = []; return splitPathCrossPlatform(input);
let current = '';
for (const char of input) {
if (char === '/' || char === '\\') {
if (current.length > 0) {
parts.push(current);
current = '';
}
} else {
current += char;
}
}
if (current.length > 0) {
parts.push(current);
}
return parts;
} }
function normalizeForComparison(input: string): string { function normalizeForComparison(input: string): string {
@ -177,7 +165,7 @@ function normalizeForComparison(input: string): string {
* Get the directory containing a file. * Get the directory containing a file.
*/ */
export function getDirectory(filePath: string): string { export function getDirectory(filePath: string): string {
const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); const lastSep = lastSeparatorIndex(filePath);
if (lastSep === -1) return ''; if (lastSep === -1) return '';
return filePath.slice(0, lastSep); return filePath.slice(0, lastSep);
} }
@ -186,7 +174,7 @@ export function getDirectory(filePath: string): string {
* Get the parent directory of a path. * Get the parent directory of a path.
*/ */
export function getParentDirectory(dirPath: string): string | null { export function getParentDirectory(dirPath: string): string | null {
const lastSep = Math.max(dirPath.lastIndexOf('/'), dirPath.lastIndexOf('\\')); const lastSep = lastSeparatorIndex(dirPath);
if (lastSep <= 0) return null; // At root or invalid if (lastSep <= 0) return null; // At root or invalid
return dirPath.slice(0, lastSep); return dirPath.slice(0, lastSep);
} }

View file

@ -5,6 +5,8 @@
* Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry). * Used by ReviewFileTree (FileChangeSummary) and EditorFileTree (FileTreeEntry).
*/ */
import { splitPath as splitPathCrossPlatform } from '@shared/utils/platformPath';
export interface TreeNode<T> { export interface TreeNode<T> {
name: string; name: string;
fullPath: string; fullPath: string;
@ -28,7 +30,7 @@ export function buildTree<T>(
const root: TreeNode<T> = { name: '', fullPath: '', isFile: false, children: [] }; const root: TreeNode<T> = { name: '', fullPath: '', isFile: false, children: [] };
for (const item of items) { for (const item of items) {
const parts = getPath(item).split('/'); const parts = splitPathCrossPlatform(getPath(item));
let current = root; let current = root;
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {

View file

@ -9,6 +9,8 @@
* Also provides resolveAbsolutePath() for clipboard copy (~ real home, relative absolute). * Also provides resolveAbsolutePath() for clipboard copy (~ real home, relative absolute).
*/ */
import { splitPath } from '@shared/utils/platformPath';
/** /**
* Shorten a file path for display in compact UI elements. * Shorten a file path for display in compact UI elements.
* Full path should still be available via tooltip (title attribute). * Full path should still be available via tooltip (title attribute).
@ -82,7 +84,7 @@ export function formatProjectPath(path: string): string {
const p = path.replace(/\\/g, '/'); const p = path.replace(/\\/g, '/');
if (p.startsWith('/Users/') || p.startsWith('/home/')) { if (p.startsWith('/Users/') || p.startsWith('/home/')) {
const parts = p.split('/').filter(Boolean); const parts = splitPath(p);
if (parts.length >= 2) { if (parts.length >= 2) {
const rest = parts.slice(2).join('/'); const rest = parts.slice(2).join('/');
return rest ? `~/${rest}` : '~'; return rest ? `~/${rest}` : '~';
@ -90,7 +92,7 @@ export function formatProjectPath(path: string): string {
} }
if (isWindowsUserPath(path)) { if (isWindowsUserPath(path)) {
const parts = p.split('/').filter(Boolean); const parts = splitPath(p);
if (parts.length >= 3) { if (parts.length >= 3) {
const rest = parts.slice(3).join('/'); const rest = parts.slice(3).join('/');
return rest ? `~/${rest}` : '~'; return rest ? `~/${rest}` : '~';

View file

@ -8,6 +8,8 @@
* 4. Unique file names get no label (disambiguatedLabel = undefined) * 4. Unique file names get no label (disambiguatedLabel = undefined)
*/ */
import { splitPath } from '@shared/utils/platformPath';
import type { EditorFileTab } from '@shared/types/editor'; import type { EditorFileTab } from '@shared/types/editor';
/** /**
@ -49,7 +51,7 @@ export function computeDisambiguatedTabs(tabs: EditorFileTab[]): EditorFileTab[]
// Split paths into segments for comparison // Split paths into segments for comparison
const pathSegments = group.map((tab) => { const pathSegments = group.map((tab) => {
const parts = tab.filePath.split('/'); const parts = splitPath(tab.filePath);
// Remove the file name (last segment) // Remove the file name (last segment)
parts.pop(); parts.pop();
return parts; return parts;

View file

@ -1,4 +1,5 @@
import { normalizePath } from '@renderer/utils/pathNormalize'; import { normalizePath } from '@renderer/utils/pathNormalize';
import { splitPath } from '@shared/utils/platformPath';
import { differenceInDays, isToday, isYesterday } from 'date-fns'; import { differenceInDays, isToday, isYesterday } from 'date-fns';
import { DATE_CATEGORY_ORDER } from '../types/tabs'; import { DATE_CATEGORY_ORDER } from '../types/tabs';
@ -101,10 +102,7 @@ function trimTrailingPathSep(p: string): string {
export function projectLabelFromPath(path: string): string { export function projectLabelFromPath(path: string): string {
const normalized = trimTrailingPathSep(path); const normalized = trimTrailingPathSep(path);
const segments = normalized const segments = splitPath(normalized);
.split('/')
.flatMap((s) => s.split('\\'))
.filter(Boolean);
return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL; return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL;
} }

View file

@ -0,0 +1,31 @@
/**
* Cross-platform path utilities for the renderer process.
*
* Node's `path` module is unavailable in the renderer, and incoming paths
* may originate from any OS (Unix `/` or Windows `\`). Every helper here
* handles both separators transparently.
*/
const SEP_RE = /[/\\]/;
/** Split a file path on both `/` and `\` separators. */
export function splitPath(filePath: string): string[] {
return filePath.split(SEP_RE).filter(Boolean);
}
/** Get the last segment (filename) from a path. */
export function getBasename(filePath: string): string {
const parts = splitPath(filePath);
return parts[parts.length - 1] ?? '';
}
/** Get directory part of a path (everything before the last separator). */
export function getDirname(filePath: string): string {
const lastSep = lastSeparatorIndex(filePath);
return lastSep === -1 ? '' : filePath.substring(0, lastSep);
}
/** Find the last path separator index (handles both `/` and `\`). */
export function lastSeparatorIndex(filePath: string): number {
return Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
}