agent-ecosystem/src/renderer/components/team/dialogs/CreateTaskDialog.tsx
iliya 7019bf6114 feat: add team restoration and permanent deletion features
- Implemented functionality for restoring soft-deleted teams and permanently deleting teams from the system.
- Introduced new IPC channels for team restoration and permanent deletion, enhancing team management capabilities.
- Updated TeamDataService and related components to handle the new operations, ensuring data integrity during team management.
- Enhanced UI components to support team restoration and deletion actions, improving user experience and management workflows.
2026-02-26 12:15:47 +02:00

446 lines
17 KiB
TypeScript

import { useMemo, useState } from 'react';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { AlertTriangle, Search } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
interface CreateTaskDialogProps {
open: boolean;
teamName: string;
members: ResolvedTeamMember[];
tasks: TeamTaskWithKanban[];
isTeamAlive?: boolean;
defaultSubject?: string;
defaultDescription?: string;
defaultOwner?: string;
defaultStartImmediately?: boolean;
onClose: () => void;
onSubmit: (
subject: string,
description: string,
owner?: string,
blockedBy?: string[],
related?: string[],
prompt?: string,
startImmediately?: boolean
) => void;
submitting?: boolean;
}
export const CreateTaskDialog = ({
open,
teamName,
members,
tasks,
isTeamAlive = false,
defaultSubject = '',
defaultDescription = '',
defaultOwner = '',
defaultStartImmediately,
onClose,
onSubmit,
submitting = false,
}: CreateTaskDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const [subject, setSubject] = useState(defaultSubject);
const descriptionDraft = useDraftPersistence({
key: `createTask:${teamName}:description`,
initialValue: defaultDescription || undefined,
});
const [owner, setOwner] = useState<string>(defaultOwner);
const [blockedBy, setBlockedBy] = useState<string[]>([]);
const [related, setRelated] = useState<string[]>([]);
const [startImmediately, setStartImmediately] = useState(true);
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
const [blockedBySearch, setBlockedBySearch] = useState('');
const [relatedSearch, setRelatedSearch] = useState('');
const [prevOpen, setPrevOpen] = useState(false);
if (open && !prevOpen) {
setSubject(defaultSubject);
if (defaultDescription) {
descriptionDraft.setValue(defaultDescription);
}
setOwner(defaultOwner);
setBlockedBy([]);
setRelated([]);
setStartImmediately(defaultStartImmediately ?? isTeamAlive);
promptDraft.clearDraft();
setBlockedBySearch('');
setRelatedSearch('');
}
if (open !== prevOpen) {
setPrevOpen(open);
}
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
id: m.name,
name: m.name,
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
color: colorMap.get(m.name),
})),
[members, colorMap]
);
const requiresOwner = defaultStartImmediately === true;
const canSubmit = subject.trim().length > 0 && !submitting && (!requiresOwner || !!owner);
// Only show non-internal, non-deleted tasks as candidates for blocking
const availableTasks = tasks.filter(
(t) => t.status !== 'deleted' && t.kanbanColumn !== 'approved'
);
const toggleBlockedBy = (taskId: string): void => {
setBlockedBy((prev) =>
prev.includes(taskId) ? prev.filter((id) => id !== taskId) : [...prev, taskId]
);
};
const toggleRelated = (taskId: string): void => {
setRelated((prev) =>
prev.includes(taskId) ? prev.filter((id) => id !== taskId) : [...prev, taskId]
);
};
const handleSubmit = (): void => {
if (!canSubmit) return;
onSubmit(
subject.trim(),
descriptionDraft.value.trim(),
owner || undefined,
blockedBy.length > 0 ? blockedBy : undefined,
related.length > 0 ? related : undefined,
promptDraft.value.trim() || undefined,
startImmediately
);
descriptionDraft.clearDraft();
promptDraft.clearDraft();
};
const handleOpenChange = (nextOpen: boolean): void => {
if (!nextOpen) {
onClose();
}
};
const assigneeField = (
<div className="grid gap-2">
<Label className={requiresOwner ? undefined : 'label-optional'}>
{requiresOwner ? 'Assignee' : 'Assignee (optional)'}
</Label>
<Select
value={owner || '__unassigned__'}
onValueChange={(v) => setOwner(v === '__unassigned__' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder={requiresOwner ? 'Select a member' : 'Unassigned'} />
</SelectTrigger>
<SelectContent>
{!requiresOwner && <SelectItem value="__unassigned__">Unassigned</SelectItem>}
{members.map((m) => {
const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
const resolvedColor = colorMap.get(m.name);
const memberColor = resolvedColor ? getTeamColorSet(resolvedColor) : null;
return (
<SelectItem key={m.name} value={m.name}>
<span className="inline-flex items-center gap-1.5">
{memberColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: memberColor.border }}
/>
) : null}
<span style={memberColor ? { color: memberColor.text } : undefined}>
{m.name}
</span>
{role ? <span className="text-[var(--color-text-muted)]">({role})</span> : null}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Create Task</DialogTitle>
<DialogDescription>
The task will be created in the team&apos;s tasks/ directory and appear on the Kanban
board.
</DialogDescription>
</DialogHeader>
{!isTeamAlive ? (
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2">
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-amber-400" />
<p className="text-xs leading-relaxed text-amber-300">
Team is offline. The task will be added to <strong>TODO</strong> &mdash; launch the
team to start execution.
</p>
</div>
) : null}
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="task-subject">Subject</Label>
<Input
id="task-subject"
placeholder="What needs to be done?"
value={subject}
autoFocus
onChange={(e) => setSubject(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && canSubmit) handleSubmit();
}}
/>
</div>
{assigneeField}
<div className="grid gap-2">
<Label htmlFor="task-description" className="label-optional">
Description (optional)
</Label>
<MentionableTextarea
id="task-description"
placeholder="Task details..."
value={descriptionDraft.value}
onValueChange={descriptionDraft.setValue}
suggestions={mentionSuggestions}
minRows={3}
maxRows={12}
footerRight={
descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="task-prompt" className="label-optional">
Prompt for assignee (optional)
</Label>
<MentionableTextarea
id="task-prompt"
placeholder="Custom instructions for the team member..."
value={promptDraft.value}
onValueChange={promptDraft.setValue}
suggestions={mentionSuggestions}
minRows={3}
maxRows={12}
footerRight={
promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div>
{owner ? (
<div className="grid gap-1">
<div className="flex items-center gap-2">
<Checkbox
id="task-start-immediately"
checked={isTeamAlive ? startImmediately : false}
onCheckedChange={(v) => setStartImmediately(v === true)}
disabled={!isTeamAlive}
/>
<Label
htmlFor="task-start-immediately"
className={`text-xs font-normal ${!isTeamAlive ? 'text-[var(--color-text-muted)]' : ''}`}
>
Start immediately
</Label>
</div>
{!isTeamAlive ? (
<p className="text-[10px] text-[var(--color-text-muted)]">
Team is offline. Launch the team first to start tasks immediately.
</p>
) : null}
</div>
) : null}
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label className="label-optional">Blocked by tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={blockedBySearch}
onChange={(e) => setBlockedBySearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!blockedBySearch ||
t.subject.toLowerCase().includes(blockedBySearch.toLowerCase()) ||
t.id.includes(blockedBySearch)
)
.map((t) => {
const isSelected = blockedBy.includes(t.id);
return (
<button
key={t.id}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-blue-500/15 text-blue-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleBlockedBy(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-blue-400 bg-blue-500/30 text-blue-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
#{t.id}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div>
{blockedBy.length > 0 ? (
<p className="text-[11px] text-yellow-300">
Task will be blocked by: {blockedBy.map((id) => `#${id}`).join(', ')}
</p>
) : null}
</div>
) : null}
{availableTasks.length > 0 ? (
<div className="grid gap-2">
<Label className="label-optional">Related tasks (optional)</Label>
<div className="overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{availableTasks.length > 3 ? (
<div className="relative border-b border-[var(--color-border)] px-2 py-1.5">
<Search
size={12}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
type="text"
placeholder="Search tasks..."
value={relatedSearch}
onChange={(e) => setRelatedSearch(e.target.value)}
className="w-full bg-transparent py-0.5 pl-5 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
/>
</div>
) : null}
<div className="max-h-[108px] overflow-y-auto p-1.5">
{availableTasks
.filter(
(t) =>
!relatedSearch ||
t.subject.toLowerCase().includes(relatedSearch.toLowerCase()) ||
t.id.includes(relatedSearch)
)
.map((t) => {
const isSelected = related.includes(t.id);
return (
<button
key={`related:${t.id}`}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
isSelected
? 'bg-purple-500/15 text-purple-300'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
}`}
onClick={() => toggleRelated(t.id)}
>
<span
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
isSelected
? 'border-purple-400 bg-purple-500/30 text-purple-300'
: 'border-[var(--color-border-emphasis)]'
}`}
>
{isSelected ? '\u2713' : ''}
</span>
<Badge
variant="secondary"
className="shrink-0 px-1 py-0 text-[10px] font-normal"
>
#{t.id}
</Badge>
<span className="truncate">{t.subject}</span>
</button>
);
})}
</div>
</div>
{related.length > 0 ? (
<p className="text-[11px] text-purple-300">
Related: {related.map((id) => `#${id}`).join(', ')}
</p>
) : null}
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
{submitting ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};