Add top_p/top_k/min_p/presence_penalty to AGENTS.md frontmatter and thread through inference (agents.ts parser → Agent type → stream-phase → sentinel summaries). Null means omit from request body, preserving provider defaults. Wire ask_user_input interactive card into both BooCoder frontends: the CoderPane in BooChat's SPA (CoderMessageList now renders AskUserInputCard instead of ToolCallLine for ask_user_input tool calls) and the standalone coder SPA (MessageBubble + new AskUserInputCard + shadcn ui primitives). Additional fixes: SessionLandingPage uses ChatInput with slash-command support and lazy chat creation; Session.tsx hydrate-race fix for empty pane promotion; AgentPicker wider dropdown with line-clamp; ModelPicker min-width; Textarea converted to forwardRef; Recon agent added to AGENTS.md; codecontext host port exposed in docker-compose. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { Check, ChevronDown } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { api } from '@/api/client';
|
|
import type { Agent, AgentParseError, ToolCostStat } 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 [parseErrors, setParseErrors] = useState<AgentParseError[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
// v1.13.10: per-tool cost rolling window. Fetched once on mount; would
|
|
// refresh on remount or page reload. Acceptable for a decision aid — the
|
|
// 100-call rolling mean doesn't shift fast.
|
|
const [costStats, setCostStats] = useState<ToolCostStat[]>([]);
|
|
|
|
// v1.8.1: per-agent parse errors are non-blocking. Silent if any agents
|
|
// loaded successfully; a gray warning toast fires only when EVERY agent
|
|
// in AGENTS.md failed to parse. Server logs a console.warn either way.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setAgents(null);
|
|
setParseErrors([]);
|
|
setError(null);
|
|
api.agents
|
|
.list(projectId)
|
|
.then((res) => {
|
|
if (cancelled) return;
|
|
setAgents(res.agents);
|
|
setParseErrors(res.errors);
|
|
if (res.errors.length > 0 && res.agents.length === 0) {
|
|
toast.warning(
|
|
`AGENTS.md: ${res.errors.length} agent${res.errors.length === 1 ? '' : 's'} failed to parse, none loaded`,
|
|
);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
setError(err instanceof Error ? err.message : 'failed to load agents');
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [projectId]);
|
|
|
|
// v1.13.10: cost stats are project-independent — the 100-call rolling
|
|
// window is global across all chats. Fetch once per mount; tolerate failure
|
|
// silently (cost line hides).
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api.tools
|
|
.costStats()
|
|
.then((r) => {
|
|
if (!cancelled) setCostStats(r.stats);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) setCostStats([]);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const costByTool = useMemo(
|
|
() => Object.fromEntries(costStats.map((s) => [s.tool_name, s])),
|
|
[costStats],
|
|
);
|
|
|
|
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-96">
|
|
{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) => {
|
|
const cost = agentCost(a, costByTool);
|
|
return (
|
|
<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] line-clamp-2 w-full">
|
|
{a.description}
|
|
</span>
|
|
)}
|
|
{cost.nWithData > 0 && (
|
|
<span className="text-muted-foreground/70 pl-[18px] truncate w-full">
|
|
~{formatK(cost.prompt)} prompt / {cost.completion} completion · {cost.nWithData}/{cost.nTools} tools{cost.mostRecent ? ` · last call ${formatAgo(cost.mostRecent)}` : ''}
|
|
</span>
|
|
)}
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
{parseErrors.length > 0 && (
|
|
<div
|
|
className="px-2 py-1.5 mt-1 text-xs text-amber-500 border-t border-border"
|
|
title={parseErrors.map((e) => `${e.agent_name}: ${e.reason}`).join('\n')}
|
|
>
|
|
{parseErrors.length} agent{parseErrors.length === 1 ? '' : 's'} skipped
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
// v1.13.10: sum the per-tool means across an agent's whitelisted tools.
|
|
// Sum-of-means, not mean-of-sums — we're combining independent rolling
|
|
// averages. nWithData reflects how many of the agent's tools have any
|
|
// history yet; the line hides entirely when zero so a fresh deploy doesn't
|
|
// render "0k / 0 / 0 tools".
|
|
function agentCost(
|
|
agent: Agent,
|
|
costByTool: Record<string, ToolCostStat>,
|
|
): {
|
|
prompt: number;
|
|
completion: number;
|
|
nTools: number;
|
|
nWithData: number;
|
|
mostRecent: string | null;
|
|
} {
|
|
let prompt = 0;
|
|
let completion = 0;
|
|
let nWithData = 0;
|
|
let mostRecent: string | null = null;
|
|
for (const t of agent.tools) {
|
|
const s = costByTool[t];
|
|
if (!s) continue;
|
|
prompt += s.mean_prompt_tokens;
|
|
completion += s.mean_completion_tokens;
|
|
nWithData++;
|
|
if (!mostRecent || s.updated_at > mostRecent) mostRecent = s.updated_at;
|
|
}
|
|
return { prompt, completion, nTools: agent.tools.length, nWithData, mostRecent };
|
|
}
|
|
|
|
function formatK(n: number): string {
|
|
if (n < 1000) return String(n);
|
|
if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
|
|
return `${Math.round(n / 1000)}k`;
|
|
}
|
|
|
|
function formatAgo(iso: string): string {
|
|
const then = new Date(iso).getTime();
|
|
if (Number.isNaN(then)) return '—';
|
|
const diff = Date.now() - then;
|
|
if (diff < 60_000) return 'just now';
|
|
if (diff < 3_600_000) return `${Math.round(diff / 60_000)}m ago`;
|
|
if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)}h ago`;
|
|
return `${Math.round(diff / 86_400_000)}d ago`;
|
|
}
|