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:
parent
fa244052e8
commit
032d9b478b
40 changed files with 820 additions and 263 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ export const ActivityItem = ({
|
|||
<MemberBadge
|
||||
name={message.from}
|
||||
color={memberColor ?? message.color}
|
||||
hideAvatar={message.from === 'user'}
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 — 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue