diff --git a/CLAUDE.md b/CLAUDE.md index d9a120ca..a5d486ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x ## Commands Always use pnpm (not npm/yarn) for this project. +Do NOT run `pnpm lint:fix` unless the user explicitly asks for it — it interferes with agents running in parallel. - `pnpm install` - Install dependencies - `pnpm dev` - Dev server with hot reload diff --git a/src/main/http/events.ts b/src/main/http/events.ts index 46f1968b..9f1fbd6b 100644 --- a/src/main/http/events.ts +++ b/src/main/http/events.ts @@ -34,6 +34,8 @@ export function registerEventRoutes(app: FastifyInstance): void { const timer = setInterval(() => { reply.raw.write(':ping\n\n'); }, KEEPALIVE_INTERVAL_MS); + // Keepalive should not prevent shutdown (socket already keeps connection alive). + timer.unref(); // Cleanup on disconnect request.raw.on('close', () => { diff --git a/src/main/index.ts b/src/main/index.ts index 9816c966..68edb1d0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1049,7 +1049,8 @@ void app.whenReady().then(() => { // Apply launch-at-login setting only in packaged builds. // In dev, macOS may deny this (and Electron logs a noisy error to stderr). - if (app.isPackaged) { + // Also guard by platform: Electron only supports this on macOS/Windows. + if (app.isPackaged && (process.platform === 'darwin' || process.platform === 'win32')) { app.setLoginItemSettings({ openAtLogin: config.general.launchAtLogin, }); diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts index 9829be8a..fc716724 100644 --- a/src/main/ipc/guards.ts +++ b/src/main/ipc/guards.ts @@ -27,6 +27,9 @@ interface ValidationResult { error?: string; } +const RESERVED_MEMBER_NAMES = new Set(['user']); +const RESERVED_TEAMMATE_NAMES = new Set(['team-lead']); + function validateString( value: unknown, fieldName: string, @@ -149,9 +152,31 @@ export function validateMemberName(memberName: unknown): ValidationResult { + const basic = validateMemberName(memberName); + if (!basic.valid) { + return basic; + } + + const lower = basic.value!.toLowerCase(); + if (RESERVED_TEAMMATE_NAMES.has(lower)) { + return { valid: false, error: `member name "${basic.value!}" is reserved` }; + } + return basic; +} + export function validateFromField(from: unknown): ValidationResult { const basic = validateString(from, 'from', 128); if (!basic.valid) { diff --git a/src/main/ipc/rendererLogs.ts b/src/main/ipc/rendererLogs.ts index aa87f4a7..daf2a4ad 100644 --- a/src/main/ipc/rendererLogs.ts +++ b/src/main/ipc/rendererLogs.ts @@ -1,24 +1,6 @@ import { RENDERER_BOOT, RENDERER_HEARTBEAT, RENDERER_LOG } from '@preload/constants/ipcChannels'; -import { createLogger } from '@shared/utils/logger'; import { type IpcMain } from 'electron'; -const logger = createLogger('IPC:rendererLogs'); - -type RendererLogLevel = 'warn' | 'error'; - -function truncate(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; - return `${text.slice(0, maxChars)}…(truncated)`; -} - -function isRendererLogPayload( - payload: unknown -): payload is { level: RendererLogLevel; message: string } { - if (!payload || typeof payload !== 'object') return false; - const p = payload as { level?: unknown; message?: unknown }; - return (p.level === 'warn' || p.level === 'error') && typeof p.message === 'string'; -} - const lastHeartbeatByWebContentsId = new Map(); const lastHeartbeatWarnedAtByWebContentsId = new Map(); const hasReceivedHeartbeatByWebContentsId = new Set(); @@ -46,7 +28,6 @@ function startHeartbeatMonitor(): void { const lastWarnedAt = lastHeartbeatWarnedAtByWebContentsId.get(id) ?? 0; if (now - lastWarnedAt < WARN_THROTTLE_MS) continue; lastHeartbeatWarnedAtByWebContentsId.set(id, now); - logger.warn(`Renderer heartbeat stale webContentsId=${id} ageMs=${age}`); } }, CHECK_EVERY_MS); @@ -57,14 +38,8 @@ function startHeartbeatMonitor(): void { export function registerRendererLogHandlers(ipcMain: IpcMain): void { startHeartbeatMonitor(); - ipcMain.on(RENDERER_LOG, (_event, payload: unknown) => { - if (!isRendererLogPayload(payload)) return; - const msg = truncate(payload.message, 4000); - if (payload.level === 'error') { - logger.error(`Renderer: ${msg}`); - } else { - logger.warn(`Renderer: ${msg}`); - } + ipcMain.on(RENDERER_LOG, () => { + // Forwarded renderer logs are intentionally silenced. }); ipcMain.on(RENDERER_BOOT, (event) => { @@ -72,7 +47,6 @@ export function registerRendererLogHandlers(ipcMain: IpcMain): void { lastHeartbeatByWebContentsId.set(id, Date.now()); lastHeartbeatWarnedAtByWebContentsId.delete(id); hasReceivedHeartbeatByWebContentsId.delete(id); - logger.warn(`Renderer boot webContentsId=${id}`); event.sender.once('destroyed', () => { lastHeartbeatByWebContentsId.delete(id); lastHeartbeatWarnedAtByWebContentsId.delete(id); @@ -82,12 +56,8 @@ export function registerRendererLogHandlers(ipcMain: IpcMain): void { ipcMain.on(RENDERER_HEARTBEAT, (event) => { const id = event.sender.id; - const isFirst = !hasReceivedHeartbeatByWebContentsId.has(id); hasReceivedHeartbeatByWebContentsId.add(id); lastHeartbeatByWebContentsId.set(id, Date.now()); - if (isFirst) { - logger.warn(`Renderer heartbeat started webContentsId=${id}`); - } }); } diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 92cc2541..9663c5dc 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -234,7 +234,7 @@ async function handleApplyDecisions( if (d.originalFullContent !== undefined || d.modifiedFullContent !== undefined) { fileContents.set(d.filePath, { filePath: d.filePath, - relativePath: d.filePath.split('/').slice(-3).join('/'), + relativePath: d.filePath.split(/[\\/]/).filter(Boolean).slice(-3).join('/'), snippets, linesAdded: 0, linesRemoved: 0, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 2a493907..9fab522a 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -62,7 +62,13 @@ import { ConfigManager } from '../services/infrastructure/ConfigManager'; import { NotificationManager } from '../services/infrastructure/NotificationManager'; import { gitIdentityResolver } from '../services/parsing/GitIdentityResolver'; -import { validateFromField, validateMemberName, validateTaskId, validateTeamName } from './guards'; +import { + validateFromField, + validateMemberName, + validateTaskId, + validateTeammateName, + validateTeamName, +} from './guards'; /** Track rate limit message keys already notified to avoid duplicate OS notifications across refreshes. */ const notifiedRateLimitKeys = new Set(); @@ -310,7 +316,7 @@ async function handleGetProjectBranch( return { success: false, error: 'projectPath must be a non-empty string' }; } try { - const branch = await gitIdentityResolver.getBranch(projectPath.trim()); + const branch = await gitIdentityResolver.getBranch(path.normalize(projectPath.trim())); return { success: true, data: branch }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -541,7 +547,7 @@ async function validateProvisioningRequest( if (!member || typeof member !== 'object') { return { valid: false, error: 'member must be object' }; } - const nameValidation = validateMemberName((member as { name?: unknown }).name); + const nameValidation = validateTeammateName((member as { name?: unknown }).name); if (!nameValidation.valid) { return { valid: false, error: nameValidation.error ?? 'Invalid member name' }; } @@ -1345,7 +1351,7 @@ async function handleCreateConfig( if (!member || typeof member !== 'object') { return { success: false, error: 'member must be object' }; } - const nameValidation = validateMemberName((member as { name?: unknown }).name); + const nameValidation = validateTeammateName((member as { name?: unknown }).name); if (!nameValidation.valid) { return { success: false, error: nameValidation.error ?? 'Invalid member name' }; } @@ -1548,7 +1554,7 @@ async function handleAddMember( return { success: false, error: 'Invalid payload' }; } const { name, role } = payload as { name?: unknown; role?: unknown }; - const vName = validateMemberName(name); + const vName = validateTeammateName(name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; if (role !== undefined && typeof role !== 'string') { return { success: false, error: 'role must be a string' }; @@ -1600,7 +1606,7 @@ async function handleReplaceMembers( return { success: false, error: 'member must be object' }; } const m = item as { name?: unknown; role?: unknown; workflow?: unknown }; - const vName = validateMemberName(m.name); + const vName = validateTeammateName(m.name); if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; const name = vName.value!; if (seenNames.has(name)) return { success: false, error: 'member names must be unique' }; diff --git a/src/main/services/discovery/WorktreeGrouper.ts b/src/main/services/discovery/WorktreeGrouper.ts index 48ba6c30..6d547d0f 100644 --- a/src/main/services/discovery/WorktreeGrouper.ts +++ b/src/main/services/discovery/WorktreeGrouper.ts @@ -142,7 +142,11 @@ export class WorktreeGrouper { // Use filtered sessions instead of raw sessions const filteredSessions = projectFilteredSessions.get(project.id) ?? []; // Detect worktree source for badge display - const source = await gitIdentityResolver.detectWorktreeSource(project.path); + // project.path may use forward slashes (e.g. decodePath() returns "C:/..."). + // detectWorktreeSource splits on path.sep, so normalize to the current platform first. + const source = await gitIdentityResolver.detectWorktreeSource( + path.normalize(project.path) + ); // Use source-aware display name generation const displayName = await gitIdentityResolver.getWorktreeDisplayName( project.path, diff --git a/src/main/services/error/ErrorTriggerChecker.ts b/src/main/services/error/ErrorTriggerChecker.ts index 4887ee6d..b56e9d23 100644 --- a/src/main/services/error/ErrorTriggerChecker.ts +++ b/src/main/services/error/ErrorTriggerChecker.ts @@ -10,6 +10,7 @@ import { type ParsedMessage } from '@main/types'; import { extractProjectName } from '@main/utils/pathDecoder'; +import * as path from 'path'; import { estimateTokens, @@ -65,7 +66,9 @@ async function resolveRepositoryId(target: string | RepositoryScopeTarget): Prom const projectPath = await projectPathResolver.resolveProjectPath(projectId, { cwdHint }); // Resolve repository identity - const identity = await gitIdentityResolver.resolveIdentity(projectPath); + // projectPath can be "C:/..." on Windows (decodePath), but GitIdentityResolver + // relies on path.sep splitting in a few code paths. Normalize to platform style. + const identity = await gitIdentityResolver.resolveIdentity(path.normalize(projectPath)); const repositoryId = identity?.id ?? null; // Cache the result diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts index 65518930..11fca601 100644 --- a/src/main/services/infrastructure/FileWatcher.ts +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -528,6 +528,8 @@ export class FileWatcher extends EventEmitter { // Prime immediately so newly created sessions appear without waiting a full interval. runPoll(); this.pollingTimer = setInterval(runPoll, FileWatcher.SSH_POLL_INTERVAL_MS); + // Polling is a background task and should not keep the process alive. + this.pollingTimer.unref(); } /** @@ -1047,6 +1049,8 @@ export class FileWatcher extends EventEmitter { logger.error('Error during catch-up scan:', err); }); }, CATCH_UP_INTERVAL_MS); + // Catch-up scan is best-effort; don't keep process alive. + this.catchUpTimer.unref(); } /** diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 6f55a4f3..d9427ce8 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -339,7 +339,7 @@ export class NotificationManager extends EventEmitter { const projectPath = await projectPathResolver.resolveProjectPath(error.projectId, { cwdHint: error.context.cwd, }); - const identity = await gitIdentityResolver.resolveIdentity(projectPath); + const identity = await gitIdentityResolver.resolveIdentity(path.normalize(projectPath)); if (!identity) { return false; diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index be991093..58474949 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -160,7 +160,7 @@ export class FileContentResolver { return { filePath, - relativePath: filePath.split('/').slice(-3).join('/'), + relativePath: this.getDisplayRelativePath(filePath, 3), snippets, linesAdded, linesRemoved, @@ -291,7 +291,7 @@ export class FileContentResolver { * For subagents, sessionId = the parent directory's parent name. */ private extractSessionId(logPath: string): string | null { - const parts = logPath.split(path.sep); + const parts = path.normalize(logPath).split(path.sep).filter(Boolean); // Check if it's a subagent path: .../{sessionId}/subagents/agent-xxx.jsonl const subagentsIdx = parts.indexOf('subagents'); @@ -448,7 +448,7 @@ export class FileContentResolver { if (!this.gitFallback) return null; // Determine project path from file path (heuristic: find .git parent) - const projectPath = this.guessProjectPath(filePath); + const projectPath = await this.guessProjectPath(filePath); if (!projectPath) return null; const isGit = await this.gitFallback.isGitRepo(projectPath); @@ -477,23 +477,52 @@ export class FileContentResolver { * Guess the project root path from a file path. * Simple heuristic: look for common markers (package.json, .git directory). */ - private guessProjectPath(filePath: string): string | null { - const parts = filePath.split('/'); - // Walk up from file, looking for typical project root indicators - for (let i = parts.length - 1; i >= 1; i--) { - const candidate = parts.slice(0, i).join('/'); - // Simple heuristic: paths with these patterns are likely project roots - if (candidate.endsWith('/src') || candidate.endsWith('/lib')) { - return parts.slice(0, i - 1).join('/') || null; + private async guessProjectPath(filePath: string): Promise { + const normalized = path.normalize(filePath); + let dir = path.dirname(normalized); + const parsed = path.parse(dir); + const root = parsed.root; + + const markers = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml'] as const; + + const hasMarker = async (candidateDir: string): Promise => { + for (const marker of markers) { + try { + await access(path.join(candidateDir, marker)); + return true; + } catch { + // ignore + } } + return false; + }; + + // Walk up from file directory; prefer stable "real" roots over string heuristics. + // This keeps git fallback working on Windows (\\ separators) and with mixed separators. + const MAX_UP = 30; + for (let i = 0; i < MAX_UP; i++) { + const base = path.basename(dir); + const candidate = base === 'src' || base === 'lib' ? path.dirname(dir) : dir; + if (await hasMarker(candidate)) return candidate; + + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; } - // Fallback: take the first 4-5 components as project path - if (parts.length > 4) { - return parts.slice(0, Math.min(parts.length - 2, 5)).join('/'); - } + + // Safety: if we can't confidently find a project root, don't guess. + // Returning null avoids running git in the wrong directory. + // (The resolver will still fall back to other content strategies.) + if (!root) return null; return null; } + private getDisplayRelativePath(filePath: string, segmentCount: number): string { + const normalized = path.normalize(filePath); + const parts = normalized.split(path.sep).filter(Boolean); + return parts.slice(-segmentCount).join('/'); + } + // ── Private: Cache helpers ── private cacheResult( diff --git a/src/main/services/team/GitDiffFallback.ts b/src/main/services/team/GitDiffFallback.ts index bfe2a3d6..1843da0c 100644 --- a/src/main/services/team/GitDiffFallback.ts +++ b/src/main/services/team/GitDiffFallback.ts @@ -1,3 +1,5 @@ +import * as path from 'node:path'; + import { execFile } from 'child_process'; import { promisify } from 'util'; @@ -6,6 +8,33 @@ const execFileAsync = promisify(execFile); const GIT_TIMEOUT = 10_000; // 10s timeout for all git operations const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB +function toRepoRelativePath(projectPath: string, filePath: string): string | null { + const normalizedProject = path.resolve(projectPath); + const normalizedFile = path.isAbsolute(filePath) ? path.resolve(filePath) : filePath; + + // If we have an absolute file path, require it to be under projectPath. + if (path.isAbsolute(normalizedFile)) { + const rel = path.relative(normalizedProject, normalizedFile); + if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null; + // Git pathspecs use forward slashes even on Windows. + const gitPath = rel.replace(/\\/g, '/'); + if (gitPath.includes(':')) return null; + return gitPath; + } + + // Relative path: normalize separators for git. + const gitPath = normalizedFile.replace(/\\/g, '/').replace(/^\.\/+/, ''); + if ( + !gitPath || + gitPath.startsWith('/') || + /^[a-zA-Z]:\//.test(gitPath) || + gitPath.includes(':') + ) { + return null; + } + return gitPath; +} + export class GitDiffFallback { private gitRepoCache = new Map(); @@ -19,9 +48,8 @@ export class GitDiffFallback { commitHash: string ): Promise { try { - const relativePath = filePath.startsWith(projectPath + '/') - ? filePath.slice(projectPath.length + 1) - : filePath; + const relativePath = toRepoRelativePath(projectPath, filePath); + if (!relativePath) return null; const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${relativePath}`], { cwd: projectPath, maxBuffer: GIT_MAX_BUFFER, @@ -42,9 +70,8 @@ export class GitDiffFallback { timestamp: string ): Promise { try { - const relativePath = filePath.startsWith(projectPath + '/') - ? filePath.slice(projectPath.length + 1) - : filePath; + const relativePath = toRepoRelativePath(projectPath, filePath); + if (!relativePath) return null; const { stdout } = await execFileAsync( 'git', ['log', '--format=%H', '--before', timestamp, '-1', '--', relativePath], @@ -66,9 +93,8 @@ export class GitDiffFallback { toCommit: string = 'HEAD' ): Promise { try { - const relativePath = filePath.startsWith(projectPath + '/') - ? filePath.slice(projectPath.length + 1) - : filePath; + const relativePath = toRepoRelativePath(projectPath, filePath); + if (!relativePath) return null; const { stdout } = await execFileAsync( 'git', ['diff', fromCommit, toCommit, '--', relativePath], @@ -89,9 +115,8 @@ export class GitDiffFallback { maxCount: number = 20 ): Promise<{ hash: string; timestamp: string; message: string }[]> { try { - const relativePath = filePath.startsWith(projectPath + '/') - ? filePath.slice(projectPath.length + 1) - : filePath; + const relativePath = toRepoRelativePath(projectPath, filePath); + if (!relativePath) return []; const { stdout } = await execFileAsync( 'git', ['log', `--max-count=${maxCount}`, '--format=%H|%aI|%s', '--', relativePath], diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index 6d22580b..72e491a1 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -268,7 +268,7 @@ function setTaskOwner(paths, taskId, owner) { function addTaskComment(paths, taskId, flags) { var text = typeof flags.text === 'string' ? flags.text.trim() : ''; if (!text) die('Missing --text'); - var from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : 'agent'; + var from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths); var ref; var task; @@ -1036,7 +1036,7 @@ async function main() { const id = rest[0] || args.flags.id; if (!id) die('Usage: task comment --text "..."'); const result = addTaskComment(paths, String(id), args.flags); - const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : 'agent'; + const from = typeof args.flags.from === 'string' && args.flags.from.trim() ? args.flags.from.trim() : inferLeadName(paths); // Notify task owner via inbox — but SKIP self-notification to prevent loop if (result.owner && result.owner !== from) { try { diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 1454bed5..674f92d1 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -223,13 +223,16 @@ export class TeamConfigReader { // Case-insensitive dedup: key is lowercase name, value keeps the original casing const memberMap = new Map(); + const removedKeys = new Set(); const mergeMember = (m: TeamMember): void => { const name = m.name?.trim(); if (!name) return; // Summary/memberCount should represent teammates (exclude the lead process). - if (name === 'team-lead' || m.agentType === 'team-lead') return; + if (name === 'team-lead' || name === 'user' || m.agentType === 'team-lead') return; const key = name.toLowerCase(); + // If meta marks this name removed, do not surface it in summaries + if (removedKeys.has(key)) return; const existing = memberMap.get(key); memberMap.set(key, { name: existing?.name ?? name, @@ -238,6 +241,27 @@ export class TeamConfigReader { }); }; + // Also read members.meta.json — UI-created teams store members there, + // and CLI-created teams may have additional members added via the UI. + try { + const metaMembers = await this.membersMetaStore.getMembers(teamName); + for (const member of metaMembers) { + const name = member.name?.trim(); + if (!name) continue; + // Summary/memberCount should represent teammates (exclude the lead process). + if (name === 'team-lead' || name === 'user' || member.agentType === 'team-lead') continue; + const key = name.toLowerCase(); + if (member.removedAt) { + removedKeys.add(key); + continue; + } + mergeMember(member); + } + } catch { + // best-effort — don't fail listing if meta file is broken + } + + // Merge config members AFTER meta so removedAt can suppress stale config entries. if (config && Array.isArray(config.members)) { for (const member of config.members) { if (member && typeof member.name === 'string') { @@ -246,19 +270,6 @@ export class TeamConfigReader { } } - // Also read members.meta.json — UI-created teams store members there, - // and CLI-created teams may have additional members added via the UI. - try { - const metaMembers = await this.membersMetaStore.getMembers(teamName); - for (const member of metaMembers) { - if (!member.removedAt) { - mergeMember(member); - } - } - } catch { - // best-effort — don't fail listing if meta file is broken - } - const members = Array.from(memberMap.values()); const summary: TeamSummary = { teamName, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 0d93a6db..c6113291 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -395,6 +395,8 @@ export class TeamDataService { this.processHealthTimer = setInterval(() => { void this.processHealthTick(); }, PROCESS_HEALTH_INTERVAL_MS); + // Background maintenance should not keep the process alive. + this.processHealthTimer.unref(); } stopProcessHealthPolling(): void { @@ -581,7 +583,7 @@ export class TeamDataService { try { // Git can hang on some Windows setups (network drives, locked repos, credential prompts). // Branch is best-effort; never block team:getData on it. - leadBranch = await withTimeout(gitIdentityResolver.getBranch(leadCwd), 2000); + leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000); } catch { // Lead cwd may not be a git repo — skip enrichment entirely return; @@ -597,7 +599,10 @@ export class TeamDataService { batch.map(async (member) => { if (!member.cwd) return; try { - const branch = await withTimeout(gitIdentityResolver.getBranch(member.cwd), 2000); + const branch = await withTimeout( + gitIdentityResolver.getBranch(path.normalize(member.cwd)), + 2000 + ); if (branch && branch !== leadBranch) { // eslint-disable-next-line no-param-reassign -- intentional in-place enrichment member.gitBranch = branch; @@ -658,6 +663,9 @@ export class TeamDataService { request: { members: { name: string; role?: string; workflow?: string }[] } ): Promise { const existing = await this.membersMetaStore.getMembers(teamName); + const isTeamLead = (m: TeamMember): boolean => + m.agentType === 'team-lead' || m.name.trim().toLowerCase() === 'team-lead'; + const existingLead = existing.find(isTeamLead) ?? null; const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m])); const joinedAt = Date.now(); const nextByName = new Set(); @@ -665,6 +673,9 @@ export class TeamDataService { const nextActive: TeamMember[] = request.members.map((member, index) => { const name = member.name.trim(); if (!name) throw new Error('Member name cannot be empty'); + if (name.toLowerCase() === 'team-lead') { + throw new Error('Member name "team-lead" is reserved'); + } nextByName.add(name.toLowerCase()); const prev = existingByName.get(name.toLowerCase()); return { @@ -681,6 +692,7 @@ export class TeamDataService { // Preserve/mark removed members so stale inbox files don't resurrect them in the UI. const nextRemoved: TeamMember[] = []; for (const prev of existing) { + if (isTeamLead(prev)) continue; const prevName = prev.name.trim(); if (!prevName) continue; const key = prevName.toLowerCase(); @@ -691,7 +703,14 @@ export class TeamDataService { }); } - await this.membersMetaStore.writeMembers(teamName, [...nextActive, ...nextRemoved]); + const out: TeamMember[] = [...nextActive, ...nextRemoved]; + if (existingLead) { + const leadKey = existingLead.name.trim().toLowerCase(); + if (!out.some((m) => m.name.trim().toLowerCase() === leadKey)) { + out.unshift({ ...existingLead, removedAt: undefined }); + } + } + await this.membersMetaStore.writeMembers(teamName, out); } async removeMember(teamName: string, memberName: string): Promise { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9385b461..9b4063be 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -597,9 +597,34 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { const isSolo = request.members.length === 0; const soloConstraint = isSolo - ? '\n- You are starting as a SOLO team lead with no teammates. Do NOT use the Task tool to spawn teammates unless/until the team has members added later. Do NOT call SendMessage to any teammate unless/until such teammates exist (you may still message "user").' + ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` + + `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + + `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + + `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + + `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + + `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + + `\n - IMPORTANT: Since you have no teammates, "user" is your only communication channel. Send progress updates to "user" frequently — after completing each task or significant milestone, and when starting a new task. The human cannot see your internal output, only SendMessage reaches them.` : ''; + const step3Block = isSolo + ? `3) If user instructions describe work to be done — create tasks on the team board and assign each task to yourself ("${leadName}") as owner.\n` + + ` - Prefer fewer, broader tasks over many micro-tasks.\n` + + ` - CRITICAL: Do NOT start working on the tasks now. Provisioning is ONLY for setting up the team structure.\n` + + ` - The tasks will be executed after the team is launched separately.` + : `3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. + - Prefer fewer, broader tasks over many micro-tasks. + - Avoid duplicate notifications for the same assignment. + - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. + - If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress. + - Review guidance: + - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X. + - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: + - Use --related to connect it to #X (non-blocking link). + - If the review truly cannot start until #X is done, ALSO add --blocked-by #X. + - There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task start / set-status in_progress) when ready. + - Use --related to connect tasks working on the same feature without blocking.`; + const step2Block = isSolo ? '2) Skip — this is a solo team with no teammates to spawn.' : `2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown: @@ -662,18 +687,7 @@ Steps (execute in this exact order): ${step2Block} -3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. - - Prefer fewer, broader tasks over many micro-tasks. - - Avoid duplicate notifications for the same assignment. - - When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by. - - If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress. - - Review guidance: - - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X. - - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: - - Use --related to connect it to #X (non-blocking link). - - If the review truly cannot start until #X is done, ALSO add --blocked-by #X. - - There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task start / set-status in_progress) when ready. - - Use --related to connect tasks working on the same feature without blocking. +${step3Block} 4) After all steps, output a short summary. @@ -702,14 +716,21 @@ function buildLaunchPrompt( const isSolo = members.length === 0; const soloConstraint = isSolo - ? '\n- You are starting as a SOLO team lead with no teammates. Do NOT use the Task tool to spawn teammates unless/until the team has members added later. Do NOT call SendMessage to any teammate unless/until such teammates exist (you may still message "user").' + ? `\n- SOLO MODE: This team CURRENTLY has ZERO teammates.` + + `\n - FORBIDDEN (until teammates exist): Do NOT spawn teammates via the Task tool with a team_name parameter — there are no teammates to spawn yet.` + + `\n - FORBIDDEN (until teammates exist): Do NOT call SendMessage to any teammate name — no teammates exist yet.` + + `\n - ALLOWED: You may message "user" (the human operator) via SendMessage.` + + `\n - ALLOWED: You may use the Task tool for regular subagents WITHOUT team_name — these are normal Claude Code helpers, not teammates.` + + `\n - If teammates are added later (e.g. via UI), you may then spawn them using the Task tool with team_name + name.` + + `\n - Work on tasks directly yourself. Use subagents for research and parallel work as needed.` + + `\n - IMPORTANT: Since you have no teammates, "user" is your only communication channel. Send progress updates to "user" frequently — after completing each task or significant milestone, and when starting a new task. The human cannot see your internal output, only SendMessage reaches them.` : ''; let step2And3Block: string; if (isSolo) { step2And3Block = `2) Skip — solo team, no teammates to spawn. -3) Check the task board. Work on pending tasks directly.`; +3) Check the task board. Claim any unassigned pending tasks by assigning yourself ("${leadName}") as owner, then work on them directly. Mark tasks in_progress when you start and completed when done.`; } else { // Build per-member task snapshots to include in each teammate's spawn prompt const memberTaskBlocks = new Map(); @@ -1423,7 +1444,7 @@ export class TeamProvisioningService { '--setting-sources', 'user,project,local', '--disallowedTools', - request.members.length === 0 ? 'TeamDelete,TodoWrite,Task' : 'TeamDelete,TodoWrite', + 'TeamDelete,TodoWrite', '--dangerously-skip-permissions', ...(request.model ? ['--model', request.model] : []), ]; @@ -1729,7 +1750,7 @@ export class TeamProvisioningService { '--setting-sources', 'user,project,local', '--disallowedTools', - expectedMemberSpecs.length === 0 ? 'TeamDelete,TodoWrite,Task' : 'TeamDelete,TodoWrite', + 'TeamDelete,TodoWrite', '--dangerously-skip-permissions', ]; if (previousSessionId) { @@ -2701,6 +2722,8 @@ export class TeamProvisioningService { run.fsMonitorHandle = setInterval(() => { void poll(); }, FS_MONITOR_POLL_MS); + // Best-effort monitor; should not keep the process alive. + run.fsMonitorHandle.unref(); // Run first poll immediately void poll(); @@ -3361,7 +3384,10 @@ export class TeamProvisioningService { const metaMembers = await this.membersMetaStore.getMembers(teamName); for (const member of metaMembers) { const name = member.name.trim(); - if (name.length > 0) baseNames.add(name); + const lower = name.toLowerCase(); + if (name.length > 0 && !member.removedAt && lower !== 'team-lead' && lower !== 'user') { + baseNames.add(name); + } } } catch { // ignore @@ -3371,14 +3397,31 @@ export class TeamProvisioningService { for (const member of members) { const name = typeof member.name === 'string' ? member.name.trim() : ''; const agentType = typeof member.agentType === 'string' ? member.agentType : ''; - if (name && agentType && agentType !== 'team-lead') { + if ( + name && + agentType && + agentType !== 'team-lead' && + name !== 'team-lead' && + name !== 'user' + ) { allConfigNames.add(name); } } + const allConfigNamesLower = new Set(Array.from(allConfigNames).map((n) => n.toLowerCase())); for (const name of allConfigNames) { - const match = /^(.+)-\d+$/.exec(name); + const match = /^(.+)-(\d+)$/.exec(name); + if (!match?.[1] || !match[2]) { + baseNames.add(name); + continue; + } + const suffix = Number(match[2]); // Only exclude CLI-suffixed names (alice-2) when the base name (alice) also exists - if (!match || !allConfigNames.has(match[1])) { + // (and only for -2+ to avoid excluding legitimate "dev-1"-style names). + if (!Number.isFinite(suffix) || suffix < 2) { + baseNames.add(name); + continue; + } + if (!allConfigNamesLower.has(match[1].toLowerCase())) { baseNames.add(name); } } @@ -3582,7 +3625,11 @@ export class TeamProvisioningService { } private async persistMembersMeta(teamName: string, request: TeamCreateRequest): Promise { - const teammateMembers = request.members.filter((member) => member.name.trim().length > 0); + const teammateMembers = request.members.filter((member) => { + const trimmed = member.name.trim(); + const lower = trimmed.toLowerCase(); + return trimmed.length > 0 && lower !== 'team-lead' && lower !== 'user'; + }); if (teammateMembers.length === 0) { return; } @@ -3593,7 +3640,7 @@ export class TeamProvisioningService { await this.membersMetaStore.writeMembers( teamName, teammateMembers.map((member, index) => ({ - name: member.name, + name: member.name.trim(), role: member.role?.trim() || undefined, workflow: member.workflow?.trim() || undefined, agentType: 'general-purpose', @@ -3622,10 +3669,12 @@ export class TeamProvisioningService { const metaMembers = await this.membersMetaStore.getMembers(teamName); const byName = new Map(); for (const member of metaMembers) { - if (member.agentType === 'team-lead' || member.name === 'team-lead') { + const rawName = member.name?.trim() ?? ''; + const lower = rawName.toLowerCase(); + if (member.agentType === 'team-lead' || lower === 'team-lead' || lower === 'user') { continue; } - const name = member.name?.trim(); + const name = rawName; if (!name) continue; if (member.removedAt) continue; const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined; @@ -3662,13 +3711,17 @@ export class TeamProvisioningService { .filter((name) => name.length > 0) ) ); - const inboxNameSet = new Set(allInboxNames); + const inboxNameSetLower = new Set(allInboxNames.map((n) => n.toLowerCase())); const inboxNames = allInboxNames - .filter((name) => name !== 'team-lead') + .filter((name) => name !== 'team-lead' && name !== 'user') .filter((name) => { - const match = /^(.+)-\d+$/.exec(name); - // Only filter CLI-suffixed names (alice-2) when the base name (alice) also exists - return !match || !inboxNameSet.has(match[1]); + const match = /^(.+)-(\d+)$/.exec(name); + if (!match?.[1] || !match[2]) return true; + const suffix = Number(match[2]); + // Only filter CLI-suffixed names (alice-2) when the base name (alice) also exists. + // Important: do NOT filter names like dev-1 (common intentional naming). Only consider -2+ as auto-suffix. + if (!Number.isFinite(suffix) || suffix < 2) return true; + return !inboxNameSetLower.has(match[1].toLowerCase()); }); if (inboxNames.length > 0) { const members = inboxNames.map((name) => ({ name })); @@ -3724,8 +3777,16 @@ export class TeamProvisioningService { } const byName = new Map(); for (const member of parsed.members) { - if (!member || member.agentType === 'team-lead' || member.name === 'team-lead') continue; - const name = typeof member.name === 'string' ? member.name.trim() : ''; + const rawName = typeof member?.name === 'string' ? member.name.trim() : ''; + const lower = rawName.toLowerCase(); + if ( + !member || + member.agentType === 'team-lead' || + lower === 'team-lead' || + lower === 'user' + ) + continue; + const name = rawName; if (!name) continue; byName.set(name, { name }); } diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index 68069a50..4da50f03 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -209,10 +209,15 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; } const memberMap = new Map(); + const removedKeys = new Set(); const mergeMember = (m: any): void => { const name = typeof m?.name === 'string' ? m.name.trim() : ''; if (!name) return; + // Summary/memberCount should represent teammates (exclude the lead process). + if (name === 'team-lead' || name === 'user' || m?.agentType === 'team-lead') return; const key = name.toLowerCase(); + // If meta marks this name removed, do not surface it in summaries + if (removedKeys.has(key)) return; const existing = memberMap.get(key); memberMap.set(key, { name: existing?.name ?? name, @@ -221,12 +226,6 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; }); }; - if (config && Array.isArray(config.members)) { - for (const member of config.members) { - mergeMember(member); - } - } - try { const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json'); const metaStat = await fs.promises.stat(metaPath); @@ -235,15 +234,30 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; const parsed = JSON.parse(raw); const members: any[] = Array.isArray(parsed?.members) ? parsed.members : []; for (const member of members) { - if (member && typeof member === 'object' && !member.removedAt) { - mergeMember(member); + if (!member || typeof member !== 'object') continue; + const name = typeof member.name === 'string' ? member.name.trim() : ''; + if (!name) continue; + // Summary/memberCount should represent teammates (exclude the lead process). + if (name === 'team-lead' || member.agentType === 'team-lead') continue; + const key = name.toLowerCase(); + if (member.removedAt) { + removedKeys.add(key); + continue; } + mergeMember(member); } } } catch { // ignore } + // Merge config members AFTER meta so removedAt can suppress stale config entries. + if (config && Array.isArray(config.members)) { + for (const member of config.members) { + mergeMember(member); + } + } + const members = Array.from(memberMap.values()); const summary = { teamName, diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index b948a3fe..e02380cb 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -229,27 +229,34 @@ const RepositoryCard = ({ )} - {/* Project path - monospace, muted, clickable to open in file manager */} - - -
{ - if (e.key === 'Enter' || e.key === ' ') - handleOpenPath(e as unknown as React.MouseEvent); - }} - className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary" - > - + {/* Project path - monospace, muted; folder icon opens in file manager */} +
+ + +
{ + if (e.key === 'Enter' || e.key === ' ') + handleOpenPath(e as unknown as React.MouseEvent); + }} + className="shrink-0 cursor-pointer rounded p-0.5 transition-colors hover:bg-white/5 hover:text-text-secondary" + > + +
+
+ Open +
+ + {formattedPath} -
- - -

{projectPath}

-
- + + +

{projectPath}

+
+ +
{/* Git branch / worktree info */} {mainBranch ? ( diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 9e18b173..f0538ba3 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -277,6 +277,7 @@ export const ActivityItem = ({ diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index 395fc016..23fa6ae2 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -59,6 +59,8 @@ export const AddMemberDialog = ({ if (trimmed.length > 30) return 'Name must be at most 30 characters'; if (!NAME_REGEX.test(trimmed)) return 'Name must be lowercase alphanumeric with hyphens (e.g. alice, dev-1)'; + if (trimmed === 'user') return 'Name "user" is reserved'; + if (trimmed === 'team-lead') return 'Name "team-lead" is reserved'; if (existingNames.some((n) => n.toLowerCase() === trimmed)) return 'Name is already taken'; return null; }; diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 1c2a0345..b7a07094 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -22,12 +22,13 @@ import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { cn } from '@renderer/lib/utils'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { getMemberColor } from '@shared/constants/memberColors'; -import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Info, Loader2 } from 'lucide-react'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; import { ProjectPathSelector } from './ProjectPathSelector'; @@ -201,6 +202,7 @@ export const CreateTeamDialog = ({ const [teamName, setTeamName] = useState(''); const descriptionDraft = useDraftPersistence({ key: 'createTeam:description' }); const promptDraft = useDraftPersistence({ key: 'createTeam:prompt' }); + const promptChipDraft = useChipDraftPersistence('createTeam:prompt:chips'); const [members, setMembers] = useState([]); const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project'); const [selectedProjectPath, setSelectedProjectPath] = useState(''); @@ -219,6 +221,7 @@ export const CreateTeamDialog = ({ }>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [launchTeam, setLaunchTeam] = useState(true); + const [soloTeam, setSoloTeam] = useState(false); const [teamColor, setTeamColor] = useState(''); const [selectedModel, setSelectedModelRaw] = useState(() => { const stored = localStorage.getItem('team:lastSelectedModel') ?? ''; @@ -251,12 +254,14 @@ export const CreateTeamDialog = ({ setTeamName(''); descriptionDraft.clearDraft(); promptDraft.clearDraft(); + promptChipDraft.clearChipDraft(); setMembers([]); setTeamColor(''); setCwdMode('project'); setSelectedProjectPath(''); setCustomCwd(''); setLaunchTeam(true); + setSoloTeam(false); resetUIState(); }; @@ -449,12 +454,21 @@ export const CreateTeamDialog = ({ teamName: sanitizedTeamName, description: description.trim() || undefined, color: teamColor || undefined, - members: buildMembersFromDrafts(members), + members: soloTeam ? [] : buildMembersFromDrafts(members), cwd: effectiveCwd, prompt: prompt.trim() || undefined, model: effectiveModel, }), - [sanitizedTeamName, description, teamColor, members, effectiveCwd, prompt, effectiveModel] + [ + sanitizedTeamName, + description, + teamColor, + soloTeam, + members, + effectiveCwd, + prompt, + effectiveModel, + ] ); const activeError = localError ?? provisioningError; @@ -627,6 +641,35 @@ export const CreateTeamDialog = ({ showJsonEditor draftKeyPrefix="createTeam" projectPath={effectiveCwd || null} + hideContent={soloTeam} + headerExtra={ +
+
+ setSoloTeam(checked === true)} + /> + +
+ {soloTeam && ( +
+ +

+ Only the team lead (main process) will be started — no teammates will + be spawned. Works like a regular Claude session but with access to the task + board for planning. Saves tokens by avoiding teammate coordination overhead. + You can add members later from the team settings. +

+
+ )} +
+ } /> @@ -670,6 +713,9 @@ export const CreateTeamDialog = ({ onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} projectPath={effectiveCwd || null} + chips={promptChipDraft.chips} + onChipRemove={promptChipDraft.removeChip} + onFileChipInsert={promptChipDraft.addChip} placeholder="Instructions for the team lead during provisioning..." footerRight={ promptDraft.isSaved ? ( @@ -713,7 +759,7 @@ export const CreateTeamDialog = ({ {prepareWarnings.length > 0 - ? 'CLI environment ready (with warnings)' + ? 'CLI environment ready (with notes)' : 'CLI environment ready'} @@ -723,7 +769,7 @@ export const CreateTeamDialog = ({ {prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => ( -

+

{warning}

))} diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index c2199a12..8f661629 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -14,7 +14,9 @@ import { } from '@renderer/components/ui/dialog'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; +import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -58,6 +60,7 @@ export const LaunchTeamDialog = ({ const [selectedProjectPath, setSelectedProjectPath] = useState(''); const [customCwd, setCustomCwd] = useState(''); const promptDraft = useDraftPersistence({ key: `launchTeam:${teamName}:prompt` }); + const chipDraft = useChipDraftPersistence(`launchTeam:${teamName}:chips`); const [projects, setProjects] = useState([]); const [projectsLoading, setProjectsLoading] = useState(false); const [projectsError, setProjectsError] = useState(null); @@ -95,6 +98,7 @@ export const LaunchTeamDialog = ({ setSelectedProjectPath(''); setCustomCwd(''); setClearContext(false); + chipDraft.clearChipDraft(); }; // Warm up CLI on open @@ -220,6 +224,9 @@ export const LaunchTeamDialog = ({ const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); + // Pre-warm file list cache so @-mention file search is instant + useFileListCacheWarmer(effectiveCwd || null); + const conflictingTeam = useMemo(() => { if (!activeTeams?.length || !effectiveCwd) return null; const norm = normalizePath(effectiveCwd); @@ -362,6 +369,9 @@ export const LaunchTeamDialog = ({ onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} projectPath={effectiveCwd || null} + chips={chipDraft.chips} + onChipRemove={chipDraft.removeChip} + onFileChipInsert={chipDraft.addChip} placeholder="Instructions for team lead... Use @ to mention team members." footerRight={ promptDraft.isSaved ? ( @@ -438,7 +448,7 @@ export const LaunchTeamDialog = ({ {prepareWarnings.length > 0 - ? 'CLI environment ready (with warnings)' + ? 'CLI environment ready (with notes)' : 'CLI environment ready'}
@@ -448,7 +458,7 @@ export const LaunchTeamDialog = ({ {prepareWarnings.length > 0 ? (
{prepareWarnings.map((warning) => ( -

+

{warning}

))} diff --git a/src/renderer/components/team/editor/EditorBreadcrumb.tsx b/src/renderer/components/team/editor/EditorBreadcrumb.tsx index ad5455f5..28c55a8c 100644 --- a/src/renderer/components/team/editor/EditorBreadcrumb.tsx +++ b/src/renderer/components/team/editor/EditorBreadcrumb.tsx @@ -7,7 +7,7 @@ import { useCallback, useMemo } from 'react'; import { useStore } from '@renderer/store'; -import { splitPath } from '@shared/utils/platformPath'; +import { isWindowsishPath, joinPath, splitPath } from '@shared/utils/platformPath'; import { ChevronRight } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; @@ -27,20 +27,26 @@ export const EditorBreadcrumb = (): React.ReactElement | null => { const expandDirectory = useStore((s) => s.expandDirectory); const segments = useMemo(() => { - if (!activeTabId || !projectPath) return []; + if (!activeTabId) return []; + if (!projectPath) return splitPath(activeTabId); - const relativePath = activeTabId.startsWith(projectPath) - ? activeTabId.slice(projectPath.length + 1) - : activeTabId; + const fullParts = splitPath(activeTabId); + const rootParts = splitPath(projectPath); + if (rootParts.length === 0) return fullParts; - return splitPath(relativePath); + const win = isWindowsishPath(projectPath); + const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + const hasPrefix = + fullParts.length >= rootParts.length && rootParts.every((seg, i) => eq(seg, fullParts[i])); + + return hasPrefix ? fullParts.slice(rootParts.length) : fullParts; }, [activeTabId, projectPath]); const handleSegmentClick = useCallback( (segmentIndex: number): void => { if (!projectPath) return; const dirSegments = segments.slice(0, segmentIndex + 1); - const dirPath = `${projectPath}/${dirSegments.join('/')}`; + const dirPath = joinPath(projectPath, ...dirSegments); void expandDirectory(dirPath); }, [segments, projectPath, expandDirectory] diff --git a/src/renderer/components/team/editor/EditorFileTree.tsx b/src/renderer/components/team/editor/EditorFileTree.tsx index d64da1ea..9c0a4271 100644 --- a/src/renderer/components/team/editor/EditorFileTree.tsx +++ b/src/renderer/components/team/editor/EditorFileTree.tsx @@ -27,7 +27,13 @@ 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 { + getBasename, + isPathPrefix, + joinPath, + lastSeparatorIndex, + splitPath, +} 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'; @@ -210,9 +216,7 @@ export const EditorFileTree = ({ const map = new Map(); if (!gitFiles.length || !projectPath) return map; for (const file of gitFiles) { - const absPath = projectPath.endsWith('/') - ? `${projectPath}${file.path}` - : `${projectPath}/${file.path}`; + const absPath = joinPath(projectPath, ...splitPath(file.path)); map.set(absPath, file.status); } const ms = performance.now() - t0; @@ -400,7 +404,7 @@ export const EditorFileTree = ({ } // Validation: parent → child prevention - if (destDir.startsWith(sourcePath + '/') || destDir === sourcePath) { + if (isPathPrefix(sourcePath, destDir)) { setDraggedItem(null); setDropTargetPath(null); return; @@ -665,7 +669,9 @@ const DraggableTreeItem = React.memo( // Visual: highlight drop target directory and its visible children const isDropTarget = !node.isFile && dropTargetPath === node.fullPath; const isInsideDropTarget = - dropTargetPath != null && node.fullPath.startsWith(dropTargetPath + '/'); + dropTargetPath != null && + dropTargetPath !== node.fullPath && + isPathPrefix(dropTargetPath, node.fullPath); const dataAttrs: Record = {}; if (node.data) { diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 8ad38520..73e13c20 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; import { Pencil } from 'lucide-react'; @@ -29,6 +30,7 @@ export const MemberDetailHeader = ({ }: MemberDetailHeaderProps): React.JSX.Element => { const [editing, setEditing] = useState(false); + const colors = getTeamColorSet(member.color ?? ''); const role = member.role || formatAgentRole(member.agentType); const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity); @@ -51,7 +53,9 @@ export const MemberDetailHeader = ({ />
- {member.name} + + {member.name} +
{editing ? ( diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 094019af..3e4b8e8b 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -13,6 +13,7 @@ import { import { getTeamColorSet } from '@renderer/constants/teamColors'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { getMemberColor } from '@shared/constants/memberColors'; import { ChevronDown, ChevronRight } from 'lucide-react'; @@ -55,6 +56,9 @@ export const MemberDraftRow = ({ const memberColorSet = getTeamColorSet(getMemberColor(index)); const [workflowExpanded, setWorkflowExpanded] = useState(false); + // Pre-warm file list cache when workflow section is expanded + useFileListCacheWarmer(workflowExpanded && projectPath ? projectPath : null); + const draftKey = draftKeyPrefix && (member.name.trim() || member.id) ? `${draftKeyPrefix}:workflow:${member.name.trim() || member.id}` diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 27e50f73..06122f27 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -62,6 +62,10 @@ export interface MembersEditorSectionProps { draftKeyPrefix?: string; /** Project path for @file mentions in workflow */ projectPath?: string | null; + /** Extra content rendered right below the "Members" label row */ + headerExtra?: React.ReactNode; + /** When true, hides member rows and action buttons (label + headerExtra still visible) */ + hideContent?: boolean; } export const MembersEditorSection = ({ @@ -73,6 +77,8 @@ export const MembersEditorSection = ({ showJsonEditor = true, draftKeyPrefix, projectPath, + headerExtra, + hideContent = false, }: MembersEditorSectionProps): React.JSX.Element => { const [jsonEditorOpen, setJsonEditorOpen] = useState(false); const [jsonText, setJsonText] = useState(''); @@ -166,45 +172,52 @@ export const MembersEditorSection = ({
-
- - {showJsonEditor ? ( - + {showJsonEditor ? ( + + ) : null} +
+ )} +
+ {headerExtra} + {!hideContent && ( + <> +
+ {members.map((member, index) => ( + + ))} + {jsonEditorOpen && showJsonEditor ? ( + + ) : null} +
+ {hasDuplicates ? ( +

Member names must be unique

+ ) : fieldError ? ( +

{fieldError}

) : null} -
-
-
- {members.map((member, index) => ( - - ))} - {jsonEditorOpen && showJsonEditor ? ( - - ) : null} -
- {hasDuplicates ? ( -

Member names must be unique

- ) : fieldError ? ( -

{fieldError}

- ) : null} + + )}
); }; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 774f358a..0d302436 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -12,7 +12,6 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; -import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { getModifierKeyName } from '@renderer/utils/keyboardUtils'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; @@ -54,6 +53,18 @@ export const MessageComposer = ({ const dragCounterRef = useRef(0); const fileInputRef = useRef(null); + // Members load async with team data; keep recipient stable if valid, otherwise default to lead/first. + useEffect(() => { + if (recipient && members.some((m) => m.name === recipient)) { + return; + } + const lead = members.find((m) => m.role === 'lead' || m.name === 'team-lead'); + const next = lead?.name ?? members[0]?.name ?? ''; + if (next && next !== recipient) { + setRecipient(next); + } + }, [members, recipient]); + const projectPath = useStore((s) => s.selectedTeamData?.config.projectPath ?? null); const draft = useDraftPersistence({ key: `compose:${teamName}` }); const chipDraft = useChipDraftPersistence(`compose:${teamName}:chips`); @@ -94,17 +105,6 @@ export const MessageComposer = ({ // Track whether we initiated a send — clear draft only on confirmed success const pendingSendRef = useRef(false); - const handleChipRemove = useCallback( - (chipId: string) => { - const chip = chipDraft.chips.find((c) => c.id === chipId); - if (chip) { - draft.setValue(removeChipTokenFromText(draft.value, chip)); - } - chipDraft.setChips(chipDraft.chips.filter((c) => c.id !== chipId)); - }, - [chipDraft, draft] - ); - const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; @@ -325,9 +325,9 @@ export const MessageComposer = ({ onValueChange={draft.setValue} suggestions={mentionSuggestions} chips={chipDraft.chips} - onChipRemove={handleChipRemove} + onChipRemove={chipDraft.removeChip} projectPath={projectPath} - onFileChipInsert={(chip) => chipDraft.setChips([...chipDraft.chips, chip])} + onFileChipInsert={chipDraft.addChip} minRows={2} maxRows={6} maxLength={MAX_MESSAGE_LENGTH} diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index 20d994fc..0823ef33 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef } from 'react'; +import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { getTeamColorSet } from '@renderer/constants/teamColors'; -import { FileText } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -12,6 +13,8 @@ interface MentionSuggestionListProps { query: string; /** When true, adjusts empty state text to mention files */ hasFileSearch?: boolean; + /** When true, shows a loading spinner for file search */ + filesLoading?: boolean; } const HighlightedName = ({ name, query }: { name: string; query: string }): React.JSX.Element => { @@ -49,6 +52,7 @@ export const MentionSuggestionList = ({ onSelect, query, hasFileSearch, + filesLoading, }: MentionSuggestionListProps): React.JSX.Element => { const listRef = useRef(null); @@ -111,7 +115,7 @@ export const MentionSuggestionList = ({ }} > {isFile ? ( - + ) : ( {items} + {filesLoading ? ( +
  • + + Searching files... +
  • + ) : null} ); }; diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index fcdd3f0b..2b304812 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -267,7 +267,7 @@ export const MentionableTextarea = React.forwardRef ) : null} diff --git a/src/renderer/hooks/useChipDraftPersistence.ts b/src/renderer/hooks/useChipDraftPersistence.ts index 16f16ad8..b88b337a 100644 --- a/src/renderer/hooks/useChipDraftPersistence.ts +++ b/src/renderer/hooks/useChipDraftPersistence.ts @@ -15,6 +15,10 @@ interface UseChipDraftResult { chips: InlineChip[]; /** Accepts a direct value (not a callback). Saves to draftStorage with debounce. */ setChips: (chips: InlineChip[]) => void; + /** Append a single chip. Safe for passing directly as onFileChipInsert. */ + addChip: (chip: InlineChip) => void; + /** Remove a chip by id. Safe for passing directly as onChipRemove. */ + removeChip: (chipId: string) => void; clearChipDraft: () => void; isSaved: boolean; } @@ -44,7 +48,10 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { const timerRef = useRef | null>(null); const pendingRef = useRef(null); const keyRef = useRef(key); - // eslint-disable-next-line react-hooks/refs -- sync ref with prop for stable callbacks + // Ref for current chips — allows addChip/removeChip to read latest value + // without stale closures, using the same sync-ref pattern as keyRef. + const chipsRef = useRef([]); + keyRef.current = key; // Load on mount @@ -56,6 +63,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { try { const parsed: unknown = JSON.parse(raw); if (isValidChipArray(parsed)) { + chipsRef.current = parsed; setChipsState(parsed); setIsSaved(true); } @@ -92,6 +100,7 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { }, [flushPending]); const setChips = useCallback((nextChips: InlineChip[]) => { + chipsRef.current = nextChips; setChipsState(nextChips); setIsSaved(false); pendingRef.current = nextChips; @@ -116,16 +125,31 @@ export function useChipDraftPersistence(key: string): UseChipDraftResult { }, DEBOUNCE_MS); }, []); + const addChip = useCallback( + (chip: InlineChip) => { + setChips([...chipsRef.current, chip]); + }, + [setChips] + ); + + const removeChip = useCallback( + (chipId: string) => { + setChips(chipsRef.current.filter((c) => c.id !== chipId)); + }, + [setChips] + ); + const clearChipDraft = useCallback(() => { if (timerRef.current != null) { clearTimeout(timerRef.current); timerRef.current = null; } pendingRef.current = null; + chipsRef.current = []; setChipsState([]); setIsSaved(false); void draftStorage.deleteDraft(keyRef.current); }, []); - return { chips, setChips, clearChipDraft, isSaved }; + return { chips, setChips, addChip, removeChip, clearChipDraft, isSaved }; } diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 02ac5703..7d7562ed 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -18,6 +18,11 @@ import type { QuickOpenFile } from '@shared/types/editor'; const MAX_FILE_SUGGESTIONS = 8; +export interface UseFileSuggestionsResult { + suggestions: MentionSuggestion[]; + loading: boolean; +} + /** * Filters files by query (name or relative path) and converts to MentionSuggestion[]. * Exported for testing. @@ -57,12 +62,17 @@ export function useFileSuggestions( projectPath: string | null, query: string, enabled: boolean -): MentionSuggestion[] { - const [allFiles, setAllFiles] = useState([]); +): UseFileSuggestionsResult { + // Seed from cache on initial mount (lazy initializer) AND on projectPath change + const [allFiles, setAllFiles] = useState(() => { + if (!projectPath) return []; + return getQuickOpenCache(projectPath)?.files ?? []; + }); + const [loading, setLoading] = useState(false); // Bumped on cache invalidation (file create/delete) to trigger refetch const [fetchTrigger, setFetchTrigger] = useState(0); - // Seed from cache immediately when projectPath changes (setState-during-render pattern) + // Re-seed from cache when projectPath changes (setState-during-render pattern) const [prevPath, setPrevPath] = useState(projectPath); if (prevPath !== projectPath) { setPrevPath(projectPath); @@ -93,6 +103,7 @@ export function useFileSuggestions( const fetchFiles = useCallback( (projectRoot: string) => { let cancelled = false; + setLoading(true); window.electronAPI.project .listFiles(projectRoot) .then((files) => { @@ -102,6 +113,9 @@ export function useFileSuggestions( }) .catch(() => { // Project path may be invalid — will retry on next trigger + }) + .finally(() => { + if (!cancelled) setLoading(false); }); return () => { cancelled = true; @@ -110,10 +124,12 @@ export function useFileSuggestions( [] // listFiles API is stable ); + // Fetch only when cache is empty. Cache seeding is handled by: + // - lazy initializer (first mount) + // - setState-during-render (projectPath change) useEffect(() => { if (!projectPath) return; - // Cache already seeded during render — only fetch if missing const cached = getQuickOpenCache(projectPath); if (cached) return; @@ -121,8 +137,10 @@ export function useFileSuggestions( }, [projectPath, fetchTrigger, fetchFiles]); // Filter by query and convert to MentionSuggestion[] - return useMemo( + const suggestions = useMemo( () => (enabled ? filterFileSuggestions(allFiles, query) : []), [enabled, query, allFiles] ); + + return { suggestions, loading }; } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 99e38afb..c54b8d71 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -96,13 +96,22 @@ export function initializeNotificationListeners(): () => void { // CLI status check is non-critical for initial render (spawns child processes // + iterates PATH directories with stat() calls — heavy on Windows). - // Defer until the app is fully interactive. + // Defer on Windows; run immediately elsewhere so status is available quickly. let cliStatusTimer: ReturnType | null = null; if (api.cliInstaller) { + // On macOS/Linux, run immediately so the Dashboard can render status fast. + // On Windows, keep the existing defer to avoid competing with initial scans. + type NavigatorWithUserAgentData = Navigator & { userAgentData?: { platform?: string } }; + const nav: NavigatorWithUserAgentData | null = + typeof navigator !== 'undefined' ? (navigator as NavigatorWithUserAgentData) : null; + // Prefer UA-CH when available; fall back to deprecated-but-still-supported navigator.platform. + const platform = nav?.userAgentData?.platform ?? nav?.platform ?? nav?.userAgent ?? ''; + const isWindows = platform.toLowerCase().includes('win'); + const delayMs = isWindows ? 3000 : 0; cliStatusTimer = setTimeout(() => { void useStore.getState().fetchCliStatus(); cliStatusTimer = null; - }, 5000); + }, delayMs); } cleanupFns.push(() => { if (cliStatusTimer) clearTimeout(cliStatusTimer); diff --git a/src/renderer/store/slices/editorSlice.ts b/src/renderer/store/slices/editorSlice.ts index 77eddd5a..5e2a1c99 100644 --- a/src/renderer/store/slices/editorSlice.ts +++ b/src/renderer/store/slices/editorSlice.ts @@ -13,7 +13,15 @@ import { editorBridge } from '@renderer/utils/editorBridge'; import { invalidateQuickOpenCache } from '@renderer/utils/quickOpenCache'; import { computeDisambiguatedTabs } from '@renderer/utils/tabLabelDisambiguation'; import { createLogger } from '@shared/utils/logger'; -import { getBasename, lastSeparatorIndex, splitPath } from '@shared/utils/platformPath'; +import { + getBasename, + isPathPrefix, + isWindowsishPath, + joinPath, + lastSeparatorIndex, + splitPath, + stripTrailingSeparators, +} from '@shared/utils/platformPath'; import type { AppState } from '../types'; import type { @@ -322,23 +330,24 @@ export const createEditorSlice: StateCreator = (s } // Compute parent directories from projectRoot to the file. - // Normalize: strip trailing slash from project path to avoid double-slash. - const normalizedRoot = editorProjectPath.endsWith('/') - ? editorProjectPath.slice(0, -1) - : editorProjectPath; - const relative = filePath.startsWith(normalizedRoot + '/') - ? filePath.slice(normalizedRoot.length + 1) - : null; + // Must handle both `/` and `\` because paths may arrive from any OS. + const root = stripTrailingSeparators(editorProjectPath); + const rootParts = splitPath(root); + const fileParts = splitPath(filePath); + const win = isWindowsishPath(root); + const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + const hasPrefix = + fileParts.length >= rootParts.length && rootParts.every((seg, i) => eq(seg, fileParts[i])); - if (relative) { - const segments = splitPath(relative); + if (hasPrefix) { + const segments = fileParts.slice(rootParts.length); // Expand each parent directory sequentially (root → child → grandchild). // Skip the last segment (the file name itself). // Each expandDirectory call is awaited so that its children are merged // into the tree before the next level is expanded. - let currentDir = normalizedRoot; + let currentDir = root; for (let i = 0; i < segments.length - 1; i++) { - currentDir = `${currentDir}/${segments[i]}`; + currentDir = joinPath(currentDir, segments[i] ?? ''); await expandDirectory(currentDir); } } @@ -874,9 +883,7 @@ export const createEditorSlice: StateCreator = (s // Close tab if the deleted file is open const { editorOpenTabs } = get(); - const tabsToClose = editorOpenTabs.filter( - (t) => t.filePath === filePath || t.filePath.startsWith(filePath + '/') - ); + const tabsToClose = editorOpenTabs.filter((t) => isPathPrefix(filePath, t.filePath)); for (const tab of tabsToClose) { get().closeEditorTab(tab.id); } @@ -1332,11 +1339,19 @@ async function refreshDirectory( * replace the prefix with newPath. */ function remapPath(p: string, oldPath: string, newPath: string): string { - if (p === oldPath) return newPath; - if (p.startsWith(oldPath + '/')) { - return newPath + p.slice(oldPath.length); - } - return p; + const oldParts = splitPath(oldPath); + const pParts = splitPath(p); + if (oldParts.length === 0) return p; + + const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath); + const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + + const matchesPrefix = + pParts.length >= oldParts.length && oldParts.every((seg, i) => eq(seg, pParts[i])); + if (!matchesPrefix) return p; + + const suffix = pParts.slice(oldParts.length); + return suffix.length > 0 ? joinPath(newPath, ...suffix) : newPath; } /** @@ -1344,11 +1359,19 @@ function remapPath(p: string, oldPath: string, newPath: string): string { * Used to identify which bridge caches to remap. */ function reverseRemapPath(p: string, oldPath: string, newPath: string): string { - if (p === newPath) return oldPath; - if (p.startsWith(newPath + '/')) { - return oldPath + p.slice(newPath.length); - } - return p; + const newParts = splitPath(newPath); + const pParts = splitPath(p); + if (newParts.length === 0) return p; + + const win = isWindowsishPath(oldPath) || isWindowsishPath(p) || isWindowsishPath(newPath); + const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + + const matchesPrefix = + pParts.length >= newParts.length && newParts.every((seg, i) => eq(seg, pParts[i])); + if (!matchesPrefix) return p; + + const suffix = pParts.slice(newParts.length); + return suffix.length > 0 ? joinPath(oldPath, ...suffix) : oldPath; } /** diff --git a/src/renderer/utils/buildSelectionAction.ts b/src/renderer/utils/buildSelectionAction.ts index 76a834aa..3b6e4519 100644 --- a/src/renderer/utils/buildSelectionAction.ts +++ b/src/renderer/utils/buildSelectionAction.ts @@ -5,7 +5,7 @@ * without pulling in CodeMirror dependencies. */ -import { getBasename } from '@shared/utils/platformPath'; +import { getBasename, isWindowsishPath, splitPath } from '@shared/utils/platformPath'; import type { EditorSelectionAction, EditorSelectionInfo } from '@shared/types/editor'; @@ -69,10 +69,18 @@ export function buildFileAction( projectPath?: string | null ): EditorSelectionAction { const fileName = getBasename(filePath) || 'file'; - const displayPath = - projectPath && filePath.startsWith(projectPath + '/') - ? filePath.slice(projectPath.length + 1) - : filePath; + let displayPath = filePath; + if (projectPath) { + const fullParts = splitPath(filePath); + const rootParts = splitPath(projectPath); + const win = isWindowsishPath(projectPath); + const eq = (a: string, b: string) => (win ? a.toLowerCase() === b.toLowerCase() : a === b); + const hasPrefix = + fullParts.length >= rootParts.length && rootParts.every((seg, i) => eq(seg, fullParts[i])); + if (hasPrefix) { + displayPath = fullParts.slice(rootParts.length).join('/'); + } + } return { type, filePath, diff --git a/src/shared/utils/platformPath.ts b/src/shared/utils/platformPath.ts index 5652d4bc..416483e9 100644 --- a/src/shared/utils/platformPath.ts +++ b/src/shared/utils/platformPath.ts @@ -13,6 +13,70 @@ export function splitPath(filePath: string): string[] { return filePath.split(SEP_RE).filter(Boolean); } +/** + * Returns true if the string looks like a Windows path (drive letter or UNC). + * Used only to decide case-sensitivity for comparisons. + */ +export function isWindowsishPath(filePath: string): boolean { + const p = filePath.replace(/\\/g, '/'); + return /^[A-Za-z]:\//.test(p) || p.startsWith('//'); +} + +/** + * Normalize for comparisons: + * - Convert `\` → `/` + * - Lowercase only for Windows-ish paths (Windows is case-insensitive) + * + * Do NOT use this for filesystem operations; it's for comparisons only. + */ +export function normalizePathForComparison(filePath: string): string { + const p = filePath.replace(/\\/g, '/'); + return isWindowsishPath(p) ? p.toLowerCase() : p; +} + +/** Strip trailing path separators (except for root paths like "/" or "C:/"). */ +export function stripTrailingSeparators(filePath: string): string { + if (!filePath) return filePath; + const p = filePath.replace(/\\/g, '/'); + if (p === '/' || /^[A-Za-z]:\/$/.test(p)) return filePath; + return filePath.replace(/[/\\]+$/, ''); +} + +/** Prefer the separator style already present in the path. */ +export function getPreferredSeparator(filePath: string): '/' | '\\' { + const hasBackslash = filePath.includes('\\'); + const hasSlash = filePath.includes('/'); + if (hasBackslash && !hasSlash) return '\\'; + return '/'; +} + +/** Join base + segments using the base path's preferred separator. */ +export function joinPath(base: string, ...segments: string[]): string { + const sep = getPreferredSeparator(base); + let out = stripTrailingSeparators(base); + for (const seg of segments) { + const cleaned = seg.replace(/^[\\/]+|[\\/]+$/g, ''); + if (!cleaned) continue; + if (!out || out.endsWith('/') || out.endsWith('\\')) { + out += cleaned; + } else { + out += sep + cleaned; + } + } + return out; +} + +/** True if fullPath is equal to prefix or is nested under prefix. */ +export function isPathPrefix(prefix: string, fullPath: string): boolean { + const p = stripTrailingSeparators(normalizePathForComparison(prefix)); + const f = stripTrailingSeparators(normalizePathForComparison(fullPath)); + if (f === p) return true; + // Root prefixes are special: p already ends with "/" ("/" or "c:/"). + if (p === '/') return f.startsWith('/'); + if (/^[a-z]:\/$/.test(p)) return f.startsWith(p); + return f.startsWith(p + '/'); +} + /** Get the last segment (filename) from a path. */ export function getBasename(filePath: string): string { const parts = splitPath(filePath); diff --git a/test/main/ipc/guards.test.ts b/test/main/ipc/guards.test.ts index 6a61b93b..ff0cc1d9 100644 --- a/test/main/ipc/guards.test.ts +++ b/test/main/ipc/guards.test.ts @@ -5,6 +5,7 @@ import { coerceSearchMaxResults, validateFromField, validateMemberName, + validateTeammateName, validateProjectId, validateSearchQuery, validateSessionId, @@ -61,6 +62,11 @@ describe('ipc guards', () => { expect(validateTaskId('123').valid).toBe(true); expect(validateMemberName('alice_1').valid).toBe(true); expect(validateFromField('team-lead').valid).toBe(true); + expect(validateMemberName('team-lead').valid).toBe(true); + expect(validateMemberName('user').valid).toBe(false); + expect(validateTeammateName('alice_1').valid).toBe(true); + expect(validateTeammateName('team-lead').valid).toBe(false); + expect(validateTeammateName('user').valid).toBe(false); }); it('rejects traversal and invalid chars for team-related fields', () => { diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 672ab54b..e377c187 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -684,4 +684,46 @@ describe('ipc teams handlers', () => { expect(result.error).toContain('members must be an array'); }); }); + + describe('reserved teammate names', () => { + it('rejects teammate name "user" in createTeam', async () => { + const handler = handlers.get(TEAM_CREATE)!; + const result = (await handler({ sender: { send: vi.fn() } } as never, { + teamName: 'solo-team', + members: [{ name: 'user' }], + cwd: os.tmpdir(), + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error.toLowerCase()).toContain('reserved'); + }); + + it('rejects teammate name "team-lead" in createTeam', async () => { + const handler = handlers.get(TEAM_CREATE)!; + const result = (await handler({ sender: { send: vi.fn() } } as never, { + teamName: 'solo-team', + members: [{ name: 'team-lead' }], + cwd: os.tmpdir(), + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error.toLowerCase()).toContain('reserved'); + }); + + it('rejects addMember name "user"', async () => { + const handler = handlers.get(TEAM_ADD_MEMBER)!; + const result = (await handler({} as never, 'my-team', { + name: 'user', + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error.toLowerCase()).toContain('reserved'); + }); + + it('rejects addMember name "team-lead"', async () => { + const handler = handlers.get(TEAM_ADD_MEMBER)!; + const result = (await handler({} as never, 'my-team', { + name: 'team-lead', + })) as { success: boolean; error: string }; + expect(result.success).toBe(false); + expect(result.error.toLowerCase()).toContain('reserved'); + }); + }); }); diff --git a/test/main/services/team/TeamProvisioningServiceRoster.test.ts b/test/main/services/team/TeamProvisioningServiceRoster.test.ts new file mode 100644 index 00000000..62f6886d --- /dev/null +++ b/test/main/services/team/TeamProvisioningServiceRoster.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService'; + +describe('TeamProvisioningService (launch roster discovery)', () => { + it('inbox fallback keeps -1 names but drops auto-suffixed -2+ when base exists', async () => { + const svc = new TeamProvisioningService( + {} as never, + { + listInboxNames: vi.fn(async () => [ + 'dev', + 'dev-1', + 'dev-2', + 'dev-3', + 'user', + 'team-lead', + 'DEV-2', + ]), + } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', '{}'); + expect(result.source).toBe('inboxes'); + expect(result.members.map((m: { name: string }) => m.name)).toEqual(['dev', 'dev-1']); + }); + + it('inbox fallback keeps suffixed name if base is absent', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => ['alice-2']) } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', '{}'); + expect(result.source).toBe('inboxes'); + expect(result.members.map((m: { name: string }) => m.name)).toEqual(['alice-2']); + }); + + it('members.meta.json fallback never returns reserved names (user/team-lead)', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => []) } as never, + { + getMembers: vi.fn(async () => [ + { name: 'user', agentType: 'general-purpose' }, + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'Alice', role: 'dev', agentType: 'general-purpose' }, + ]), + } as never, + {} as never + ); + + const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', '{}'); + expect(result.source).toBe('members-meta'); + expect(result.members.map((m: { name: string }) => m.name)).toEqual(['Alice']); + }); + + it('config fallback never returns reserved names (user/team-lead)', async () => { + const svc = new TeamProvisioningService( + {} as never, + { listInboxNames: vi.fn(async () => []) } as never, + { getMembers: vi.fn(async () => []) } as never, + {} as never + ); + + const configRaw = JSON.stringify({ + name: 't', + members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'user' }, { name: 'bob' }], + }); + + const result = await (svc as unknown as any).resolveLaunchExpectedMembers('t', configRaw); + expect(result.source).toBe('config-fallback'); + expect(result.members.map((m: { name: string }) => m.name)).toEqual(['bob']); + }); +});