feat(agents): Tier 2 — AGENTS.md + per-session picker
Six builtin defaults (Code Reviewer, Debugger, Refactorer, Architect, Security Auditor, Prompt Builder) with no model field so session.model wins. Project root AGENTS.md parsed on demand with mtime cache; when present, only its agents are shown. sessions.agent_id resolves per turn into effective system prompt, temperature, and a tool whitelist applied in inference. AgentPicker mounts in the ChatInput toolbar; SettingsDrawer agent surface deferred to Batch 7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
apps/web/src/components/AgentPicker.tsx
Normal file
108
apps/web/src/components/AgentPicker.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Agent } from '@/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
value: string | null;
|
||||
onChange: (agentId: string | null) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function AgentPicker({ projectId, value, onChange }: Props) {
|
||||
const [agents, setAgents] = useState<Agent[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Load on mount (and on projectId change) so the trigger shows the agent
|
||||
// name immediately, not the raw id. AGENTS.md parse errors surface as a
|
||||
// toast once per load.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setAgents(null);
|
||||
setError(null);
|
||||
api.agents
|
||||
.list(projectId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setAgents(res.agents);
|
||||
if (res.parse_error) {
|
||||
toast.error(`AGENTS.md parse error: ${res.parse_error}`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'failed to load agents');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const selectedAgent = agents?.find((a) => a.id === value) ?? null;
|
||||
const triggerLabel = value === null
|
||||
? 'No agent'
|
||||
: selectedAgent?.name ?? value;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60"
|
||||
title={selectedAgent?.description ?? undefined}
|
||||
>
|
||||
<span className="truncate max-w-[160px]">{triggerLabel}</span>
|
||||
<ChevronDown className="size-3 opacity-70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-80 overflow-y-auto w-72">
|
||||
{error && (
|
||||
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
{agents === null && !error && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
{agents !== null && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => void onChange(null)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={`size-3 ${value === null ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span className="font-medium">No agent</span>
|
||||
</DropdownMenuItem>
|
||||
{agents.length > 0 && <DropdownMenuSeparator />}
|
||||
{agents.map((a) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onSelect={() => void onChange(a.id)}
|
||||
className="text-xs flex-col items-start gap-0.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check
|
||||
className={`size-3 ${a.id === value ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
</div>
|
||||
{a.description && (
|
||||
<span className="text-muted-foreground pl-[18px] truncate w-full">
|
||||
{a.description}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user