v2.1.0-provider-picker: BooCoder systemd migration + provider picker
- BooCoder moves from Docker to host systemd service (boocoder.service) - Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec - SSH helpers marked @deprecated (kept for one release cycle) - Provider registry (5 providers: boocode, opencode, goose, claude, qwen) - Agent probe with direct which/exec + model discovery (qwen settings, static claude models) - GET /api/providers route with installed status, models, transport fallback - ProviderPicker frontend component in CoderPane header - External provider messages route through tasks row instead of inference enqueue - Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold) - DB: available_agents gets models, label, transport columns - Bug fix: loadContext SELECT includes allowed_read_paths - Bug fix: cap hit sentinel inserted before buildMessagesPayload - docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added - CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Chat, Message } from '@/api/types';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
@@ -142,13 +142,26 @@ function stampCapHits(items: RenderItem[]): RenderItem[] {
|
||||
});
|
||||
}
|
||||
|
||||
const SCROLL_THRESHOLD_PX = 150;
|
||||
|
||||
export function MessageList({ messages, sessionChats }: Props) {
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isNearBottomRef = useRef(true);
|
||||
|
||||
const renderItems = useMemo(() => stampCapHits(group(flatten(messages))), [messages]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
isNearBottomRef.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD_PX;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
endRef.current?.scrollIntoView({ block: 'end' });
|
||||
if (isNearBottomRef.current) {
|
||||
endRef.current?.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
@@ -160,7 +173,7 @@ export function MessageList({ messages, sessionChats }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef} onScroll={handleScroll}>
|
||||
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'message') {
|
||||
|
||||
178
apps/web/src/components/ProviderPicker.tsx
Normal file
178
apps/web/src/components/ProviderPicker.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { Provider } from '@/api/types';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
|
||||
interface Props {
|
||||
provider: string;
|
||||
model: string;
|
||||
onChange: (provider: string, model: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function ProviderModelList({
|
||||
providers,
|
||||
error,
|
||||
currentProvider,
|
||||
currentModel,
|
||||
onPick,
|
||||
}: {
|
||||
providers: Provider[] | null;
|
||||
error: string | null;
|
||||
currentProvider: string;
|
||||
currentModel: string;
|
||||
onPick: (provider: string, model: string) => void;
|
||||
}) {
|
||||
if (error) {
|
||||
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
|
||||
}
|
||||
if (providers === null) {
|
||||
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>;
|
||||
}
|
||||
|
||||
const singleProvider = providers.length === 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
{providers.map((p) => (
|
||||
<div key={p.name}>
|
||||
{!singleProvider && (
|
||||
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
{p.label}
|
||||
</div>
|
||||
)}
|
||||
{p.models.map((m) => (
|
||||
<button
|
||||
key={`${p.name}:${m.id}`}
|
||||
type="button"
|
||||
onClick={() => onPick(p.name, m.id)}
|
||||
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={`size-3 shrink-0 ${
|
||||
p.name === currentProvider && m.id === currentModel
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
<span className="truncate">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderPicker({ provider, model, onChange }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [providers, setProviders] = useState<Provider[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || providers !== null) return;
|
||||
api.coder
|
||||
.providers()
|
||||
.then(setProviders)
|
||||
.catch((err) =>
|
||||
setError(err instanceof Error ? err.message : 'failed to load providers'),
|
||||
);
|
||||
}, [open, providers]);
|
||||
|
||||
function handlePick(prov: string, mod: string) {
|
||||
setOpen(false);
|
||||
void onChange(prov, mod);
|
||||
}
|
||||
|
||||
const currentProviderLabel =
|
||||
providers?.find((p) => p.name === provider)?.label ?? provider;
|
||||
|
||||
const triggerText = providers && providers.length > 1
|
||||
? `${currentProviderLabel} / ${model}`
|
||||
: model;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={`Provider: ${currentProviderLabel}, Model: ${model}`}
|
||||
title={`${currentProviderLabel} / ${model}`}
|
||||
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Cpu className="size-4" />
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={() => setOpen(false)} title="Provider / Model">
|
||||
<div className="px-2 py-2 space-y-1">
|
||||
<ProviderModelList
|
||||
providers={providers}
|
||||
error={error}
|
||||
currentProvider={provider}
|
||||
currentModel={model}
|
||||
onPick={handlePick}
|
||||
/>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
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 max-w-[260px]"
|
||||
>
|
||||
<span className="truncate">{triggerText}</span>
|
||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto min-w-[200px]">
|
||||
{error && (
|
||||
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
{providers === null && !error && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>
|
||||
)}
|
||||
{providers && providers.map((p) => {
|
||||
const singleProvider = providers.length === 1;
|
||||
return (
|
||||
<div key={p.name}>
|
||||
{!singleProvider && (
|
||||
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70 select-none">
|
||||
{p.label}
|
||||
</div>
|
||||
)}
|
||||
{p.models.map((m) => (
|
||||
<DropdownMenuItem
|
||||
key={`${p.name}:${m.id}`}
|
||||
onSelect={() => handlePick(p.name, m.id)}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
<Check
|
||||
className={`size-3 shrink-0 ${
|
||||
p.name === provider && m.id === model
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
{m.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Code, Send, Check, X, RefreshCw } from 'lucide-react';
|
||||
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
|
||||
import { ProviderPicker } from '@/components/ProviderPicker';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -300,6 +301,8 @@ export function CoderPane({ sessionId }: Props) {
|
||||
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [provider, setProvider] = useState('boocode');
|
||||
const [model, setModel] = useState('qwen3.6-35b-a3b-mxfp4');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -331,7 +334,11 @@ export function CoderPane({ sessionId }: Props) {
|
||||
const res = await fetch(`/api/coder/sessions/${sessionId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ content: text }),
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
provider: provider !== 'boocode' ? provider : undefined,
|
||||
model: model || undefined,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
@@ -347,7 +354,7 @@ export function CoderPane({ sessionId }: Props) {
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [input, sending, sessionId, setMessages]);
|
||||
}, [input, sending, sessionId, provider, model, setMessages]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -363,11 +370,18 @@ export function CoderPane({ sessionId }: Props) {
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30 shrink-0">
|
||||
<Code size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground">BooCoder</span>
|
||||
<Code size={14} className="text-muted-foreground shrink-0" />
|
||||
<ProviderPicker
|
||||
provider={provider}
|
||||
model={model}
|
||||
onChange={(prov, mod) => {
|
||||
setProvider(prov);
|
||||
setModel(mod);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-1.5 h-1.5 rounded-full ml-auto',
|
||||
'inline-block w-1.5 h-1.5 rounded-full ml-auto shrink-0',
|
||||
connected ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
title={connected ? 'Connected' : 'Disconnected'}
|
||||
|
||||
Reference in New Issue
Block a user