400 lines
14 KiB
TypeScript
400 lines
14 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 { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
|
import { Plus } from 'lucide-react';
|
|
|
|
import { MembersJsonEditor } from '../dialogs/MembersJsonEditor';
|
|
|
|
import { MemberDraftRow } from './MemberDraftRow';
|
|
import { getNextSuggestedMemberName } from './memberNameSets';
|
|
import {
|
|
buildMemberDraftColorMap,
|
|
buildMemberDraftSuggestions,
|
|
createMemberDraft,
|
|
getMemberDraftRole,
|
|
getWorkflowForExport,
|
|
} from './membersEditorUtils';
|
|
|
|
import type { MemberDraft } from './membersEditorTypes';
|
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
|
|
|
function membersToJsonText(drafts: MemberDraft[]): string {
|
|
const arr = drafts
|
|
.filter((d) => d.name.trim())
|
|
.map((d) => {
|
|
const role = getMemberDraftRole(d);
|
|
const obj: Record<string, string> = { name: d.name.trim() };
|
|
if (role) obj.role = role;
|
|
const workflow = getWorkflowForExport(d);
|
|
if (workflow) obj.workflow = workflow;
|
|
if (d.providerId) obj.providerId = d.providerId;
|
|
if (d.model?.trim()) obj.model = d.model.trim();
|
|
if (d.effort) obj.effort = d.effort;
|
|
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 providerId = normalizeOptionalTeamProviderId(item.providerId);
|
|
const model = typeof item.model === 'string' ? item.model.trim() : '';
|
|
const effort: EffortLevel | undefined =
|
|
item.effort === 'low' || item.effort === 'medium' || item.effort === 'high'
|
|
? item.effort
|
|
: undefined;
|
|
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,
|
|
providerId,
|
|
model,
|
|
effort,
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
/** Task suggestions for #task references in workflow */
|
|
taskSuggestions?: MentionSuggestion[];
|
|
/** Team suggestions for @@team mentions in workflow */
|
|
teamSuggestions?: MentionSuggestion[];
|
|
/** 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;
|
|
/** Existing team members — used to reserve their colors so drafts get the next available ones */
|
|
existingMembers?: readonly { name: string; color?: string; removedAt?: number | string | null }[];
|
|
/** Default provider to use for newly added member rows. */
|
|
defaultProviderId?: TeamProviderId;
|
|
/** When true, provider/model controls stay read-only for existing rows. */
|
|
lockProviderModel?: boolean;
|
|
inheritedProviderId?: TeamProviderId;
|
|
inheritedModel?: string;
|
|
inheritedEffort?: EffortLevel;
|
|
inheritModelSettingsByDefault?: boolean;
|
|
forceInheritedModelSettings?: boolean;
|
|
modelLockReason?: string;
|
|
softDeleteMembers?: boolean;
|
|
memberWarningById?: Record<string, string | null | undefined>;
|
|
disableGeminiOption?: boolean;
|
|
}
|
|
|
|
export const MembersEditorSection = ({
|
|
members,
|
|
onChange,
|
|
fieldError,
|
|
validateMemberName,
|
|
showWorkflow = false,
|
|
showJsonEditor = true,
|
|
draftKeyPrefix,
|
|
projectPath,
|
|
taskSuggestions,
|
|
teamSuggestions,
|
|
headerExtra,
|
|
hideContent = false,
|
|
existingMembers,
|
|
defaultProviderId = 'anthropic',
|
|
lockProviderModel = false,
|
|
inheritedProviderId,
|
|
inheritedModel,
|
|
inheritedEffort,
|
|
inheritModelSettingsByDefault = false,
|
|
forceInheritedModelSettings = false,
|
|
modelLockReason,
|
|
softDeleteMembers = false,
|
|
memberWarningById,
|
|
disableGeminiOption = 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 updateMemberProvider = (memberId: string, providerId: TeamProviderId): void => {
|
|
onChange(
|
|
members.map((c) =>
|
|
c.id === memberId
|
|
? {
|
|
...c,
|
|
providerId,
|
|
model: c.providerId === providerId ? c.model : '',
|
|
}
|
|
: c
|
|
)
|
|
);
|
|
};
|
|
|
|
const updateMemberModel = (memberId: string, model: string): void => {
|
|
onChange(members.map((c) => (c.id === memberId ? { ...c, model } : c)));
|
|
};
|
|
|
|
const updateMemberEffort = (memberId: string, effort: string): void => {
|
|
onChange(
|
|
members.map((c) =>
|
|
c.id === memberId
|
|
? {
|
|
...c,
|
|
effort:
|
|
effort === 'low' || effort === 'medium' || effort === 'high' ? effort : undefined,
|
|
}
|
|
: c
|
|
)
|
|
);
|
|
};
|
|
|
|
const removeMember = (memberId: string): void => {
|
|
if (!softDeleteMembers) {
|
|
onChange(members.filter((c) => c.id !== memberId));
|
|
return;
|
|
}
|
|
onChange(
|
|
members.map((member) =>
|
|
member.id === memberId ? { ...member, removedAt: member.removedAt ?? Date.now() } : member
|
|
)
|
|
);
|
|
};
|
|
|
|
const restoreMember = (memberId: string): void => {
|
|
onChange(
|
|
members.map((member) => (member.id === memberId ? { ...member, removedAt: null } : member))
|
|
);
|
|
};
|
|
|
|
const addMember = (): void => {
|
|
const suggestedName = getNextSuggestedMemberName(members.map((member) => member.name));
|
|
onChange([
|
|
...members,
|
|
createMemberDraft(
|
|
inheritModelSettingsByDefault
|
|
? { name: suggestedName }
|
|
: { name: suggestedName, providerId: defaultProviderId }
|
|
),
|
|
]);
|
|
};
|
|
|
|
const activeMembers = members.filter((member) => !member.removedAt);
|
|
const removedMembers = members.filter((member) => member.removedAt);
|
|
const names = activeMembers.map((m) => m.name.trim().toLowerCase()).filter(Boolean);
|
|
const hasDuplicates = new Set(names).size !== names.length;
|
|
const memberColorMap = useMemo(
|
|
() => buildMemberDraftColorMap(members, existingMembers),
|
|
[members, existingMembers]
|
|
);
|
|
|
|
const mentionSuggestions = useMemo(
|
|
() => buildMemberDraftSuggestions(members, memberColorMap),
|
|
[members, memberColorMap]
|
|
);
|
|
|
|
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" className="gap-1.5" onClick={addMember}>
|
|
<Plus className="size-3.5" />
|
|
Add member
|
|
</Button>
|
|
{showJsonEditor && !jsonEditorOpen ? (
|
|
<Button variant="ghost" size="sm" onClick={toggleJsonEditor}>
|
|
Edit as JSON
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{headerExtra}
|
|
{!hideContent && (
|
|
<>
|
|
<div className="space-y-2">
|
|
{activeMembers.map((member, index) => (
|
|
<MemberDraftRow
|
|
key={member.id}
|
|
member={member}
|
|
index={index}
|
|
resolvedColor={memberColorMap.get(member.name.trim())}
|
|
nameError={validateMemberName?.(member.name) ?? null}
|
|
onNameChange={updateMemberName}
|
|
onRoleChange={updateMemberRole}
|
|
onCustomRoleChange={updateMemberCustomRole}
|
|
onRemove={removeMember}
|
|
showWorkflow={showWorkflow}
|
|
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
|
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
|
onProviderChange={updateMemberProvider}
|
|
onModelChange={updateMemberModel}
|
|
onEffortChange={updateMemberEffort}
|
|
inheritedProviderId={inheritedProviderId}
|
|
inheritedModel={inheritedModel}
|
|
inheritedEffort={inheritedEffort}
|
|
forceInheritedModelSettings={forceInheritedModelSettings}
|
|
draftKeyPrefix={draftKeyPrefix}
|
|
projectPath={projectPath}
|
|
mentionSuggestions={mentionSuggestions}
|
|
taskSuggestions={taskSuggestions}
|
|
teamSuggestions={teamSuggestions}
|
|
lockProviderModel={lockProviderModel}
|
|
modelLockReason={modelLockReason}
|
|
warningText={memberWarningById?.[member.id] ?? null}
|
|
disableGeminiOption={disableGeminiOption}
|
|
/>
|
|
))}
|
|
{softDeleteMembers && removedMembers.length > 0 ? (
|
|
<div className="pt-2">
|
|
<div className="mb-2 text-[10px] text-[var(--color-text-muted)]">
|
|
Removed ({removedMembers.length})
|
|
</div>
|
|
<div className="space-y-2">
|
|
{removedMembers.map((member, index) => (
|
|
<MemberDraftRow
|
|
key={member.id}
|
|
member={member}
|
|
index={activeMembers.length + index}
|
|
resolvedColor={memberColorMap.get(member.name.trim())}
|
|
nameError={null}
|
|
onNameChange={updateMemberName}
|
|
onRoleChange={updateMemberRole}
|
|
onCustomRoleChange={updateMemberCustomRole}
|
|
onRemove={removeMember}
|
|
onRestore={restoreMember}
|
|
showWorkflow={showWorkflow}
|
|
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
|
|
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
|
|
onProviderChange={updateMemberProvider}
|
|
onModelChange={updateMemberModel}
|
|
onEffortChange={updateMemberEffort}
|
|
inheritedProviderId={inheritedProviderId}
|
|
inheritedModel={inheritedModel}
|
|
inheritedEffort={inheritedEffort}
|
|
forceInheritedModelSettings={forceInheritedModelSettings}
|
|
draftKeyPrefix={draftKeyPrefix}
|
|
projectPath={projectPath}
|
|
mentionSuggestions={mentionSuggestions}
|
|
taskSuggestions={taskSuggestions}
|
|
teamSuggestions={teamSuggestions}
|
|
lockProviderModel
|
|
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
|
|
isRemoved
|
|
warningText={null}
|
|
disableGeminiOption={disableGeminiOption}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{jsonEditorOpen && showJsonEditor ? (
|
|
<MembersJsonEditor
|
|
value={jsonText}
|
|
onChange={handleJsonChange}
|
|
error={jsonError}
|
|
onClose={toggleJsonEditor}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
{hasDuplicates ? (
|
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
|
Member names must be unique
|
|
</p>
|
|
) : fieldError ? (
|
|
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>
|
|
{fieldError}
|
|
</p>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export type { MemberDraft } from './membersEditorTypes';
|
|
export {
|
|
buildMemberDraftColorMap,
|
|
buildMemberDraftSuggestions,
|
|
buildMembersFromDrafts,
|
|
clearMemberModelOverrides,
|
|
createMemberDraft,
|
|
createMemberDraftsFromInputs,
|
|
getMemberDraftRole,
|
|
normalizeMemberDraftForProviderMode,
|
|
normalizeProviderForMode,
|
|
validateMemberNameInline,
|
|
} from './membersEditorUtils';
|