- Improved lightbox toolbar button hit targets for better accessibility. - Updated ActivityItem and ActivityTimeline components to support managed collapse states for messages. - Refactored message collapsing logic to allow for user-controlled expansion in various components. - Enhanced CreateTeamDialog and LaunchTeamDialog with improved loading indicators and layout adjustments. - Increased maximum message length in SendMessageDialog to accommodate larger inputs. - Added icons and visual enhancements in ProjectPathSelector and EffortLevelSelector for better user experience.
577 lines
20 KiB
TypeScript
577 lines
20 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
|
import { SkipPermissionsCheckbox } from '@renderer/components/team/dialogs/SkipPermissionsCheckbox';
|
|
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 { Label } from '@renderer/components/ui/label';
|
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
|
import { useChipDraftPersistence } from '@renderer/hooks/useChipDraftPersistence';
|
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
|
import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer';
|
|
import { useStore } from '@renderer/store';
|
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
|
import { AlertTriangle, CheckCircle2, Loader2, RotateCcw, X } from 'lucide-react';
|
|
|
|
import { EffortLevelSelector } from './EffortLevelSelector';
|
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
|
import { computeEffectiveTeamModel, TeamModelSelector } from './TeamModelSelector';
|
|
|
|
import type { ActiveTeamRef } from './CreateTeamDialog';
|
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
|
import type {
|
|
EffortLevel,
|
|
Project,
|
|
ResolvedTeamMember,
|
|
TeamLaunchRequest,
|
|
TeamProvisioningPrepareResult,
|
|
} from '@shared/types';
|
|
|
|
interface LaunchTeamDialogProps {
|
|
open: boolean;
|
|
teamName: string;
|
|
members: ResolvedTeamMember[];
|
|
defaultProjectPath?: string;
|
|
provisioningError: string | null;
|
|
clearProvisioningError?: () => void;
|
|
activeTeams?: ActiveTeamRef[];
|
|
onClose: () => void;
|
|
onLaunch: (request: TeamLaunchRequest) => Promise<void>;
|
|
}
|
|
|
|
export const LaunchTeamDialog = ({
|
|
open,
|
|
teamName,
|
|
members,
|
|
defaultProjectPath,
|
|
provisioningError,
|
|
clearProvisioningError,
|
|
activeTeams,
|
|
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 chipDraft = useChipDraftPersistence(`launchTeam:${teamName}:chips`);
|
|
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 [selectedModel, setSelectedModelRaw] = useState(() => {
|
|
const stored = localStorage.getItem('team:lastSelectedModel') ?? '';
|
|
return stored === '__default__' ? '' : stored;
|
|
});
|
|
const [extendedContext, setExtendedContextRaw] = useState(
|
|
() => localStorage.getItem('team:lastExtendedContext') === 'true'
|
|
);
|
|
const [skipPermissions, setSkipPermissionsRaw] = useState(
|
|
() => localStorage.getItem('team:lastSkipPermissions') !== 'false'
|
|
);
|
|
const [selectedEffort, setSelectedEffortRaw] = useState(
|
|
() => localStorage.getItem('team:lastSelectedEffort') ?? ''
|
|
);
|
|
const [clearContext, setClearContext] = useState(false);
|
|
const [conflictDismissed, setConflictDismissed] = useState(false);
|
|
|
|
const setSelectedModel = (value: string): void => {
|
|
setSelectedModelRaw(value);
|
|
localStorage.setItem('team:lastSelectedModel', value);
|
|
};
|
|
|
|
const setExtendedContext = (value: boolean): void => {
|
|
setExtendedContextRaw(value);
|
|
localStorage.setItem('team:lastExtendedContext', String(value));
|
|
};
|
|
|
|
const setSkipPermissions = (value: boolean): void => {
|
|
setSkipPermissionsRaw(value);
|
|
localStorage.setItem('team:lastSkipPermissions', String(value));
|
|
};
|
|
|
|
const setSelectedEffort = (value: string): void => {
|
|
setSelectedEffortRaw(value);
|
|
localStorage.setItem('team:lastSelectedEffort', value);
|
|
};
|
|
|
|
const resetFormState = (): void => {
|
|
setLocalError(null);
|
|
setIsSubmitting(false);
|
|
setPrepareState('idle');
|
|
setPrepareMessage(null);
|
|
setPrepareWarnings([]);
|
|
setCwdMode('project');
|
|
setSelectedProjectPath('');
|
|
setCustomCwd('');
|
|
setClearContext(false);
|
|
setConflictDismissed(false);
|
|
chipDraft.clearChipDraft();
|
|
};
|
|
|
|
// Clear stale provisioning error when dialog opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
clearProvisioningError?.();
|
|
}
|
|
}, [open, clearProvisioningError]);
|
|
|
|
// 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]);
|
|
|
|
const repositoryGroups = useStore((s) => s.repositoryGroups);
|
|
|
|
// Fetch projects on open, merging with repositoryGroups from store
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
|
|
setProjectsLoading(true);
|
|
setProjectsError(null);
|
|
|
|
let cancelled = false;
|
|
void (async () => {
|
|
try {
|
|
const apiProjects = await api.getProjects();
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
// Merge repositoryGroups (may include synthetic folders without sessions)
|
|
const pathSet = new Set(apiProjects.map((p) => p.path));
|
|
const extras: Project[] = [];
|
|
for (const repo of repositoryGroups) {
|
|
for (const wt of repo.worktrees) {
|
|
if (!pathSet.has(wt.path)) {
|
|
pathSet.add(wt.path);
|
|
extras.push({
|
|
id: wt.id,
|
|
path: wt.path,
|
|
name: wt.name,
|
|
sessions: [],
|
|
totalSessions: 0,
|
|
createdAt: wt.createdAt ?? Date.now(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
setProjects([...apiProjects, ...extras]);
|
|
} 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, repositoryGroups]);
|
|
|
|
// Pre-select defaultProjectPath when projects loaded (only while dialog is open)
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
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);
|
|
}, [open, cwdMode, projects, selectedProjectPath, defaultProjectPath]);
|
|
|
|
const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim();
|
|
|
|
// Pre-warm file list cache so @-mention file search is instant
|
|
useFileListCacheWarmer(effectiveCwd || null);
|
|
|
|
const conflictingTeam = useMemo(() => {
|
|
if (!activeTeams?.length || !effectiveCwd) return null;
|
|
const norm = normalizePath(effectiveCwd);
|
|
return (
|
|
activeTeams.find((t) => t.teamName !== teamName && normalizePath(t.projectPath) === norm) ??
|
|
null
|
|
);
|
|
}, [activeTeams, effectiveCwd, teamName]);
|
|
|
|
// Reset dismiss when conflict target changes (different path or different conflicting team)
|
|
useEffect(() => {
|
|
setConflictDismissed(false);
|
|
}, [conflictingTeam?.teamName, effectiveCwd]);
|
|
|
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
|
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 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,
|
|
model: computeEffectiveTeamModel(selectedModel, extendedContext),
|
|
effort: (selectedEffort as EffortLevel) || undefined,
|
|
clearContext: clearContext || undefined,
|
|
skipPermissions,
|
|
});
|
|
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-w-2xl">
|
|
<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>
|
|
|
|
{conflictingTeam && !conflictDismissed ? (
|
|
<div
|
|
className="rounded-md border p-3 text-xs"
|
|
style={{
|
|
backgroundColor: 'var(--warning-bg)',
|
|
borderColor: 'var(--warning-border)',
|
|
color: 'var(--warning-text)',
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
<p className="font-medium">
|
|
Another team “{conflictingTeam.displayName}” is already running for
|
|
this working directory
|
|
</p>
|
|
<p className="opacity-80">
|
|
Running two teams in the same directory is risky — they may conflict editing the
|
|
same files. Consider using a different directory or a git worktree for isolation.
|
|
</p>
|
|
<p className="text-[11px] opacity-70">
|
|
Working directory: <span className="font-mono">{effectiveCwd}</span>
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-0.5 opacity-60 transition-colors hover:opacity-100"
|
|
onClick={() => setConflictDismissed(true)}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{prepareState === 'failed' ? (
|
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 p-3 text-xs">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
|
<div className="min-w-0 space-y-1">
|
|
<p className="font-medium text-red-300">
|
|
CLI environment is not available — launch is blocked
|
|
</p>
|
|
<p className="text-red-300/80">
|
|
{prepareMessage ?? 'Failed to prepare environment'}
|
|
</p>
|
|
{prepareWarnings.length > 0 ? (
|
|
<div className="space-y-0.5">
|
|
{prepareWarnings.map((warning) => (
|
|
<p
|
|
key={warning}
|
|
className="text-[11px]"
|
|
style={{ color: 'var(--warning-text)' }}
|
|
>
|
|
{warning}
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
|
Make sure <span className="font-mono">claude</span> CLI is installed and available
|
|
in PATH, then reopen this dialog.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-4">
|
|
<ProjectPathSelector
|
|
cwdMode={cwdMode}
|
|
onCwdModeChange={setCwdMode}
|
|
selectedProjectPath={selectedProjectPath}
|
|
onSelectedProjectPathChange={setSelectedProjectPath}
|
|
customCwd={customCwd}
|
|
onCustomCwdChange={setCustomCwd}
|
|
projects={projects}
|
|
projectsLoading={projectsLoading}
|
|
projectsError={projectsError}
|
|
/>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="launch-prompt" className="label-optional">
|
|
Prompt for team lead (optional)
|
|
</Label>
|
|
<MentionableTextarea
|
|
id="launch-prompt"
|
|
className="min-h-[100px] text-xs"
|
|
minRows={4}
|
|
maxRows={12}
|
|
value={promptDraft.value}
|
|
onValueChange={promptDraft.setValue}
|
|
suggestions={mentionSuggestions}
|
|
projectPath={effectiveCwd || null}
|
|
chips={chipDraft.chips}
|
|
onChipRemove={chipDraft.removeChip}
|
|
onFileChipInsert={chipDraft.addChip}
|
|
placeholder="Instructions for team lead..."
|
|
footerRight={
|
|
promptDraft.isSaved ? (
|
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
|
) : null
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<TeamModelSelector
|
|
value={selectedModel}
|
|
onValueChange={setSelectedModel}
|
|
id="launch-model"
|
|
/>
|
|
<EffortLevelSelector
|
|
value={selectedEffort}
|
|
onValueChange={setSelectedEffort}
|
|
id="launch-effort"
|
|
/>
|
|
<ExtendedContextCheckbox
|
|
id="launch-extended-context"
|
|
checked={extendedContext}
|
|
onCheckedChange={setExtendedContext}
|
|
disabled={selectedModel === 'haiku'}
|
|
/>
|
|
<SkipPermissionsCheckbox
|
|
id="launch-skip-permissions"
|
|
checked={skipPermissions}
|
|
onCheckedChange={setSkipPermissions}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="clear-context"
|
|
checked={clearContext}
|
|
onCheckedChange={(checked) => setClearContext(checked === true)}
|
|
/>
|
|
<Label
|
|
htmlFor="clear-context"
|
|
className="flex cursor-pointer items-center gap-1.5 text-xs font-normal text-text-secondary"
|
|
>
|
|
<RotateCcw className="size-3 shrink-0" />
|
|
Clear context (fresh session)
|
|
</Label>
|
|
</div>
|
|
{clearContext && (
|
|
<div
|
|
className="rounded-md border px-3 py-2 text-xs"
|
|
style={{
|
|
backgroundColor: 'var(--warning-bg)',
|
|
borderColor: 'var(--warning-border)',
|
|
color: 'var(--warning-text)',
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
|
<p>
|
|
The team lead will start a new session without resuming previous context. All
|
|
accumulated session memory and conversation history will not be available.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</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}
|
|
|
|
<DialogFooter className="pt-4 sm:justify-between">
|
|
<div className="min-w-0">
|
|
{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>
|
|
<div className="flex items-center gap-1.5 text-xs font-medium text-emerald-400">
|
|
<CheckCircle2 className="size-3.5 shrink-0" />
|
|
<span>
|
|
{prepareWarnings.length > 0
|
|
? 'CLI environment ready (with notes)'
|
|
: 'CLI environment ready'}
|
|
</span>
|
|
</div>
|
|
{prepareMessage ? (
|
|
<p className="mt-0.5 pl-5 text-[11px] text-[var(--color-text-muted)]">
|
|
{prepareMessage}
|
|
</p>
|
|
) : null}
|
|
{prepareWarnings.length > 0 ? (
|
|
<div className="mt-0.5 space-y-0.5 pl-5">
|
|
{prepareWarnings.map((warning) => (
|
|
<p key={warning} className="text-[11px] text-sky-300">
|
|
{warning}
|
|
</p>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{prepareState === 'failed' ? <div /> : null}
|
|
</div>
|
|
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<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>
|
|
</div>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|