feat: enhance team member validation and improve logging

- Added validation to prevent the use of reserved names ("user" and "team-lead") for team members, ensuring clearer error messages during member addition.
- Updated IPC handlers to improve logging functionality, enhancing observability of team-related actions.
- Implemented normalization of file paths across various services to ensure consistent handling on different platforms.
- Enhanced UI components to provide better feedback on team member statuses and actions.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-03 23:58:19 +02:00
parent fa244052e8
commit 032d9b478b
40 changed files with 820 additions and 263 deletions

View file

@ -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

View file

@ -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', () => {

View file

@ -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,
});

View file

@ -27,6 +27,9 @@ interface ValidationResult<T> {
error?: string;
}
const RESERVED_MEMBER_NAMES = new Set<string>(['user']);
const RESERVED_TEAMMATE_NAMES = new Set<string>(['team-lead']);
function validateString(
value: unknown,
fieldName: string,
@ -149,9 +152,31 @@ export function validateMemberName(memberName: unknown): ValidationResult<string
return { valid: false, error: 'member contains invalid characters' };
}
const lower = basic.value!.toLowerCase();
if (RESERVED_MEMBER_NAMES.has(lower)) {
return { valid: false, error: `member name "${basic.value!}" is reserved` };
}
return { valid: true, value: basic.value };
}
/**
* Teammate names are user-created members (not the lead process).
* This validation forbids reserved system names (lead + human).
*/
export function validateTeammateName(memberName: unknown): ValidationResult<string> {
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<string> {
const basic = validateString(from, 'from', 128);
if (!basic.valid) {

View file

@ -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<number, number>();
const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
@ -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}`);
}
});
}

View file

@ -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,

View file

@ -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<string>();
@ -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' };

View file

@ -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,

View file

@ -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

View file

@ -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();
}
/**

View file

@ -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;

View file

@ -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<string | null> {
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<boolean> => {
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(

View file

@ -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<string, boolean>();
@ -19,9 +48,8 @@ export class GitDiffFallback {
commitHash: string
): Promise<string | null> {
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<string | null> {
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<string | null> {
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],

View file

@ -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 <id> --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 {

View file

@ -223,13 +223,16 @@ export class TeamConfigReader {
// Case-insensitive dedup: key is lowercase name, value keeps the original casing
const memberMap = new Map<string, TeamSummaryMember>();
const removedKeys = new Set<string>();
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,

View file

@ -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<void> {
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<string>();
@ -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<void> {

View file

@ -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<string, string>();
@ -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<void> {
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<string, TeamCreateRequest['members'][number]>();
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<string, TeamCreateRequest['members'][number]>();
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 });
}

View file

@ -209,10 +209,15 @@ async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[];
}
const memberMap = new Map<string, { name: string; role?: string; color?: string }>();
const removedKeys = new Set<string>();
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,

View file

@ -229,27 +229,34 @@ const RepositoryCard = ({
)}
</div>
{/* Project path - monospace, muted, clickable to open in file manager */}
<Tooltip>
<TooltipTrigger asChild>
<div
role="button"
tabIndex={0}
onClick={handleOpenPath}
onKeyDown={(e) => {
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"
>
<FolderOpen className="size-3 shrink-0" />
{/* Project path - monospace, muted; folder icon opens in file manager */}
<div className="flex w-full min-w-0 items-center gap-1 font-mono text-[10px] text-text-muted">
<Tooltip>
<TooltipTrigger asChild>
<div
role="button"
tabIndex={0}
onClick={handleOpenPath}
onKeyDown={(e) => {
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"
>
<FolderOpen className="size-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom">Open</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">{formattedPath}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p className="font-mono text-[11px]">{projectPath}</p>
</TooltipContent>
</Tooltip>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p className="font-mono text-[11px]">{projectPath}</p>
</TooltipContent>
</Tooltip>
</div>
{/* Git branch / worktree info */}
{mainBranch ? (

View file

@ -277,6 +277,7 @@ export const ActivityItem = ({
<MemberBadge
name={message.from}
color={memberColor ?? message.color}
hideAvatar={message.from === 'user'}
onClick={onMemberNameClick}
/>

View file

@ -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;
};

View file

@ -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<MemberDraft[]>([]);
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={
<div className="space-y-2">
<div className="flex items-center gap-2">
<Checkbox
id="solo-team"
checked={soloTeam}
onCheckedChange={(checked) => setSoloTeam(checked === true)}
/>
<Label
htmlFor="solo-team"
className="cursor-pointer text-xs font-normal text-text-secondary"
>
Solo team
</Label>
</div>
{soloTeam && (
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; 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.
</p>
</div>
)}
</div>
}
/>
</div>
@ -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 = ({
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
? 'CLI environment ready (with warnings)'
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
</div>
@ -723,7 +769,7 @@ export const CreateTeamDialog = ({
{prepareWarnings.length > 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
<p key={warning} className="text-[11px] text-sky-300">
{warning}
</p>
))}

View file

@ -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<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(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 = ({
<CheckCircle2 className="size-3.5 shrink-0" />
<span>
{prepareWarnings.length > 0
? 'CLI environment ready (with warnings)'
? 'CLI environment ready (with notes)'
: 'CLI environment ready'}
</span>
</div>
@ -448,7 +458,7 @@ export const LaunchTeamDialog = ({
{prepareWarnings.length > 0 ? (
<div className="space-y-0.5">
{prepareWarnings.map((warning) => (
<p key={warning} className="text-[11px] text-amber-300">
<p key={warning} className="text-[11px] text-sky-300">
{warning}
</p>
))}

View file

@ -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]

View file

@ -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<string, GitFileStatusType>();
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<string, string> = {};
if (node.data) {

View file

@ -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 = ({
/>
</div>
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">{member.name}</DialogTitle>
<DialogTitle className="truncate" style={{ color: colors.text }}>
{member.name}
</DialogTitle>
<DialogDescription asChild className="mt-1 flex items-center gap-2">
<div>
{editing ? (

View file

@ -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}`

View file

@ -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 = ({
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>Members</Label>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={addMember}>
Add member
</Button>
{showJsonEditor ? (
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
{jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
{!hideContent && (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={addMember}>
Add member
</Button>
{showJsonEditor ? (
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
{jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
</Button>
) : null}
</div>
)}
</div>
{headerExtra}
{!hideContent && (
<>
<div className="space-y-2">
{members.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={index}
nameError={validateMemberName?.(member.name) ?? null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
/>
))}
{jsonEditorOpen && showJsonEditor ? (
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
) : null}
</div>
{hasDuplicates ? (
<p className="text-[11px] text-red-300">Member names must be unique</p>
) : fieldError ? (
<p className="text-[11px] text-red-300">{fieldError}</p>
) : null}
</div>
</div>
<div className="space-y-2">
{members.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={index}
nameError={validateMemberName?.(member.name) ?? null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
/>
))}
{jsonEditorOpen && showJsonEditor ? (
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
) : null}
</div>
{hasDuplicates ? (
<p className="text-[11px] text-red-300">Member names must be unique</p>
) : fieldError ? (
<p className="text-[11px] text-red-300">{fieldError}</p>
) : null}
</>
)}
</div>
);
};

View file

@ -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<HTMLInputElement>(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}

View file

@ -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<HTMLUListElement>(null);
@ -111,7 +115,7 @@ export const MentionSuggestionList = ({
}}
>
{isFile ? (
<FileText size={10} className="shrink-0 text-[var(--color-text-muted)]" />
<FileIcon fileName={s.name} className="size-3.5" />
) : (
<span
className="inline-block size-2.5 shrink-0 rounded-full"
@ -138,6 +142,12 @@ export const MentionSuggestionList = ({
className="max-h-48 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface-overlay)] py-1"
>
{items}
{filesLoading ? (
<li className="flex items-center gap-2 px-3 py-1.5 text-[10px] text-[var(--color-text-muted)]">
<Loader2 size={10} className="shrink-0 animate-spin" />
<span>Searching files...</span>
</li>
) : null}
</ul>
);
};

View file

@ -267,7 +267,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
});
// --- File suggestions ---
const fileSuggestions = useFileSuggestions(
const { suggestions: fileSuggestions, loading: filesLoading } = useFileSuggestions(
enableFiles ? projectPath : null,
query,
isOpen && enableFiles
@ -689,6 +689,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
onSelect={enableFiles ? handleMergedSelect : selectSuggestion}
query={query}
hasFileSearch={enableFiles}
filesLoading={enableFiles && filesLoading}
/>
</div>
) : null}

View file

@ -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<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<InlineChip[] | null>(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<InlineChip[]>([]);
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 };
}

View file

@ -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<QuickOpenFile[]>([]);
): UseFileSuggestionsResult {
// Seed from cache on initial mount (lazy initializer) AND on projectPath change
const [allFiles, setAllFiles] = useState<QuickOpenFile[]>(() => {
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 };
}

View file

@ -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<typeof setTimeout> | 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);

View file

@ -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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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;
}
/**

View file

@ -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,

View file

@ -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);

View file

@ -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', () => {

View file

@ -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');
});
});
});

View file

@ -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']);
});
});