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:
parent
27eacfa77c
commit
265becae2d
16 changed files with 402 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 “{conflictingTeam.displayName}” 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">
|
||||
|
|
|
|||
|
|
@ -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 “{conflictingTeam.displayName}” 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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
src/renderer/components/team/members/MemberRoleEditor.tsx
Normal file
115
src/renderer/components/team/members/MemberRoleEditor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
|
|
@ -335,3 +335,8 @@ export interface AddMemberRequest {
|
|||
export interface RemoveMemberRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UpdateMemberRoleRequest {
|
||||
name: string;
|
||||
role: string | undefined;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue