feat: implement member role update functionality

- Added IPC channel for updating a team member's role, including validation for team and member names.
- Implemented `handleUpdateMemberRole` function to manage role changes and notify the team lead of updates.
- Updated `TeamDataService` to support role changes and ensure data consistency.
- Enhanced UI components to allow role editing and display loading states during updates.
- Integrated role update handling in the team management dialogs, improving user experience.
This commit is contained in:
iliya 2026-02-24 17:31:26 +02:00
parent 27eacfa77c
commit 265becae2d
16 changed files with 402 additions and 9 deletions

View file

@ -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<IpcResult<void>> {
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,

View file

@ -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<void> {
const members = await this.membersMetaStore.getMembers(teamName);
const member = members.find((m) => m.name === memberName);

View file

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

View file

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

View file

@ -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<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
},
updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => {
return invokeIpcWithResult<void>(TEAM_UPDATE_MEMBER_ROLE, teamName, memberName, role);
},
getProjectBranch: async (projectPath: string) => {
return invokeIpcWithResult<string | null>(TEAM_GET_PROJECT_BRANCH, projectPath);
},

View file

@ -748,6 +748,9 @@ export class HttpAPIClient implements ElectronAPI {
removeMember: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
updateMemberRole: async (): Promise<void> => {
throw new Error('Team member management is not available in browser mode');
},
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
return null;
},

View file

@ -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<string | null>(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<Session[]>([]);
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);

View file

@ -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<ActiveTeamRef[]>(() => {
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}

View file

@ -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 = ({
</DialogDescription>
</DialogHeader>
{conflictingTeam ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-amber-300">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this
project
</p>
<p className="text-amber-300/80">
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.
</p>
</div>
</div>
</div>
) : null}
{canCreate && launchTeam && prepareState === 'failed' ? (
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<div className="flex items-start gap-2">

View file

@ -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<void>;
}
@ -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<MentionSuggestion[]>(
() =>
members.map((m) => ({
@ -293,6 +306,24 @@ export const LaunchTeamDialog = ({
</DialogDescription>
</DialogHeader>
{conflictingTeam ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-400" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-amber-300">
Team &ldquo;{conflictingTeam.displayName}&rdquo; is already running in this
project
</p>
<p className="text-amber-300/80">
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.
</p>
</div>
</div>
</div>
) : null}
{prepareState === 'failed' ? (
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
<div className="flex items-start gap-2">

View file

@ -27,6 +27,8 @@ interface MemberDetailDialogProps {
onAssignTask: () => void;
onTaskClick: (task: TeamTaskWithKanban) => void;
onRemoveMember?: () => void;
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | 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}
/>
</DialogHeader>

View file

@ -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> | 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 (
<div className="flex items-center gap-3">
<div className="relative shrink-0">
@ -37,13 +51,43 @@ export const MemberDetailHeader = ({
<div className="min-w-0 flex-1">
<DialogTitle className="truncate">{member.name}</DialogTitle>
<DialogDescription className="mt-1 flex items-center gap-2">
{role && <span>{role}</span>}
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
{presenceLabel}
</Badge>
{editing ? (
<MemberRoleEditor
currentRole={member.role}
saving={updatingRole}
onSave={async (newRole) => {
try {
await onUpdateRole?.(newRole);
setEditing(false);
} catch {
// stay in editing mode so user can retry
}
}}
onCancel={() => setEditing(false)}
/>
) : (
<>
<span>{role || 'No role'}</span>
{canEditRole && (
<button
type="button"
className="inline-flex items-center text-[var(--color-text-muted)] transition-colors hover:text-[var(--color-text-secondary)]"
onClick={() => setEditing(true)}
aria-label="Edit role"
>
<Pencil size={12} />
</button>
)}
</>
)}
{!editing && (
<Badge
variant="secondary"
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
>
{presenceLabel}
</Badge>
)}
</DialogDescription>
</div>
</div>

View file

@ -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> | 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<string>(
!currentRole ? NO_ROLE : isPreset ? currentRole : CUSTOM_ROLE
);
const [customInput, setCustomInput] = useState(isPreset ? '' : (currentRole ?? ''));
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center gap-1.5">
<Select value={selectValue} onValueChange={handleSelectChange}>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_ROLE}>No role</SelectItem>
{ROLE_PRESETS.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
<SelectItem value={CUSTOM_ROLE}>Custom...</SelectItem>
</SelectContent>
</Select>
{showCustomInput && (
<div className="flex flex-col">
<Input
value={customInput}
onChange={(e) => {
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 && <span className="mt-0.5 text-[10px] text-red-400">{error}</span>}
</div>
)}
<Button variant="ghost" size="icon" className="size-6" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
</Button>
<Button variant="ghost" size="icon" className="size-6" onClick={onCancel} disabled={saving}>
<X size={12} />
</Button>
</div>
);
};

View file

@ -89,6 +89,11 @@ export interface TeamSlice {
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>;
updateMemberRole: (
teamName: string,
memberName: string,
role: string | undefined
) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
@ -458,6 +463,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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();

View file

@ -382,6 +382,11 @@ export interface TeamsAPI {
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>;
updateMemberRole: (
teamName: string,
memberName: string,
role: string | undefined
) => Promise<void>;
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
getProjectBranch: (projectPath: string) => Promise<string | null>;
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;

View file

@ -335,3 +335,8 @@ export interface AddMemberRequest {
export interface RemoveMemberRequest {
name: string;
}
export interface UpdateMemberRoleRequest {
name: string;
role: string | undefined;
}