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.
|
// Content changes: debounced (500ms) to coalesce rapid saves/builds.
|
||||||
if (event.type === 'create' || event.type === 'delete') {
|
if (event.type === 'create' || event.type === 'delete') {
|
||||||
gitStatusService.invalidateCache();
|
gitStatusService.invalidateCache();
|
||||||
|
if (activeProjectRoot) {
|
||||||
|
fileSearchService.invalidateListFilesCache(activeProjectRoot);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
gitStatusService.invalidateCacheDebounced();
|
gitStatusService.invalidateCacheDebounced();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
|
TEAM_REPLACE_MEMBERS,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
|
|
@ -207,6 +208,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks);
|
||||||
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
|
ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment);
|
||||||
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
||||||
|
ipcMain.handle(TEAM_REPLACE_MEMBERS, handleReplaceMembers);
|
||||||
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
||||||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
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_GET_ALL_TASKS);
|
||||||
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
|
ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT);
|
||||||
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
||||||
|
ipcMain.removeHandler(TEAM_REPLACE_MEMBERS);
|
||||||
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||||
|
|
@ -546,7 +549,15 @@ async function validateProvisioningRequest(
|
||||||
if (role !== undefined && typeof role !== 'string') {
|
if (role !== undefined && typeof role !== 'string') {
|
||||||
return { valid: false, error: 'member role must be 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) {
|
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
|
||||||
|
|
@ -1342,7 +1353,15 @@ async function handleCreateConfig(
|
||||||
if (role !== undefined && typeof role !== 'string') {
|
if (role !== undefined && typeof role !== 'string') {
|
||||||
return { success: false, error: 'member role must be 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', () =>
|
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(
|
async function handleRemoveMember(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import { isBinaryFile } from 'isbinaryfile';
|
import { isBinaryFile } from 'isbinaryfile';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { simpleGit } from 'simple-git';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
SearchFileResult,
|
SearchFileResult,
|
||||||
|
|
@ -27,6 +28,7 @@ const MAX_FILE_SIZE = 1024 * 1024; // 1 MB
|
||||||
const DEFAULT_MAX_RESULT_FILES = 100;
|
const DEFAULT_MAX_RESULT_FILES = 100;
|
||||||
const DEFAULT_MAX_MATCHES = 500;
|
const DEFAULT_MAX_MATCHES = 500;
|
||||||
const SEARCH_TIMEOUT_MS = 5000;
|
const SEARCH_TIMEOUT_MS = 5000;
|
||||||
|
const LIST_FILES_CACHE_TTL_MS = 5 * 60_000; // 5 minutes
|
||||||
|
|
||||||
const IGNORED_DIRS = new Set([
|
const IGNORED_DIRS = new Set([
|
||||||
'.git',
|
'.git',
|
||||||
|
|
@ -52,6 +54,26 @@ const log = createLogger('FileSearchService');
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export class 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).
|
* List all files in the project recursively (for Quick Open).
|
||||||
* Lightweight — no content reading, no binary checks, no stat.
|
* Lightweight — no content reading, no binary checks, no stat.
|
||||||
|
|
@ -61,9 +83,117 @@ export class FileSearchService {
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ path: string; name: string; relativePath: string }[]> {
|
): Promise<{ path: string; name: string; relativePath: string }[]> {
|
||||||
const files: { path: string; name: string; relativePath: string }[] = [];
|
if (signal?.aborted) return [];
|
||||||
await this.collectFilePaths(projectRoot, projectRoot, files, signal);
|
|
||||||
return files;
|
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 };
|
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> {
|
async removeMember(teamName: string, memberName: string): Promise<void> {
|
||||||
const members = await this.membersMetaStore.getMembers(teamName);
|
const members = await this.membersMetaStore.getMembers(teamName);
|
||||||
const member = members.find((m) => m.name === memberName);
|
const member = members.find((m) => m.name === memberName);
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export class TeamMemberResolver {
|
||||||
|
|
||||||
const configMemberMap = new Map<
|
const configMemberMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ agentType?: string; role?: string; color?: string; cwd?: string }
|
{ agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string }
|
||||||
>();
|
>();
|
||||||
if (Array.isArray(config.members)) {
|
if (Array.isArray(config.members)) {
|
||||||
for (const m of config.members) {
|
for (const m of config.members) {
|
||||||
|
|
@ -48,6 +48,7 @@ export class TeamMemberResolver {
|
||||||
configMemberMap.set(m.name.trim(), {
|
configMemberMap.set(m.name.trim(), {
|
||||||
agentType: m.agentType,
|
agentType: m.agentType,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
|
workflow: m.workflow,
|
||||||
color: m.color,
|
color: m.color,
|
||||||
cwd: m.cwd,
|
cwd: m.cwd,
|
||||||
});
|
});
|
||||||
|
|
@ -57,7 +58,7 @@ export class TeamMemberResolver {
|
||||||
|
|
||||||
const metaMemberMap = new Map<
|
const metaMemberMap = new Map<
|
||||||
string,
|
string,
|
||||||
{ agentType?: string; role?: string; color?: string; removedAt?: number }
|
{ agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number }
|
||||||
>();
|
>();
|
||||||
if (Array.isArray(metaMembers)) {
|
if (Array.isArray(metaMembers)) {
|
||||||
for (const member of metaMembers) {
|
for (const member of metaMembers) {
|
||||||
|
|
@ -65,6 +66,7 @@ export class TeamMemberResolver {
|
||||||
metaMemberMap.set(member.name.trim(), {
|
metaMemberMap.set(member.name.trim(), {
|
||||||
agentType: member.agentType,
|
agentType: member.agentType,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
|
workflow: member.workflow,
|
||||||
color: member.color,
|
color: member.color,
|
||||||
removedAt: member.removedAt,
|
removedAt: member.removedAt,
|
||||||
});
|
});
|
||||||
|
|
@ -94,6 +96,7 @@ export class TeamMemberResolver {
|
||||||
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
|
||||||
agentType: configMember?.agentType ?? metaMember?.agentType,
|
agentType: configMember?.agentType ?? metaMember?.agentType,
|
||||||
role: configMember?.role ?? metaMember?.role,
|
role: configMember?.role ?? metaMember?.role,
|
||||||
|
workflow: configMember?.workflow ?? metaMember?.workflow,
|
||||||
cwd: configMember?.cwd,
|
cwd: configMember?.cwd,
|
||||||
removedAt: metaMember?.removedAt,
|
removedAt: metaMember?.removedAt,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ function normalizeMember(member: TeamMember): TeamMember | null {
|
||||||
return {
|
return {
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined,
|
||||||
|
workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||||
agentType:
|
agentType:
|
||||||
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined,
|
||||||
color: typeof member.color === 'string' ? member.color.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}`;
|
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 {
|
function buildMembersPrompt(members: TeamCreateRequest['members']): string {
|
||||||
return members
|
return members
|
||||||
.map((member) => {
|
.map((member) => {
|
||||||
const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : '';
|
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');
|
.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 {
|
function buildTaskStatusProtocol(teamName: string): string {
|
||||||
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task:
|
||||||
1. Use this command to mark task started:
|
1. Use this command to mark task started:
|
||||||
|
|
@ -546,20 +586,18 @@ Steps (execute in this exact order):
|
||||||
1) TeamCreate — create team "${request.teamName}":
|
1) TeamCreate — create team "${request.teamName}":
|
||||||
- description: "${description}"
|
- description: "${description}"
|
||||||
|
|
||||||
2) Spawn each member as a live teammate using the Task tool:
|
2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown:
|
||||||
- team_name: "${request.teamName}"
|
|
||||||
- name: the member's name
|
${request.members
|
||||||
- subagent_type: "general-purpose"
|
.map(
|
||||||
|
(m) => ` For "${m.name}":
|
||||||
- prompt:
|
- prompt:
|
||||||
You are {name}, a {role} on team "${displayName}" (${request.teamName}).
|
${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration)
|
||||||
${languageInstruction}
|
.split('\n')
|
||||||
Introduce yourself briefly (name and role) and confirm you are ready.
|
.map((line) => ` ${line}`)
|
||||||
Then wait for task assignments.
|
.join('\n')}`
|
||||||
Include the following agent-only instructions verbatim in the prompt:
|
)
|
||||||
|
.join('\n\n')}
|
||||||
${taskProtocol}
|
|
||||||
|
|
||||||
${processRegistration}
|
|
||||||
|
|
||||||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
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.
|
- Prefer fewer, broader tasks over many micro-tasks.
|
||||||
|
|
@ -612,10 +650,14 @@ function buildLaunchPrompt(
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const taskBlock = memberTaskBlocks.get(m.name) || '';
|
const taskBlock = memberTaskBlocks.get(m.name) || '';
|
||||||
const hasTasks = Boolean(taskBlock);
|
const hasTasks = Boolean(taskBlock);
|
||||||
|
const workflowBlock = m.workflow?.trim()
|
||||||
|
? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(m.workflow, ' ')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
return ` For "${m.name}":
|
return ` For "${m.name}":
|
||||||
- prompt:
|
- 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}
|
${languageInstruction}
|
||||||
The team has been reconnected after a restart.
|
The team has been reconnected after a restart.
|
||||||
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
|
${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) => ({
|
teammateMembers.map((member, index) => ({
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
|
workflow: member.workflow?.trim() || undefined,
|
||||||
agentType: 'general-purpose',
|
agentType: 'general-purpose',
|
||||||
color: getMemberColor(index),
|
color: getMemberColor(index),
|
||||||
joinedAt,
|
joinedAt,
|
||||||
|
|
@ -3299,11 +3342,17 @@ export class TeamProvisioningService {
|
||||||
const name = member.name?.trim();
|
const name = member.name?.trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined;
|
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);
|
const prev = byName.get(name);
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
byName.set(name, { name, role });
|
byName.set(name, { name, role, workflow });
|
||||||
} else if (!prev.role && role) {
|
} else {
|
||||||
byName.set(name, { ...prev, role });
|
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));
|
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 */
|
/** Add a new member to an existing team */
|
||||||
export const TEAM_ADD_MEMBER = 'team:addMember';
|
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 */
|
/** Soft-delete a team member */
|
||||||
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
|
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ import {
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
|
TEAM_REPLACE_MEMBERS,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
|
|
@ -741,6 +742,12 @@ const electronAPI: ElectronAPI = {
|
||||||
addMember: async (teamName: string, request: AddMemberRequest) => {
|
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
|
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) => {
|
removeMember: async (teamName: string, memberName: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
|
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -772,6 +772,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
addMember: async (): Promise<void> => {
|
addMember: async (): Promise<void> => {
|
||||||
throw new Error('Team member management is not available in browser mode');
|
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> => {
|
removeMember: async (): Promise<void> => {
|
||||||
throw new Error('Team member management is not available in browser mode');
|
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}
|
currentName={data.config.name}
|
||||||
currentDescription={data.config.description ?? ''}
|
currentDescription={data.config.description ?? ''}
|
||||||
currentColor={data.config.color ?? ''}
|
currentColor={data.config.color ?? ''}
|
||||||
|
currentMembers={data.members}
|
||||||
|
projectPath={data.config.projectPath}
|
||||||
onClose={() => setEditDialogOpen(false)}
|
onClose={() => setEditDialogOpen(false)}
|
||||||
onSaved={() => void selectTeam(teamName)}
|
onSaved={() => void selectTeam(teamName)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
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 { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
|
|
@ -15,23 +21,19 @@ import {
|
||||||
import { Input } from '@renderer/components/ui/input';
|
import { Input } from '@renderer/components/ui/input';
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
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 { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||||
import { MembersJsonEditor } from './MembersJsonEditor';
|
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { TeamModelSelector } from './TeamModelSelector';
|
||||||
|
|
||||||
|
import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes';
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
'blue',
|
'blue',
|
||||||
|
|
@ -93,56 +95,12 @@ const DEV_DEFAULT_TEAM = {
|
||||||
description: 'Dev test team for provisioning flow',
|
description: 'Dev test team for provisioning flow',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface MemberDraft {
|
const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
roleSelection: string;
|
|
||||||
customRole: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEV_DEFAULT_MEMBERS: Pick<MemberDraft, 'name' | 'roleSelection'>[] = [
|
|
||||||
{ name: 'alice', roleSelection: 'reviewer' },
|
{ name: 'alice', roleSelection: 'reviewer' },
|
||||||
{ name: 'bob', roleSelection: 'developer' },
|
{ name: 'bob', roleSelection: 'developer' },
|
||||||
{ name: 'carol', 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. */
|
/** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */
|
||||||
function sanitizeTeamName(name: string): string {
|
function sanitizeTeamName(name: string): string {
|
||||||
let result = name
|
let result = name
|
||||||
|
|
@ -155,12 +113,6 @@ function sanitizeTeamName(name: string): string {
|
||||||
return result;
|
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 {
|
function validateTeamNameInline(name: string): string | null {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
@ -174,15 +126,6 @@ function validateTeamNameInline(name: string): string | null {
|
||||||
return 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(
|
function validateRequest(
|
||||||
request: TeamCreateRequest,
|
request: TeamCreateRequest,
|
||||||
options?: { requireCwd?: boolean }
|
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 {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
errors: {
|
errors: {
|
||||||
|
|
@ -285,15 +228,13 @@ export const CreateTeamDialog = ({
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [launchTeam, setLaunchTeam] = useState(true);
|
const [launchTeam, setLaunchTeam] = useState(true);
|
||||||
const [teamColor, setTeamColor] = useState('');
|
const [teamColor, setTeamColor] = useState('');
|
||||||
const [selectedModel, setSelectedModelRaw] = useState(
|
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||||
() => localStorage.getItem('team:lastSelectedModel') ?? ''
|
const stored = localStorage.getItem('team:lastSelectedModel') ?? '';
|
||||||
);
|
return stored === '__default__' ? '' : stored;
|
||||||
|
});
|
||||||
const [extendedContext, setExtendedContextRaw] = useState(
|
const [extendedContext, setExtendedContextRaw] = useState(
|
||||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
() => 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 => {
|
const setSelectedModel = (value: string): void => {
|
||||||
setSelectedModelRaw(value);
|
setSelectedModelRaw(value);
|
||||||
|
|
@ -324,9 +265,6 @@ export const CreateTeamDialog = ({
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
setLaunchTeam(true);
|
setLaunchTeam(true);
|
||||||
setJsonEditorOpen(false);
|
|
||||||
setJsonText('');
|
|
||||||
setJsonError(null);
|
|
||||||
resetUIState();
|
resetUIState();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -349,29 +287,29 @@ export const CreateTeamDialog = ({
|
||||||
setPrepareMessage('Warming up CLI environment...');
|
setPrepareMessage('Warming up CLI environment...');
|
||||||
setPrepareWarnings([]);
|
setPrepareWarnings([]);
|
||||||
|
|
||||||
void (async () => {
|
// Defer so file list fetch (triggered by project select) can run first
|
||||||
try {
|
const timer = setTimeout(() => {
|
||||||
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
|
void (async () => {
|
||||||
if (cancelled) {
|
try {
|
||||||
return;
|
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);
|
}, 250);
|
||||||
setPrepareWarnings(prepResult.warnings ?? []);
|
|
||||||
} catch (error) {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPrepareState('failed');
|
|
||||||
setPrepareWarnings([]);
|
|
||||||
setPrepareMessage(
|
|
||||||
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [open, canCreate, launchTeam]);
|
}, [open, canCreate, launchTeam]);
|
||||||
|
|
||||||
|
|
@ -427,6 +365,7 @@ export const CreateTeamDialog = ({
|
||||||
name: m.name,
|
name: m.name,
|
||||||
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
||||||
customRole: isCustom ? 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 effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
||||||
|
|
||||||
const membersToJsonText = (drafts: MemberDraft[]): string => {
|
useFileListCacheWarmer(effectiveCwd || null);
|
||||||
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]);
|
|
||||||
|
|
||||||
const description = descriptionDraft.value;
|
const description = descriptionDraft.value;
|
||||||
const prompt = promptDraft.value;
|
const prompt = promptDraft.value;
|
||||||
|
|
@ -559,7 +446,7 @@ export const CreateTeamDialog = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveModel = useMemo(() => {
|
const effectiveModel = useMemo(() => {
|
||||||
const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
const base = selectedModel || undefined;
|
||||||
if (!extendedContext) return base;
|
if (!extendedContext) return base;
|
||||||
// 1M context is only supported for opus and sonnet
|
// 1M context is only supported for opus and sonnet
|
||||||
if (base === 'haiku') return base;
|
if (base === 'haiku') return base;
|
||||||
|
|
@ -573,7 +460,7 @@ export const CreateTeamDialog = ({
|
||||||
teamName: sanitizedTeamName,
|
teamName: sanitizedTeamName,
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
color: teamColor || undefined,
|
color: teamColor || undefined,
|
||||||
members: buildMembers(members),
|
members: buildMembersFromDrafts(members),
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: prompt.trim() || undefined,
|
prompt: prompt.trim() || undefined,
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
|
|
@ -591,39 +478,6 @@ export const CreateTeamDialog = ({
|
||||||
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
|
return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null;
|
||||||
}, [activeTeams, effectiveCwd]);
|
}, [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 => {
|
const handleSubmit = (): void => {
|
||||||
if (existingTeamNames.includes(sanitizedTeamName)) {
|
if (existingTeamNames.includes(sanitizedTeamName)) {
|
||||||
setFieldErrors({ teamName: 'Team name already exists' });
|
setFieldErrors({ teamName: 'Team name already exists' });
|
||||||
|
|
@ -774,116 +628,17 @@ export const CreateTeamDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<MembersEditorSection
|
||||||
<Label>Members</Label>
|
members={members}
|
||||||
<div className="flex gap-2">
|
onChange={setMembers}
|
||||||
<Button
|
fieldError={fieldErrors.members}
|
||||||
variant="outline"
|
validateMemberName={validateMemberNameInline}
|
||||||
size="sm"
|
showWorkflow
|
||||||
onClick={() => {
|
showJsonEditor
|
||||||
setMembers((prev) => [...prev, createMemberDraft()]);
|
draftKeyPrefix="createTeam"
|
||||||
}}
|
projectPath={effectiveCwd || null}
|
||||||
>
|
/>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-4 md:col-span-2">
|
<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>
|
<div>
|
||||||
<div className="flex items-center gap-2.5">
|
<TeamModelSelector
|
||||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
value={selectedModel}
|
||||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
onValueChange={setSelectedModel}
|
||||||
<SelectTrigger className="h-8 w-auto min-w-[180px] text-xs">
|
id="create-model"
|
||||||
<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>
|
|
||||||
<ExtendedContextCheckbox
|
<ExtendedContextCheckbox
|
||||||
id="create-extended-context"
|
id="create-extended-context"
|
||||||
checked={extendedContext}
|
checked={extendedContext}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import {
|
||||||
|
buildMembersFromDrafts,
|
||||||
|
createMemberDraft,
|
||||||
|
MembersEditorSection,
|
||||||
|
validateMemberNameInline,
|
||||||
|
} from '@renderer/components/team/members/MembersEditorSection';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -11,9 +17,13 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
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 { cn } from '@renderer/lib/utils';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
const TEAM_COLOR_NAMES = [
|
const TEAM_COLOR_NAMES = [
|
||||||
'blue',
|
'blue',
|
||||||
'green',
|
'green',
|
||||||
|
|
@ -31,39 +41,67 @@ interface EditTeamDialogProps {
|
||||||
currentName: string;
|
currentName: string;
|
||||||
currentDescription: string;
|
currentDescription: string;
|
||||||
currentColor: string;
|
currentColor: string;
|
||||||
|
currentMembers: ResolvedTeamMember[];
|
||||||
|
projectPath?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => 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 = ({
|
export const EditTeamDialog = ({
|
||||||
open,
|
open,
|
||||||
teamName,
|
teamName,
|
||||||
currentName,
|
currentName,
|
||||||
currentDescription,
|
currentDescription,
|
||||||
currentColor,
|
currentColor,
|
||||||
|
currentMembers,
|
||||||
|
projectPath,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: EditTeamDialogProps): React.JSX.Element => {
|
}: EditTeamDialogProps): React.JSX.Element => {
|
||||||
const [name, setName] = useState(currentName);
|
const [name, setName] = useState(currentName);
|
||||||
const [description, setDescription] = useState(currentDescription);
|
const [description, setDescription] = useState(currentDescription);
|
||||||
const [color, setColor] = useState(currentColor);
|
const [color, setColor] = useState(currentColor);
|
||||||
|
const [members, setMembers] = useState(() => membersToDrafts(currentMembers));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useFileListCacheWarmer(projectPath ?? null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setName(currentName);
|
setName(currentName);
|
||||||
setDescription(currentDescription);
|
setDescription(currentDescription);
|
||||||
setColor(currentColor);
|
setColor(currentColor);
|
||||||
|
setMembers(membersToDrafts(currentMembers));
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
}, [open, currentName, currentDescription, currentColor]);
|
}, [open, currentName, currentDescription, currentColor, currentMembers]);
|
||||||
|
|
||||||
const handleSave = (): void => {
|
const handleSave = (): void => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Team name cannot be empty');
|
setError('Team name cannot be empty');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const builtMembers = buildMembersFromDrafts(members);
|
||||||
|
if (builtMembers.length === 0) {
|
||||||
|
setError('At least one member is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -73,6 +111,7 @@ export const EditTeamDialog = ({
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
color,
|
color,
|
||||||
});
|
});
|
||||||
|
await api.teams.replaceMembers(teamName, { members: builtMembers });
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -85,7 +124,7 @@ export const EditTeamDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="max-w-2xl sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Team</DialogTitle>
|
<DialogTitle>Edit Team</DialogTitle>
|
||||||
<DialogDescription>Change team name, description and color</DialogDescription>
|
<DialogDescription>Change team name, description and color</DialogDescription>
|
||||||
|
|
@ -127,6 +166,17 @@ export const EditTeamDialog = ({
|
||||||
placeholder="Team description (optional)"
|
placeholder="Team description (optional)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<MembersEditorSection
|
||||||
|
members={members}
|
||||||
|
onChange={setMembers}
|
||||||
|
validateMemberName={validateMemberNameInline}
|
||||||
|
showWorkflow
|
||||||
|
showJsonEditor
|
||||||
|
draftKeyPrefix={`editTeam:${teamName}`}
|
||||||
|
projectPath={projectPath ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
|
{/* 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">
|
<label className="label-optional mb-1 block text-xs font-medium">
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import {
|
||||||
import { Label } from '@renderer/components/ui/label';
|
import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
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 { AlertTriangle, CheckCircle2, Loader2, RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
import { TeamModelSelector } from './TeamModelSelector';
|
||||||
|
|
||||||
import type { ActiveTeamRef } from './CreateTeamDialog';
|
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
|
|
@ -66,9 +66,10 @@ export const LaunchTeamDialog = ({
|
||||||
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [selectedModel, setSelectedModelRaw] = useState(
|
const [selectedModel, setSelectedModelRaw] = useState(() => {
|
||||||
() => localStorage.getItem('team:lastSelectedModel') ?? ''
|
const stored = localStorage.getItem('team:lastSelectedModel') ?? '';
|
||||||
);
|
return stored === '__default__' ? '' : stored;
|
||||||
|
});
|
||||||
const [extendedContext, setExtendedContextRaw] = useState(
|
const [extendedContext, setExtendedContextRaw] = useState(
|
||||||
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
||||||
);
|
);
|
||||||
|
|
@ -375,31 +376,11 @@ export const LaunchTeamDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2.5">
|
<TeamModelSelector
|
||||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
value={selectedModel}
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
onValueChange={setSelectedModel}
|
||||||
{[
|
id="launch-model"
|
||||||
{ 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>
|
|
||||||
<ExtendedContextCheckbox
|
<ExtendedContextCheckbox
|
||||||
id="launch-extended-context"
|
id="launch-extended-context"
|
||||||
checked={extendedContext}
|
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;
|
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
|
handleSelect: (e: React.SyntheticEvent<HTMLTextAreaElement>) => void;
|
||||||
/** Current @-trigger character position in text (-1 if no active trigger) */
|
/** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */
|
||||||
triggerIndex: number;
|
|
||||||
/** Getter for trigger index — use at call time to avoid stale closure */
|
|
||||||
getTriggerIndex: () => number;
|
getTriggerIndex: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,8 +323,6 @@ export function useMentionDetection({
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleChange,
|
handleChange,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
// eslint-disable-next-line react-hooks/refs -- expose current trigger position to caller
|
|
||||||
triggerIndex: triggerIndexRef.current,
|
|
||||||
getTriggerIndex,
|
getTriggerIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ import type {
|
||||||
LeadActivityState,
|
LeadActivityState,
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
|
ReplaceMembersRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
@ -440,6 +441,7 @@ export interface TeamsAPI {
|
||||||
getAllTasks: () => Promise<GlobalTask[]>;
|
getAllTasks: () => Promise<GlobalTask[]>;
|
||||||
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
||||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
|
replaceMembers: (teamName: string, request: ReplaceMembersRequest) => Promise<void>;
|
||||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
updateMemberRole: (
|
updateMemberRole: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ export interface TeamMember {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
agentType?: string;
|
agentType?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||||
|
workflow?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
joinedAt?: number;
|
joinedAt?: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
|
@ -191,6 +193,7 @@ export interface ResolvedTeamMember {
|
||||||
color?: string;
|
color?: string;
|
||||||
agentType?: string;
|
agentType?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
/** Set only when member's git branch differs from the lead's branch. */
|
/** Set only when member's git branch differs from the lead's branch. */
|
||||||
gitBranch?: string;
|
gitBranch?: string;
|
||||||
|
|
@ -267,6 +270,8 @@ export type TeamProvisioningState =
|
||||||
export interface TeamProvisioningMemberInput {
|
export interface TeamProvisioningMemberInput {
|
||||||
name: string;
|
name: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
|
/** Per-agent workflow/instructions injected into spawn prompt. */
|
||||||
|
workflow?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamCreateRequest {
|
export interface TeamCreateRequest {
|
||||||
|
|
@ -395,6 +400,10 @@ export interface UpdateMemberRoleRequest {
|
||||||
role: string | undefined;
|
role: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReplaceMembersRequest {
|
||||||
|
members: TeamProvisioningMemberInput[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Data sent from renderer to main for native OS team message notification. */
|
/** Data sent from renderer to main for native OS team message notification. */
|
||||||
export interface TeamMessageNotificationData {
|
export interface TeamMessageNotificationData {
|
||||||
teamDisplayName: string;
|
teamDisplayName: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue