From b08a4d376403f8d5cdc99f043a6bc1a2c4c3669a Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 3 Mar 2026 00:56:58 +0200 Subject: [PATCH] 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. --- src/main/ipc/editor.ts | 3 + src/main/ipc/teams.ts | 67 +++- src/main/services/editor/FileSearchService.ts | 136 ++++++- src/main/services/team/TeamDataService.ts | 26 ++ src/main/services/team/TeamMemberResolver.ts | 7 +- .../services/team/TeamMembersMetaStore.ts | 1 + .../services/team/TeamProvisioningService.ts | 85 +++- src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 7 + src/renderer/api/httpClient.ts | 3 + .../components/team/TeamDetailView.tsx | 2 + .../team/dialogs/CreateTeamDialog.tsx | 364 +++--------------- .../team/dialogs/EditTeamDialog.tsx | 54 ++- .../team/dialogs/LaunchTeamDialog.tsx | 39 +- .../team/dialogs/TeamModelSelector.tsx | 47 +++ .../team/members/MemberDraftRow.tsx | 228 +++++++++++ .../team/members/MembersEditorSection.tsx | 223 +++++++++++ .../team/members/membersEditorTypes.ts | 14 + .../team/members/membersEditorUtils.ts | 66 ++++ src/renderer/hooks/useFileListCacheWarmer.ts | 38 ++ src/renderer/hooks/useMentionDetection.ts | 6 +- src/shared/types/api.ts | 2 + src/shared/types/team.ts | 9 + 23 files changed, 1060 insertions(+), 370 deletions(-) create mode 100644 src/renderer/components/team/dialogs/TeamModelSelector.tsx create mode 100644 src/renderer/components/team/members/MemberDraftRow.tsx create mode 100644 src/renderer/components/team/members/MembersEditorSection.tsx create mode 100644 src/renderer/components/team/members/membersEditorTypes.ts create mode 100644 src/renderer/components/team/members/membersEditorUtils.ts create mode 100644 src/renderer/hooks/useFileListCacheWarmer.ts diff --git a/src/main/ipc/editor.ts b/src/main/ipc/editor.ts index 9ae47eab..120df2ae 100644 --- a/src/main/ipc/editor.ts +++ b/src/main/ipc/editor.ts @@ -359,6 +359,9 @@ async function handleEditorWatchDir( // Content changes: debounced (500ms) to coalesce rapid saves/builds. if (event.type === 'create' || event.type === 'delete') { gitStatusService.invalidateCache(); + if (activeProjectRoot) { + fileSearchService.invalidateListFilesCache(activeProjectRoot); + } } else { gitStatusService.invalidateCacheDebounced(); } diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f8e0e41e..7de99ccb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -31,6 +31,7 @@ import { TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, TEAM_REMOVE_TASK_RELATIONSHIP, + TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, @@ -207,6 +208,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_ALL_TASKS, handleGetAllTasks); ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember); + ipcMain.handle(TEAM_REPLACE_MEMBERS, handleReplaceMembers); ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember); ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole); ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch); @@ -255,6 +257,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_ALL_TASKS); ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_MEMBER); + ipcMain.removeHandler(TEAM_REPLACE_MEMBERS); ipcMain.removeHandler(TEAM_REMOVE_MEMBER); ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE); ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH); @@ -546,7 +549,15 @@ async function validateProvisioningRequest( if (role !== undefined && typeof role !== 'string') { return { valid: false, error: 'member role must be string' }; } - members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined }); + const workflow = (member as { workflow?: unknown }).workflow; + if (workflow !== undefined && typeof workflow !== 'string') { + return { valid: false, error: 'member workflow must be string' }; + } + members.push({ + name: memberName, + role: typeof role === 'string' ? role.trim() : undefined, + workflow: typeof workflow === 'string' ? workflow.trim() : undefined, + }); } if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) { @@ -1342,7 +1353,15 @@ async function handleCreateConfig( if (role !== undefined && typeof role !== 'string') { return { success: false, error: 'member role must be string' }; } - members.push({ name: memberName, role: typeof role === 'string' ? role.trim() : undefined }); + const workflow = (member as { workflow?: unknown }).workflow; + if (workflow !== undefined && typeof workflow !== 'string') { + return { success: false, error: 'member workflow must be string' }; + } + members.push({ + name: memberName, + role: typeof role === 'string' ? role.trim() : undefined, + workflow: typeof workflow === 'string' ? workflow.trim() : undefined, + }); } return wrapTeamHandler('createConfig', () => @@ -1544,6 +1563,50 @@ async function handleAddMember( }); } +async function handleReplaceMembers( + _event: IpcMainInvokeEvent, + teamName: unknown, + request: unknown +): Promise> { + 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(); + const members: { name: string; role?: string; workflow?: string }[] = []; + for (const item of payload.members) { + if (!item || typeof item !== 'object') { + return { success: false, error: 'member must be object' }; + } + const m = item as { name?: unknown; role?: unknown; workflow?: unknown }; + const vName = validateMemberName(m.name); + if (!vName.valid) return { success: false, error: vName.error ?? 'Invalid member name' }; + const name = vName.value!; + if (seenNames.has(name)) return { success: false, error: 'member names must be unique' }; + seenNames.add(name); + if (m.role !== undefined && typeof m.role !== 'string') { + return { success: false, error: 'member role must be string' }; + } + if (m.workflow !== undefined && typeof m.workflow !== 'string') { + return { success: false, error: 'member workflow must be string' }; + } + members.push({ + name, + role: typeof m.role === 'string' ? m.role.trim() : undefined, + workflow: typeof m.workflow === 'string' ? m.workflow.trim() : undefined, + }); + } + + return wrapTeamHandler('replaceMembers', async () => { + await getTeamDataService().replaceMembers(vTeam.value!, { members }); + }); +} + async function handleRemoveMember( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/services/editor/FileSearchService.ts b/src/main/services/editor/FileSearchService.ts index eb85bfe3..52ce0927 100644 --- a/src/main/services/editor/FileSearchService.ts +++ b/src/main/services/editor/FileSearchService.ts @@ -10,6 +10,7 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; import { isBinaryFile } from 'isbinaryfile'; import * as path from 'path'; +import { simpleGit } from 'simple-git'; import type { SearchFileResult, @@ -27,6 +28,7 @@ const MAX_FILE_SIZE = 1024 * 1024; // 1 MB const DEFAULT_MAX_RESULT_FILES = 100; const DEFAULT_MAX_MATCHES = 500; const SEARCH_TIMEOUT_MS = 5000; +const LIST_FILES_CACHE_TTL_MS = 5 * 60_000; // 5 minutes const IGNORED_DIRS = new Set([ '.git', @@ -52,6 +54,26 @@ const log = createLogger('FileSearchService'); // ============================================================================= export class FileSearchService { + // Cache for listFiles() — avoids repeated full project walks for @file mentions / Quick Open. + private listFilesCache = new Map< + string, + { files: { path: string; name: string; relativePath: string }[]; timestamp: number } + >(); + private listFilesInFlight = new Map< + string, + Promise<{ path: string; name: string; relativePath: string }[]> + >(); + + invalidateListFilesCache(projectRoot?: string): void { + if (projectRoot) { + this.listFilesCache.delete(projectRoot); + this.listFilesInFlight.delete(projectRoot); + return; + } + this.listFilesCache.clear(); + this.listFilesInFlight.clear(); + } + /** * List all files in the project recursively (for Quick Open). * Lightweight — no content reading, no binary checks, no stat. @@ -61,9 +83,117 @@ export class FileSearchService { projectRoot: string, signal?: AbortSignal ): Promise<{ path: string; name: string; relativePath: string }[]> { - const files: { path: string; name: string; relativePath: string }[] = []; - await this.collectFilePaths(projectRoot, projectRoot, files, signal); - return files; + if (signal?.aborted) return []; + + const cached = this.listFilesCache.get(projectRoot); + if (cached && Date.now() - cached.timestamp < LIST_FILES_CACHE_TTL_MS) { + log.info(`[perf] listFiles: cache hit (${cached.files.length} files)`); + return cached.files; + } + + const inFlight = this.listFilesInFlight.get(projectRoot); + if (inFlight) { + log.info('[perf] listFiles: awaiting in-flight scan'); + return inFlight; + } + + const promise = (async () => { + const t0 = performance.now(); + + // Prefer git for performance when available (large repos can take seconds with fs walk). + const gitFiles = await this.tryListFilesWithGit(projectRoot, signal); + const files = + gitFiles ?? + (await (async () => { + const next: { path: string; name: string; relativePath: string }[] = []; + await this.collectFilePaths(projectRoot, projectRoot, next, signal); + return next; + })()); + + const durationMs = performance.now() - t0; + log.info(`[perf] listFiles: ${durationMs.toFixed(1)}ms, files=${files.length}`); + + // Cache the result (even if empty) to avoid repeated work. + this.listFilesCache.set(projectRoot, { files, timestamp: Date.now() }); + return files; + })() + .catch((error) => { + // Do not cache failures; allow retry on next call. + log.warn(`listFiles failed: ${error instanceof Error ? error.message : String(error)}`); + throw error; + }) + .finally(() => { + this.listFilesInFlight.delete(projectRoot); + }); + + this.listFilesInFlight.set(projectRoot, promise); + return promise; + } + + private shouldIgnoreRelativePath(relativePath: string): boolean { + const parts = relativePath.split(/[\\/]/g).filter(Boolean); + for (const part of parts) { + if (IGNORED_DIRS.has(part)) return true; + if (part.startsWith('.')) return true; + } + return false; + } + + private async tryListFilesWithGit( + projectRoot: string, + signal?: AbortSignal + ): Promise<{ path: string; name: string; relativePath: string }[] | null> { + try { + // Fast pre-check to avoid spawning git for non-repos. + await fs.access(path.join(projectRoot, '.git')); + } catch { + return null; + } + + try { + const git = simpleGit({ + baseDir: projectRoot, + timeout: { block: 10_000 }, + }).env('GIT_OPTIONAL_LOCKS', '0'); + + // Include tracked + untracked (excluding ignored) for better UX. + // Use -z for safe parsing of filenames. + const output = await git.raw([ + 'ls-files', + '-z', + '--cached', + '--others', + '--exclude-standard', + ]); + if (signal?.aborted) return []; + + const relPaths = output.split('\0').filter(Boolean); + const files: { path: string; name: string; relativePath: string }[] = []; + + for (const rel of relPaths) { + if (signal?.aborted || files.length >= MAX_FILES) break; + if (!rel) continue; + if (this.shouldIgnoreRelativePath(rel)) continue; + + const fullPath = path.resolve(projectRoot, rel); + if (!isPathWithinRoot(fullPath, projectRoot)) continue; + if (isGitInternalPath(fullPath)) continue; + + files.push({ path: fullPath, name: path.basename(rel), relativePath: rel }); + } + + // Stable ordering for UI (cheap at <= MAX_FILES) + files.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + return files; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('not a git repository')) { + return null; + } + // Unexpected git error — fall back to fs traversal. + log.warn(`git listFiles failed, falling back to fs: ${message}`); + return null; + } } /** diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 4218ab06..5b0372a4 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -605,6 +605,32 @@ export class TeamDataService { return { oldRole, changed: true }; } + async replaceMembers( + teamName: string, + request: { members: { name: string; role?: string; workflow?: string }[] } + ): Promise { + 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 { const members = await this.membersMetaStore.getMembers(teamName); const member = members.find((m) => m.name === memberName); diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index 3ebbd4c6..14f87c22 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -40,7 +40,7 @@ export class TeamMemberResolver { const configMemberMap = new Map< string, - { agentType?: string; role?: string; color?: string; cwd?: string } + { agentType?: string; role?: string; workflow?: string; color?: string; cwd?: string } >(); if (Array.isArray(config.members)) { for (const m of config.members) { @@ -48,6 +48,7 @@ export class TeamMemberResolver { configMemberMap.set(m.name.trim(), { agentType: m.agentType, role: m.role, + workflow: m.workflow, color: m.color, cwd: m.cwd, }); @@ -57,7 +58,7 @@ export class TeamMemberResolver { const metaMemberMap = new Map< string, - { agentType?: string; role?: string; color?: string; removedAt?: number } + { agentType?: string; role?: string; workflow?: string; color?: string; removedAt?: number } >(); if (Array.isArray(metaMembers)) { for (const member of metaMembers) { @@ -65,6 +66,7 @@ export class TeamMemberResolver { metaMemberMap.set(member.name.trim(), { agentType: member.agentType, role: member.role, + workflow: member.workflow, color: member.color, removedAt: member.removedAt, }); @@ -94,6 +96,7 @@ export class TeamMemberResolver { color: latestMessage?.color ?? configMember?.color ?? metaMember?.color, agentType: configMember?.agentType ?? metaMember?.agentType, role: configMember?.role ?? metaMember?.role, + workflow: configMember?.workflow ?? metaMember?.workflow, cwd: configMember?.cwd, removedAt: metaMember?.removedAt, }); diff --git a/src/main/services/team/TeamMembersMetaStore.ts b/src/main/services/team/TeamMembersMetaStore.ts index 2f4f7b8b..14705ba0 100644 --- a/src/main/services/team/TeamMembersMetaStore.ts +++ b/src/main/services/team/TeamMembersMetaStore.ts @@ -21,6 +21,7 @@ function normalizeMember(member: TeamMember): TeamMember | null { return { name: trimmedName, role: typeof member.role === 'string' ? member.role.trim() || undefined : undefined, + workflow: typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, agentType: typeof member.agentType === 'string' ? member.agentType.trim() || undefined : undefined, color: typeof member.color === 'string' ? member.color.trim() || undefined : undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6acba5f9..da806286 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -307,15 +307,55 @@ function wrapInAgentBlock(text: string): string { return `${AGENT_BLOCK_OPEN}\n${trimmed}\n${AGENT_BLOCK_CLOSE}`; } +function indentMultiline(text: string, indent: string): string { + return text + .split(/\r?\n/g) + .map((line) => `${indent}${line}`) + .join('\n'); +} + +function formatWorkflowBlock(workflow: string, indent: string): string { + const trimmed = workflow.trim(); + if (trimmed.length === 0) return ''; + const body = indentMultiline(trimmed, indent); + return `\n${indent}---BEGIN WORKFLOW---\n${body}\n${indent}---END WORKFLOW---`; +} + function buildMembersPrompt(members: TeamCreateRequest['members']): string { return members .map((member) => { const rolePart = member.role?.trim() ? ` (role: ${member.role.trim()})` : ''; - return `- ${member.name}${rolePart}`; + const workflowPart = member.workflow?.trim() + ? `\n Workflow/instructions:${formatWorkflowBlock(member.workflow, ' ')}` + : ''; + return `- ${member.name}${rolePart}${workflowPart}`; }) .join('\n'); } +function buildMemberSpawnPrompt( + member: TeamCreateRequest['members'][number], + displayName: string, + teamName: string, + taskProtocol: string, + processRegistration: string +): string { + const role = member.role?.trim() || 'team member'; + const workflowBlock = member.workflow?.trim() + ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(member.workflow, '')}` + : ''; + return `You are ${member.name}, a ${role} on team "${displayName}" (${teamName}).${workflowBlock} + +${getAgentLanguageInstruction()} +Introduce yourself briefly (name and role) and confirm you are ready. +Then wait for task assignments. +Include the following agent-only instructions verbatim in the prompt: + +${taskProtocol} + +${processRegistration}`; +} + function buildTaskStatusProtocol(teamName: string): string { return wrapInAgentBlock(`MANDATORY TASK STATUS PROTOCOL — you MUST follow this for EVERY task: 1. Use this command to mark task started: @@ -546,20 +586,18 @@ Steps (execute in this exact order): 1) TeamCreate — create team "${request.teamName}": - description: "${description}" -2) Spawn each member as a live teammate using the Task tool: - - team_name: "${request.teamName}" - - name: the member's name - - subagent_type: "general-purpose" +2) Spawn each member as a live teammate using the Task tool. For each member below, use the exact prompt shown: + +${request.members + .map( + (m) => ` For "${m.name}": - prompt: - You are {name}, a {role} on team "${displayName}" (${request.teamName}). - ${languageInstruction} - Introduce yourself briefly (name and role) and confirm you are ready. - Then wait for task assignments. - Include the following agent-only instructions verbatim in the prompt: - -${taskProtocol} - -${processRegistration} +${buildMemberSpawnPrompt(m, displayName, request.teamName, taskProtocol, processRegistration) + .split('\n') + .map((line) => ` ${line}`) + .join('\n')}` + ) + .join('\n\n')} 3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board. - Prefer fewer, broader tasks over many micro-tasks. @@ -612,10 +650,14 @@ function buildLaunchPrompt( .map((m) => { const taskBlock = memberTaskBlocks.get(m.name) || ''; const hasTasks = Boolean(taskBlock); + const workflowBlock = m.workflow?.trim() + ? `\n\nYour workflow and how you should behave:${formatWorkflowBlock(m.workflow, ' ')}` + : ''; return ` For "${m.name}": - prompt: - You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}". + You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".${workflowBlock} + ${languageInstruction} The team has been reconnected after a restart. ${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'} @@ -3270,6 +3312,7 @@ export class TeamProvisioningService { teammateMembers.map((member, index) => ({ name: member.name, role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, agentType: 'general-purpose', color: getMemberColor(index), joinedAt, @@ -3299,11 +3342,17 @@ export class TeamProvisioningService { const name = member.name?.trim(); if (!name) continue; const role = typeof member.role === 'string' ? member.role.trim() || undefined : undefined; + const workflow = + typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined; const prev = byName.get(name); if (!prev) { - byName.set(name, { name, role }); - } else if (!prev.role && role) { - byName.set(name, { ...prev, role }); + byName.set(name, { name, role, workflow }); + } else { + byName.set(name, { + ...prev, + role: prev.role || role, + workflow: prev.workflow || workflow, + }); } } const members = Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index a24a6284..fc26df0b 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -294,6 +294,9 @@ export const TEAM_GET_PROJECT_BRANCH = 'team:getProjectBranch'; /** Add a new member to an existing team */ export const TEAM_ADD_MEMBER = 'team:addMember'; +/** Replace all team members (bulk edit) */ +export const TEAM_REPLACE_MEMBERS = 'team:replaceMembers'; + /** Soft-delete a team member */ export const TEAM_REMOVE_MEMBER = 'team:removeMember'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 91decfa3..72ac0752 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -85,6 +85,7 @@ import { TEAM_PROVISIONING_STATUS, TEAM_REMOVE_MEMBER, TEAM_REMOVE_TASK_RELATIONSHIP, + TEAM_REPLACE_MEMBERS, TEAM_REQUEST_REVIEW, TEAM_RESTORE, TEAM_RESTORE_TASK, @@ -741,6 +742,12 @@ const electronAPI: ElectronAPI = { addMember: async (teamName: string, request: AddMemberRequest) => { return invokeIpcWithResult(TEAM_ADD_MEMBER, teamName, request); }, + replaceMembers: async ( + teamName: string, + request: import('@shared/types').ReplaceMembersRequest + ) => { + return invokeIpcWithResult(TEAM_REPLACE_MEMBERS, teamName, request); + }, removeMember: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_REMOVE_MEMBER, teamName, memberName); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 90628029..e9905e9d 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -772,6 +772,9 @@ export class HttpAPIClient implements ElectronAPI { addMember: async (): Promise => { throw new Error('Team member management is not available in browser mode'); }, + replaceMembers: async (): Promise => { + throw new Error('Team member management is not available in browser mode'); + }, removeMember: async (): Promise => { throw new Error('Team member management is not available in browser mode'); }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 3a226ce1..4e9dc7cf 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1379,6 +1379,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele currentName={data.config.name} currentDescription={data.config.description ?? ''} currentColor={data.config.color ?? ''} + currentMembers={data.members} + projectPath={data.config.projectPath} onClose={() => setEditDialogOpen(false)} onSaved={() => void selectTeam(teamName)} /> diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index a5023b59..d6e4ccdb 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1,6 +1,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; +import { + buildMembersFromDrafts, + createMemberDraft, + MembersEditorSection, + validateMemberNameInline, +} from '@renderer/components/team/members/MembersEditorSection'; import { AutoResizeTextarea } from '@renderer/components/ui/auto-resize-textarea'; import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; @@ -15,23 +21,19 @@ import { import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@renderer/components/ui/select'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; +import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { cn } from '@renderer/lib/utils'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { getMemberColor } from '@shared/constants/memberColors'; import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react'; import { ExtendedContextCheckbox } from './ExtendedContextCheckbox'; -import { MembersJsonEditor } from './MembersJsonEditor'; import { ProjectPathSelector } from './ProjectPathSelector'; +import { TeamModelSelector } from './TeamModelSelector'; + +import type { MemberDraft } from '@renderer/components/team/members/membersEditorTypes'; const TEAM_COLOR_NAMES = [ 'blue', @@ -93,56 +95,12 @@ const DEV_DEFAULT_TEAM = { description: 'Dev test team for provisioning flow', } as const; -interface MemberDraft { - id: string; - name: string; - roleSelection: string; - customRole: string; -} - -const DEV_DEFAULT_MEMBERS: Pick[] = [ +const DEV_DEFAULT_MEMBERS: { name: string; roleSelection: string }[] = [ { name: 'alice', roleSelection: 'reviewer' }, { name: 'bob', roleSelection: 'developer' }, { name: 'carol', roleSelection: 'developer' }, ]; -function newDraftId(): string { - // eslint-disable-next-line sonarjs/pseudo-random -- Used for generating unique UI keys, not security - return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function createMemberDraft(initial?: Partial): MemberDraft { - 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 => member !== null); -} - /** Mirrors Claude CLI's `zuA()` sanitization: non-alphanumeric → `-`, then lowercase. */ function sanitizeTeamName(name: string): string { let result = name @@ -155,12 +113,6 @@ function sanitizeTeamName(name: string): string { return result; } -function isValidMemberName(name: string): boolean { - if (name.length < 1 || name.length > 128) return false; - if (!/^[a-zA-Z0-9]/.test(name)) return false; - return /^[a-zA-Z0-9._-]+$/.test(name); -} - function validateTeamNameInline(name: string): string | null { const trimmed = name.trim(); if (!trimmed) return null; @@ -174,15 +126,6 @@ function validateTeamNameInline(name: string): string | null { return null; } -function validateMemberNameInline(name: string): string | null { - const trimmed = name.trim(); - if (!trimmed) return null; - if (!isValidMemberName(trimmed)) { - return 'Start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars'; - } - return null; -} - function validateRequest( request: TeamCreateRequest, options?: { requireCwd?: boolean } @@ -229,7 +172,7 @@ function validateRequest( }, }; } - if (request.members.some((member) => !isValidMemberName(member.name.trim()))) { + if (request.members.some((member) => validateMemberNameInline(member.name.trim()) !== null)) { return { valid: false, errors: { @@ -285,15 +228,13 @@ export const CreateTeamDialog = ({ const [isSubmitting, setIsSubmitting] = useState(false); const [launchTeam, setLaunchTeam] = useState(true); const [teamColor, setTeamColor] = useState(''); - const [selectedModel, setSelectedModelRaw] = useState( - () => localStorage.getItem('team:lastSelectedModel') ?? '' - ); + const [selectedModel, setSelectedModelRaw] = useState(() => { + const stored = localStorage.getItem('team:lastSelectedModel') ?? ''; + return stored === '__default__' ? '' : stored; + }); const [extendedContext, setExtendedContextRaw] = useState( () => localStorage.getItem('team:lastExtendedContext') === 'true' ); - const [jsonEditorOpen, setJsonEditorOpen] = useState(false); - const [jsonText, setJsonText] = useState(''); - const [jsonError, setJsonError] = useState(null); const setSelectedModel = (value: string): void => { setSelectedModelRaw(value); @@ -324,9 +265,6 @@ export const CreateTeamDialog = ({ setSelectedProjectPath(''); setCustomCwd(''); setLaunchTeam(true); - setJsonEditorOpen(false); - setJsonText(''); - setJsonError(null); resetUIState(); }; @@ -349,29 +287,29 @@ export const CreateTeamDialog = ({ setPrepareMessage('Warming up CLI environment...'); setPrepareWarnings([]); - void (async () => { - try { - const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(); - if (cancelled) { - return; + // Defer so file list fetch (triggered by project select) can run first + const timer = setTimeout(() => { + void (async () => { + try { + const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning(); + if (cancelled) return; + setPrepareState(prepResult.ready ? 'ready' : 'failed'); + setPrepareMessage(prepResult.message); + setPrepareWarnings(prepResult.warnings ?? []); + } catch (error) { + if (cancelled) return; + setPrepareState('failed'); + setPrepareWarnings([]); + setPrepareMessage( + error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' + ); } - setPrepareState(prepResult.ready ? 'ready' : 'failed'); - setPrepareMessage(prepResult.message); - setPrepareWarnings(prepResult.warnings ?? []); - } catch (error) { - if (cancelled) { - return; - } - setPrepareState('failed'); - setPrepareWarnings([]); - setPrepareMessage( - error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment' - ); - } - })(); + })(); + }, 250); return () => { cancelled = true; + clearTimeout(timer); }; }, [open, canCreate, launchTeam]); @@ -427,6 +365,7 @@ export const CreateTeamDialog = ({ name: m.name, roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), customRole: isCustom ? m.role : '', + workflow: m.workflow, }); }) ); @@ -483,59 +422,7 @@ export const CreateTeamDialog = ({ const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); - const membersToJsonText = (drafts: MemberDraft[]): string => { - const arr = drafts - .filter((d) => d.name.trim()) - .map((d) => { - const role = - d.roleSelection === CUSTOM_ROLE - ? d.customRole.trim() || undefined - : d.roleSelection === NO_ROLE - ? undefined - : d.roleSelection.trim() || undefined; - return role ? { name: d.name.trim(), role } : { name: d.name.trim() }; - }); - return JSON.stringify(arr, null, 2); - }; - - const handleJsonChange = (text: string): void => { - setJsonText(text); - try { - const arr: unknown = JSON.parse(text); - if (!Array.isArray(arr)) { - setJsonError('Root must be an array'); - return; - } - const drafts: MemberDraft[] = (arr as Record[]).map((item) => { - const name = typeof item.name === 'string' ? item.name : ''; - const role = typeof item.role === 'string' ? item.role.trim() : ''; - const presetRoles: readonly string[] = PRESET_ROLES; - const isPreset = presetRoles.includes(role); - return createMemberDraft({ - name, - roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '', - customRole: role && !isPreset ? role : '', - }); - }); - setMembers(drafts); - setJsonError(null); - } catch (e) { - setJsonError(e instanceof Error ? e.message : 'Invalid JSON'); - } - }; - - const toggleJsonEditor = (): void => { - if (!jsonEditorOpen) { - setJsonText(membersToJsonText(members)); - setJsonError(null); - } - setJsonEditorOpen((prev) => !prev); - }; - - useEffect(() => { - if (!jsonEditorOpen || jsonError !== null) return; - setJsonText(membersToJsonText(members)); - }, [members, jsonEditorOpen, jsonError]); + useFileListCacheWarmer(effectiveCwd || null); const description = descriptionDraft.value; const prompt = promptDraft.value; @@ -559,7 +446,7 @@ export const CreateTeamDialog = ({ ); const effectiveModel = useMemo(() => { - const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined; + const base = selectedModel || undefined; if (!extendedContext) return base; // 1M context is only supported for opus and sonnet if (base === 'haiku') return base; @@ -573,7 +460,7 @@ export const CreateTeamDialog = ({ teamName: sanitizedTeamName, description: description.trim() || undefined, color: teamColor || undefined, - members: buildMembers(members), + members: buildMembersFromDrafts(members), cwd: effectiveCwd, prompt: prompt.trim() || undefined, model: effectiveModel, @@ -591,39 +478,6 @@ export const CreateTeamDialog = ({ return activeTeams.find((t) => normalizePath(t.projectPath) === norm) ?? null; }, [activeTeams, effectiveCwd]); - const updateMemberName = (memberId: string, name: string): void => { - setMembers((prev) => - prev.map((candidate) => (candidate.id === memberId ? { ...candidate, name } : candidate)) - ); - }; - - const updateMemberRole = (memberId: string, roleSelection: string): void => { - const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection; - setMembers((prev) => - prev.map((candidate) => - candidate.id === memberId - ? { - ...candidate, - roleSelection: resolvedRole, - customRole: resolvedRole === CUSTOM_ROLE ? candidate.customRole : '', - } - : candidate - ) - ); - }; - - const updateMemberCustomRole = (memberId: string, customRole: string): void => { - setMembers((prev) => - prev.map((candidate) => - candidate.id === memberId ? { ...candidate, customRole } : candidate - ) - ); - }; - - const removeMember = (memberId: string): void => { - setMembers((prev) => prev.filter((candidate) => candidate.id !== memberId)); - }; - const handleSubmit = (): void => { if (existingTeamNames.includes(sanitizedTeamName)) { setFieldErrors({ teamName: 'Team name already exists' }); @@ -774,116 +628,17 @@ export const CreateTeamDialog = ({ ) : null} -
-
- -
- - -
-
-
- {members.map((member, index) => { - const memberColorSet = getTeamColorSet(getMemberColor(index)); - return ( -
-
- updateMemberName(member.id, event.target.value)} - placeholder="member-name" - style={ - member.name.trim() - ? { - color: memberColorSet.text, - } - : undefined - } - /> - {validateMemberNameInline(member.name) ? ( -

- {validateMemberNameInline(member.name)} -

- ) : null} -
-
- - {member.roleSelection === CUSTOM_ROLE ? ( - - updateMemberCustomRole(member.id, event.target.value) - } - placeholder="e.g. architect" - /> - ) : null} -
- -
- ); - })} - {jsonEditorOpen ? ( - - ) : null} -
- {(() => { - const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean); - const hasDuplicates = new Set(names).size !== names.length; - if (hasDuplicates) - return

Member names must be unique

; - if (fieldErrors.members) - return

{fieldErrors.members}

; - return null; - })()} +
+
@@ -938,20 +693,11 @@ export const CreateTeamDialog = ({
-
- - -
+ void; onSaved: () => void; } +function membersToDrafts(members: ResolvedTeamMember[]) { + const active = members.filter((m) => !m.removedAt); + return active.map((m) => { + const presetRoles: readonly string[] = PRESET_ROLES; + const isPreset = m.role != null && presetRoles.includes(m.role); + const isCustom = m.role != null && m.role.length > 0 && !isPreset; + return createMemberDraft({ + name: m.name, + roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''), + customRole: isCustom ? m.role : '', + workflow: m.workflow, + }); + }); +} + export const EditTeamDialog = ({ open, teamName, currentName, currentDescription, currentColor, + currentMembers, + projectPath, onClose, onSaved, }: EditTeamDialogProps): React.JSX.Element => { const [name, setName] = useState(currentName); const [description, setDescription] = useState(currentDescription); const [color, setColor] = useState(currentColor); + const [members, setMembers] = useState(() => membersToDrafts(currentMembers)); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + useFileListCacheWarmer(projectPath ?? null); + useEffect(() => { if (open) { setName(currentName); setDescription(currentDescription); setColor(currentColor); + setMembers(membersToDrafts(currentMembers)); setError(null); } - }, [open, currentName, currentDescription, currentColor]); + }, [open, currentName, currentDescription, currentColor, currentMembers]); const handleSave = (): void => { if (!name.trim()) { setError('Team name cannot be empty'); return; } + const builtMembers = buildMembersFromDrafts(members); + if (builtMembers.length === 0) { + setError('At least one member is required'); + return; + } setSaving(true); setError(null); void (async () => { @@ -73,6 +111,7 @@ export const EditTeamDialog = ({ description: description.trim(), color, }); + await api.teams.replaceMembers(teamName, { members: builtMembers }); onSaved(); onClose(); } catch (e) { @@ -85,7 +124,7 @@ export const EditTeamDialog = ({ return ( !nextOpen && onClose()}> - + Edit Team Change team name, description and color @@ -127,6 +166,17 @@ export const EditTeamDialog = ({ placeholder="Team description (optional)" />
+
+ +
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- Color picker is a group of buttons, not a single input */}
-
- -
- {[ - { value: '', label: 'Default' }, - { value: 'opus', label: 'Opus 4.6' }, - { value: 'sonnet', label: 'Sonnet 4.5' }, - { value: 'haiku', label: 'Haiku 4.5' }, - ].map((opt) => ( - - ))} -
-
+ void; + id?: string; +} + +export const TeamModelSelector: React.FC = ({ + value, + onValueChange, + id, +}) => ( +
+ +
+ {MODEL_OPTIONS.map((opt) => ( + + ))} +
+
+); diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx new file mode 100644 index 00000000..91b2db4d --- /dev/null +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -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 ( +
+
+ onNameChange(member.id, event.target.value)} + placeholder="member-name" + style={ + member.name.trim() + ? { + color: memberColorSet.text, + } + : undefined + } + /> + {nameError ?

{nameError}

: null} +
+
+ + {member.roleSelection === CUSTOM_ROLE ? ( + onCustomRoleChange(member.id, event.target.value)} + placeholder="e.g. architect" + /> + ) : null} +
+
+ {showWorkflow && onWorkflowChange ? ( + + ) : null} + +
+ {showWorkflow && onWorkflowChange && workflowExpanded ? ( +
+ + Draft saved + ) : null + } + /> +
+ ) : null} +
+ ); +}; diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx new file mode 100644 index 00000000..9f7f9ab1 --- /dev/null +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -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 = { 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[]).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(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( + () => + 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 ( +
+
+ +
+ + {showJsonEditor ? ( + + ) : null} +
+
+
+ {members.map((member, index) => ( + + ))} + {jsonEditorOpen && showJsonEditor ? ( + + ) : null} +
+ {hasDuplicates ? ( +

Member names must be unique

+ ) : fieldError ? ( +

{fieldError}

+ ) : null} +
+ ); +}; + +export type { MemberDraft } from './membersEditorTypes'; +export { + buildMembersFromDrafts, + createMemberDraft, + validateMemberNameInline, +} from './membersEditorUtils'; diff --git a/src/renderer/components/team/members/membersEditorTypes.ts b/src/renderer/components/team/members/membersEditorTypes.ts new file mode 100644 index 00000000..f4649308 --- /dev/null +++ b/src/renderer/components/team/members/membersEditorTypes.ts @@ -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[]; +} diff --git a/src/renderer/components/team/members/membersEditorUtils.ts b/src/renderer/components/team/members/membersEditorUtils.ts new file mode 100644 index 00000000..76b5bc61 --- /dev/null +++ b/src/renderer/components/team/members/membersEditorUtils.ts @@ -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 { + 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 => member !== null); +} diff --git a/src/renderer/hooks/useFileListCacheWarmer.ts b/src/renderer/hooks/useFileListCacheWarmer.ts new file mode 100644 index 00000000..8ef1cfa8 --- /dev/null +++ b/src/renderer/hooks/useFileListCacheWarmer.ts @@ -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]); +} diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index 0a0a59db..01554b88 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -27,9 +27,7 @@ interface UseMentionDetectionResult { handleKeyDown: (e: React.KeyboardEvent) => void; handleChange: (e: React.ChangeEvent) => void; handleSelect: (e: React.SyntheticEvent) => void; - /** Current @-trigger character position in text (-1 if no active trigger) */ - triggerIndex: number; - /** Getter for trigger index — use at call time to avoid stale closure */ + /** Getter for trigger index — use at call time to avoid stale closure (returns -1 if no active trigger) */ getTriggerIndex: () => number; } @@ -325,8 +323,6 @@ export function useMentionDetection({ handleKeyDown, handleChange, handleSelect, - // eslint-disable-next-line react-hooks/refs -- expose current trigger position to caller - triggerIndex: triggerIndexRef.current, getTriggerIndex, }; } diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 04d4ee69..6a93f82d 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -36,6 +36,7 @@ import type { LeadActivityState, MemberFullStats, MemberLogSummary, + ReplaceMembersRequest, SendMessageRequest, SendMessageResult, TaskComment, @@ -440,6 +441,7 @@ export interface TeamsAPI { getAllTasks: () => Promise; updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; + replaceMembers: (teamName: string, request: ReplaceMembersRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; updateMemberRole: ( teamName: string, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index eda87d33..591593db 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -3,6 +3,8 @@ export interface TeamMember { agentId?: string; agentType?: string; role?: string; + /** Per-agent workflow/instructions injected into spawn prompt. */ + workflow?: string; color?: string; joinedAt?: number; cwd?: string; @@ -191,6 +193,7 @@ export interface ResolvedTeamMember { color?: string; agentType?: string; role?: string; + workflow?: string; cwd?: string; /** Set only when member's git branch differs from the lead's branch. */ gitBranch?: string; @@ -267,6 +270,8 @@ export type TeamProvisioningState = export interface TeamProvisioningMemberInput { name: string; role?: string; + /** Per-agent workflow/instructions injected into spawn prompt. */ + workflow?: string; } export interface TeamCreateRequest { @@ -395,6 +400,10 @@ export interface UpdateMemberRoleRequest { role: string | undefined; } +export interface ReplaceMembersRequest { + members: TeamProvisioningMemberInput[]; +} + /** Data sent from renderer to main for native OS team message notification. */ export interface TeamMessageNotificationData { teamDisplayName: string;