agent-ecosystem/src/renderer/components/team/dialogs/AdvancedCliSection.tsx
iliya f7130c3437 chore: update package scripts and improve CI workflow
- Enhanced package.json scripts by adding a new linting command for the MCP workspace and updating the check script to include MCP linting.
- Modified CI workflow to replace individual typecheck and lint steps with a consolidated validation step for improved efficiency.
- Introduced a new TypeScript configuration for testing in the MCP server, enhancing type checking capabilities.
- Reorganized imports in several components for better clarity and maintainability.
2026-03-07 19:37:59 +02:00

335 lines
12 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import { Popover, PopoverAnchor, PopoverContent } from '@renderer/components/ui/popover';
import { parseCliArgs, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser';
import {
AlertTriangle,
CheckCircle2,
ChevronRight,
Clock,
Loader2,
Terminal,
XCircle,
} from 'lucide-react';
interface AdvancedCliSectionProps {
teamName: string;
/** All CLI args from parent (model, effort, permissions, resume, etc.) */
internalArgs: string[];
worktreeEnabled: boolean;
onWorktreeEnabledChange: (enabled: boolean) => void;
worktreeName: string;
onWorktreeNameChange: (name: string) => void;
customArgs: string;
onCustomArgsChange: (args: string) => void;
}
/** Infrastructure flags that are dimmed in command preview. */
const INFRA_FLAGS = new Set([
'--input-format',
'--output-format',
'--setting-sources',
'--mcp-config',
'--disallowedTools',
'--verbose',
]);
type ValidationState = 'idle' | 'loading' | 'success' | 'error';
type TokenType = 'command' | 'visible' | 'infra' | 'custom';
/** Map token type → Tailwind color class (pure function, no state dependency). */
const TOKEN_COLOR_CLASS: Record<TokenType, string> = {
command: 'text-text',
visible: 'text-text',
infra: 'text-text-muted',
custom: 'text-emerald-400',
};
/** Read worktree history from localStorage for a given team. */
function readWorktreeHistory(teamName: string): string[] {
try {
const raw = localStorage.getItem(`team:worktreeHistory:${teamName}`);
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: [];
} catch {
return [];
}
}
/**
* Collapsible "Advanced" section for CreateTeamDialog and LaunchTeamDialog.
* Contains: worktree checkbox with history, command preview, custom args + validate.
*/
export const AdvancedCliSection: React.FC<AdvancedCliSectionProps> = ({
teamName,
internalArgs,
worktreeEnabled,
onWorktreeEnabledChange,
worktreeName,
onWorktreeNameChange,
customArgs,
onCustomArgsChange,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [validationState, setValidationState] = useState<ValidationState>('idle');
const [validationMessage, setValidationMessage] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
// Read worktree history from localStorage; re-read when teamName changes
const [worktreeHistory, setWorktreeHistory] = useState<string[]>(() =>
readWorktreeHistory(teamName)
);
useEffect(() => {
setWorktreeHistory(readWorktreeHistory(teamName));
}, [teamName]);
// Commit worktree name to history on blur
const commitWorktreeName = useCallback(() => {
const name = worktreeName.trim();
if (!name) return;
setWorktreeHistory((prev) => {
const next = [name, ...prev.filter((n) => n !== name)].slice(0, 10);
localStorage.setItem(`team:worktreeHistory:${teamName}`, JSON.stringify(next));
return next;
});
}, [worktreeName, teamName]);
// Build command preview tokens
const previewTokens = useMemo(() => {
const tokens: { text: string; type: 'command' | 'visible' | 'infra' | 'custom' }[] = [];
tokens.push({ text: 'claude', type: 'command' });
// Process internalArgs: classify each as visible or infra
let i = 0;
while (i < internalArgs.length) {
const arg = internalArgs[i];
const isInfra = INFRA_FLAGS.has(arg);
const type = isInfra ? 'infra' : 'visible';
tokens.push({ text: arg, type });
// Check if next token is the value for this flag (not starting with --)
if (i + 1 < internalArgs.length && !internalArgs[i + 1].startsWith('--')) {
tokens.push({ text: internalArgs[i + 1], type });
i += 2;
} else {
i += 1;
}
}
// Worktree
if (worktreeEnabled && worktreeName.trim()) {
tokens.push({ text: '--worktree', type: 'visible' });
tokens.push({ text: worktreeName.trim(), type: 'visible' });
}
// Custom args
const parsed = parseCliArgs(customArgs);
for (const t of parsed) {
tokens.push({ text: t, type: 'custom' });
}
return tokens;
}, [internalArgs, worktreeEnabled, worktreeName, customArgs]);
// Validate handler
const handleValidate = useCallback(async () => {
if (!customArgs.trim()) return;
setValidationState('loading');
setValidationMessage(null);
try {
const result = await window.electronAPI.teams.validateCliArgs(customArgs);
if (result.valid) {
setValidationState('success');
setValidationMessage('All flags valid');
} else {
setValidationState('error');
const flags = result.invalidFlags ?? [];
const unknown = flags.filter((f) => !PROTECTED_CLI_FLAGS.has(f));
const protectedOnes = flags.filter((f) => PROTECTED_CLI_FLAGS.has(f));
const parts: string[] = [];
if (unknown.length > 0) parts.push(`Unknown: ${unknown.join(', ')}`);
if (protectedOnes.length > 0) parts.push(`Protected: ${protectedOnes.join(', ')}`);
setValidationMessage(parts.join(' | '));
}
} catch (err) {
setValidationState('error');
setValidationMessage(err instanceof Error ? err.message : 'Validation failed');
}
}, [customArgs]);
// Reset validation when custom args change
const handleCustomArgsChange = useCallback(
(value: string) => {
onCustomArgsChange(value);
if (validationState !== 'idle') {
setValidationState('idle');
setValidationMessage(null);
}
},
[onCustomArgsChange, validationState]
);
const filteredHistory = useMemo(
() =>
worktreeHistory.filter(
(name) => name !== worktreeName && (!worktreeName || name.includes(worktreeName))
),
[worktreeHistory, worktreeName]
);
return (
<div className="mt-3">
{/* Collapsible header */}
<button
type="button"
className="flex items-center gap-1 text-xs text-text-secondary transition-colors hover:text-text"
onClick={() => setIsOpen(!isOpen)}
>
<ChevronRight
className={`size-3.5 transition-transform duration-150 ${isOpen ? 'rotate-90' : ''}`}
/>
<Terminal className="size-3" />
<span>Advanced</span>
</button>
{isOpen && (
<div className="mt-2 space-y-3 pl-5">
{/* Worktree */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Checkbox
id={`worktree-${teamName}`}
checked={worktreeEnabled}
onCheckedChange={(value) => onWorktreeEnabledChange(value === true)}
/>
<Label
htmlFor={`worktree-${teamName}`}
className="cursor-pointer text-xs font-normal text-text-secondary"
>
Use worktree
</Label>
</div>
{worktreeEnabled && (
<Popover open={showHistory && filteredHistory.length > 0}>
<PopoverAnchor asChild>
<Input
placeholder="worktree-name"
className="h-7 font-mono text-xs"
value={worktreeName}
onChange={(e) => onWorktreeNameChange(e.target.value)}
onFocus={() => setShowHistory(true)}
onBlur={() => {
// Delay to allow click on history items
setTimeout(() => {
setShowHistory(false);
commitWorktreeName();
}, 150);
}}
/>
</PopoverAnchor>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-1"
align="start"
sideOffset={2}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex items-center gap-1.5 px-2 py-1 text-[10px] text-text-muted">
<Clock className="size-3" />
<span>Recent</span>
</div>
{filteredHistory.map((name) => (
<button
key={name}
type="button"
className="w-full rounded px-2 py-1 text-left font-mono text-xs text-text-secondary hover:bg-surface-raised hover:text-text"
onMouseDown={(e) => {
e.preventDefault(); // Prevent input blur
onWorktreeNameChange(name);
setShowHistory(false);
}}
>
{name}
</button>
))}
</PopoverContent>
</Popover>
)}
</div>
{/* Command preview */}
<div className="space-y-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">
Command preview
</span>
<div className="overflow-x-auto rounded border border-border bg-surface-sidebar px-2.5 py-1.5">
<code className="flex flex-wrap gap-x-1 gap-y-0.5 font-mono text-[11px] leading-relaxed">
{previewTokens.map((token, i) => (
<span key={i} className={TOKEN_COLOR_CLASS[token.type]}>
{token.text}
</span>
))}
</code>
</div>
</div>
{/* Custom arguments */}
<div className="space-y-1.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-text-muted">
Custom arguments
</span>
<div className="flex items-center gap-2">
<Input
placeholder="--max-turns 5"
className="h-7 flex-1 font-mono text-xs"
value={customArgs}
onChange={(e) => handleCustomArgsChange(e.target.value)}
/>
{customArgs.trim() && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={validationState === 'loading'}
onClick={handleValidate}
>
{validationState === 'loading' ? (
<Loader2 className="mr-1 size-3 animate-spin" />
) : null}
Validate
</Button>
)}
</div>
{/* Validation result */}
{validationState === 'success' && validationMessage && (
<div className="flex items-center gap-1.5 text-xs text-emerald-400">
<CheckCircle2 className="size-3" />
<span>{validationMessage}</span>
</div>
)}
{validationState === 'error' && validationMessage && (
<div className="flex items-start gap-1.5 text-xs">
{validationMessage.includes('Protected') ? (
<AlertTriangle className="mt-0.5 size-3 shrink-0 text-amber-400" />
) : (
<XCircle className="mt-0.5 size-3 shrink-0 text-red-400" />
)}
<span className="text-text-secondary">{validationMessage}</span>
</div>
)}
</div>
</div>
)}
</div>
);
};