DiffPanel renders a per-row agent badge (icon+label; null -> 'manual') + a 'Changes from X, Y' note when the pending set spans >1 agent. AgentComposerBar gains an optional sessionId prop -> resumed/history/new-session chip beside the Provider picker (gated, so BooChat callers are unchanged), driven by a new useAgentSessions hook (refetch on message-complete). providerIcon extracted to shared components/coder/providerIcons.tsx; api.coder gains agentSessions(sessionId); PendingChange type gains agent. web tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
426 lines
15 KiB
TypeScript
426 lines
15 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 { providerIcon } from '@/components/coder/providerIcons';
|
|
import { useAgentSessions } from '@/hooks/useAgentSessions';
|
|
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;
|
|
// v2.6 Phase 1-UX §9b: chat id for the resumed/new-session chip. Optional so
|
|
// BooChat and any other AgentComposerBar caller renders no chip and is
|
|
// otherwise unaffected. When present + connected + the chat has ≥1 prior
|
|
// turn, a chip right of the Provider picker reports whether switching to the
|
|
// current provider resumes an agent session, replays history (boocode), or
|
|
// starts fresh.
|
|
sessionId?: string;
|
|
// True once the chat has at least one prior turn — gates the chip so it stays
|
|
// hidden on a brand-new chat. Defaults to false (no chip).
|
|
hasPriorTurn?: boolean;
|
|
}
|
|
|
|
// Relative-time formatter for the resumed-chip title (e.g. "3m ago").
|
|
function relativeTime(iso: string | null): string {
|
|
if (!iso) return 'unknown';
|
|
const then = new Date(iso).getTime();
|
|
if (Number.isNaN(then)) return 'unknown';
|
|
const diffMs = Date.now() - then;
|
|
if (diffMs < 0) return 'just now';
|
|
const sec = Math.floor(diffMs / 1000);
|
|
if (sec < 60) return 'just now';
|
|
const min = Math.floor(sec / 60);
|
|
if (min < 60) return `${min}m ago`;
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 24) return `${hr}h ago`;
|
|
const day = Math.floor(hr / 24);
|
|
return `${day}d ago`;
|
|
}
|
|
|
|
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: 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);
|
|
|
|
// v2.6 Phase 1-UX §9b: chat-scoped agent-session rows for the resumed/new
|
|
// chip. Hook is unconditional (hooks rule); it self-no-ops when sessionId is
|
|
// undefined or the chat has no prior turn, so BooChat callers cost nothing.
|
|
const { sessions: agentSessions } = useAgentSessions(
|
|
sessionId && hasPriorTurn ? sessionId : undefined,
|
|
);
|
|
|
|
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 }));
|
|
|
|
// v2.6 Phase 1-UX §9b: resumed / history / new-session chip. Only meaningful
|
|
// when this is a real chat (sessionId), the WS is connected, and the chat has
|
|
// ≥1 prior turn — otherwise render nothing so fresh chats and non-coder
|
|
// callers stay clean.
|
|
const sessionRow = agentSessions.find((s) => s.agent === value.provider);
|
|
const sessionChip: { label: string; title: string } | null =
|
|
sessionId && hasPriorTurn && connected
|
|
? value.provider === 'boocode'
|
|
? // Native boocode never holds an agent_sessions row — it reconstructs
|
|
// the conversation from the chat transcript each turn.
|
|
{ label: 'history', title: 'BooCode replays the chat transcript each turn' }
|
|
: sessionRow?.has_session
|
|
? {
|
|
label: 'resumed',
|
|
title: `Resuming ${value.provider} · last active ${relativeTime(sessionRow.last_active_at)}`,
|
|
}
|
|
: { label: 'new session', title: `${value.provider} starts a fresh session this turn` }
|
|
: null;
|
|
|
|
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={
|
|
currentEntry?.status === 'loading'
|
|
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
|
: providerIcon(value.provider)
|
|
}
|
|
/>
|
|
{sessionChip && (
|
|
<span
|
|
title={sessionChip.title}
|
|
className="inline-flex items-center rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground shrink-0"
|
|
>
|
|
{sessionChip.label}
|
|
</span>
|
|
)}
|
|
<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 as one right-aligned unit so the refresh button
|
|
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
|
<div className="ml-auto flex items-center gap-1 shrink-0">
|
|
{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>
|
|
);
|
|
}
|