agent-ecosystem/src/renderer/components/team/dialogs/AddMemberDialog.tsx
iliya 6bcb81d337 feat: implement structured task references and enhance task handling
- Introduced a new structured task reference format `{ taskId, displayId, teamName }` for consistent task mention persistence across UI and storage.
- Enhanced message handling in various components to support the new task reference structure, including normalization and validation.
- Updated task-related functions to accommodate optional task reference fields, improving task management and messaging capabilities.
- Improved rendering and navigation of task references in the UI, ensuring stable links across messages and comments.
- Refactored task reference utilities for better integration and usability within the application.
2026-03-11 15:14:19 +02:00

203 lines
6.3 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react';
import { RoleSelect } from '@renderer/components/team/RoleSelect';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
import { CUSTOM_ROLE, NO_ROLE } from '@renderer/constants/teamRoles';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
import { Loader2 } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types';
const NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/;
interface AddMemberDialogProps {
open: boolean;
teamName: string;
existingNames: string[];
onClose: () => void;
onAdd: (name: string, role?: string, workflow?: string) => void;
adding?: boolean;
/** Project path for @file mentions in workflow field. */
projectPath?: string | null;
/** Existing team members for @mention suggestions. */
existingMembers?: ResolvedTeamMember[];
}
export const AddMemberDialog = ({
open,
teamName,
existingNames,
onClose,
onAdd,
adding,
projectPath,
existingMembers = [],
}: AddMemberDialogProps): React.JSX.Element => {
const [name, setName] = useState('');
const [roleSelect, setRoleSelect] = useState<string>(NO_ROLE);
const [customRole, setCustomRole] = useState('');
const [error, setError] = useState<string | null>(null);
const draftKey = `addMember:${teamName}:workflow`;
const workflowDraft = useDraftPersistence({
key: draftKey,
enabled: open,
});
// Pre-warm file list cache for @file mentions
useFileListCacheWarmer(open && projectPath ? projectPath : null);
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
existingMembers
.filter((m) => !m.removedAt)
.map((m) => ({
id: m.name,
name: m.name,
subtitle: m.role ?? undefined,
color: m.color,
})),
[existingMembers]
);
const effectiveRole =
roleSelect === CUSTOM_ROLE
? customRole.trim()
: roleSelect === NO_ROLE
? undefined
: roleSelect;
const validate = (): string | null => {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return 'Name is required';
if (trimmed.length < 2) return 'Name must be at least 2 characters';
if (trimmed.length > 30) return 'Name must be at most 30 characters';
if (!NAME_REGEX.test(trimmed))
return 'Name must be lowercase alphanumeric with hyphens (e.g. alice, dev-1)';
if (trimmed === 'user') return 'Name "user" is reserved';
if (trimmed === 'team-lead') return 'Name "team-lead" is reserved';
if (existingNames.some((n) => n.toLowerCase() === trimmed)) return 'Name is already taken';
return null;
};
const handleSubmit = (): void => {
const err = validate();
if (err) {
setError(err);
return;
}
setError(null);
const wf = workflowDraft.value.trim() || undefined;
onAdd(name.trim().toLowerCase(), effectiveRole, wf);
// Reset form fields after successful submission
setName('');
setRoleSelect(NO_ROLE);
setCustomRole('');
workflowDraft.clearDraft();
};
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
setName('');
setRoleSelect(NO_ROLE);
setCustomRole('');
workflowDraft.setValue('');
workflowDraft.clearDraft();
setError(null);
onClose();
}
};
const handleWorkflowChange = useCallback(
(v: string) => {
workflowDraft.setValue(v);
},
[workflowDraft]
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>Add a new member to {teamName}</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>Name</Label>
<Input
placeholder="e.g. alice, dev-1"
value={name}
onChange={(e) => {
setName(e.target.value);
setError(null);
}}
autoFocus
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<div className="space-y-2">
<Label className="label-optional">Role (optional)</Label>
<RoleSelect
value={roleSelect}
onValueChange={setRoleSelect}
customRole={customRole}
onCustomRoleChange={setCustomRole}
/>
</div>
<div className="space-y-2">
<Label className="label-optional">Workflow (optional)</Label>
<MentionableTextarea
className="text-xs"
minRows={3}
maxRows={8}
value={workflowDraft.value}
onValueChange={handleWorkflowChange}
suggestions={mentionSuggestions}
projectPath={projectPath ?? undefined}
placeholder="How this agent should behave, what tasks it handles..."
footerRight={
workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null
}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={adding}>
Cancel
</Button>
<Button type="submit" disabled={adding || !name.trim()}>
{adding ? <Loader2 className="mr-1.5 size-4 animate-spin" /> : null}
Add
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};