901 lines
29 KiB
TypeScript
901 lines
29 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
|
import { Combobox } from '@renderer/components/ui/combobox';
|
|
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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@renderer/components/ui/select';
|
|
import { Textarea } from '@renderer/components/ui/textarea';
|
|
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|
import { cn } from '@renderer/lib/utils';
|
|
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
|
|
|
const TEAM_COLOR_NAMES = [
|
|
'blue',
|
|
'green',
|
|
'red',
|
|
'yellow',
|
|
'purple',
|
|
'cyan',
|
|
'orange',
|
|
'pink',
|
|
] as const;
|
|
|
|
import type {
|
|
Project,
|
|
TeamCreateRequest,
|
|
TeamProvisioningMemberInput,
|
|
TeamProvisioningPrepareResult,
|
|
} from '@shared/types';
|
|
|
|
export interface TeamCopyData {
|
|
teamName: string;
|
|
description?: string;
|
|
color?: string;
|
|
members: TeamProvisioningMemberInput[];
|
|
}
|
|
|
|
interface CreateTeamDialogProps {
|
|
open: boolean;
|
|
canCreate: boolean;
|
|
provisioningError: string | null;
|
|
existingTeamNames: string[];
|
|
initialData?: TeamCopyData;
|
|
onClose: () => void;
|
|
onCreate: (request: TeamCreateRequest) => Promise<void>;
|
|
onOpenTeam: (teamName: string, projectPath?: string) => void;
|
|
}
|
|
|
|
interface ValidationResult {
|
|
valid: boolean;
|
|
errors?: {
|
|
teamName?: string;
|
|
members?: string;
|
|
cwd?: string;
|
|
};
|
|
}
|
|
|
|
const PRESET_ROLES = ['lead', 'reviewer', 'developer', 'qa', 'researcher'] as const;
|
|
const CUSTOM_ROLE = '__custom__';
|
|
const NO_ROLE = '__none__';
|
|
const DEV_DEFAULT_TEAM = {
|
|
teamName: 'team-alpha',
|
|
description: 'Dev test team for provisioning flow',
|
|
} as const;
|
|
|
|
interface MemberDraft {
|
|
id: string;
|
|
name: string;
|
|
roleSelection: string;
|
|
customRole: string;
|
|
}
|
|
|
|
const DEV_DEFAULT_MEMBERS: Pick<MemberDraft, 'name' | 'roleSelection'>[] = [
|
|
{ name: 'alice', roleSelection: 'reviewer' },
|
|
{ name: 'bob', roleSelection: 'developer' },
|
|
{ name: 'carol', roleSelection: 'developer' },
|
|
];
|
|
|
|
function newDraftId(): string {
|
|
// eslint-disable-next-line sonarjs/pseudo-random -- Used for generating unique UI keys, not security
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
|
return {
|
|
id: initial?.id ?? newDraftId(),
|
|
name: initial?.name ?? '',
|
|
roleSelection: initial?.roleSelection ?? '',
|
|
customRole: initial?.customRole ?? '',
|
|
};
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function renderHighlightedText(text: string, query: string): React.JSX.Element {
|
|
if (!query.trim()) {
|
|
return <span>{text}</span>;
|
|
}
|
|
|
|
const pattern = new RegExp(`(${escapeRegExp(query)})`, 'ig');
|
|
const parts = text.split(pattern);
|
|
|
|
return (
|
|
<span>
|
|
{parts.map((part, index) => {
|
|
const isMatch = part.toLowerCase() === query.toLowerCase();
|
|
if (!isMatch) {
|
|
return <span key={`${part}-${index}`}>{part}</span>;
|
|
}
|
|
return (
|
|
<mark
|
|
key={`${part}-${index}`}
|
|
// eslint-disable-next-line tailwindcss/no-custom-classname -- Tailwind arbitrary value with CSS variable
|
|
className="bg-[var(--color-accent)]/25 rounded px-0.5 text-[var(--color-text)]"
|
|
>
|
|
{part}
|
|
</mark>
|
|
);
|
|
})}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function buildMembers(members: MemberDraft[]): TeamCreateRequest['members'] {
|
|
return members
|
|
.map((member) => {
|
|
const name = member.name.trim();
|
|
if (!name) {
|
|
return null;
|
|
}
|
|
|
|
const role =
|
|
member.roleSelection === CUSTOM_ROLE
|
|
? member.customRole.trim() || undefined
|
|
: member.roleSelection === NO_ROLE
|
|
? undefined
|
|
: member.roleSelection.trim() || undefined;
|
|
|
|
return {
|
|
name,
|
|
role,
|
|
};
|
|
})
|
|
.filter((member): member is NonNullable<typeof member> => member !== null);
|
|
}
|
|
|
|
function validateRequest(
|
|
request: TeamCreateRequest,
|
|
options?: { requireCwd?: boolean }
|
|
): ValidationResult {
|
|
const requireCwd = options?.requireCwd ?? true;
|
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(request.teamName) || request.teamName.length > 64) {
|
|
return {
|
|
valid: false,
|
|
errors: {
|
|
teamName: 'Use kebab-case [a-z0-9-], max 64 chars',
|
|
},
|
|
};
|
|
}
|
|
if (requireCwd && !request.cwd.trim()) {
|
|
return {
|
|
valid: false,
|
|
errors: {
|
|
cwd: 'Select working directory (cwd)',
|
|
},
|
|
};
|
|
}
|
|
if (request.members.length === 0) {
|
|
return {
|
|
valid: false,
|
|
errors: {
|
|
members: 'At least one member is required',
|
|
},
|
|
};
|
|
}
|
|
if (request.members.some((member) => !member.name.trim())) {
|
|
return {
|
|
valid: false,
|
|
errors: {
|
|
members: 'Member name cannot be empty',
|
|
},
|
|
};
|
|
}
|
|
const memberNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
if (request.members.some((member) => !memberNamePattern.test(member.name.trim()))) {
|
|
return {
|
|
valid: false,
|
|
errors: {
|
|
members: 'Member name must start with alphanumeric, use only [a-zA-Z0-9._-], max 128 chars',
|
|
},
|
|
};
|
|
}
|
|
const uniqueNames = new Set(request.members.map((member) => member.name.trim().toLowerCase()));
|
|
if (uniqueNames.size !== request.members.length) {
|
|
return {
|
|
valid: false,
|
|
errors: {
|
|
members: 'Member names must be unique',
|
|
},
|
|
};
|
|
}
|
|
return { valid: true };
|
|
}
|
|
|
|
export const CreateTeamDialog = ({
|
|
open,
|
|
canCreate,
|
|
provisioningError,
|
|
existingTeamNames,
|
|
initialData,
|
|
onClose,
|
|
onCreate,
|
|
onOpenTeam,
|
|
}: CreateTeamDialogProps): React.JSX.Element => {
|
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
|
|
const [teamName, setTeamName] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [prompt, setPrompt] = useState('');
|
|
const [members, setMembers] = useState<MemberDraft[]>([]);
|
|
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
|
|
const [selectedProjectPath, setSelectedProjectPath] = useState('');
|
|
const [customCwd, setCustomCwd] = useState('');
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [projectsLoading, setProjectsLoading] = useState(false);
|
|
const [projectsError, setProjectsError] = useState<string | null>(null);
|
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
const [prepareState, setPrepareState] = useState<'idle' | 'loading' | 'ready' | 'failed'>('idle');
|
|
const [prepareMessage, setPrepareMessage] = useState<string | null>(null);
|
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
|
const [fieldErrors, setFieldErrors] = useState<{
|
|
teamName?: string;
|
|
members?: string;
|
|
cwd?: string;
|
|
}>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [launchTeam, setLaunchTeam] = useState(true);
|
|
const [teamColor, setTeamColor] = useState('');
|
|
|
|
const resetFormState = (): void => {
|
|
setTeamName('');
|
|
setDescription('');
|
|
setPrompt('');
|
|
setMembers([]);
|
|
setTeamColor('');
|
|
setCwdMode('project');
|
|
setSelectedProjectPath('');
|
|
setCustomCwd('');
|
|
setLocalError(null);
|
|
setFieldErrors({});
|
|
setIsSubmitting(false);
|
|
setPrepareState('idle');
|
|
setPrepareMessage(null);
|
|
setPrepareWarnings([]);
|
|
setLaunchTeam(true);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open || !canCreate || !launchTeam) {
|
|
return;
|
|
}
|
|
|
|
if (typeof api.teams.prepareProvisioning !== 'function') {
|
|
setPrepareState('failed');
|
|
setPrepareWarnings([]);
|
|
setPrepareMessage(
|
|
'Current preload version does not support team:prepareProvisioning. Restart the dev app.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
setPrepareState('loading');
|
|
setPrepareMessage('Warming up CLI environment...');
|
|
setPrepareWarnings([]);
|
|
|
|
void (async () => {
|
|
try {
|
|
const prepResult: TeamProvisioningPrepareResult = await api.teams.prepareProvisioning();
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setPrepareState(prepResult.ready ? 'ready' : 'failed');
|
|
setPrepareMessage(prepResult.message);
|
|
setPrepareWarnings(prepResult.warnings ?? []);
|
|
} catch (error) {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setPrepareState('failed');
|
|
setPrepareWarnings([]);
|
|
setPrepareMessage(
|
|
error instanceof Error ? error.message : 'Failed to warm up Claude CLI environment'
|
|
);
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open, canCreate, launchTeam]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
setProjectsLoading(true);
|
|
setProjectsError(null);
|
|
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const nextProjects = await api.getProjects();
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setProjects(nextProjects);
|
|
} catch (error) {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setProjectsError(error instanceof Error ? error.message : 'Failed to load projects');
|
|
setProjects([]);
|
|
} finally {
|
|
if (!cancelled) {
|
|
setProjectsLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
if (initialData) {
|
|
setTeamName(initialData.teamName);
|
|
setDescription(initialData.description ?? '');
|
|
setTeamColor(initialData.color ?? '');
|
|
setMembers(
|
|
initialData.members.map((m) => {
|
|
const presetRoles: readonly string[] = PRESET_ROLES;
|
|
const isPreset = m.role != null && presetRoles.includes(m.role);
|
|
const isCustom = m.role != null && m.role.length > 0 && !isPreset;
|
|
return createMemberDraft({
|
|
name: m.name,
|
|
roleSelection: isCustom ? CUSTOM_ROLE : (m.role ?? ''),
|
|
customRole: isCustom ? m.role : '',
|
|
});
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (members.length > 0) {
|
|
return;
|
|
}
|
|
|
|
if (isDev) {
|
|
setMembers(
|
|
DEV_DEFAULT_MEMBERS.map((member) =>
|
|
createMemberDraft({
|
|
name: member.name,
|
|
roleSelection: member.roleSelection,
|
|
})
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
setMembers([createMemberDraft()]);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is checked once on open
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !isDev || initialData) {
|
|
return;
|
|
}
|
|
if (teamName.trim().length === 0) {
|
|
setTeamName(DEV_DEFAULT_TEAM.teamName);
|
|
}
|
|
if (description.trim().length === 0) {
|
|
setDescription(DEV_DEFAULT_TEAM.description);
|
|
}
|
|
}, [open, isDev, teamName, description, initialData]);
|
|
|
|
useEffect(() => {
|
|
if (cwdMode !== 'project') {
|
|
return;
|
|
}
|
|
if (selectedProjectPath || projects.length === 0) {
|
|
return;
|
|
}
|
|
setSelectedProjectPath(projects[0].path);
|
|
}, [cwdMode, projects, selectedProjectPath]);
|
|
|
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
|
|
|
const request = useMemo<TeamCreateRequest>(
|
|
() => ({
|
|
teamName: teamName.trim(),
|
|
description: description.trim() || undefined,
|
|
color: teamColor || undefined,
|
|
members: buildMembers(members),
|
|
cwd: effectiveCwd,
|
|
prompt: prompt.trim() || undefined,
|
|
}),
|
|
[teamName, description, teamColor, members, effectiveCwd, prompt]
|
|
);
|
|
|
|
const activeError = localError ?? provisioningError;
|
|
const canOpenExistingTeam =
|
|
activeError?.includes('Team already exists') === true && request.teamName.length > 0;
|
|
|
|
const updateMemberName = (memberId: string, name: string): void => {
|
|
setMembers((prev) =>
|
|
prev.map((candidate) => (candidate.id === memberId ? { ...candidate, name } : candidate))
|
|
);
|
|
};
|
|
|
|
const updateMemberRole = (memberId: string, roleSelection: string): void => {
|
|
const resolvedRole = roleSelection === NO_ROLE ? '' : roleSelection;
|
|
setMembers((prev) =>
|
|
prev.map((candidate) =>
|
|
candidate.id === memberId
|
|
? {
|
|
...candidate,
|
|
roleSelection: resolvedRole,
|
|
customRole: resolvedRole === CUSTOM_ROLE ? candidate.customRole : '',
|
|
}
|
|
: candidate
|
|
)
|
|
);
|
|
};
|
|
|
|
const updateMemberCustomRole = (memberId: string, customRole: string): void => {
|
|
setMembers((prev) =>
|
|
prev.map((candidate) =>
|
|
candidate.id === memberId ? { ...candidate, customRole } : candidate
|
|
)
|
|
);
|
|
};
|
|
|
|
const removeMember = (memberId: string): void => {
|
|
setMembers((prev) => prev.filter((candidate) => candidate.id !== memberId));
|
|
};
|
|
|
|
const handleSubmit = (): void => {
|
|
if (existingTeamNames.includes(request.teamName)) {
|
|
setFieldErrors({ teamName: 'Team name already exists' });
|
|
setLocalError('Check form fields');
|
|
return;
|
|
}
|
|
const validation = validateRequest(request, { requireCwd: launchTeam });
|
|
if (!validation.valid) {
|
|
setFieldErrors(validation.errors ?? {});
|
|
setLocalError('Check form fields');
|
|
return;
|
|
}
|
|
setFieldErrors({});
|
|
setLocalError(null);
|
|
setIsSubmitting(true);
|
|
|
|
if (!launchTeam) {
|
|
void (async () => {
|
|
try {
|
|
await api.teams.createConfig({
|
|
teamName: request.teamName,
|
|
displayName: request.displayName,
|
|
description: request.description,
|
|
color: request.color,
|
|
members: request.members,
|
|
});
|
|
onOpenTeam(request.teamName, effectiveCwd || undefined);
|
|
resetFormState();
|
|
onClose();
|
|
} catch (error) {
|
|
setLocalError(error instanceof Error ? error.message : 'Failed to create team config');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
|
|
void (async () => {
|
|
try {
|
|
await onCreate(request);
|
|
onOpenTeam(request.teamName, effectiveCwd || undefined);
|
|
resetFormState();
|
|
onClose();
|
|
} catch {
|
|
// error is shown via provisioningError prop
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
})();
|
|
};
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
if (!nextOpen) {
|
|
resetFormState();
|
|
onClose();
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-sm">{initialData ? 'Copy Team' : 'Create Team'}</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{initialData
|
|
? 'Create a new team based on an existing one.'
|
|
: 'Team provisioning via local Claude CLI.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{canCreate && launchTeam && prepareState === 'failed' ? (
|
|
<div className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
|
|
<p>{prepareMessage ?? 'Failed to prepare environment'}</p>
|
|
{prepareWarnings.length > 0 ? (
|
|
<div className="mt-1 space-y-1">
|
|
{prepareWarnings.map((warning) => (
|
|
<p key={warning} className="text-[11px] text-amber-300">
|
|
{warning}
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{!canCreate ? (
|
|
<p className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-300">
|
|
Available only in local Electron mode.
|
|
</p>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label htmlFor="team-name" className="text-xs text-[var(--color-text-muted)]">
|
|
teamName
|
|
</Label>
|
|
<Input
|
|
id="team-name"
|
|
className="h-8 text-xs"
|
|
value={teamName}
|
|
onChange={(event) => setTeamName(event.target.value)}
|
|
placeholder="team-alpha"
|
|
/>
|
|
{existingTeamNames.includes(teamName.trim()) ? (
|
|
<p className="text-[11px] text-red-300">Team name already exists</p>
|
|
) : fieldErrors.teamName ? (
|
|
<p className="text-[11px] text-red-300">{fieldErrors.teamName}</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label htmlFor="team-description" className="text-xs text-[var(--color-text-muted)]">
|
|
description (optional)
|
|
</Label>
|
|
<Textarea
|
|
id="team-description"
|
|
className="min-h-[40px] resize-none text-xs"
|
|
rows={2}
|
|
value={description}
|
|
onChange={(event) => setDescription(event.target.value)}
|
|
placeholder="Brief description of the team purpose"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label className="text-xs text-[var(--color-text-muted)]">color (optional)</Label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{TEAM_COLOR_NAMES.map((colorName) => {
|
|
const colorSet = getTeamColorSet(colorName);
|
|
const isSelected = teamColor === colorName;
|
|
return (
|
|
<button
|
|
key={colorName}
|
|
type="button"
|
|
className={cn(
|
|
'flex size-7 items-center justify-center rounded-full border-2 transition-all',
|
|
isSelected ? 'scale-110' : 'opacity-70 hover:opacity-100'
|
|
)}
|
|
style={{
|
|
backgroundColor: colorSet.badge,
|
|
borderColor: isSelected ? colorSet.border : 'transparent',
|
|
}}
|
|
title={colorName}
|
|
onClick={() => setTeamColor(isSelected ? '' : colorName)}
|
|
>
|
|
<span
|
|
className="size-3.5 rounded-full"
|
|
style={{ backgroundColor: colorSet.border }}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label className="text-xs text-[var(--color-text-muted)]">members</Label>
|
|
<div className="space-y-2">
|
|
{members.map((member, index) => (
|
|
<div
|
|
key={member.id}
|
|
className="grid grid-cols-1 gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-2 md:grid-cols-[1fr_220px_auto]"
|
|
>
|
|
<Input
|
|
className="h-8 text-xs"
|
|
value={member.name}
|
|
aria-label={`Member ${index + 1} name`}
|
|
onChange={(event) => updateMemberName(member.id, event.target.value)}
|
|
placeholder="member-name"
|
|
/>
|
|
<div className="space-y-1">
|
|
<Select
|
|
value={member.roleSelection || NO_ROLE}
|
|
onValueChange={(roleSelection) => updateMemberRole(member.id, roleSelection)}
|
|
>
|
|
<SelectTrigger
|
|
className="h-8 text-xs"
|
|
aria-label={`Member ${index + 1} role`}
|
|
>
|
|
<SelectValue placeholder="No role" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={NO_ROLE}>No role</SelectItem>
|
|
{PRESET_ROLES.map((role) => (
|
|
<SelectItem key={role} value={role}>
|
|
{role}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value={CUSTOM_ROLE}>Custom role...</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{member.roleSelection === CUSTOM_ROLE ? (
|
|
<Input
|
|
className="h-8 text-xs"
|
|
value={member.customRole}
|
|
aria-label={`Member ${index + 1} custom role`}
|
|
onChange={(event) => updateMemberCustomRole(member.id, event.target.value)}
|
|
placeholder="e.g. architect"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="border-red-500/40 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
|
onClick={() => removeMember(member.id)}
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setMembers((prev) => [...prev, createMemberDraft()]);
|
|
}}
|
|
>
|
|
Add member
|
|
</Button>
|
|
</div>
|
|
{fieldErrors.members ? (
|
|
<p className="text-[11px] text-red-300">{fieldErrors.members}</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 md:col-span-2">
|
|
<Checkbox
|
|
id="launch-team"
|
|
checked={launchTeam}
|
|
onCheckedChange={(checked) => setLaunchTeam(checked === true)}
|
|
/>
|
|
<Label
|
|
htmlFor="launch-team"
|
|
className="cursor-pointer text-xs text-[var(--color-text)]"
|
|
>
|
|
Launch team
|
|
</Label>
|
|
</div>
|
|
|
|
{launchTeam ? (
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label htmlFor="team-prompt" className="text-xs text-[var(--color-text-muted)]">
|
|
Prompt for team lead (optional)
|
|
</Label>
|
|
<Textarea
|
|
id="team-prompt"
|
|
className="min-h-[40px] resize-none text-xs"
|
|
rows={3}
|
|
value={prompt}
|
|
onChange={(event) => setPrompt(event.target.value)}
|
|
placeholder="Instructions for the team lead during provisioning..."
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{launchTeam ? (
|
|
<div className="space-y-1.5 md:col-span-2">
|
|
<Label className="text-xs text-[var(--color-text-muted)]">cwd</Label>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
type="button"
|
|
variant={cwdMode === 'project' ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => setCwdMode('project')}
|
|
>
|
|
From project list
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={cwdMode === 'custom' ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
onClick={() => setCwdMode('custom')}
|
|
>
|
|
Custom path
|
|
</Button>
|
|
</div>
|
|
|
|
{cwdMode === 'project' ? (
|
|
<div className="space-y-1.5">
|
|
<Combobox
|
|
options={projects.map((project) => ({
|
|
value: project.path,
|
|
label: project.name,
|
|
description: project.path,
|
|
}))}
|
|
value={selectedProjectPath}
|
|
onValueChange={setSelectedProjectPath}
|
|
placeholder={projectsLoading ? 'Loading projects...' : 'Select a project...'}
|
|
searchPlaceholder="Search project by name or path"
|
|
emptyMessage="Nothing found"
|
|
disabled={projectsLoading || projects.length === 0}
|
|
renderOption={(option, isSelected, query) => (
|
|
<>
|
|
<Check
|
|
className={cn(
|
|
'mr-2 size-3.5 shrink-0',
|
|
isSelected ? 'opacity-100' : 'opacity-0'
|
|
)}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate font-medium text-[var(--color-text)]">
|
|
{renderHighlightedText(option.label, query)}
|
|
</p>
|
|
<p className="truncate text-[var(--color-text-muted)]">
|
|
{renderHighlightedText(option.description ?? '', query)}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
/>
|
|
{!selectedProjectPath ? (
|
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
|
Select a project from the list
|
|
</p>
|
|
) : null}
|
|
{projectsError ? (
|
|
<p className="text-[11px] text-red-300">{projectsError}</p>
|
|
) : null}
|
|
{!projectsLoading && projects.length === 0 ? (
|
|
<p className="text-[11px] text-amber-300">
|
|
No projects found, switch to custom path.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
className="h-8 text-xs"
|
|
value={customCwd}
|
|
aria-label="Custom working directory"
|
|
onChange={(event) => setCustomCwd(event.target.value)}
|
|
placeholder="/absolute/path/to/project"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
void (async () => {
|
|
const paths = await api.config.selectFolders();
|
|
if (paths.length > 0) {
|
|
setCustomCwd(paths[0]);
|
|
}
|
|
})();
|
|
}}
|
|
>
|
|
Browse
|
|
</Button>
|
|
</div>
|
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
|
If the directory does not exist, it will be created automatically.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{fieldErrors.cwd ? (
|
|
<p className="text-[11px] text-red-300">{fieldErrors.cwd}</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{activeError ? (
|
|
<p className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
|
|
{activeError}
|
|
</p>
|
|
) : null}
|
|
|
|
{canCreate && launchTeam && (prepareState === 'idle' || prepareState === 'loading') ? (
|
|
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
|
|
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
<span>
|
|
{prepareMessage ??
|
|
(prepareState === 'idle'
|
|
? 'Warming up CLI environment...'
|
|
: 'Preparing environment...')}
|
|
</span>
|
|
<span className="text-[var(--color-text-muted)]">·</span>
|
|
<span>Team provisioning via local Claude CLI.</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{canCreate && launchTeam && prepareState === 'ready' ? (
|
|
<div className="flex items-center gap-2 text-xs text-emerald-400">
|
|
<CheckCircle2 className="size-3.5 shrink-0" />
|
|
<span>CLI environment ready</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
{canOpenExistingTeam ? (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
onOpenTeam(request.teamName);
|
|
onClose();
|
|
}}
|
|
>
|
|
Open Existing Team
|
|
</Button>
|
|
) : null}
|
|
<Button variant="outline" size="sm" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={!canCreate || isSubmitting || (launchTeam && prepareState !== 'ready')}
|
|
onClick={handleSubmit}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
|
Creating...
|
|
</>
|
|
) : (
|
|
'Create'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|