Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
|
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
|
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 gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
>
|
|
{icon}
|
|
<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;
|
|
}
|
|
|
|
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: 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 providerIcon = (name: string) => {
|
|
switch (name) {
|
|
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
|
|
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
|
|
case 'goose': return <Bird size={13} className="shrink-0" />;
|
|
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
|
|
default: return <Dog size={13} className="shrink-0" />;
|
|
}
|
|
};
|
|
|
|
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={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" />}
|
|
/>
|
|
<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" />}
|
|
/>
|
|
)}
|
|
{connected !== undefined && (
|
|
<span
|
|
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
|
|
title={connected ? 'Connected' : 'Disconnected'}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRefresh()}
|
|
disabled={refreshing}
|
|
className={cn('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', connected === undefined && 'ml-auto')}
|
|
aria-label="Refresh provider list"
|
|
title="Refresh providers"
|
|
>
|
|
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|