diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 6bcd9cab..a4a7ae60 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -25,6 +25,7 @@ import { type SessionMetadataLevel, type SessionsByIdsOptions, type SessionsPaginationOptions, + type WorktreeSource, } from '@main/types'; import { analyzeSessionFileMetadata, @@ -45,6 +46,19 @@ import { import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; +import { + AUTO_CLAUDE_DIR, + CCSWITCH_DIR, + CLAUDE_CODE_DIR, + CLAUDE_WORKTREES_DIR, + CONDUCTOR_DIR, + CURSOR_DIR, + TWENTYFIRST_DIR, + VIBE_KANBAN_DIR, + WORKSPACES_DIR, + WORKTREES_DIR, +} from '@main/constants/worktreePatterns'; + import { LocalFileSystemProvider } from '../infrastructure/LocalFileSystemProvider'; import { ProjectPathResolver } from './ProjectPathResolver'; @@ -52,7 +66,6 @@ import { SessionContentFilter } from './SessionContentFilter'; import { SessionSearcher } from './SessionSearcher'; import { SubagentLocator } from './SubagentLocator'; import { subprojectRegistry } from './SubprojectRegistry'; -import { WorktreeGrouper } from './WorktreeGrouper'; import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemProvider'; @@ -64,6 +77,51 @@ const logger = createLogger('Discovery:ProjectScanner'); // for lookups and navigation; a small cap preserves that behavior without huge payloads. const MAX_SESSION_IDS_EXPORTED = 200; +/** + * Fast, zero-I/O worktree detection based on path patterns only. + * Used by scanWithWorktreeGrouping to provide accurate worktree metadata + * without expensive git filesystem operations. + */ +function detectWorktreeFromPath(projectPath: string): { + isWorktree: boolean; + source: WorktreeSource; +} { + const parts = projectPath.split(path.sep).filter(Boolean); + + if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'vibe-kanban' }; + } + if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) { + // Only subpaths after workspaces/{repo} are worktrees + const idx = parts.indexOf(CONDUCTOR_DIR); + if (idx >= 0 && parts.length > idx + 3) { + return { isWorktree: true, source: 'conductor' }; + } + } + if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'auto-claude' }; + } + if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: '21st' }; + } + if (parts.includes(CLAUDE_WORKTREES_DIR)) { + return { isWorktree: true, source: 'claude-desktop' }; + } + if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'ccswitch' }; + } + if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) { + return { isWorktree: true, source: 'git' }; + } + { + const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR); + if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) { + return { isWorktree: true, source: 'claude-code' }; + } + } + return { isWorktree: false, source: 'unknown' }; +} + export class ProjectScanner { private readonly projectsDir: string; private readonly todosDir: string; @@ -96,7 +154,6 @@ export class ProjectScanner { // Delegated services private readonly fsProvider: FileSystemProvider; private readonly sessionContentFilter: typeof SessionContentFilter; - private readonly worktreeGrouper: WorktreeGrouper; private readonly subagentLocator: SubagentLocator; private readonly sessionSearcher: SessionSearcher; private readonly projectPathResolver: ProjectPathResolver; @@ -108,7 +165,6 @@ export class ProjectScanner { // Initialize delegated services this.sessionContentFilter = SessionContentFilter; - this.worktreeGrouper = new WorktreeGrouper(this.projectsDir, this.fsProvider); this.subagentLocator = new SubagentLocator(this.projectsDir, this.fsProvider); this.sessionSearcher = new SessionSearcher(this.projectsDir, this.fsProvider); this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider); @@ -230,6 +286,7 @@ export class ProjectScanner { // Each project becomes a single-worktree group. const groups: RepositoryGroup[] = projects.map((project) => { const totalSessions = project.totalSessions ?? project.sessions.length; + const worktreeInfo = detectWorktreeFromPath(project.path); return { id: project.id, identity: null, @@ -238,8 +295,8 @@ export class ProjectScanner { id: project.id, path: project.path, name: project.name, - isMainWorktree: true, - source: 'unknown' as const, + isMainWorktree: !worktreeInfo.isWorktree, + source: worktreeInfo.source, sessions: project.sessions, totalSessions, createdAt: project.createdAt, diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 140828ee..b8723175 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -19,6 +19,7 @@ import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess'; import { appendCliAuthDiag } from '@main/utils/cliAuthDiagLog'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { buildMergedCliPath } from '@main/utils/cliPathMerge'; import { getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder'; import { @@ -32,7 +33,7 @@ import { createHash } from 'crypto'; import { createWriteStream, existsSync, promises as fsp } from 'fs'; import http from 'http'; import https from 'https'; -import { tmpdir, userInfo } from 'os'; +import { tmpdir } from 'os'; import { join, posix as pathPosix, win32 as pathWin32 } from 'path'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; @@ -82,29 +83,6 @@ const AUTH_STATUS_MAX_RETRIES = 2; /** Delay before retrying auth status check (ms) — gives previous process time to clean up */ const AUTH_STATUS_RETRY_DELAY_MS = 1500; -/** - * Build env for child processes with correct HOME and enriched PATH. - * PATH merging lives in `cliPathMerge.ts` (shared with binary discovery). - */ -function buildChildEnv(binaryPath?: string | null): NodeJS.ProcessEnv { - const home = getShellPreferredHome(); - const shellEnv = getCachedShellEnv(); - let osUsername = ''; - try { - osUsername = userInfo().username; - } catch { - // userInfo() can throw in restricted environments (Docker, no passwd entry) - } - const user = shellEnv?.USER?.trim() || process.env.USER?.trim() || osUsername || ''; - return { - ...process.env, - HOME: home, - USERPROFILE: home, - PATH: buildMergedCliPath(binaryPath), - ...(user ? { USER: user, LOGNAME: user } : {}), - }; -} - /** `claude auth status` may prefix stderr noise or warnings; extract the JSON object. */ function parseClaudeAuthStatusStdout(stdout: string): { loggedIn?: boolean; authMethod?: string } { const trimmed = stdout.trim(); @@ -388,12 +366,7 @@ export class CliInstallerService { * Env for CLI subprocesses: login-shell vars + consistent HOME/PATH + same config root as the app. */ private envForCli(binaryPath: string): NodeJS.ProcessEnv { - return { - ...process.env, - ...(getCachedShellEnv() ?? {}), - ...buildChildEnv(binaryPath), - CLAUDE_CONFIG_DIR: getClaudeBasePath(), - }; + return buildEnrichedEnv(binaryPath); } // --------------------------------------------------------------------------- diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 4bd19749..a16cb57a 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -9,6 +9,7 @@ */ import { killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; @@ -102,7 +103,9 @@ export class ScheduledTaskExecutor { const child = spawnCli(binaryPath, args, { cwd: request.config.cwd, - env: { ...process.env, ...shellEnv, CLAUDECODE: undefined }, + // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values + // take precedence over the cached snapshot inside buildEnrichedEnv. + env: { ...buildEnrichedEnv(binaryPath), ...shellEnv, CLAUDECODE: undefined }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/utils/cliEnv.ts b/src/main/utils/cliEnv.ts index bf08528a..c6ac68f6 100644 --- a/src/main/utils/cliEnv.ts +++ b/src/main/utils/cliEnv.ts @@ -1,22 +1,47 @@ /** * Builds an enriched environment for Claude CLI child processes. * - * Packaged Electron apps on macOS receive a minimal PATH (often just /usr/bin:/bin). + * Packaged Electron apps on macOS receive a minimal PATH (often just /usr/bin:/bin) + * and may lack USER (needed for macOS Keychain credential lookup). * This helper merges the user's interactive-shell env (cached during startup) with * common install locations so that `claude` and its subprocesses (node, npx, etc.) - * can find the tools they need. + * can find the tools they need and authenticate properly. */ +import { userInfo } from 'os'; + import { buildMergedCliPath } from '@main/utils/cliPathMerge'; +import { getClaudeBasePath } from '@main/utils/pathDecoder'; import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv'; export function buildEnrichedEnv(binaryPath?: string | null): NodeJS.ProcessEnv { + const shellEnv = getCachedShellEnv(); const home = getShellPreferredHome(); + let osUsername = ''; + try { + osUsername = userInfo().username; + } catch { + // userInfo() can throw in restricted environments (Docker, no passwd entry) + } + const user = + shellEnv?.USER?.trim() || + process.env.USER?.trim() || + process.env.USERNAME?.trim() || + osUsername || + ''; + return { ...process.env, - ...(getCachedShellEnv() ?? {}), + ...(shellEnv ?? {}), HOME: home, USERPROFILE: home, PATH: buildMergedCliPath(binaryPath), + CLAUDE_CONFIG_DIR: getClaudeBasePath(), + ...(user + ? { + USER: user, + LOGNAME: shellEnv?.LOGNAME?.trim() || process.env.LOGNAME?.trim() || user, + } + : {}), }; } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 8ae46996..e6605826 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1819,6 +1819,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele open={addMemberDialogOpen} teamName={teamName} existingNames={data.members.map((m) => m.name)} + existingMembers={data.members} projectPath={data.config.projectPath} adding={addingMemberLoading} onClose={() => setAddMemberDialogOpen(false)} diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index fae8169f..4f10d048 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -405,7 +405,13 @@ export const TeamListView = (): React.JSX.Element => { if (projA !== projB) return projA - projB; } - return 0; + // 3. Most recently active teams first (stable secondary sort) + const tsA = a.lastActivity ? new Date(a.lastActivity).getTime() : 0; + const tsB = b.lastActivity ? new Date(b.lastActivity).getTime() : 0; + if (tsA !== tsB) return tsB - tsA; + + // 4. Fallback: alphabetical by team name for deterministic order + return a.teamName.localeCompare(b.teamName); }); return result; @@ -808,17 +814,7 @@ export const TeamListView = (): React.JSX.Element => { } }} > - {teamColorSet ? ( -
- ) : null} -