- Added `stopTeam` method in `TeamProvisioningService` to terminate running processes for teams, improving resource management. - Introduced IPC channel `TEAM_STOP` to handle stop requests from the renderer process. - Enhanced message relaying for team leads by implementing `relayLeadInboxMessages`, ensuring timely communication of unread messages. - Updated UI components to support stopping teams and display pending replies, enhancing user experience during team management.
442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
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 { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|
import { cn } from '@renderer/lib/utils';
|
|
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
|
|
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
|
import type { Project, TeamLaunchRequest, TeamProvisioningPrepareResult } from '@shared/types';
|
|
|
|
interface LaunchTeamDialogProps {
|
|
open: boolean;
|
|
teamName: string;
|
|
defaultProjectPath?: string;
|
|
provisioningError: string | null;
|
|
onClose: () => void;
|
|
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
export const LaunchTeamDialog = ({
|
|
open,
|
|
teamName,
|
|
defaultProjectPath,
|
|
provisioningError,
|
|
onClose,
|
|
onLaunch,
|
|
}: LaunchTeamDialogProps): React.JSX.Element => {
|
|
const [cwdMode, setCwdMode] = useState<'project' | 'custom'>('project');
|
|
const [selectedProjectPath, setSelectedProjectPath] = useState('');
|
|
const [customCwd, setCustomCwd] = useState('');
|
|
const promptDraft = useDraftPersistence({ key: `launchTeam:${teamName}:prompt` });
|
|
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 [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const resetFormState = (): void => {
|
|
setLocalError(null);
|
|
setIsSubmitting(false);
|
|
setPrepareState('idle');
|
|
setPrepareMessage(null);
|
|
setPrepareWarnings([]);
|
|
setCwdMode('project');
|
|
setSelectedProjectPath('');
|
|
setCustomCwd('');
|
|
};
|
|
|
|
// Warm up CLI on open
|
|
useEffect(() => {
|
|
if (!open) {
|
|
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]);
|
|
|
|
// Fetch projects on open
|
|
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]);
|
|
|
|
// Pre-select defaultProjectPath when projects loaded
|
|
useEffect(() => {
|
|
if (cwdMode !== 'project') {
|
|
return;
|
|
}
|
|
if (selectedProjectPath || projects.length === 0) {
|
|
return;
|
|
}
|
|
if (defaultProjectPath) {
|
|
const match = projects.find((p) => p.path === defaultProjectPath);
|
|
if (match) {
|
|
setSelectedProjectPath(match.path);
|
|
return;
|
|
}
|
|
}
|
|
setSelectedProjectPath(projects[0].path);
|
|
}, [cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
|
|
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
|
|
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
|
() =>
|
|
projects.map((p) => ({
|
|
id: p.path,
|
|
name: p.name,
|
|
subtitle: p.path,
|
|
})),
|
|
[projects]
|
|
);
|
|
|
|
const activeError = localError ?? provisioningError;
|
|
|
|
const handleSubmit = (): void => {
|
|
if (!effectiveCwd) {
|
|
setLocalError('Select working directory (cwd)');
|
|
return;
|
|
}
|
|
setLocalError(null);
|
|
setIsSubmitting(true);
|
|
|
|
void (async () => {
|
|
try {
|
|
await onLaunch({
|
|
teamName,
|
|
cwd: effectiveCwd,
|
|
prompt: promptDraft.value.trim() || 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">Launch Team</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
Start team <span className="font-mono font-medium">{teamName}</span> via local Claude
|
|
CLI.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{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}
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs text-[var(--color-text-muted)]">Project</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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="launch-prompt" className="text-xs text-[var(--color-text-muted)]">
|
|
Prompt (optional)
|
|
</Label>
|
|
<MentionableTextarea
|
|
id="launch-prompt"
|
|
className="min-h-[100px] text-xs"
|
|
minRows={4}
|
|
maxRows={12}
|
|
value={promptDraft.value}
|
|
onValueChange={promptDraft.setValue}
|
|
suggestions={mentionSuggestions}
|
|
placeholder="Instructions for team lead... Use @ to mention projects."
|
|
footerRight={
|
|
promptDraft.isSaved ? (
|
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{activeError ? (
|
|
<p className="rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
|
|
{activeError}
|
|
</p>
|
|
) : null}
|
|
|
|
{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>
|
|
</div>
|
|
) : null}
|
|
|
|
{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">
|
|
<Button variant="outline" size="sm" onClick={onClose}>
|
|
Close
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
|
disabled={isSubmitting || prepareState !== 'ready'}
|
|
onClick={handleSubmit}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
|
Launching...
|
|
</>
|
|
) : (
|
|
'Launch'
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|