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_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
TEAM_UPDATE_TASK_OWNER,
|
TEAM_UPDATE_TASK_OWNER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
|
// 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_TASK_COMMENT, handleAddTaskComment);
|
||||||
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
ipcMain.handle(TEAM_ADD_MEMBER, handleAddMember);
|
||||||
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
ipcMain.handle(TEAM_REMOVE_MEMBER, handleRemoveMember);
|
||||||
|
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||||
logger.info('Team handlers registered');
|
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_TASK_COMMENT);
|
||||||
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
ipcMain.removeHandler(TEAM_ADD_MEMBER);
|
||||||
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
ipcMain.removeHandler(TEAM_REMOVE_MEMBER);
|
||||||
|
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
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(
|
async function handleAddTaskComment(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,26 @@ export class TeamDataService {
|
||||||
await this.membersMetaStore.writeMembers(teamName, members);
|
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> {
|
async removeMember(teamName: string, memberName: string): Promise<void> {
|
||||||
const members = await this.membersMetaStore.getMembers(teamName);
|
const members = await this.membersMetaStore.getMembers(teamName);
|
||||||
const member = members.find((m) => m.name === memberName);
|
const member = members.find((m) => m.name === memberName);
|
||||||
|
|
|
||||||
|
|
@ -1595,6 +1595,8 @@ export class TeamProvisioningService {
|
||||||
run.cancelRequested = true;
|
run.cancelRequested = true;
|
||||||
run.child?.stdin?.end();
|
run.child?.stdin?.end();
|
||||||
run.child?.kill();
|
run.child?.kill();
|
||||||
|
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
||||||
|
run.onProgress(progress);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
logger.info(`[${teamName}] Process stopped by user`);
|
logger.info(`[${teamName}] Process stopped by user`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -276,5 +276,8 @@ export const TEAM_ADD_MEMBER = 'team:addMember';
|
||||||
/** Soft-delete a team member */
|
/** Soft-delete a team member */
|
||||||
export const TEAM_REMOVE_MEMBER = 'team:removeMember';
|
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 */
|
/** Get attachment data for a message */
|
||||||
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import {
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
TEAM_UPDATE_KANBAN,
|
TEAM_UPDATE_KANBAN,
|
||||||
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
TEAM_UPDATE_KANBAN_COLUMN_ORDER,
|
||||||
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
TEAM_UPDATE_TASK_OWNER,
|
TEAM_UPDATE_TASK_OWNER,
|
||||||
TEAM_UPDATE_TASK_STATUS,
|
TEAM_UPDATE_TASK_STATUS,
|
||||||
UPDATER_CHECK,
|
UPDATER_CHECK,
|
||||||
|
|
@ -631,6 +632,9 @@ const electronAPI: ElectronAPI = {
|
||||||
removeMember: async (teamName: string, memberName: string) => {
|
removeMember: async (teamName: string, memberName: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
|
return invokeIpcWithResult<void>(TEAM_REMOVE_MEMBER, teamName, memberName);
|
||||||
},
|
},
|
||||||
|
updateMemberRole: async (teamName: string, memberName: string, role: string | undefined) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_UPDATE_MEMBER_ROLE, teamName, memberName, role);
|
||||||
|
},
|
||||||
getProjectBranch: async (projectPath: string) => {
|
getProjectBranch: async (projectPath: string) => {
|
||||||
return invokeIpcWithResult<string | null>(TEAM_GET_PROJECT_BRANCH, projectPath);
|
return invokeIpcWithResult<string | null>(TEAM_GET_PROJECT_BRANCH, projectPath);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -748,6 +748,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
removeMember: async (): Promise<void> => {
|
removeMember: async (): Promise<void> => {
|
||||||
throw new Error('Team member management is not available in browser mode');
|
throw new Error('Team member management is not available in browser mode');
|
||||||
},
|
},
|
||||||
|
updateMemberRole: async (): Promise<void> => {
|
||||||
|
throw new Error('Team member management is not available in browser mode');
|
||||||
|
},
|
||||||
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
|
getProjectBranch: async (_projectPath: string): Promise<string | null> => {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false);
|
||||||
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
const [addingMemberLoading, setAddingMemberLoading] = useState(false);
|
||||||
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
const [removeMemberConfirm, setRemoveMemberConfirm] = useState<string | null>(null);
|
||||||
|
const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false);
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
||||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||||
|
|
@ -118,6 +119,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Active teams for conflict warning in LaunchTeamDialog
|
||||||
|
const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState<
|
||||||
|
{ teamName: string; displayName: string; projectPath: string }[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
// Session loading and filtering state
|
// Session loading and filtering state
|
||||||
const [sessions, setSessions] = useState<Session[]>([]);
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
|
|
@ -132,6 +138,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
projects,
|
projects,
|
||||||
|
teams,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
updateKanban,
|
updateKanban,
|
||||||
updateKanbanColumnOrder,
|
updateKanbanColumnOrder,
|
||||||
|
|
@ -149,9 +156,11 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
reviewActionError,
|
reviewActionError,
|
||||||
addMember,
|
addMember,
|
||||||
removeMember,
|
removeMember,
|
||||||
|
updateMemberRole,
|
||||||
launchTeam,
|
launchTeam,
|
||||||
provisioningError,
|
provisioningError,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
refreshTeamData,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
|
|
@ -160,6 +169,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
loading: s.selectedTeamLoading,
|
loading: s.selectedTeamLoading,
|
||||||
error: s.selectedTeamError,
|
error: s.selectedTeamError,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
|
teams: s.teams,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
updateKanban: s.updateKanban,
|
updateKanban: s.updateKanban,
|
||||||
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
|
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
|
||||||
|
|
@ -177,11 +187,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
reviewActionError: s.reviewActionError,
|
reviewActionError: s.reviewActionError,
|
||||||
addMember: s.addMember,
|
addMember: s.addMember,
|
||||||
removeMember: s.removeMember,
|
removeMember: s.removeMember,
|
||||||
|
updateMemberRole: s.updateMemberRole,
|
||||||
launchTeam: s.launchTeam,
|
launchTeam: s.launchTeam,
|
||||||
provisioningError: s.provisioningError,
|
provisioningError: s.provisioningError,
|
||||||
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||||||
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||||
),
|
),
|
||||||
|
refreshTeamData: s.refreshTeamData,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
}))
|
}))
|
||||||
|
|
@ -202,6 +214,32 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
void selectTeam(teamName);
|
void selectTeam(teamName);
|
||||||
}, [teamName, selectTeam]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (kanbanFilterQuery) {
|
if (kanbanFilterQuery) {
|
||||||
setKanbanSearch(kanbanFilterQuery);
|
setKanbanSearch(kanbanFilterQuery);
|
||||||
|
|
@ -437,12 +475,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
setStoppingTeam(true);
|
setStoppingTeam(true);
|
||||||
try {
|
try {
|
||||||
await api.teams.stop(teamName);
|
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) {
|
} catch (err) {
|
||||||
console.error('Failed to stop team:', err);
|
console.error('Failed to stop team:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setStoppingTeam(false);
|
setStoppingTeam(false);
|
||||||
}
|
}
|
||||||
}, [teamName]);
|
}, [teamName, refreshTeamData]);
|
||||||
|
|
||||||
const handleDeleteTeam = useCallback((): void => {
|
const handleDeleteTeam = useCallback((): void => {
|
||||||
setDeleteConfirmOpen(true);
|
setDeleteConfirmOpen(true);
|
||||||
|
|
@ -1088,6 +1129,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
setSelectedMember(null);
|
setSelectedMember(null);
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
}}
|
}}
|
||||||
|
onUpdateRole={async (memberName, role) => {
|
||||||
|
setUpdatingRoleLoading(true);
|
||||||
|
try {
|
||||||
|
await updateMemberRole(teamName, memberName, role);
|
||||||
|
} finally {
|
||||||
|
setUpdatingRoleLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
updatingRole={updatingRoleLoading}
|
||||||
onRemoveMember={() => {
|
onRemoveMember={() => {
|
||||||
const name = selectedMember?.name;
|
const name = selectedMember?.name;
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
@ -1201,6 +1251,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
members={data?.members ?? []}
|
members={data?.members ?? []}
|
||||||
defaultProjectPath={data.config.projectPath}
|
defaultProjectPath={data.config.projectPath}
|
||||||
provisioningError={provisioningError}
|
provisioningError={provisioningError}
|
||||||
|
activeTeams={activeTeamsForLaunch}
|
||||||
onClose={() => setLaunchDialogOpen(false)}
|
onClose={() => setLaunchDialogOpen(false)}
|
||||||
onLaunch={async (request) => {
|
onLaunch={async (request) => {
|
||||||
await launchTeam(request);
|
await launchTeam(request);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
import { CreateTeamDialog } from './dialogs/CreateTeamDialog';
|
||||||
import { TeamEmptyState } from './TeamEmptyState';
|
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';
|
import type { TeamCreateRequest, TeamProvisioningProgress, TeamSummary } from '@shared/types';
|
||||||
|
|
||||||
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
function generateUniqueName(sourceName: string, existingNames: string[]): string {
|
||||||
|
|
@ -326,6 +326,17 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
|
|
||||||
const taskCountsByTeam = useMemo(() => buildTaskCountsByTeam(globalTasks), [globalTasks]);
|
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(() => {
|
const handleCreateDialogClose = useCallback(() => {
|
||||||
setShowCreateDialog(false);
|
setShowCreateDialog(false);
|
||||||
setCopyData(null);
|
setCopyData(null);
|
||||||
|
|
@ -359,6 +370,7 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
canCreate={canCreate}
|
canCreate={canCreate}
|
||||||
provisioningError={provisioningError}
|
provisioningError={provisioningError}
|
||||||
existingTeamNames={teams.map((t) => t.teamName)}
|
existingTeamNames={teams.map((t) => t.teamName)}
|
||||||
|
activeTeams={activeTeams}
|
||||||
initialData={copyData ?? undefined}
|
initialData={copyData ?? undefined}
|
||||||
defaultProjectPath={currentProjectPath}
|
defaultProjectPath={currentProjectPath}
|
||||||
onClose={handleCreateDialogClose}
|
onClose={handleCreateDialogClose}
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,18 @@ export interface TeamCopyData {
|
||||||
members: TeamProvisioningMemberInput[];
|
members: TeamProvisioningMemberInput[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ActiveTeamRef {
|
||||||
|
teamName: string;
|
||||||
|
displayName: string;
|
||||||
|
projectPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface CreateTeamDialogProps {
|
interface CreateTeamDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
provisioningError: string | null;
|
provisioningError: string | null;
|
||||||
existingTeamNames: string[];
|
existingTeamNames: string[];
|
||||||
|
activeTeams?: ActiveTeamRef[];
|
||||||
initialData?: TeamCopyData;
|
initialData?: TeamCopyData;
|
||||||
defaultProjectPath?: string | null;
|
defaultProjectPath?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -232,6 +239,7 @@ export const CreateTeamDialog = ({
|
||||||
canCreate,
|
canCreate,
|
||||||
provisioningError,
|
provisioningError,
|
||||||
existingTeamNames,
|
existingTeamNames,
|
||||||
|
activeTeams,
|
||||||
initialData,
|
initialData,
|
||||||
defaultProjectPath,
|
defaultProjectPath,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -483,6 +491,12 @@ export const CreateTeamDialog = ({
|
||||||
const canOpenExistingTeam =
|
const canOpenExistingTeam =
|
||||||
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
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 => {
|
const updateMemberName = (memberId: string, name: string): void => {
|
||||||
setMembers((prev) =>
|
setMembers((prev) =>
|
||||||
prev.map((candidate) => (candidate.id === memberId ? { ...candidate, name } : candidate))
|
prev.map((candidate) => (candidate.id === memberId ? { ...candidate, name } : candidate))
|
||||||
|
|
@ -588,6 +602,24 @@ export const CreateTeamDialog = ({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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' ? (
|
{canCreate && launchTeam && prepareState === 'failed' ? (
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { ActiveTeamRef } from './CreateTeamDialog';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type {
|
import type {
|
||||||
Project,
|
Project,
|
||||||
|
|
@ -41,6 +43,7 @@ interface LaunchTeamDialogProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
defaultProjectPath?: string;
|
defaultProjectPath?: string;
|
||||||
provisioningError: string | null;
|
provisioningError: string | null;
|
||||||
|
activeTeams?: ActiveTeamRef[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +87,7 @@ export const LaunchTeamDialog = ({
|
||||||
members,
|
members,
|
||||||
defaultProjectPath,
|
defaultProjectPath,
|
||||||
provisioningError,
|
provisioningError,
|
||||||
|
activeTeams,
|
||||||
onClose,
|
onClose,
|
||||||
onLaunch,
|
onLaunch,
|
||||||
}: LaunchTeamDialogProps): React.JSX.Element => {
|
}: LaunchTeamDialogProps): React.JSX.Element => {
|
||||||
|
|
@ -235,6 +239,15 @@ export const LaunchTeamDialog = ({
|
||||||
|
|
||||||
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
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[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
members.map((m) => ({
|
members.map((m) => ({
|
||||||
|
|
@ -293,6 +306,24 @@ export const LaunchTeamDialog = ({
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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' ? (
|
{prepareState === 'failed' ? (
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ interface MemberDetailDialogProps {
|
||||||
onAssignTask: () => void;
|
onAssignTask: () => void;
|
||||||
onTaskClick: (task: TeamTaskWithKanban) => void;
|
onTaskClick: (task: TeamTaskWithKanban) => void;
|
||||||
onRemoveMember?: () => void;
|
onRemoveMember?: () => void;
|
||||||
|
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | void;
|
||||||
|
updatingRole?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDetailDialog = ({
|
export const MemberDetailDialog = ({
|
||||||
|
|
@ -42,6 +44,8 @@ export const MemberDetailDialog = ({
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
|
onUpdateRole,
|
||||||
|
updatingRole,
|
||||||
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
||||||
const memberTasks = useMemo(
|
const memberTasks = useMemo(
|
||||||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||||
|
|
@ -76,6 +80,10 @@ export const MemberDetailDialog = ({
|
||||||
member={member}
|
member={member}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
isTeamProvisioning={isTeamProvisioning}
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
onUpdateRole={
|
||||||
|
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||||
|
}
|
||||||
|
updatingRole={updatingRole}
|
||||||
/>
|
/>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||||
|
import { Pencil } from 'lucide-react';
|
||||||
|
|
||||||
|
import { MemberRoleEditor } from './MemberRoleEditor';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -9,17 +14,26 @@ interface MemberDetailHeaderProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||||
|
updatingRole?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDetailHeader = ({
|
export const MemberDetailHeader = ({
|
||||||
member,
|
member,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
onUpdateRole,
|
||||||
|
updatingRole,
|
||||||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
const role = member.role || formatAgentRole(member.agentType);
|
const role = member.role || formatAgentRole(member.agentType);
|
||||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
||||||
|
|
||||||
|
const canEditRole =
|
||||||
|
member.agentType !== 'team-lead' && !member.removedAt && !isTeamProvisioning && !!onUpdateRole;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
|
|
@ -37,13 +51,43 @@ export const MemberDetailHeader = ({
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<DialogTitle className="truncate">{member.name}</DialogTitle>
|
<DialogTitle className="truncate">{member.name}</DialogTitle>
|
||||||
<DialogDescription className="mt-1 flex items-center gap-2">
|
<DialogDescription className="mt-1 flex items-center gap-2">
|
||||||
{role && <span>{role}</span>}
|
{editing ? (
|
||||||
<Badge
|
<MemberRoleEditor
|
||||||
variant="secondary"
|
currentRole={member.role}
|
||||||
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
saving={updatingRole}
|
||||||
>
|
onSave={async (newRole) => {
|
||||||
{presenceLabel}
|
try {
|
||||||
</Badge>
|
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>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</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>;
|
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
|
updateMemberRole: (
|
||||||
|
teamName: string,
|
||||||
|
memberName: string,
|
||||||
|
role: string | undefined
|
||||||
|
) => Promise<void>;
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
||||||
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
||||||
|
|
@ -458,6 +463,13 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
await get().refreshTeamData(teamName);
|
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) => {
|
deleteTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,11 @@ export interface TeamsAPI {
|
||||||
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
updateConfig: (teamName: string, updates: TeamUpdateConfigRequest) => Promise<TeamConfig>;
|
||||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
removeMember: (teamName: string, memberName: string) => 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>;
|
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||||
|
|
|
||||||
|
|
@ -335,3 +335,8 @@ export interface AddMemberRequest {
|
||||||
export interface RemoveMemberRequest {
|
export interface RemoveMemberRequest {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberRoleRequest {
|
||||||
|
name: string;
|
||||||
|
role: string | undefined;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue