agent-ecosystem/src/renderer/components/team/members/MembersEditorSection.tsx
iliya 032d9b478b feat: enhance team member validation and improve logging
- Added validation to prevent the use of reserved names ("user" and "team-lead") for team members, ensuring clearer error messages during member addition.
- Updated IPC handlers to improve logging functionality, enhancing observability of team-related actions.
- Implemented normalization of file paths across various services to ensure consistent handling on different platforms.
- Enhanced UI components to provide better feedback on team member statuses and actions.

Made-with: Cursor
2026-03-03 23:58:19 +02:00

230 lines
7.7 KiB
TypeScript

import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Label } from '@renderer/components/ui/label';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { getMemberColor } from '@shared/constants/memberColors';
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
import { MemberDraftRow } from './MemberDraftRow';
import { createMemberDraft, getWorkflowForExport } from './membersEditorUtils';
import type { MemberDraft } from './membersEditorTypes';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
function membersToJsonText(drafts: MemberDraft[]): string {
const arr = drafts
.filter((d) => d.name.trim())
.map((d) => {
const role =
d.roleSelection === CUSTOM_ROLE
? d.customRole.trim() || undefined
: d.roleSelection === NO_ROLE
? undefined
: d.roleSelection.trim() || undefined;
const obj: Record<string, string> = { name: d.name.trim() };
if (role) obj.role = role;
const workflow = getWorkflowForExport(d);
if (workflow) obj.workflow = workflow;
return obj;
});
return JSON.stringify(arr, null, 2);
}
function parseJsonToDrafts(text: string): MemberDraft[] {
const arr: unknown = JSON.parse(text);
if (!Array.isArray(arr)) return [];
return (arr as Record<string, unknown>[]).map((item) => {
const name = typeof item.name === 'string' ? item.name : '';
const role = typeof item.role === 'string' ? item.role.trim() : '';
const workflow = typeof item.workflow === 'string' ? item.workflow.trim() : '';
const presetRoles: readonly string[] = PRESET_ROLES;
const isPreset = presetRoles.includes(role);
return createMemberDraft({
name,
roleSelection: role ? (isPreset ? role : CUSTOM_ROLE) : '',
customRole: role && !isPreset ? role : '',
workflow: workflow || undefined,
});
});
}
export interface MembersEditorSectionProps {
members: MemberDraft[];
onChange: (members: MemberDraft[]) => void;
fieldError?: string;
validateMemberName?: (name: string) => string | null;
showWorkflow?: boolean;
showJsonEditor?: boolean;
/** Prefix for draft persistence keys (e.g. 'createTeam' or 'editTeam:team-alpha') */
draftKeyPrefix?: string;
/** Project path for @file mentions in workflow */
projectPath?: string | null;
/** Extra content rendered right below the "Members" label row */
headerExtra?: React.ReactNode;
/** When true, hides member rows and action buttons (label + headerExtra still visible) */
hideContent?: boolean;
}
export const MembersEditorSection = ({
members,
onChange,
fieldError,
validateMemberName,
showWorkflow = false,
showJsonEditor = true,
draftKeyPrefix,
projectPath,
headerExtra,
hideContent = false,
}: MembersEditorSectionProps): React.JSX.Element => {
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
const toggleJsonEditor = (): void => {
if (!jsonEditorOpen) {
setJsonText(membersToJsonText(members));
setJsonError(null);
}
setJsonEditorOpen((prev) => !prev);
};
useEffect(() => {
if (!jsonEditorOpen || jsonError !== null) return;
queueMicrotask(() => setJsonText(membersToJsonText(members)));
}, [members, jsonEditorOpen, jsonError]);
const handleJsonChange = (text: string): void => {
setJsonText(text);
try {
const drafts = parseJsonToDrafts(text);
onChange(drafts);
setJsonError(null);
} catch (e) {
setJsonError(e instanceof Error ? e.message : 'Invalid JSON');
}
};
const updateMemberName = (memberId: string, name: string): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, name } : c)));
};
const updateMemberRole = (memberId: string, roleSelection: string): void => {
const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection;
onChange(
members.map((c) =>
c.id === memberId
? {
...c,
roleSelection: resolvedRole,
customRole: resolvedRole === CUSTOM_ROLE ? c.customRole : '',
}
: c
)
);
};
const updateMemberCustomRole = (memberId: string, customRole: string): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, customRole } : c)));
};
const updateMemberWorkflow = (memberId: string, workflow: string): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, workflow } : c)));
};
const updateMemberWorkflowChips = (memberId: string, workflowChips: InlineChip[]): void => {
onChange(members.map((c) => (c.id === memberId ? { ...c, workflowChips } : c)));
};
const removeMember = (memberId: string): void => {
onChange(members.filter((c) => c.id !== memberId));
};
const addMember = (): void => {
onChange([...members, createMemberDraft()]);
};
const names = members.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
const hasDuplicates = new Set(names).size !== names.length;
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members
.filter((m) => m.name.trim())
.map((m, i) => ({
id: m.id,
name: m.name.trim(),
subtitle:
m.roleSelection === CUSTOM_ROLE
? m.customRole.trim() || undefined
: m.roleSelection && m.roleSelection !== NO_ROLE
? m.roleSelection
: undefined,
color: getMemberColor(i),
})),
[members]
);
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label>Members</Label>
{!hideContent && (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={addMember}>
Add member
</Button>
{showJsonEditor ? (
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
{jsonEditorOpen ? 'Hide JSON' : 'Edit as JSON'}
</Button>
) : null}
</div>
)}
</div>
{headerExtra}
{!hideContent && (
<>
<div className="space-y-2">
{members.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={index}
nameError={validateMemberName?.(member.name) ?? null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
/>
))}
{jsonEditorOpen && showJsonEditor ? (
<MembersJsonEditor value={jsonText} onChange={handleJsonChange} error={jsonError} />
) : null}
</div>
{hasDuplicates ? (
<p className="text-[11px] text-red-300">Member names must be unique</p>
) : fieldError ? (
<p className="text-[11px] text-red-300">{fieldError}</p>
) : null}
</>
)}
</div>
);
};
export type { MemberDraft } from './membersEditorTypes';
export {
buildMembersFromDrafts,
createMemberDraft,
validateMemberNameInline,
} from './membersEditorUtils';