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:
2026-05-16 20:06:51 +00:00
parent 934f739ca1
commit 92bd3b1cdf
16 changed files with 984 additions and 35 deletions

View 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>
);
}

View File

@@ -15,6 +15,7 @@ import { AttachmentChip } from '@/components/AttachmentChip';
import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { useViewport } from '@/hooks/useViewport';
@@ -24,11 +25,15 @@ const MAX_ATTACHMENTS = 10;
interface Props {
disabled?: boolean;
projectId: string;
// Batch 9: optional so callers that pre-date the agent picker still compile.
// When omitted, the toolbar row is hidden entirely.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void | Promise<void>;
onForceSend?: (content: string) => void | Promise<void>;
}
export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, onSend, onForceSend }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -420,6 +425,18 @@ export function ChatInput({ disabled, projectId, onSend, onForceSend }: Props) {
))}
</div>
)}
{/* Batch 9 toolbar — agent picker. Sits above the input row so it
doesn't compete with the send button for vertical alignment.
When Batch 7 lands, ModelPicker and the + button join this row. */}
{onAgentChange && (
<div className="px-4 pt-2 flex items-center gap-1.5">
<AgentPicker
projectId={projectId}
value={agentId ?? null}
onChange={onAgentChange}
/>
</div>
)}
<div className="px-4 py-3 flex items-end gap-2">
<Textarea
ref={textareaRef}

View File

@@ -20,9 +20,12 @@ import { cn } from '@/lib/utils';
interface Props {
sessionId: string;
projectId: string;
// Batch 9: threaded down to ChatPane → ChatInput → AgentPicker.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
}
export function Workspace({ sessionId, projectId }: Props) {
export function Workspace({ sessionId, projectId, agentId, onAgentChange }: Props) {
const {
panes,
activePaneIdx,
@@ -219,7 +222,14 @@ export function Workspace({ sessionId, projectId }: Props) {
<div className="flex-1 min-h-0 overflow-hidden">
{pane.kind === 'chat' && pane.chatId ? (
<ChatPane sessionId={sessionId} chatId={pane.chatId} projectId={projectId} sessionChats={chats} />
<ChatPane
sessionId={sessionId}
chatId={pane.chatId}
projectId={projectId}
agentId={agentId}
onAgentChange={onAgentChange}
sessionChats={chats}
/>
) : (
<SessionLandingPage
sessionId={sessionId}

View File

@@ -18,10 +18,13 @@ interface Props {
sessionId: string;
chatId: string;
projectId: string;
// Batch 9: optional, threaded down to ChatInput's agent picker.
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
sessionChats?: import('@/api/types').Chat[];
}
export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props) {
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats }: Props) {
const stream = useSessionStream(sessionId);
const lastErrorRef = useRef<string | null>(null);
const [queue, setQueue] = useState<string[]>([]);
@@ -167,7 +170,14 @@ export function ChatPane({ sessionId, chatId, projectId, sessionChats }: Props)
<div className="relative">
<ChatContextPopover stats={contextStats} />
<ChatInput disabled={false} projectId={projectId} onSend={handleSend} onForceSend={streaming ? handleForceSend : undefined} />
<ChatInput
disabled={false}
projectId={projectId}
agentId={agentId}
onAgentChange={onAgentChange}
onSend={handleSend}
onForceSend={streaming ? handleForceSend : undefined}
/>
</div>
</div>
);