Files
boocode/apps/web/src/components/AgentComposerBar.tsx
indifferentketchup afaca9e426 feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)
- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:01:03 +00:00

409 lines
14 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'lucide-react';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import type { AgentStatusEntry } from '@/hooks/useAgentStatus';
import { providerIcon } from '@/components/coder/providerIcons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
import { cn } from '@/lib/utils';
const PREFS_KEY = 'boocode.coder.agent-prefs';
type ProviderPrefs = Record<string, {
model: string;
modeId: string | null;
thinkingOptionId: string | null;
}>;
function loadPrefs(): ProviderPrefs {
try {
const raw = localStorage.getItem(PREFS_KEY);
return raw ? (JSON.parse(raw) as ProviderPrefs) : {};
} catch {
return {};
}
}
function savePrefs(prefs: ProviderPrefs): void {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
}
function defaultsForProvider(entry: ProviderSnapshotEntry): AgentSessionConfig {
const model =
entry.models.find((m) => m.isDefault)?.id ??
entry.models[0]?.id ??
'';
const selectedModel = entry.models.find((m) => m.id === model);
const modeId = entry.defaultModeId ?? entry.modes[0]?.id ?? null;
const thinkingOptionId =
selectedModel?.defaultThinkingOptionId ??
selectedModel?.thinkingOptions?.find((t) => t.isDefault)?.id ??
selectedModel?.thinkingOptions?.[0]?.id ??
null;
return {
provider: entry.name,
model,
modeId,
thinkingOptionId,
};
}
function resolveConfig(
entry: ProviderSnapshotEntry,
prefs: ProviderPrefs,
): AgentSessionConfig {
const saved = prefs[entry.name];
const base = defaultsForProvider(entry);
const model =
saved?.model && entry.models.some((m) => m.id === saved.model)
? saved.model
: base.model;
const selectedModel = entry.models.find((m) => m.id === model);
const modeId =
saved?.modeId && entry.modes.some((m) => m.id === saved.modeId)
? saved.modeId
: base.modeId;
const thinkingOptions = selectedModel?.thinkingOptions ?? [];
const thinkingOptionId =
saved?.thinkingOptionId &&
thinkingOptions.some((t) => t.id === saved.thinkingOptionId)
? saved.thinkingOptionId
: base.thinkingOptionId;
return { provider: entry.name, model, modeId, thinkingOptionId };
}
interface PickerProps {
label: string;
value: string;
disabled?: boolean;
options: Array<{ id: string; label: string }>;
onPick: (id: string) => void;
icon?: React.ReactNode;
/** Mobile: render icon + chevron only (no value label) to save row width. */
iconOnly?: boolean;
}
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
const { isMobile } = useViewport();
const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
const list = (
<div className="py-1">
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
onPick(o.id);
setOpen(false);
}}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{o.label}</span>
</button>
))}
</div>
);
if (isMobile) {
return (
<>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
aria-label={`${label}: ${currentLabel}`}
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{icon}
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
<div className="px-2">{list}</div>
</BottomSheet>
</>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
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 disabled:opacity-40"
>
{icon}
<span className="truncate max-w-[180px]">{currentLabel}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
{options.map((o) => (
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
{o.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface Props {
projectPath?: string;
value: AgentSessionConfig;
onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
connected?: boolean;
// #10: normalized status (working|blocked|idle|error) for the active external
// agent in this chat, or null for native boocode / before any frame. Renders
// a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for
// non-coder callers — no dot.
agentStatus?: AgentStatusEntry | null;
}
// #10: normalized external-agent status dot. Mirrors StatusDot's visual
// language but on the four normalized buckets (working|blocked|idle|error),
// and is DISTINCT from the WS-liveness `connected` dot beside it:
// working — emerald spinning ring (subtle motion, like chat streaming)
// blocked — amber dot (matches the permission/blocked state colour)
// idle — gray dot
// error — red dot
function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: string }) {
const title =
`${agent}: ${entry.status}` + (entry.reason ? `${entry.reason}` : '');
if (entry.status === 'working') {
return (
<span
aria-label={`Agent status: working${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className="inline-block w-3 h-3 rounded-full border-2 border-emerald-500 border-t-transparent animate-spin shrink-0"
/>
);
}
const bg =
entry.status === 'blocked' ? 'bg-amber-500'
: entry.status === 'error' ? 'bg-destructive'
: 'bg-muted-foreground/40';
return (
<span
aria-label={`Agent status: ${entry.status}${entry.reason ? `${entry.reason}` : ''}`}
title={title}
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', bg)}
/>
);
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are
// hidden here and managed in Settings → Providers. Native boocode is always
// enabled+ready, so it always appears.
const entries = useMemo(
() => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
[allEntries],
);
const [refreshing, setRefreshing] = useState(false);
const hydratedRef = useRef(false);
useEffect(() => {
hydratedRef.current = false;
}, [projectPath]);
useEffect(() => {
if (!entries?.length || hydratedRef.current) return;
hydratedRef.current = true;
const prefs = loadPrefs();
const entry =
entries.find((e) => e.name === value.provider) ??
entries.find((e) => e.name === 'boocode') ??
entries[0];
if (!entry) return;
onChange(resolveConfig(entry, prefs));
}, [entries, onChange, value.provider]);
// If the active provider is disabled in the settings drawer it drops out of
// `entries` (the 5.5 filter) — fall back to boocode so the composer never
// strands on an unselectable provider with empty model/mode pickers.
useEffect(() => {
if (!entries?.length) return;
if (entries.some((e) => e.name === value.provider)) return;
const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
if (!fallback) return;
onChange(resolveConfig(fallback, loadPrefs()));
}, [entries, value.provider, onChange]);
// 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
// return), refetch until terminal. Capped; no provider_snapshot_updated WS
// frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
const pollsRef = useRef(0);
useEffect(() => {
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
if (!anyLoading) {
pollsRef.current = 0;
return;
}
if (pollsRef.current >= 10) return;
const t = setTimeout(() => {
pollsRef.current += 1;
void refreshProviderSnapshot(projectPath);
}, 2000);
return () => clearTimeout(t);
}, [allEntries, projectPath]);
const currentEntry = useMemo(
() => entries?.find((e) => e.name === value.provider),
[entries, value.provider],
);
const currentModel = useMemo(
() => currentEntry?.models.find((m) => m.id === value.model),
[currentEntry, value.model],
);
const thinkingOptions = currentModel?.thinkingOptions ?? [];
useEffect(() => {
onProviderCommandsChange?.(currentEntry?.commands ?? []);
}, [currentEntry, onProviderCommandsChange]);
function persist(next: AgentSessionConfig): void {
const prefs = loadPrefs();
prefs[next.provider] = {
model: next.model,
modeId: next.modeId,
thinkingOptionId: next.thinkingOptionId,
};
savePrefs(prefs);
onChange(next);
}
function pickProvider(name: string): void {
const entry = entries?.find((e) => e.name === name);
if (!entry) return;
persist(resolveConfig(entry, loadPrefs()));
}
function pickModel(model: string): void {
const entry = currentEntry;
if (!entry) return;
const selected = entry.models.find((m) => m.id === model);
const thinkingOptionId =
selected?.defaultThinkingOptionId ??
selected?.thinkingOptions?.find((t) => t.isDefault)?.id ??
selected?.thinkingOptions?.[0]?.id ??
null;
persist({ ...value, model, thinkingOptionId });
}
async function handleRefresh(): Promise<void> {
setRefreshing(true);
try {
await api.coder.refreshProviders();
await refreshProviderSnapshot(projectPath);
} finally {
setRefreshing(false);
}
}
if (!entries) {
return (
<div className="text-xs text-muted-foreground px-2 py-1">Loading agents</div>
);
}
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
return (
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
<CompactPicker
label="Provider"
value={value.provider}
options={providerOptions}
onPick={pickProvider}
icon={
currentEntry?.status === 'loading'
? <Loader2 size={13} className="shrink-0 animate-spin" />
: providerIcon(value.provider)
}
/>
<CompactPicker
label="Mode"
value={value.modeId ?? ''}
disabled={modeOptions.length === 0}
options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />}
iconOnly
/>
<CompactPicker
label="Model"
value={value.model}
disabled={modelOptions.length === 0}
options={modelOptions}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
/>
{thinkingOpts.length > 0 && (
<CompactPicker
label="Thinking"
value={value.thinkingOptionId ?? ''}
options={thinkingOpts}
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
icon={<Brain className="size-3 shrink-0" />}
/>
)}
{/* Status dot + refresh — pinned right (ml-auto), never on its own line. */}
<div className="ml-auto flex items-center gap-1 shrink-0">
{/* #10: normalized agent status — only for an external agent with a
live status frame. Distinct from the WS-liveness dot that follows. */}
{agentStatus && value.provider !== 'boocode' && (
<AgentStatusDot entry={agentStatus} agent={value.provider} />
)}
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
title={connected ? 'Connected' : 'Disconnected'}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
aria-label="Refresh provider list"
title="Refresh providers"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</button>
</div>
</div>
);
}