- Add ComparePane.tsx: side-by-side AI response comparison - Add Memory.tsx: memory management page with CRUD UI - Add McpPermissionDialog.tsx: MCP tool permission approval dialog - Add McpResponseDisplay.tsx: MCP response visualization - Add MessageBoundary.tsx + MessageListErrorBoundary.tsx: error resilience - Add EmptyState.tsx: contextual empty state component - Add KeyboardShortcutsDialog.tsx: keyboard shortcut reference - Add message-parts/: ActionRow, CompactCard, MistakeRecoverySentinel, ReasoningBlock, SendToTerminalMenu, StatsLine, SummaryCard - Add useDraftPersistence.ts: draft message persistence hook - Add useTerminals.ts: terminal session management hook - Add keyboard-shortcuts.ts + tool-utils.ts: shared utilities - Extend components: ChatInput, MessageBubble, MessageList, Workspace, panes - Extend hooks: useTerminalSocket, useSessionStream test suite - Update pages: Home, Project — workspace layout and session flow
134 lines
4.2 KiB
TypeScript
134 lines
4.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Check, ChevronDown, Cpu } from 'lucide-react';
|
|
import { api } from '@/api/client';
|
|
import type { ModelInfo } from '@/api/types';
|
|
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';
|
|
|
|
interface Props {
|
|
value: string | null;
|
|
onChange: (model: string) => void | Promise<void>;
|
|
}
|
|
|
|
// v1.9: shared list rendered inside both shells. Lazy-fetches /api/models on
|
|
// first open so the picker doesn't pay for a request when it's never shown.
|
|
function ModelList({
|
|
models,
|
|
error,
|
|
value,
|
|
onPick,
|
|
}: {
|
|
models: ModelInfo[] | null;
|
|
error: string | null;
|
|
value: string | null;
|
|
onPick: (id: string) => void;
|
|
}) {
|
|
if (error) {
|
|
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
|
|
}
|
|
if (models === null) {
|
|
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading…</div>;
|
|
}
|
|
return (
|
|
<>
|
|
{models.map((m) => (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
onClick={() => onPick(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 ${m.id === value ? 'opacity-100' : 'opacity-0'}`} />
|
|
<span className="truncate">{formatModelLabel(m.id)}</span>
|
|
</button>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ModelPicker({ value, onChange }: Props) {
|
|
const { isMobile } = useViewport();
|
|
const [models, setModels] = useState<ModelInfo[] | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open || models !== null) return;
|
|
api
|
|
.models()
|
|
.then(setModels)
|
|
.catch((err) =>
|
|
setError(err instanceof Error ? err.message : 'failed to load models'),
|
|
);
|
|
}, [open, models]);
|
|
|
|
function handlePick(id: string) {
|
|
setOpen(false);
|
|
void onChange(id);
|
|
}
|
|
|
|
// v1.9: mobile = icon-only trigger + bottom-sheet shell. Desktop = labeled
|
|
// trigger (model name + chevron) + dropdown. Same ModelList under the hood.
|
|
if (isMobile) {
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
aria-label={`Model: ${value ?? 'default'}`}
|
|
title={value ?? undefined}
|
|
className="inline-flex items-center justify-center min-h-[36px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Cpu className="size-4" />
|
|
</button>
|
|
<BottomSheet open={open} onClose={() => setOpen(false)} title="Model">
|
|
<div className="px-2 py-2 space-y-1">
|
|
<ModelList models={models} error={error} value={value} 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"
|
|
>
|
|
{value ? formatModelLabel(value) : 'Model'}
|
|
<ChevronDown className="size-3 opacity-70" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="max-h-72 min-w-[16rem] overflow-y-auto">
|
|
{error && (
|
|
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
|
|
)}
|
|
{models === null && !error && (
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading…</div>
|
|
)}
|
|
{models?.map((m) => (
|
|
<DropdownMenuItem
|
|
key={m.id}
|
|
onSelect={() => handlePick(m.id)}
|
|
className="font-mono text-xs"
|
|
>
|
|
<Check
|
|
className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`}
|
|
/>
|
|
{formatModelLabel(m.id)}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|