Replace the raw per-agent mode dropdown in the BooCoder composer with a curated three-option permission ladder mapped generically onto each provider's native modes: `plan` id -> Plan, default -> Ask, isUnattended -> Bypass (claude bypassPermissions, qwen yolo, opencode full-access). modeId stays the single wire field; the active unified mode is derived from it (no contracts change). Native BooCode gains its own mode set: Ask stages to the pending-changes queue (today's behavior), Bypass auto-applies the queue to disk after the turn (interactive messages path + task dispatcher path), Plan falls back to Ask. The shared apps/server inference engine is left untouched. Also preserve isUnattended on live-probed ACP modes so opencode's bypass mode stays detectable from the wire. Coder 373 tests green; coder + web typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, 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 { formatModelLabel } from '@/lib/model-label';
|
|
import {
|
|
availablePermissionModes,
|
|
permissionForModeId,
|
|
nativeModeForPermission,
|
|
type PermissionMode,
|
|
} from '@/lib/permission-mode';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// Permission picker icon — varies with the active mode so the (icon-only) control
|
|
// is glanceable: Eye = Plan (read-only), Shield = Ask, ShieldAlert = Bypass.
|
|
function permissionIcon(mode: PermissionMode): React.ReactNode {
|
|
if (mode === 'plan') return <Eye className="size-3 shrink-0" />;
|
|
if (mode === 'bypass') return <ShieldAlert className="size-3 shrink-0 text-amber-500" />;
|
|
return <Shield className="size-3 shrink-0" />;
|
|
}
|
|
|
|
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;
|
|
/** Grow to fill the row's free space and render the value brighter — used for
|
|
* the Model picker so the active model is the most visible control. */
|
|
flexible?: boolean;
|
|
}
|
|
|
|
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible }: 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={cn(
|
|
'inline-flex items-center gap-1 min-h-[36px] rounded-lg border border-border bg-muted/50 hover:bg-muted text-xs hover:text-foreground disabled:opacity-40',
|
|
iconOnly ? 'px-1.5' : 'px-2.5',
|
|
flexible ? 'flex-1 min-w-0 text-foreground' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{icon}
|
|
{!iconOnly && (
|
|
<span className={cn('truncate', flexible ? 'flex-1 text-left' : '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={cn(
|
|
'text-xs hover:text-foreground flex items-center gap-1 py-1 rounded-lg border border-border bg-muted/50 hover:bg-muted disabled:opacity-40',
|
|
iconOnly ? 'px-1.5' : 'px-2.5',
|
|
flexible ? 'flex-1 min-w-0 text-foreground' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{icon}
|
|
<span className={cn('truncate', flexible ? 'flex-1 text-left' : '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 }));
|
|
// Unified permission ladder (Plan / Ask / Bypass) mapped onto this provider's
|
|
// native modes. `value.modeId` stays the wire field; the active unified mode is
|
|
// derived from it.
|
|
const permissionModes = availablePermissionModes(currentEntry?.modes ?? []);
|
|
const currentPermission = permissionForModeId(value.modeId, currentEntry?.modes ?? []);
|
|
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
|
|
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
|
<CompactPicker
|
|
label="Provider"
|
|
value={value.provider}
|
|
options={providerOptions}
|
|
onPick={pickProvider}
|
|
icon={
|
|
<>
|
|
{/* Status indicator lives inside the agent button, to its left:
|
|
normalized agent status (external agents only) + WS liveness. */}
|
|
{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'}
|
|
/>
|
|
)}
|
|
{currentEntry?.status === 'loading'
|
|
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
|
: providerIcon(value.provider)}
|
|
</>
|
|
}
|
|
/>
|
|
{/* Permission ladder (Plan / Ask / Bypass) — shown when the provider exposes
|
|
modes. Picks the unified mode; we resolve it to the provider's native
|
|
modeId. Icon varies with the active mode (Bypass is amber). */}
|
|
{permissionModes.length > 0 && (
|
|
<CompactPicker
|
|
label="Permission"
|
|
value={currentPermission}
|
|
options={permissionModes}
|
|
onPick={(perm) =>
|
|
persist({
|
|
...value,
|
|
modeId: nativeModeForPermission(
|
|
perm as PermissionMode,
|
|
currentEntry?.modes ?? [],
|
|
currentEntry?.defaultModeId ?? null,
|
|
),
|
|
})
|
|
}
|
|
icon={permissionIcon(currentPermission)}
|
|
iconOnly
|
|
/>
|
|
)}
|
|
<CompactPicker
|
|
label="Model"
|
|
value={value.model}
|
|
disabled={modelOptions.length === 0}
|
|
options={modelOptions}
|
|
onPick={pickModel}
|
|
icon={<Bot size={13} className="shrink-0" />}
|
|
flexible
|
|
/>
|
|
{thinkingOpts.length > 0 && (
|
|
<CompactPicker
|
|
label="Thinking"
|
|
value={value.thinkingOptionId ?? ''}
|
|
options={thinkingOpts}
|
|
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
|
|
icon={<Brain className="size-3 shrink-0" />}
|
|
iconOnly
|
|
/>
|
|
)}
|
|
{/* Refresh — pinned right. Status now lives in the agent button (left). */}
|
|
<div className="ml-auto flex items-center shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRefresh()}
|
|
disabled={refreshing}
|
|
className="inline-flex items-center justify-center size-9 max-md:min-h-[36px] max-md:min-w-[44px] rounded-lg border border-border bg-muted/50 hover:bg-muted 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>
|
|
);
|
|
}
|