diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 337a664d..8a19c0db 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -31,6 +31,7 @@ import { TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, + TEAM_UPDATE_MEMBER_ROLE, TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_STATUS, // eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design @@ -188,6 +189,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_ADD_TASK_COMMENT, handleAddTaskComment); ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember); ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember); + ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole); ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch); ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments); logger.info('Team handlers registered'); @@ -223,6 +225,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_ADD_TASK_COMMENT); ipcMain.removeHandler(TEAM_ADD_MEMBER); ipcMain.removeHandler(TEAM_REMOVE_MEMBER); + ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE); ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH); ipcMain.removeHandler(TEAM_GET_ATTACHMENTS); } @@ -1354,6 +1357,49 @@ async function handleRemoveMember( ); } +async function handleUpdateMemberRole( + _event: IpcMainInvokeEvent, + teamName: unknown, + memberName: unknown, + role: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + const vMember = validateMemberName(memberName); + if (!vMember.valid) return { success: false, error: vMember.error ?? 'Invalid memberName' }; + + const normalizedRole = + role === undefined || role === null + ? undefined + : typeof role === 'string' + ? role.trim() || undefined + : undefined; + + return wrapTeamHandler('updateMemberRole', async () => { + const tn = vTeam.value!; + const name = vMember.value!; + const { oldRole, changed } = await getTeamDataService().updateMemberRole( + tn, + name, + normalizedRole + ); + + if (changed) { + const provisioning = getTeamProvisioningService(); + if (provisioning.isTeamAlive(tn)) { + const oldDesc = oldRole ? `"${oldRole}"` : 'none'; + const newDesc = normalizedRole ? `"${normalizedRole}"` : 'none'; + const message = `Teammate "${name}" role changed from ${oldDesc} to ${newDesc}. This will take effect on next launch.`; + try { + await provisioning.sendMessageToTeam(tn, message); + } catch { + logger.warn(`Failed to notify lead about role change for "${name}" in ${tn}`); + } + } + } + }); +} + async function handleAddTaskComment( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 2ee107cd..607bc234 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -323,6 +323,26 @@ export class TeamDataService { await this.membersMetaStore.writeMembers(teamName, members); } + async updateMemberRole( + teamName: string, + memberName: string, + newRole: string | undefined + ): Promise<{ oldRole: string | undefined; changed: boolean }> { + const members = await this.membersMetaStore.getMembers(teamName); + const member = members.find((m) => m.name === memberName); + if (!member) throw new Error(`Member "${memberName}" not found`); + if (member.removedAt) throw new Error(`Member "${memberName}" is removed`); + if (member.agentType === 'team-lead') throw new Error('Cannot change team lead role'); + + const oldRole = member.role; + const normalized = typeof newRole === 'string' && newRole.trim() ? newRole.trim() : undefined; + if (oldRole === normalized) return { oldRole, changed: false }; + + member.role = normalized; + await this.membersMetaStore.writeMembers(teamName, members); + return { oldRole, changed: true }; + } + 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/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 9a0b742e..ee04cc92 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1595,6 +1595,8 @@ export class TeamProvisioningService { run.cancelRequested = true; run.child?.stdin?.end(); run.child?.kill(); + const progress = updateProgress(run, 'disconnected', 'Team stopped by user'); + run.onProgress(progress); this.cleanupRun(run); logger.info(`[${teamName}] Process stopped by user`); } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index ff33600b..a40fd826 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -276,5 +276,8 @@ export const TEAM_ADD_MEMBER = 'team:addMember'; /** Soft-delete a team member */ export const TEAM_REMOVE_MEMBER = 'team:removeMember'; +/** Update a team member's role */ +export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole'; + /** Get attachment data for a message */ export const TEAM_GET_ATTACHMENTS = 'team:getAttachments'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 3d3d3f03..d73f0d24 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -49,6 +49,7 @@ import { TEAM_UPDATE_CONFIG, TEAM_UPDATE_KANBAN, TEAM_UPDATE_KANBAN_COLUMN_ORDER, + TEAM_UPDATE_MEMBER_ROLE, TEAM_UPDATE_TASK_OWNER, TEAM_UPDATE_TASK_STATUS, UPDATER_CHECK, @@ -631,6 +632,9 @@ const electronAPI: ElectronAPI = { removeMember: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_REMOVE_MEMBER, teamName, memberName); }, + updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => { + return invokeIpcWithResult(TEAM_UPDATE_MEMBER_ROLE, teamName, memberName, role); + }, getProjectBranch: async (projectPath: string) => { return invokeIpcWithResult(TEAM_GET_PROJECT_BRANCH, projectPath); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 86ab617f..eb5b5885 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -748,6 +748,9 @@ export class HttpAPIClient implements ElectronAPI { removeMember: async (): Promise => { throw new Error('Team member management is not available in browser mode'); }, + updateMemberRole: async (): Promise => { + throw new Error('Team member management is not available in browser mode'); + }, getProjectBranch: async (_projectPath: string): Promise => { return null; }, diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 28311c47..87ca9bd0 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -108,6 +108,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); const [addingMemberLoading, setAddingMemberLoading] = useState(false); const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); + const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false); @@ -118,6 +119,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele undefined ); + // Active teams for conflict warning in LaunchTeamDialog + const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< + { teamName: string; displayName: string; projectPath: string }[] + >([]); + // Session loading and filtering state const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); @@ -132,6 +138,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele loading, error, projects, + teams, selectTeam, updateKanban, updateKanbanColumnOrder, @@ -149,9 +156,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele reviewActionError, addMember, removeMember, + updateMemberRole, launchTeam, provisioningError, isTeamProvisioning, + refreshTeamData, kanbanFilterQuery, clearKanbanFilter, } = useStore( @@ -160,6 +169,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele loading: s.selectedTeamLoading, error: s.selectedTeamError, projects: s.projects, + teams: s.teams, selectTeam: s.selectTeam, updateKanban: s.updateKanban, updateKanbanColumnOrder: s.updateKanbanColumnOrder, @@ -177,11 +187,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele reviewActionError: s.reviewActionError, addMember: s.addMember, removeMember: s.removeMember, + updateMemberRole: s.updateMemberRole, launchTeam: s.launchTeam, provisioningError: s.provisioningError, isTeamProvisioning: Object.values(s.provisioningRuns).some( (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) ), + refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, })) @@ -202,6 +214,32 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele void selectTeam(teamName); }, [teamName, selectTeam]); + // Fetch active teams when launch dialog opens (for conflict warning) + useEffect(() => { + if (!launchDialogOpen) return; + let cancelled = false; + void (async () => { + try { + const aliveList = await api.teams.aliveList(); + if (cancelled) return; + const aliveSet = new Set(aliveList); + const refs = teams + .filter((t) => aliveSet.has(t.teamName) && t.projectPath) + .map((t) => ({ + teamName: t.teamName, + displayName: t.displayName, + projectPath: t.projectPath!, + })); + setActiveTeamsForLaunch(refs); + } catch { + // best-effort + } + })(); + return () => { + cancelled = true; + }; + }, [launchDialogOpen, teams]); + useEffect(() => { if (kanbanFilterQuery) { setKanbanSearch(kanbanFilterQuery); @@ -437,12 +475,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setStoppingTeam(true); try { await api.teams.stop(teamName); + // Backend sends 'disconnected' progress which triggers store refresh, + // but refresh here too as a safety net (e.g. if progress event is missed). + await refreshTeamData(teamName); } catch (err) { console.error('Failed to stop team:', err); } finally { setStoppingTeam(false); } - }, [teamName]); + }, [teamName, refreshTeamData]); const handleDeleteTeam = useCallback((): void => { setDeleteConfirmOpen(true); @@ -1088,6 +1129,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setSelectedMember(null); setSelectedTask(task); }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} onRemoveMember={() => { const name = selectedMember?.name; if (!name) return; @@ -1201,6 +1251,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele members={data?.members ?? []} defaultProjectPath={data.config.projectPath} provisioningError={provisioningError} + activeTeams={activeTeamsForLaunch} onClose={() => setLaunchDialogOpen(false)} onLaunch={async (request) => { await launchTeam(request); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index a2074e60..3f7e856a 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -30,7 +30,7 @@ import { useShallow } from 'zustand/react/shallow'; import { CreateTeamDialog } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; -import type { TeamCopyData } from './dialogs/CreateTeamDialog'; +import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamCreateRequest, TeamProvisioningProgress, TeamSummary } from '@shared/types'; function generateUniqueName(sourceName: string, existingNames: string[]): string { @@ -326,6 +326,17 @@ export const TeamListView = (): React.JSX.Element => { const taskCountsByTeam = useMemo(() => buildTaskCountsByTeam(globalTasks), [globalTasks]); + const activeTeams = useMemo(() => { + const aliveSet = new Set(aliveTeams); + return teams + .filter((t) => aliveSet.has(t.teamName) && t.projectPath) + .map((t) => ({ + teamName: t.teamName, + displayName: t.displayName, + projectPath: t.projectPath!, + })); + }, [teams, aliveTeams]); + const handleCreateDialogClose = useCallback(() => { setShowCreateDialog(false); setCopyData(null); @@ -359,6 +370,7 @@ export const TeamListView = (): React.JSX.Element => { canCreate={canCreate} provisioningError={provisioningError} existingTeamNames={teams.map((t) => t.teamName)} + activeTeams={activeTeams} initialData={copyData ?? undefined} defaultProjectPath={currentProjectPath} onClose={handleCreateDialogClose} diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 2cdaf47f..768a2629 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -56,11 +56,18 @@ export interface TeamCopyData { members: TeamProvisioningMemberInput[]; } +export interface ActiveTeamRef { + teamName: string; + displayName: string; + projectPath: string; +} + interface CreateTeamDialogProps { open: boolean; canCreate: boolean; provisioningError: string | null; existingTeamNames: string[]; + activeTeams?: ActiveTeamRef[]; initialData?: TeamCopyData; defaultProjectPath?: string | null; onClose: () => void; @@ -232,6 +239,7 @@ export const CreateTeamDialog = ({ canCreate, provisioningError, existingTeamNames, + activeTeams, initialData, defaultProjectPath, onClose, @@ -483,6 +491,12 @@ export const CreateTeamDialog = ({ const canOpenExistingTeam = activeError?.includes('Team already exists') === true && request.teamName.length > 0; + const conflictingTeam = useMemo(() => { + if (!activeTeams?.length || !effectiveCwd) return null; + const norm = normalizePath(effectiveCwd); + 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)) @@ -588,6 +602,24 @@ export const CreateTeamDialog = ({ + {conflictingTeam ? ( +
+
+ +
+

+ Team “{conflictingTeam.displayName}” is already running in this + project +

+

+ Running two teams in the same directory is risky — they may conflict editing the + same files. Consider using a different directory or a git worktree for isolation. +

+
+
+
+ ) : null} + {canCreate && launchTeam && prepareState === 'failed' ? (
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3eeb7ba4..396dc7ad 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -25,8 +25,10 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; +import { normalizePath } from '@renderer/utils/pathNormalize'; import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react'; +import type { ActiveTeamRef } from './CreateTeamDialog'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { Project, @@ -41,6 +43,7 @@ interface LaunchTeamDialogProps { members: ResolvedTeamMember[]; defaultProjectPath?: string; provisioningError: string | null; + activeTeams?: ActiveTeamRef[]; onClose: () => void; onLaunch: (request: TeamLaunchRequest) => Promise; } @@ -84,6 +87,7 @@ export const LaunchTeamDialog = ({ members, defaultProjectPath, provisioningError, + activeTeams, onClose, onLaunch, }: LaunchTeamDialogProps): React.JSX.Element => { @@ -235,6 +239,15 @@ export const LaunchTeamDialog = ({ const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); + const conflictingTeam = useMemo(() => { + if (!activeTeams?.length || !effectiveCwd) return null; + const norm = normalizePath(effectiveCwd); + return ( + activeTeams.find((t) => t.teamName !== teamName && normalizePath(t.projectPath) === norm) ?? + null + ); + }, [activeTeams, effectiveCwd, teamName]); + const mentionSuggestions = useMemo( () => members.map((m) => ({ @@ -293,6 +306,24 @@ export const LaunchTeamDialog = ({ + {conflictingTeam ? ( +
+
+ +
+

+ Team “{conflictingTeam.displayName}” is already running in this + project +

+

+ Running two teams in the same directory is risky — they may conflict editing the + same files. Consider using a different directory or a git worktree for isolation. +

+
+
+
+ ) : null} + {prepareState === 'failed' ? (
diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 67adb6e8..574495af 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -27,6 +27,8 @@ interface MemberDetailDialogProps { onAssignTask: () => void; onTaskClick: (task: TeamTaskWithKanban) => void; onRemoveMember?: () => void; + onUpdateRole?: (memberName: string, role: string | undefined) => Promise | void; + updatingRole?: boolean; } export const MemberDetailDialog = ({ @@ -42,6 +44,8 @@ export const MemberDetailDialog = ({ onAssignTask, onTaskClick, onRemoveMember, + onUpdateRole, + updatingRole, }: MemberDetailDialogProps): React.JSX.Element | null => { const memberTasks = useMemo( () => (member ? tasks.filter((t) => t.owner === member.name) : []), @@ -76,6 +80,10 @@ export const MemberDetailDialog = ({ member={member} isTeamAlive={isTeamAlive} isTeamProvisioning={isTeamProvisioning} + onUpdateRole={ + onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined + } + updatingRole={updatingRole} /> diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index cc649151..4d104ead 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -1,7 +1,12 @@ +import { useState } from 'react'; + import { Badge } from '@renderer/components/ui/badge'; import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers'; +import { Pencil } from 'lucide-react'; + +import { MemberRoleEditor } from './MemberRoleEditor'; import type { ResolvedTeamMember } from '@shared/types'; @@ -9,17 +14,26 @@ interface MemberDetailHeaderProps { member: ResolvedTeamMember; isTeamAlive?: boolean; isTeamProvisioning?: boolean; + onUpdateRole?: (newRole: string | undefined) => Promise | void; + updatingRole?: boolean; } export const MemberDetailHeader = ({ member, isTeamAlive, isTeamProvisioning, + onUpdateRole, + updatingRole, }: MemberDetailHeaderProps): React.JSX.Element => { + const [editing, setEditing] = useState(false); + const role = member.role || formatAgentRole(member.agentType); const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning); const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning); + const canEditRole = + member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole; + return (
@@ -37,13 +51,43 @@ export const MemberDetailHeader = ({
{member.name} - {role && {role}} - - {presenceLabel} - + {editing ? ( + { + try { + await onUpdateRole?.(newRole); + setEditing(false); + } catch { + // stay in editing mode so user can retry + } + }} + onCancel={() => setEditing(false)} + /> + ) : ( + <> + {role || 'No role'} + {canEditRole && ( + + )} + + )} + {!editing && ( + + {presenceLabel} + + )}
diff --git a/src/renderer/components/team/members/MemberRoleEditor.tsx b/src/renderer/components/team/members/MemberRoleEditor.tsx new file mode 100644 index 00000000..c200ab49 --- /dev/null +++ b/src/renderer/components/team/members/MemberRoleEditor.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; + +import { Button } from '@renderer/components/ui/button'; +import { Input } from '@renderer/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@renderer/components/ui/select'; +import { Check, Loader2, X } from 'lucide-react'; + +const ROLE_PRESETS = ['reviewer', 'developer', 'qa', 'researcher'] as const; +const FORBIDDEN_ROLES = new Set(['lead', 'team-lead']); +const NO_ROLE = '__none__'; +const CUSTOM_ROLE = '__custom__'; + +interface MemberRoleEditorProps { + currentRole: string | undefined; + onSave: (role: string | undefined) => Promise | void; + onCancel: () => void; + saving?: boolean; +} + +export const MemberRoleEditor = ({ + currentRole, + onSave, + onCancel, + saving, +}: MemberRoleEditorProps): React.JSX.Element => { + const isPreset = currentRole && (ROLE_PRESETS as readonly string[]).includes(currentRole); + const [selectValue, setSelectValue] = useState( + !currentRole ? NO_ROLE : isPreset ? currentRole : CUSTOM_ROLE + ); + const [customInput, setCustomInput] = useState(isPreset ? '' : (currentRole ?? '')); + const [error, setError] = useState(null); + + const showCustomInput = selectValue === CUSTOM_ROLE; + + const handleSelectChange = (value: string): void => { + setSelectValue(value); + setError(null); + if (value !== CUSTOM_ROLE) { + setCustomInput(''); + } + }; + + const handleSave = (): void => { + if (selectValue === NO_ROLE) { + void onSave(undefined); + return; + } + if (selectValue !== CUSTOM_ROLE) { + void onSave(selectValue); + return; + } + const trimmed = customInput.trim(); + if (!trimmed) { + setError('Role cannot be empty'); + return; + } + if (FORBIDDEN_ROLES.has(trimmed.toLowerCase())) { + setError('This role is reserved'); + return; + } + void onSave(trimmed); + }; + + return ( +
+ + + {showCustomInput && ( +
+ { + setCustomInput(e.target.value); + setError(null); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') onCancel(); + }} + placeholder="Enter role..." + className="h-7 w-28 text-xs" + autoFocus + /> + {error && {error}} +
+ )} + + + +
+ ); +}; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 920ec507..c8e35d74 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -89,6 +89,11 @@ export interface TeamSlice { addTaskComment: (teamName: string, taskId: string, text: string) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; + updateMemberRole: ( + teamName: string, + memberName: string, + role: string | undefined + ) => Promise; deleteTeam: (teamName: string) => Promise; createTeam: (request: TeamCreateRequest) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; @@ -458,6 +463,13 @@ export const createTeamSlice: StateCreator = (set, await get().refreshTeamData(teamName); }, + updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => { + await unwrapIpc('team:updateMemberRole', () => + api.teams.updateMemberRole(teamName, memberName, role) + ); + await get().refreshTeamData(teamName); + }, + deleteTeam: async (teamName: string) => { await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName)); const state = get(); diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 5e617c9a..7b4a334a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -382,6 +382,11 @@ export interface TeamsAPI { updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; + updateMemberRole: ( + teamName: string, + memberName: string, + role: string | undefined + ) => Promise; addTaskComment: (teamName: string, taskId: string, text: string) => Promise; getProjectBranch: (projectPath: string) => Promise; getAttachments: (teamName: string, messageId: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 24200b37..ad720c50 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -335,3 +335,8 @@ export interface AddMemberRequest { export interface RemoveMemberRequest { name: string; } + +export interface UpdateMemberRoleRequest { + name: string; + role: string | undefined; +}