Files
boocode/apps/web/src/components/AgentComposerBar.tsx
indifferentketchup 93d3f86c2b v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes
Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch
rewrite with streaming/persist, permission prompts, and agent commands.
Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline,
WS user-delta replace, and inference orphan tool_call stripping.
Archive openspec v2-2; update CHANGELOG and CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:18:31 +00:00

309 lines
9.6 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } from 'lucide-react';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
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;
}
function CompactPicker({ label, value, disabled, options, onPick, icon }: 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 justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{icon ?? <Cpu className="size-4" />}
</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 font-mono 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 max-w-[140px]"
>
{icon}
<span className="truncate">{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="font-mono 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;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: Props) {
const allEntries = useProviderSnapshot(projectPath);
const entries = useMemo(
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? 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]);
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 flex-wrap 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={<Cpu className="size-3 shrink-0" />}
/>
<CompactPicker
label="Mode"
value={value.modeId ?? ''}
disabled={modeOptions.length === 0}
options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />}
/>
<CompactPicker
label="Model"
value={value.model}
disabled={modelOptions.length === 0}
options={modelOptions}
onPick={pickModel}
/>
{thinkingOpts.length > 0 && (
<CompactPicker
label="Thinking"
value={value.thinkingOptionId ?? ''}
options={thinkingOpts}
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
icon={<Brain className="size-3 shrink-0" />}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="ml-auto 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>
);
}