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:
iliya 2026-03-03 00:56:58 +02:00
parent 7206b231f0
commit b08a4d3764
23 changed files with 1060 additions and 370 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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>
);
};

View 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';

View 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[];
}

View 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);
}

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

View file

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

View file

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

View file

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