feat: implement team member replacement functionality and enhance file search caching
- Added a new handler for replacing team members, allowing bulk updates to team member roles and workflows. - Enhanced the FileSearchService to include caching for file listings, improving performance by reducing redundant file system scans. - Updated editor and team-related services to support the new member replacement feature, ensuring proper validation and error handling. - Improved UI components to accommodate workflow input for team members, enhancing user experience during team management.
This commit is contained in:
parent
7206b231f0
commit
b08a4d3764
23 changed files with 1060 additions and 370 deletions
|
|
@ -359,6 +359,9 @@ async function handleEditorWatchDir(
|
|||
// Content changes: debounced (500ms) to coalesce rapid saves/builds.
|
||||
if (event.type === 'create' || event.type === 'delete') {
|
||||
gitStatusService.invalidateCache();
|
||||
if (activeProjectRoot) {
|
||||
fileSearchService.invalidateListFilesCache(activeProjectRoot);
|
||||
}
|
||||
} else {
|
||||
gitStatusService.invalidateCacheDebounced();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REPLACE_MEMBERS,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
|
|
@ -207,6 +208,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
||||
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
|
||||
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
||||
ipcMain.handle(TEAM_REPLACE_MEMBERS, handleReplaceMembers);
|
||||
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
||||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||
|
|
@ -255,6 +257,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_ALL_TASKS);
|
||||
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
|
||||
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_REPLACE_MEMBERS);
|
||||
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||
|
|
@ -546,7 +549,15 @@ async function validateProvisioningRequest(
|
|||
if (role !== undefined && typeof role !== 'string') {
|
||||
return { valid: false, error: 'member role must be string' };
|
||||
}
|
||||
members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined });
|
||||
const workflow = (member as { workflow?: unknown }).workflow;
|
||||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { valid: false, error: 'member workflow must be string' };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
|
||||
|
|
@ -1342,7 +1353,15 @@ async function handleCreateConfig(
|
|||
if (role !== undefined && typeof role !== 'string') {
|
||||
return { success: false, error: 'member role must be string' };
|
||||
}
|
||||
members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined });
|
||||
const workflow = (member as { workflow?: unknown }).workflow;
|
||||
if (workflow !== undefined && typeof workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return wrapTeamHandler('createConfig', () =>
|
||||
|
|
@ -1544,6 +1563,50 @@ async function handleAddMember(
|
|||
});
|
||||
}
|
||||
|
||||
async function handleReplaceMembers(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
request: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
if (!request || typeof request !== 'object') {
|
||||
return { success: false, error: 'request must be an object' };
|
||||
}
|
||||
const payload = request as { members?: unknown };
|
||||
if (!Array.isArray(payload.members) || payload.members.length === 0) {
|
||||
return { success: false, error: 'members must contain at least one member' };
|
||||
}
|
||||
const seenNames = new Set<string>();
|
||||
const members: { name: string; role?: string; workflow?: string }[] = [];
|
||||
for (const item of payload.members) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return { success: false, error: 'member must be object' };
|
||||
}
|
||||
const m = item as { name?: unknown; role?: unknown; workflow?: unknown };
|
||||
const vName = validateMemberName(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' };
|
||||
seenNames.add(name);
|
||||
if (m.role !== undefined && typeof m.role !== 'string') {
|
||||
return { success: false, error: 'member role must be string' };
|
||||
}
|
||||
if (m.workflow !== undefined && typeof m.workflow !== 'string') {
|
||||
return { success: false, error: 'member workflow must be string' };
|
||||
}
|
||||
members.push({
|
||||
name,
|
||||
role: typeof m.role === 'string' ? m.role.trim() : undefined,
|
||||
workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return wrapTeamHandler('replaceMembers', async () => {
|
||||
await getTeamDataService().replaceMembers(vTeam.value!, { members });
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRemoveMember(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs/promises';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
import * as path from 'path';
|
||||
import { simpleGit } from 'simple-git';
|
||||
|
||||
import type {
|
||||
SearchFileResult,
|
||||
|
|
@ -27,6 +28,7 @@ const MAX_FILE_SIZE = 1024 * 1024; // 1 MB
|
|||
const DEFAULT_MAX_RESULT_FILES = 100;
|
||||
const DEFAULT_MAX_MATCHES = 500;
|
||||
const SEARCH_TIMEOUT_MS = 5000;
|
||||
const LIST_FILES_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
|
||||
|
||||
const IGNORED_DIRS = new Set([
|
||||
'.git',
|
||||
|
|
@ -52,6 +54,26 @@ const log = createLogger('FileSearchService');
|
|||
// =============================================================================
|
||||
|
||||
export class FileSearchService {
|
||||
// Cache for listFiles() — avoids repeated full project walks for @file mentions / Quick Open.
|
||||
private listFilesCache = new Map<
|
||||
string,
|
||||
{ files: { path: string; name: string; relativePath: string }[]; timestamp: number }
|
||||
>();
|
||||
private listFilesInFlight = new Map<
|
||||
string,
|
||||
Promise<{ path: string; name: string; relativePath: string }[]>
|
||||
>();
|
||||
|
||||
invalidateListFilesCache(projectRoot?: string): void {
|
||||
if (projectRoot) {
|
||||
this.listFilesCache.delete(projectRoot);
|
||||
this.listFilesInFlight.delete(projectRoot);
|
||||
return;
|
||||
}
|
||||
this.listFilesCache.clear();
|
||||
this.listFilesInFlight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in the project recursively (for Quick Open).
|
||||
* Lightweight — no content reading, no binary checks, no stat.
|
||||
|
|
@ -61,9 +83,117 @@ export class FileSearchService {
|
|||
projectRoot: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ path: string; name: string; relativePath: string }[]> {
|
||||
const files: { path: string; name: string; relativePath: string }[] = [];
|
||||
await this.collectFilePaths(projectRoot, projectRoot, files, signal);
|
||||
return files;
|
||||
if (signal?.aborted) return [];
|
||||
|
||||
const cached = this.listFilesCache.get(projectRoot);
|
||||
if (cached && Date.now() - cached.timestamp < LIST_FILES_CACHE_TTL_MS) {
|
||||
log.info(`[perf] listFiles: cache hit (${cached.files.length} files)`);
|
||||
return cached.files;
|
||||
}
|
||||
|
||||
const inFlight = this.listFilesInFlight.get(projectRoot);
|
||||
if (inFlight) {
|
||||
log.info('[perf] listFiles: awaiting in-flight scan');
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const t0 = performance.now();
|
||||
|
||||
// Prefer git for performance when available (large repos can take seconds with fs walk).
|
||||
const gitFiles = await this.tryListFilesWithGit(projectRoot, signal);
|
||||
const files =
|
||||
gitFiles ??
|
||||
(await (async () => {
|
||||
const next: { path: string; name: string; relativePath: string }[] = [];
|
||||
await this.collectFilePaths(projectRoot, projectRoot, next, signal);
|
||||
return next;
|
||||
})());
|
||||
|
||||
const durationMs = performance.now() - t0;
|
||||
log.info(`[perf] listFiles: ${durationMs.toFixed(1)}ms, files=${files.length}`);
|
||||
|
||||
// Cache the result (even if empty) to avoid repeated work.
|
||||
this.listFilesCache.set(projectRoot, { files, timestamp: Date.now() });
|
||||
return files;
|
||||
})()
|
||||
.catch((error) => {
|
||||
// Do not cache failures; allow retry on next call.
|
||||
log.warn(`listFiles failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.listFilesInFlight.delete(projectRoot);
|
||||
});
|
||||
|
||||
this.listFilesInFlight.set(projectRoot, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private shouldIgnoreRelativePath(relativePath: string): boolean {
|
||||
const parts = relativePath.split(/[\\/]/g).filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (IGNORED_DIRS.has(part)) return true;
|
||||
if (part.startsWith('.')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async tryListFilesWithGit(
|
||||
projectRoot: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ path: string; name: string; relativePath: string }[] | null> {
|
||||
try {
|
||||
// Fast pre-check to avoid spawning git for non-repos.
|
||||
await fs.access(path.join(projectRoot, '.git'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const git = simpleGit({
|
||||
baseDir: projectRoot,
|
||||
timeout: { block: 10_000 },
|
||||
}).env('GIT_OPTIONAL_LOCKS', '0');
|
||||
|
||||
// Include tracked + untracked (excluding ignored) for better UX.
|
||||
// Use -z for safe parsing of filenames.
|
||||
const output = await git.raw([
|
||||
'ls-files',
|
||||
'-z',
|
||||
'--cached',
|
||||
'--others',
|
||||
'--exclude-standard',
|
||||
]);
|
||||
if (signal?.aborted) return [];
|
||||
|
||||
const relPaths = output.split('\0').filter(Boolean);
|
||||
const files: { path: string; name: string; relativePath: string }[] = [];
|
||||
|
||||
for (const rel of relPaths) {
|
||||
if (signal?.aborted || files.length >= MAX_FILES) break;
|
||||
if (!rel) continue;
|
||||
if (this.shouldIgnoreRelativePath(rel)) continue;
|
||||
|
||||
const fullPath = path.resolve(projectRoot, rel);
|
||||
if (!isPathWithinRoot(fullPath, projectRoot)) continue;
|
||||
if (isGitInternalPath(fullPath)) continue;
|
||||
|
||||
files.push({ path: fullPath, name: path.basename(rel), relativePath: rel });
|
||||
}
|
||||
|
||||
// Stable ordering for UI (cheap at <= MAX_FILES)
|
||||
files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
return files;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('not a git repository')) {
|
||||
return null;
|
||||
}
|
||||
// Unexpected git error — fall back to fs traversal.
|
||||
log.warn(`git listFiles failed, falling back to fs: ${message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -605,6 +605,32 @@ export class TeamDataService {
|
|||
return { oldRole, changed: true };
|
||||
}
|
||||
|
||||
async replaceMembers(
|
||||
teamName: string,
|
||||
request: { members: { name: string; role?: string; workflow?: string }[] }
|
||||
): Promise<void> {
|
||||
if (!request.members.length) {
|
||||
throw new Error('At least one member is required');
|
||||
}
|
||||
const existing = await this.membersMetaStore.getMembers(teamName);
|
||||
const existingByName = new Map(existing.map((m) => [m.name.toLowerCase(), m]));
|
||||
const joinedAt = Date.now();
|
||||
const newMembers: TeamMember[] = request.members.map((member, index) => {
|
||||
const name = member.name.trim();
|
||||
if (!name) throw new Error('Member name cannot be empty');
|
||||
const prev = existingByName.get(name.toLowerCase());
|
||||
return {
|
||||
name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
agentType: prev?.agentType ?? 'general-purpose',
|
||||
color: prev?.color ?? getMemberColor(index),
|
||||
joinedAt: prev?.joinedAt ?? joinedAt,
|
||||
};
|
||||
});
|
||||
await this.membersMetaStore.writeMembers(teamName, newMembers);
|
||||
}
|
||||
|
||||
async removeMember(teamName: string, memberName: string): Promise<void> {
|
||||
const members = await this.membersMetaStore.getMembers(teamName);
|
||||
const member = members.find((m) => m.name === memberName);
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export class TeamMemberResolver {
|
|||
|
||||
const configMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; color?: string; cwd?: string }
|
||||
{ agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string }
|
||||
>();
|
||||
if (Array.isArray(config.members)) {
|
||||
for (const m of config.members) {
|
||||
|
|
@ -48,6 +48,7 @@ export class TeamMemberResolver {
|
|||
configMemberMap.set(m.name.trim(), {
|
||||
agentType: m.agentType,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
color: m.color,
|
||||
cwd: m.cwd,
|
||||
});
|
||||
|
|
@ -57,7 +58,7 @@ export class TeamMemberResolver {
|
|||
|
||||
const metaMemberMap = new Map<
|
||||
string,
|
||||
{ agentType?: string; role?: string; color?: string; removedAt?: number }
|
||||
{ agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number }
|
||||
>();
|
||||
if (Array.isArray(metaMembers)) {
|
||||
for (const member of metaMembers) {
|
||||
|
|
@ -65,6 +66,7 @@ export class TeamMemberResolver {
|
|||
metaMemberMap.set(member.name.trim(), {
|
||||
agentType: member.agentType,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
color: member.color,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
|
|
@ -94,6 +96,7 @@ export class TeamMemberResolver {
|
|||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||
role: configMember?.role ?? metaMember?.role,
|
||||
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||
cwd: configMember?.cwd,
|
||||
removedAt: metaMember?.removedAt,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
|||
return {
|
||||
name: trimmedName,
|
||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||
agentType:
|
||||
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
||||
color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined,
|
||||
|
|
|
|||
|
|
@ -307,15 +307,55 @@ function wrapInAgentBlock(text: string): string {
|
|||
return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`;
|
||||
}
|
||||
|
||||
function indentMultiline(text: string, indent: string): string {
|
||||
return text
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => `${indent}${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatWorkflowBlock(workflow: string, indent: string): string {
|
||||
const trimmed = workflow.trim();
|
||||
if (trimmed.length === 0) return '';
|
||||
const body = indentMultiline(trimmed, indent);
|
||||
return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`;
|
||||
}
|
||||
|
||||
function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
||||
return members
|
||||
.map((member) => {
|
||||
const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : '';
|
||||
return `- ${member.name}${rolePart}`;
|
||||
const workflowPart = member.workflow?.trim()
|
||||
? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}`
|
||||
: '';
|
||||
return `- ${member.name}${rolePart}${workflowPart}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildMemberSpawnPrompt(
|
||||
member: TeamCreateRequest['members'][number],
|
||||
displayName: string,
|
||||
teamName: string,
|
||||
taskProtocol: string,
|
||||
processRegistration: string
|
||||
): string {
|
||||
const role = member.role?.trim() || 'team member';
|
||||
const workflowBlock = member.workflow?.trim()
|
||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}`
|
||||
: '';
|
||||
return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock}
|
||||
|
||||
${getAgentLanguageInstruction()}
|
||||
Introduce yourself briefly (name and role) and confirm you are ready.
|
||||
Then wait for task assignments.
|
||||
Include the following agent-only instructions verbatim in the prompt:
|
||||
|
||||
${taskProtocol}
|
||||
|
||||
${processRegistration}`;
|
||||
}
|
||||
|
||||
function buildTaskStatusProtocol(teamName: string): string {
|
||||
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||
1. Use this command to mark task started:
|
||||
|
|
@ -546,20 +586,18 @@ Steps (execute in this exact order):
|
|||
1) TeamCreate — create team "${request.teamName}":
|
||||
- description: "${description}"
|
||||
|
||||
2) Spawn each member as a live teammate using the Task tool:
|
||||
- team_name: "${request.teamName}"
|
||||
- name: the member's name
|
||||
- subagent_type: "general-purpose"
|
||||
2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown:
|
||||
|
||||
${request.members
|
||||
.map(
|
||||
(m) => ` For "${m.name}":
|
||||
- prompt:
|
||||
You are {name}, a {role} on team "${displayName}" (${request.teamName}).
|
||||
${languageInstruction}
|
||||
Introduce yourself briefly (name and role) and confirm you are ready.
|
||||
Then wait for task assignments.
|
||||
Include the following agent-only instructions verbatim in the prompt:
|
||||
|
||||
${taskProtocol}
|
||||
|
||||
${processRegistration}
|
||||
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
|
||||
.split('\n')
|
||||
.map((line) => ` ${line}`)
|
||||
.join('\n')}`
|
||||
)
|
||||
.join('\n\n')}
|
||||
|
||||
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.
|
||||
|
|
@ -612,10 +650,14 @@ function buildLaunchPrompt(
|
|||
.map((m) => {
|
||||
const taskBlock = memberTaskBlocks.get(m.name) || '';
|
||||
const hasTasks = Boolean(taskBlock);
|
||||
const workflowBlock = m.workflow?.trim()
|
||||
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(m.workflow, ' ')}`
|
||||
: '';
|
||||
|
||||
return ` For "${m.name}":
|
||||
- prompt:
|
||||
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".
|
||||
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".${workflowBlock}
|
||||
|
||||
${languageInstruction}
|
||||
The team has been reconnected after a restart.
|
||||
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
|
||||
|
|
@ -3270,6 +3312,7 @@ export class TeamProvisioningService {
|
|||
teammateMembers.map((member, index) => ({
|
||||
name: member.name,
|
||||
role: member.role?.trim() || undefined,
|
||||
workflow: member.workflow?.trim() || undefined,
|
||||
agentType: 'general-purpose',
|
||||
color: getMemberColor(index),
|
||||
joinedAt,
|
||||
|
|
@ -3299,11 +3342,17 @@ export class TeamProvisioningService {
|
|||
const name = member.name?.trim();
|
||||
if (!name) continue;
|
||||
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
||||
const workflow =
|
||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined;
|
||||
const prev = byName.get(name);
|
||||
if (!prev) {
|
||||
byName.set(name, { name, role });
|
||||
} else if (!prev.role && role) {
|
||||
byName.set(name, { ...prev, role });
|
||||
byName.set(name, { name, role, workflow });
|
||||
} else {
|
||||
byName.set(name, {
|
||||
...prev,
|
||||
role: prev.role || role,
|
||||
workflow: prev.workflow || workflow,
|
||||
});
|
||||
}
|
||||
}
|
||||
const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
|
|
|||
|
|
@ -294,6 +294,9 @@ export const TEAM_GET_PROJECT_BRANCH = 'team:getProjectBranch';
|
|||
/** Add a new member to an existing team */
|
||||
export const TEAM_ADD_MEMBER = 'team:addMember';
|
||||
|
||||
/** Replace all team members (bulk edit) */
|
||||
export const TEAM_REPLACE_MEMBERS = 'team:replaceMembers';
|
||||
|
||||
/** Soft-delete a team member */
|
||||
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import {
|
|||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REPLACE_MEMBERS,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
|
|
@ -741,6 +742,12 @@ const electronAPI: ElectronAPI = {
|
|||
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
|
||||
},
|
||||
replaceMembers: async (
|
||||
teamName: string,
|
||||
request: import('@shared/types').ReplaceMembersRequest
|
||||
) => {
|
||||
return invokeIpcWithResult<void>(TEAM_REPLACE_MEMBERS, teamName, request);
|
||||
},
|
||||
removeMember: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -772,6 +772,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
addMember: async (): Promise<void> => {
|
||||
throw new Error('Team member management is not available in browser mode');
|
||||
},
|
||||
replaceMembers: async (): Promise<void> => {
|
||||
throw new Error('Team member management is not available in browser mode');
|
||||
},
|
||||
removeMember: async (): Promise<void> => {
|
||||
throw new Error('Team member management is not available in browser mode');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1379,6 +1379,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
currentName={data.config.name}
|
||||
currentDescription={data.config.description ?? ''}
|
||||
currentColor={data.config.color ?? ''}
|
||||
currentMembers={data.members}
|
||||
projectPath={data.config.projectPath}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
onSaved={() => void selectTeam(teamName)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraft,
|
||||
MembersEditorSection,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
|
|
@ -15,23 +21,19 @@ import {
|
|||
import { Input } from '@renderer/components/ui/input';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
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 { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||
import { MembersJsonEditor } from './MembersJsonEditor';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { TeamModelSelector } from './TeamModelSelector';
|
||||
|
||||
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
'blue',
|
||||
|
|
@ -93,56 +95,12 @@ const DEV_DEFAULT_TEAM = {
|
|||
description: 'Dev test team for provisioning flow',
|
||||
} as const;
|
||||
|
||||
interface MemberDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
roleSelection: string;
|
||||
customRole: string;
|
||||
}
|
||||
|
||||
const DEV_DEFAULT_MEMBERS: Pick<MemberDraft, 'name' | 'roleSelection'>[] = [
|
||||
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [
|
||||
{ name: 'alice', roleSelection: 'reviewer' },
|
||||
{ name: 'bob', roleSelection: 'developer' },
|
||||
{ name: 'carol', roleSelection: 'developer' },
|
||||
];
|
||||
|
||||
function newDraftId(): string {
|
||||
// eslint-disable-next-line sonarjs/pseudo-random -- Used for generating unique UI keys, not security
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||
return {
|
||||
id: initial?.id ?? newDraftId(),
|
||||
name: initial?.name ?? '',
|
||||
roleSelection: initial?.roleSelection ?? '',
|
||||
customRole: initial?.customRole ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
|
||||
return members
|
||||
.map((member) => {
|
||||
const name = member.name.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
member.roleSelection === CUSTOM_ROLE
|
||||
? member.customRole.trim() || undefined
|
||||
: member.roleSelection === NO_ROLE
|
||||
? undefined
|
||||
: member.roleSelection.trim() || undefined;
|
||||
|
||||
return {
|
||||
name,
|
||||
role,
|
||||
};
|
||||
})
|
||||
.filter((member): member is NonNullable<typeof member> => member !== null);
|
||||
}
|
||||
|
||||
/** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
|
||||
function sanitizeTeamName(name: string): string {
|
||||
let result = name
|
||||
|
|
@ -155,12 +113,6 @@ function sanitizeTeamName(name: string): string {
|
|||
return result;
|
||||
}
|
||||
|
||||
function isValidMemberName(name: string): boolean {
|
||||
if (name.length < 1 || name.length > 128) return false;
|
||||
if (!/^[a-zA-Z0-9]/.test(name)) return false;
|
||||
return /^[a-zA-Z0-9._-]+$/.test(name);
|
||||
}
|
||||
|
||||
function validateTeamNameInline(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
|
|
@ -174,15 +126,6 @@ function validateTeamNameInline(name: string): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function validateMemberNameInline(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!isValidMemberName(trimmed)) {
|
||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateRequest(
|
||||
request: TeamCreateRequest,
|
||||
options?: { requireCwd?: boolean }
|
||||
|
|
@ -229,7 +172,7 @@ function validateRequest(
|
|||
},
|
||||
};
|
||||
}
|
||||
if (request.members.some((member) => !isValidMemberName(member.name.trim()))) {
|
||||
if (request.members.some((member) => validateMemberNameInline(member.name.trim()) !== null)) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: {
|
||||
|
|
@ -285,15 +228,13 @@ export const CreateTeamDialog = ({
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [launchTeam, setLaunchTeam] = useState(true);
|
||||
const [teamColor, setTeamColor] = useState('');
|
||||
const [selectedModel, setSelectedModelRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSelectedModel') ?? ''
|
||||
);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||
const stored = localStorage.getItem('team:lastSelectedModel') ?? '';
|
||||
return stored === '__default__' ? '' : stored;
|
||||
});
|
||||
const [extendedContext, setExtendedContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||
);
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
const setSelectedModel = (value: string): void => {
|
||||
setSelectedModelRaw(value);
|
||||
|
|
@ -324,9 +265,6 @@ export const CreateTeamDialog = ({
|
|||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setLaunchTeam(true);
|
||||
setJsonEditorOpen(false);
|
||||
setJsonText('');
|
||||
setJsonError(null);
|
||||
resetUIState();
|
||||
};
|
||||
|
||||
|
|
@ -349,29 +287,29 @@ export const CreateTeamDialog = ({
|
|||
setPrepareMessage('Warming up CLI environment...');
|
||||
setPrepareWarnings([]);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
|
||||
if (cancelled) {
|
||||
return;
|
||||
// Defer so file list fetch (triggered by project select) can run first
|
||||
const timer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
|
||||
if (cancelled) return;
|
||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
||||
setPrepareMessage(prepResult.message);
|
||||
setPrepareWarnings(prepResult.warnings ?? []);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage(
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
||||
);
|
||||
}
|
||||
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
||||
setPrepareMessage(prepResult.message);
|
||||
setPrepareWarnings(prepResult.warnings ?? []);
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setPrepareState('failed');
|
||||
setPrepareWarnings([]);
|
||||
setPrepareMessage(
|
||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
||||
);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, canCreate, launchTeam]);
|
||||
|
||||
|
|
@ -427,6 +365,7 @@ export const CreateTeamDialog = ({
|
|||
name: m.name,
|
||||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||
customRole: isCustom ? m.role : '',
|
||||
workflow: m.workflow,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
@ -483,59 +422,7 @@ export const CreateTeamDialog = ({
|
|||
|
||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||
|
||||
const membersToJsonText = (drafts: MemberDraft[]): string => {
|
||||
const arr = drafts
|
||||
.filter((d) => d.name.trim())
|
||||
.map((d) => {
|
||||
const role =
|
||||
d.roleSelection === CUSTOM_ROLE
|
||||
? d.customRole.trim() || undefined
|
||||
: d.roleSelection === NO_ROLE
|
||||
? undefined
|
||||
: d.roleSelection.trim() || undefined;
|
||||
return role ? { name: d.name.trim(), role } : { name: d.name.trim() };
|
||||
});
|
||||
return JSON.stringify(arr, null, 2);
|
||||
};
|
||||
|
||||
const handleJsonChange = (text: string): void => {
|
||||
setJsonText(text);
|
||||
try {
|
||||
const arr: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(arr)) {
|
||||
setJsonError('Root must be an array');
|
||||
return;
|
||||
}
|
||||
const drafts: MemberDraft[] = (arr as Record<string, unknown>[]).map((item) => {
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
||||
const presetRoles: readonly string[] = PRESET_ROLES;
|
||||
const isPreset = presetRoles.includes(role);
|
||||
return createMemberDraft({
|
||||
name,
|
||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
});
|
||||
});
|
||||
setMembers(drafts);
|
||||
setJsonError(null);
|
||||
} catch (e) {
|
||||
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleJsonEditor = (): void => {
|
||||
if (!jsonEditorOpen) {
|
||||
setJsonText(membersToJsonText(members));
|
||||
setJsonError(null);
|
||||
}
|
||||
setJsonEditorOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!jsonEditorOpen || jsonError !== null) return;
|
||||
setJsonText(membersToJsonText(members));
|
||||
}, [members, jsonEditorOpen, jsonError]);
|
||||
useFileListCacheWarmer(effectiveCwd || null);
|
||||
|
||||
const description = descriptionDraft.value;
|
||||
const prompt = promptDraft.value;
|
||||
|
|
@ -559,7 +446,7 @@ export const CreateTeamDialog = ({
|
|||
);
|
||||
|
||||
const effectiveModel = useMemo(() => {
|
||||
const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
||||
const base = selectedModel || undefined;
|
||||
if (!extendedContext) return base;
|
||||
// 1M context is only supported for opus and sonnet
|
||||
if (base === 'haiku') return base;
|
||||
|
|
@ -573,7 +460,7 @@ export const CreateTeamDialog = ({
|
|||
teamName: sanitizedTeamName,
|
||||
description: description.trim() || undefined,
|
||||
color: teamColor || undefined,
|
||||
members: buildMembers(members),
|
||||
members: buildMembersFromDrafts(members),
|
||||
cwd: effectiveCwd,
|
||||
prompt: prompt.trim() || undefined,
|
||||
model: effectiveModel,
|
||||
|
|
@ -591,39 +478,6 @@ export const CreateTeamDialog = ({
|
|||
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
|
||||
}, [activeTeams, effectiveCwd]);
|
||||
|
||||
const updateMemberName = (memberId: string, name: string): void => {
|
||||
setMembers((prev) =>
|
||||
prev.map((candidate) => (candidate.id === memberId ? { ...candidate, name } : candidate))
|
||||
);
|
||||
};
|
||||
|
||||
const updateMemberRole = (memberId: string, roleSelection: string): void => {
|
||||
const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection;
|
||||
setMembers((prev) =>
|
||||
prev.map((candidate) =>
|
||||
candidate.id === memberId
|
||||
? {
|
||||
...candidate,
|
||||
roleSelection: resolvedRole,
|
||||
customRole: resolvedRole === CUSTOM_ROLE ? candidate.customRole : '',
|
||||
}
|
||||
: candidate
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const updateMemberCustomRole = (memberId: string, customRole: string): void => {
|
||||
setMembers((prev) =>
|
||||
prev.map((candidate) =>
|
||||
candidate.id === memberId ? { ...candidate, customRole } : candidate
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const removeMember = (memberId: string): void => {
|
||||
setMembers((prev) => prev.filter((candidate) => candidate.id !== memberId));
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (existingTeamNames.includes(sanitizedTeamName)) {
|
||||
setFieldErrors({ teamName: 'Team name already exists' });
|
||||
|
|
@ -774,116 +628,17 @@ export const CreateTeamDialog = ({
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Members</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMembers((prev) => [...prev, createMemberDraft()]);
|
||||
}}
|
||||
>
|
||||
Add member
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
|
||||
{jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{members.map((member, index) => {
|
||||
const memberColorSet = getTeamColorSet(getMemberColor(index));
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="grid grid-cols-1 gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2 md:grid-cols-[1fr_220px_auto]"
|
||||
style={{
|
||||
borderLeftWidth: '3px',
|
||||
borderLeftColor: memberColorSet.border,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={member.name}
|
||||
aria-label={`Member ${index + 1} name`}
|
||||
onChange={(event) => updateMemberName(member.id, event.target.value)}
|
||||
placeholder="member-name"
|
||||
style={
|
||||
member.name.trim()
|
||||
? {
|
||||
color: memberColorSet.text,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{validateMemberNameInline(member.name) ? (
|
||||
<p className="text-[10px] text-red-300">
|
||||
{validateMemberNameInline(member.name)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Select
|
||||
value={member.roleSelection || NO_ROLE}
|
||||
onValueChange={(roleSelection) =>
|
||||
updateMemberRole(member.id, roleSelection)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 text-xs"
|
||||
aria-label={`Member ${index + 1} role`}
|
||||
>
|
||||
<SelectValue placeholder="No role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
||||
{PRESET_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{member.roleSelection === CUSTOM_ROLE ? (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={member.customRole}
|
||||
aria-label={`Member ${index + 1} custom role`}
|
||||
onChange={(event) =>
|
||||
updateMemberCustomRole(member.id, event.target.value)
|
||||
}
|
||||
placeholder="e.g. architect"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
onClick={() => removeMember(member.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{jsonEditorOpen ? (
|
||||
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
|
||||
) : null}
|
||||
</div>
|
||||
{(() => {
|
||||
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||
const hasDuplicates = new Set(names).size !== names.length;
|
||||
if (hasDuplicates)
|
||||
return <p className="text-[11px] text-red-300">Member names must be unique</p>;
|
||||
if (fieldErrors.members)
|
||||
return <p className="text-[11px] text-red-300">{fieldErrors.members}</p>;
|
||||
return null;
|
||||
})()}
|
||||
<div className="md:col-span-2">
|
||||
<MembersEditorSection
|
||||
members={members}
|
||||
onChange={setMembers}
|
||||
fieldError={fieldErrors.members}
|
||||
validateMemberName={validateMemberNameInline}
|
||||
showWorkflow
|
||||
showJsonEditor
|
||||
draftKeyPrefix="createTeam"
|
||||
projectPath={effectiveCwd || null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4 md:col-span-2">
|
||||
|
|
@ -938,20 +693,11 @@ export const CreateTeamDialog = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[180px] text-xs">
|
||||
<SelectValue placeholder="Default (account setting)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<TeamModelSelector
|
||||
value={selectedModel}
|
||||
onValueChange={setSelectedModel}
|
||||
id="create-model"
|
||||
/>
|
||||
<ExtendedContextCheckbox
|
||||
id="create-extended-context"
|
||||
checked={extendedContext}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import {
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraft,
|
||||
MembersEditorSection,
|
||||
validateMemberNameInline,
|
||||
} from '@renderer/components/team/members/MembersEditorSection';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -11,9 +17,13 @@ import {
|
|||
DialogTitle,
|
||||
} from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
const TEAM_COLOR_NAMES = [
|
||||
'blue',
|
||||
'green',
|
||||
|
|
@ -31,39 +41,67 @@ interface EditTeamDialogProps {
|
|||
currentName: string;
|
||||
currentDescription: string;
|
||||
currentColor: string;
|
||||
currentMembers: ResolvedTeamMember[];
|
||||
projectPath?: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
function membersToDrafts(members: ResolvedTeamMember[]) {
|
||||
const active = members.filter((m) => !m.removedAt);
|
||||
return active.map((m) => {
|
||||
const presetRoles: readonly string[] = PRESET_ROLES;
|
||||
const isPreset = m.role != null && presetRoles.includes(m.role);
|
||||
const isCustom = m.role != null && m.role.length > 0 && !isPreset;
|
||||
return createMemberDraft({
|
||||
name: m.name,
|
||||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||
customRole: isCustom ? m.role : '',
|
||||
workflow: m.workflow,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const EditTeamDialog = ({
|
||||
open,
|
||||
teamName,
|
||||
currentName,
|
||||
currentDescription,
|
||||
currentColor,
|
||||
currentMembers,
|
||||
projectPath,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditTeamDialogProps): React.JSX.Element => {
|
||||
const [name, setName] = useState(currentName);
|
||||
const [description, setDescription] = useState(currentDescription);
|
||||
const [color, setColor] = useState(currentColor);
|
||||
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useFileListCacheWarmer(projectPath ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(currentName);
|
||||
setDescription(currentDescription);
|
||||
setColor(currentColor);
|
||||
setMembers(membersToDrafts(currentMembers));
|
||||
setError(null);
|
||||
}
|
||||
}, [open, currentName, currentDescription, currentColor]);
|
||||
}, [open, currentName, currentDescription, currentColor, currentMembers]);
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (!name.trim()) {
|
||||
setError('Team name cannot be empty');
|
||||
return;
|
||||
}
|
||||
const builtMembers = buildMembersFromDrafts(members);
|
||||
if (builtMembers.length === 0) {
|
||||
setError('At least one member is required');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
|
|
@ -73,6 +111,7 @@ export const EditTeamDialog = ({
|
|||
description: description.trim(),
|
||||
color,
|
||||
});
|
||||
await api.teams.replaceMembers(teamName, { members: builtMembers });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
|
|
@ -85,7 +124,7 @@ export const EditTeamDialog = ({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="max-w-2xl sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Team</DialogTitle>
|
||||
<DialogDescription>Change team name, description and color</DialogDescription>
|
||||
|
|
@ -127,6 +166,17 @@ export const EditTeamDialog = ({
|
|||
placeholder="Team description (optional)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<MembersEditorSection
|
||||
members={members}
|
||||
onChange={setMembers}
|
||||
validateMemberName={validateMemberNameInline}
|
||||
showWorkflow
|
||||
showJsonEditor
|
||||
draftKeyPrefix={`editTeam:${teamName}`}
|
||||
projectPath={projectPath ?? null}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
|
||||
<label className="label-optional mb-1 block text-xs font-medium">
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import {
|
|||
import { Label } from '@renderer/components/ui/label';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -23,6 +22,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { TeamModelSelector } from './TeamModelSelector';
|
||||
|
||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -66,9 +66,10 @@ export const LaunchTeamDialog = ({
|
|||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(
|
||||
() => localStorage.getItem('team:lastSelectedModel') ?? ''
|
||||
);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||
const stored = localStorage.getItem('team:lastSelectedModel') ?? '';
|
||||
return stored === '__default__' ? '' : stored;
|
||||
});
|
||||
const [extendedContext, setExtendedContextRaw] = useState(
|
||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||
);
|
||||
|
|
@ -375,31 +376,11 @@ export const LaunchTeamDialog = ({
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{[
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
selectedModel === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => setSelectedModel(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TeamModelSelector
|
||||
value={selectedModel}
|
||||
onValueChange={setSelectedModel}
|
||||
id="launch-model"
|
||||
/>
|
||||
<ExtendedContextCheckbox
|
||||
id="launch-extended-context"
|
||||
checked={extendedContext}
|
||||
|
|
|
|||
47
src/renderer/components/team/dialogs/TeamModelSelector.tsx
Normal file
47
src/renderer/components/team/dialogs/TeamModelSelector.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
{ value: '', label: 'Default (account setting)' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
] as const;
|
||||
|
||||
export interface TeamModelSelectorProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
id,
|
||||
}) => (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label htmlFor={id} className="label-optional shrink-0">
|
||||
Model (optional)
|
||||
</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{MODEL_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value || '__default__'}
|
||||
type="button"
|
||||
id={opt.value === value ? id : undefined}
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
value === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => onValueChange(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
228
src/renderer/components/team/members/MemberDraftRow.tsx
Normal file
228
src/renderer/components/team/members/MemberDraftRow.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Input } from '@renderer/components/ui/input';
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@renderer/components/ui/select';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||
import { reconcileChips, removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
||||
interface MemberDraftRowProps {
|
||||
member: MemberDraft;
|
||||
index: number;
|
||||
nameError: string | null;
|
||||
onNameChange: (id: string, name: string) => void;
|
||||
onRoleChange: (id: string, roleSelection: string) => void;
|
||||
onCustomRoleChange: (id: string, customRole: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
showWorkflow?: boolean;
|
||||
onWorkflowChange?: (id: string, workflow: string) => void;
|
||||
onWorkflowChipsChange?: (
|
||||
id: string,
|
||||
chips: import('@renderer/types/inlineChip').InlineChip[]
|
||||
) => void;
|
||||
draftKeyPrefix?: string;
|
||||
projectPath?: string | null;
|
||||
mentionSuggestions?: MentionSuggestion[];
|
||||
}
|
||||
|
||||
export const MemberDraftRow = ({
|
||||
member,
|
||||
index,
|
||||
nameError,
|
||||
onNameChange,
|
||||
onRoleChange,
|
||||
onCustomRoleChange,
|
||||
onRemove,
|
||||
showWorkflow = false,
|
||||
onWorkflowChange,
|
||||
onWorkflowChipsChange,
|
||||
draftKeyPrefix,
|
||||
projectPath,
|
||||
mentionSuggestions = [],
|
||||
}: MemberDraftRowProps): React.JSX.Element => {
|
||||
const memberColorSet = getTeamColorSet(getMemberColor(index));
|
||||
const [workflowExpanded, setWorkflowExpanded] = useState(false);
|
||||
|
||||
const draftKey =
|
||||
draftKeyPrefix && (member.name.trim() || member.id)
|
||||
? `${draftKeyPrefix}:workflow:${member.name.trim() || member.id}`
|
||||
: null;
|
||||
|
||||
const workflowDraft = useDraftPersistence({
|
||||
key: draftKey ?? `workflow:${member.id}`,
|
||||
initialValue: member.workflow?.trim() ? member.workflow : undefined,
|
||||
enabled: !!draftKey,
|
||||
});
|
||||
|
||||
const chips = member.workflowChips ?? [];
|
||||
|
||||
const handleWorkflowChange = useCallback(
|
||||
(v: string) => {
|
||||
const reconciled = reconcileChips(chips, v);
|
||||
if (reconciled.length !== chips.length) {
|
||||
onWorkflowChipsChange?.(member.id, reconciled);
|
||||
}
|
||||
workflowDraft.setValue(v);
|
||||
onWorkflowChange?.(member.id, v);
|
||||
},
|
||||
[member.id, chips, onWorkflowChange, onWorkflowChipsChange, workflowDraft]
|
||||
);
|
||||
|
||||
const handleFileChipInsert = useCallback(
|
||||
(chip: import('@renderer/types/inlineChip').InlineChip) => {
|
||||
onWorkflowChipsChange?.(member.id, [...chips, chip]);
|
||||
},
|
||||
[member.id, chips, onWorkflowChipsChange]
|
||||
);
|
||||
|
||||
const handleChipRemove = useCallback(
|
||||
(chipId: string) => {
|
||||
const chip = chips.find((c) => c.id === chipId);
|
||||
if (!chip) return;
|
||||
const newChips = chips.filter((c) => c.id !== chipId);
|
||||
const newValue = removeChipTokenFromText(workflowDraft.value, chip);
|
||||
onWorkflowChipsChange?.(member.id, newChips);
|
||||
workflowDraft.setValue(newValue);
|
||||
onWorkflowChange?.(member.id, newValue);
|
||||
},
|
||||
[chips, member.id, onWorkflowChange, onWorkflowChipsChange, workflowDraft]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
onWorkflowChange &&
|
||||
workflowDraft.value &&
|
||||
workflowDraft.value !== (member.workflow ?? '')
|
||||
) {
|
||||
onWorkflowChange(member.id, workflowDraft.value);
|
||||
}
|
||||
}, [workflowDraft.value, member.id, member.workflow, onWorkflowChange]);
|
||||
|
||||
const suggestionsExcludingSelf = mentionSuggestions.filter(
|
||||
(s) => s.name.toLowerCase() !== member.name.trim().toLowerCase()
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2 md:grid-cols-[1fr_220px_auto]"
|
||||
style={{
|
||||
borderLeftWidth: '3px',
|
||||
borderLeftColor: memberColorSet.border,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={member.name}
|
||||
aria-label={`Member ${index + 1} name`}
|
||||
onChange={(event) => onNameChange(member.id, event.target.value)}
|
||||
placeholder="member-name"
|
||||
style={
|
||||
member.name.trim()
|
||||
? {
|
||||
color: memberColorSet.text,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{nameError ? <p className="text-[10px] text-red-300">{nameError}</p> : null}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Select
|
||||
value={member.roleSelection || NO_ROLE}
|
||||
onValueChange={(roleSelection) => onRoleChange(member.id, roleSelection)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs" aria-label={`Member ${index + 1} role`}>
|
||||
<SelectValue placeholder="No role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
||||
{PRESET_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{member.roleSelection === CUSTOM_ROLE ? (
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={member.customRole}
|
||||
aria-label={`Member ${index + 1} custom role`}
|
||||
onChange={(event) => onCustomRoleChange(member.id, event.target.value)}
|
||||
placeholder="e.g. architect"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
{showWorkflow && onWorkflowChange ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1"
|
||||
onClick={() => setWorkflowExpanded((prev) => !prev)}
|
||||
>
|
||||
{workflowExpanded ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
Workflow
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
onClick={() => onRemove(member.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
{showWorkflow && onWorkflowChange && workflowExpanded ? (
|
||||
<div className="space-y-0.5 md:col-span-3">
|
||||
<label
|
||||
htmlFor={`member-${member.id}-workflow`}
|
||||
className="block text-[10px] font-medium text-[var(--color-text-muted)]"
|
||||
>
|
||||
Workflow (optional)
|
||||
</label>
|
||||
<MentionableTextarea
|
||||
id={`member-${member.id}-workflow`}
|
||||
className="min-h-[80px] text-xs"
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
value={workflowDraft.value}
|
||||
onValueChange={handleWorkflowChange}
|
||||
suggestions={suggestionsExcludingSelf}
|
||||
chips={chips}
|
||||
onChipRemove={handleChipRemove}
|
||||
projectPath={projectPath ?? undefined}
|
||||
onFileChipInsert={handleFileChipInsert}
|
||||
placeholder="How this agent should behave, interact with others. Use @ to mention teammates or add files."
|
||||
footerRight={
|
||||
workflowDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
223
src/renderer/components/team/members/MembersEditorSection.tsx
Normal file
223
src/renderer/components/team/members/MembersEditorSection.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
|
||||
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
||||
|
||||
import { MemberDraftRow } from './MemberDraftRow';
|
||||
import {
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraft,
|
||||
getWorkflowForExport,
|
||||
} from './membersEditorUtils';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
||||
function membersToJsonText(drafts: MemberDraft[]): string {
|
||||
const arr = drafts
|
||||
.filter((d) => d.name.trim())
|
||||
.map((d) => {
|
||||
const role =
|
||||
d.roleSelection === CUSTOM_ROLE
|
||||
? d.customRole.trim() || undefined
|
||||
: d.roleSelection === NO_ROLE
|
||||
? undefined
|
||||
: d.roleSelection.trim() || undefined;
|
||||
const obj: Record<string, string> = { name: d.name.trim() };
|
||||
if (role) obj.role = role;
|
||||
const workflow = getWorkflowForExport(d);
|
||||
if (workflow) obj.workflow = workflow;
|
||||
return obj;
|
||||
});
|
||||
return JSON.stringify(arr, null, 2);
|
||||
}
|
||||
|
||||
function parseJsonToDrafts(text: string): MemberDraft[] {
|
||||
const arr: unknown = JSON.parse(text);
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return (arr as Record<string, unknown>[]).map((item) => {
|
||||
const name = typeof item.name === 'string' ? item.name : '';
|
||||
const role = typeof item.role === 'string' ? item.role.trim() : '';
|
||||
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
|
||||
const presetRoles: readonly string[] = PRESET_ROLES;
|
||||
const isPreset = presetRoles.includes(role);
|
||||
return createMemberDraft({
|
||||
name,
|
||||
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
|
||||
customRole: role && !isPreset ? role : '',
|
||||
workflow: workflow || undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface MembersEditorSectionProps {
|
||||
members: MemberDraft[];
|
||||
onChange: (members: MemberDraft[]) => void;
|
||||
fieldError?: string;
|
||||
validateMemberName?: (name: string) => string | null;
|
||||
showWorkflow?: boolean;
|
||||
showJsonEditor?: boolean;
|
||||
/** Prefix for draft persistence keys (e.g. 'createTeam' or 'editTeam:team-alpha') */
|
||||
draftKeyPrefix?: string;
|
||||
/** Project path for @file mentions in workflow */
|
||||
projectPath?: string | null;
|
||||
}
|
||||
|
||||
export const MembersEditorSection = ({
|
||||
members,
|
||||
onChange,
|
||||
fieldError,
|
||||
validateMemberName,
|
||||
showWorkflow = false,
|
||||
showJsonEditor = true,
|
||||
draftKeyPrefix,
|
||||
projectPath,
|
||||
}: MembersEditorSectionProps): React.JSX.Element => {
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
const toggleJsonEditor = (): void => {
|
||||
if (!jsonEditorOpen) {
|
||||
setJsonText(membersToJsonText(members));
|
||||
setJsonError(null);
|
||||
}
|
||||
setJsonEditorOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!jsonEditorOpen || jsonError !== null) return;
|
||||
setJsonText(membersToJsonText(members));
|
||||
}, [members, jsonEditorOpen, jsonError]);
|
||||
|
||||
const handleJsonChange = (text: string): void => {
|
||||
setJsonText(text);
|
||||
try {
|
||||
const drafts = parseJsonToDrafts(text);
|
||||
onChange(drafts);
|
||||
setJsonError(null);
|
||||
} catch (e) {
|
||||
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberName = (memberId: string, name: string): void => {
|
||||
onChange(members.map((c) => (c.id === memberId ? { ...c, name } : c)));
|
||||
};
|
||||
|
||||
const updateMemberRole = (memberId: string, roleSelection: string): void => {
|
||||
const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection;
|
||||
onChange(
|
||||
members.map((c) =>
|
||||
c.id === memberId
|
||||
? {
|
||||
...c,
|
||||
roleSelection: resolvedRole,
|
||||
customRole: resolvedRole === CUSTOM_ROLE ? c.customRole : '',
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const updateMemberCustomRole = (memberId: string, customRole: string): void => {
|
||||
onChange(members.map((c) => (c.id === memberId ? { ...c, customRole } : c)));
|
||||
};
|
||||
|
||||
const updateMemberWorkflow = (memberId: string, workflow: string): void => {
|
||||
onChange(members.map((c) => (c.id === memberId ? { ...c, workflow } : c)));
|
||||
};
|
||||
|
||||
const updateMemberWorkflowChips = (
|
||||
memberId: string,
|
||||
workflowChips: import('@renderer/types/inlineChip').InlineChip[]
|
||||
): void => {
|
||||
onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c)));
|
||||
};
|
||||
|
||||
const removeMember = (memberId: string): void => {
|
||||
onChange(members.filter((c) => c.id !== memberId));
|
||||
};
|
||||
|
||||
const addMember = (): void => {
|
||||
onChange([...members, createMemberDraft()]);
|
||||
};
|
||||
|
||||
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
||||
const hasDuplicates = new Set(names).size !== names.length;
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members
|
||||
.filter((m) => m.name.trim())
|
||||
.map((m, i) => ({
|
||||
id: m.id,
|
||||
name: m.name.trim(),
|
||||
subtitle:
|
||||
m.roleSelection === CUSTOM_ROLE
|
||||
? m.customRole.trim() || undefined
|
||||
: m.roleSelection && m.roleSelection !== NO_ROLE
|
||||
? m.roleSelection
|
||||
: undefined,
|
||||
color: getMemberColor(i),
|
||||
})),
|
||||
[members]
|
||||
);
|
||||
|
||||
return (
|
||||
<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'}
|
||||
</Button>
|
||||
) : 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>
|
||||
);
|
||||
};
|
||||
|
||||
export type { MemberDraft } from './membersEditorTypes';
|
||||
export {
|
||||
buildMembersFromDrafts,
|
||||
createMemberDraft,
|
||||
validateMemberNameInline,
|
||||
} from './membersEditorUtils';
|
||||
14
src/renderer/components/team/members/membersEditorTypes.ts
Normal file
14
src/renderer/components/team/members/membersEditorTypes.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
||||
export interface MemberDraft {
|
||||
id: string;
|
||||
name: string;
|
||||
roleSelection: string;
|
||||
customRole: string;
|
||||
workflow?: string;
|
||||
workflowChips?: InlineChip[];
|
||||
}
|
||||
|
||||
export interface MembersEditorValue {
|
||||
members: MemberDraft[];
|
||||
}
|
||||
66
src/renderer/components/team/members/membersEditorUtils.ts
Normal file
66
src/renderer/components/team/members/membersEditorUtils.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { TeamProvisioningMemberInput } from '@shared/types';
|
||||
|
||||
function isValidMemberName(name: string): boolean {
|
||||
if (name.length < 1 || name.length > 128) return false;
|
||||
if (!/^[a-zA-Z0-9]/.test(name)) return false;
|
||||
return /^[a-zA-Z0-9._-]+$/.test(name);
|
||||
}
|
||||
|
||||
export function validateMemberNameInline(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!isValidMemberName(trimmed)) {
|
||||
return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function newDraftId(): string {
|
||||
// eslint-disable-next-line sonarjs/pseudo-random -- Used for generating unique UI keys, not security
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
||||
return {
|
||||
id: initial?.id ?? newDraftId(),
|
||||
name: initial?.name ?? '',
|
||||
roleSelection: initial?.roleSelection ?? '',
|
||||
customRole: initial?.customRole ?? '',
|
||||
workflow: initial?.workflow,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolves workflow for export (JSON or API): serializes chips when present. */
|
||||
export function getWorkflowForExport(member: MemberDraft): string | undefined {
|
||||
const workflowRaw = member.workflow?.trim();
|
||||
if (!workflowRaw) return undefined;
|
||||
const chips = member.workflowChips ?? [];
|
||||
return chips.length > 0 ? serializeChipsWithText(workflowRaw, chips) : workflowRaw;
|
||||
}
|
||||
|
||||
export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioningMemberInput[] {
|
||||
return members
|
||||
.map((member) => {
|
||||
const name = member.name.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
member.roleSelection === CUSTOM_ROLE
|
||||
? member.customRole.trim() || undefined
|
||||
: member.roleSelection === NO_ROLE
|
||||
? undefined
|
||||
: member.roleSelection.trim() || undefined;
|
||||
|
||||
const result: TeamProvisioningMemberInput = { name, role };
|
||||
const workflow = getWorkflowForExport(member);
|
||||
if (workflow) result.workflow = workflow;
|
||||
return result;
|
||||
})
|
||||
.filter((member): member is NonNullable<typeof member> => member !== null);
|
||||
}
|
||||
38
src/renderer/hooks/useFileListCacheWarmer.ts
Normal file
38
src/renderer/hooks/useFileListCacheWarmer.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Pre-warms the Quick Open file list cache when a project path is available.
|
||||
* Use in dialogs (CreateTeam, EditTeam) so that @file mentions work immediately
|
||||
* when the user expands the workflow field, without waiting for the first fetch.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { getQuickOpenCache, setQuickOpenCache } from '@renderer/utils/quickOpenCache';
|
||||
|
||||
/**
|
||||
* Triggers a file list fetch when projectPath is set and cache is empty.
|
||||
* Safe to call from any component; no-op in browser mode (project API unavailable).
|
||||
*/
|
||||
export function useFileListCacheWarmer(projectPath: string | null): void {
|
||||
useEffect(() => {
|
||||
if (!projectPath?.trim()) return;
|
||||
|
||||
const cached = getQuickOpenCache(projectPath);
|
||||
if (cached) return;
|
||||
|
||||
let cancelled = false;
|
||||
api.project
|
||||
.listFiles(projectPath)
|
||||
.then((files) => {
|
||||
if (cancelled) return;
|
||||
setQuickOpenCache(projectPath, files);
|
||||
})
|
||||
.catch(() => {
|
||||
// Project path may be invalid or API unavailable (browser mode)
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectPath]);
|
||||
}
|
||||
|
|
@ -27,9 +27,7 @@ interface UseMentionDetectionResult {
|
|||
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
|
||||
/** Current @-trigger character position in text (-1 if no active trigger) */
|
||||
triggerIndex: number;
|
||||
/** Getter for trigger index — use at call time to avoid stale closure */
|
||||
/** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */
|
||||
getTriggerIndex: () => number;
|
||||
}
|
||||
|
||||
|
|
@ -325,8 +323,6 @@ export function useMentionDetection({
|
|||
handleKeyDown,
|
||||
handleChange,
|
||||
handleSelect,
|
||||
// eslint-disable-next-line react-hooks/refs -- expose current trigger position to caller
|
||||
triggerIndex: triggerIndexRef.current,
|
||||
getTriggerIndex,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import type {
|
|||
LeadActivityState,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
ReplaceMembersRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
|
|
@ -440,6 +441,7 @@ export interface TeamsAPI {
|
|||
getAllTasks: () => Promise<GlobalTask[]>;
|
||||
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||
replaceMembers: (teamName: string, request: ReplaceMembersRequest) => Promise<void>;
|
||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
updateMemberRole: (
|
||||
teamName: string,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ export interface TeamMember {
|
|||
agentId?: string;
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||
workflow?: string;
|
||||
color?: string;
|
||||
joinedAt?: number;
|
||||
cwd?: string;
|
||||
|
|
@ -191,6 +193,7 @@ export interface ResolvedTeamMember {
|
|||
color?: string;
|
||||
agentType?: string;
|
||||
role?: string;
|
||||
workflow?: string;
|
||||
cwd?: string;
|
||||
/** Set only when member's git branch differs from the lead's branch. */
|
||||
gitBranch?: string;
|
||||
|
|
@ -267,6 +270,8 @@ export type TeamProvisioningState =
|
|||
export interface TeamProvisioningMemberInput {
|
||||
name: string;
|
||||
role?: string;
|
||||
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||
workflow?: string;
|
||||
}
|
||||
|
||||
export interface TeamCreateRequest {
|
||||
|
|
@ -395,6 +400,10 @@ export interface UpdateMemberRoleRequest {
|
|||
role: string | undefined;
|
||||
}
|
||||
|
||||
export interface ReplaceMembersRequest {
|
||||
members: TeamProvisioningMemberInput[];
|
||||
}
|
||||
|
||||
/** Data sent from renderer to main for native OS team message notification. */
|
||||
export interface TeamMessageNotificationData {
|
||||
teamDisplayName: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue