authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-02 19:23:49+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 13:23:49-04:00
log6effe0a2fab1bcd48315485b580419377451c757
tree53e80892e89972ae0d43c7fa8c85ac6a8d43465d
parentbc918e74ace5dbb4fe1ce73b59d06a9ca1be9ed3
signature Signed by PGP key B5690EEEBB952194

feat(web): redesign provider editor and models list (#8508)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

7 files changed, 819 insertions(+), 562 deletions(-)

apps/web/src/components/settings/ProviderAccentColorPicker.tsx+53-32
...@@ -228,8 +228,17 @@ export function ProviderAccentColorPicker(props: {...@@ -228,8 +228,17 @@ export function ProviderAccentColorPicker(props: {
228 readonly onCommit: (value: string) => void;228 readonly onCommit: (value: string) => void;
229 readonly description?: string;229 readonly description?: string;
230 readonly commitDelayMs?: number;230 readonly commitDelayMs?: number;
231 /** `inline` renders only the swatch row, for callers that supply their own label. */
232 readonly layout?: "stacked" | "inline";
231}) {233}) {
232 const { commitDelayMs = 0, description, displayName, onCommit, value } = props;234 const {
235 commitDelayMs = 0,
236 description,
237 displayName,
238 layout = "stacked",
239 onCommit,
240 value,
241 } = props;
233 const [optimisticValue, setOptimisticValue] = useState(() => value ?? "");242 const [optimisticValue, setOptimisticValue] = useState(() => value ?? "");
234 const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);243 const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
235 const pendingCommitRef = useRef<string | null>(null);244 const pendingCommitRef = useRef<string | null>(null);
...@@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: {...@@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: {
295 : "";304 : "";
296 const customSelected = Boolean(normalized && selectedValue === "");305 const customSelected = Boolean(normalized && selectedValue === "");
297306
307 const swatchRow = (
308 <div
309 role="group"
310 aria-label="Accent color"
311 className="flex min-w-0 flex-wrap items-center gap-2"
312 >
313 <ProviderCustomColorPicker
314 displayName={displayName}
315 value={normalized}
316 selected={customSelected}
317 onCommit={commitAccentColor}
318 />
319 <ColorSelector
320 key={selectedValue}
321 colors={[...PROVIDER_ACCENT_SWATCHES]}
322 defaultValue={selectedValue}
323 size="lg"
324 onColorSelect={commitAccentColor}
325 className="flex-wrap gap-1.5"
326 />
327 <Button
328 type="button"
329 size="icon"
330 variant="ghost"
331 className={cn(
332 "size-7 shrink-0 text-muted-foreground transition-opacity",
333 normalized ? "opacity-100" : "pointer-events-none opacity-0",
334 )}
335 onClick={() => commitAccentColor("")}
336 aria-label={`Clear accent color for ${displayName}`}
337 aria-hidden={!normalized}
338 tabIndex={normalized ? 0 : -1}
339 >
340 <XIcon className="size-3.5" aria-hidden />
341 </Button>
342 </div>
343 );
344
345 if (layout === "inline") {
346 return swatchRow;
347 }
348
298 return (349 return (
299 <div className="grid gap-2">350 <div className="grid gap-2">
300 <span className="text-xs font-medium text-foreground">Accent color</span>351 <span className="text-xs font-medium text-foreground">Accent color</span>
301 <div className="flex min-w-0 flex-wrap items-center gap-2">352 {swatchRow}
302 <ProviderCustomColorPicker
303 displayName={displayName}
304 value={normalized}
305 selected={customSelected}
306 onCommit={commitAccentColor}
307 />
308 <ColorSelector
309 key={selectedValue}
310 colors={[...PROVIDER_ACCENT_SWATCHES]}
311 defaultValue={selectedValue}
312 size="lg"
313 onColorSelect={commitAccentColor}
314 className="flex-wrap gap-1.5"
315 />
316 <Button
317 type="button"
318 size="icon"
319 variant="ghost"
320 className={cn(
321 "size-7 shrink-0 text-muted-foreground transition-opacity",
322 normalized ? "opacity-100" : "pointer-events-none opacity-0",
323 )}
324 onClick={() => commitAccentColor("")}
325 aria-label={`Clear accent color for ${displayName}`}
326 aria-hidden={!normalized}
327 tabIndex={normalized ? 0 : -1}
328 >
329 <XIcon className="size-3.5" aria-hidden />
330 </Button>
331 </div>
332 {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}353 {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
333 </div>354 </div>
334 );355 );
apps/web/src/components/settings/ProviderInstanceCard.tsx+181-190
...@@ -5,6 +5,8 @@ import {...@@ -5,6 +5,8 @@ import {
5 CopyIcon,5 CopyIcon,
6 DownloadIcon,6 DownloadIcon,
7 LoaderIcon,7 LoaderIcon,
8 LockIcon,
9 LockOpenIcon,
8 PlusIcon,10 PlusIcon,
9 Trash2Icon,11 Trash2Icon,
10 XIcon,12 XIcon,
...@@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";...@@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
28import { normalizeProviderAccentColor } from "../../providerInstances";30import { normalizeProviderAccentColor } from "../../providerInstances";
29import { Badge } from "../ui/badge";31import { Badge } from "../ui/badge";
30import { Button } from "../ui/button";32import { Button } from "../ui/button";
31import { Checkbox } from "../ui/checkbox";
32import { DraftInput } from "../ui/draft-input";33import { DraftInput } from "../ui/draft-input";
33import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";34import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
34import { ScrollArea } from "../ui/scroll-area";35import { ScrollArea } from "../ui/scroll-area";
35import { Switch } from "../ui/switch";36import { Switch } from "../ui/switch";
36import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
37import { stackedThreadToast, toastManager } from "../ui/toast";37import { stackedThreadToast, toastManager } from "../ui/toast";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
39import type { DriverOption } from "./providerDriverMeta";39import type { DriverOption } from "./providerDriverMeta";
...@@ -53,6 +53,13 @@ import {...@@ -53,6 +53,13 @@ import {
5353
54const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;54const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
5555
56/** Label-left field grid for the Configuration tab: one row per field. */
57const PROVIDER_FIELD_GRID_CLASS_NAME =
58 "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start";
59/** Full-width divider row that names the group of fields below it. */
60const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME =
61 "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground";
62
56let environmentVariableDraftId = 0;63let environmentVariableDraftId = 0;
57const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;64const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
5865
...@@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: {...@@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: {
235 publishRows(nextRows);242 publishRows(nextRows);
236 };243 };
237244
245 const addVariable = () =>
246 setRows([
247 ...rows,
248 {
249 id: nextEnvironmentVariableDraftId(),
250 name: "",
251 value: "",
252 sensitive: true,
253 },
254 ]);
255
238 return (256 return (
239 <div className="grid gap-2">257 <div className="min-w-0 space-y-1.5">
240 <div className="flex items-center justify-between gap-3">258 {rows.map((variable, index) => (
241 <span className="text-xs font-medium text-foreground">Environment variables</span>259 <div key={variable.id} className="flex min-w-0 items-center gap-1.5">
242 <Button260 <DraftInput
243 type="button"261 size="compact"
244 size="sm"262 className="w-44 shrink-0 font-mono"
245 variant="outline"263 value={variable.name}
246 className="h-7 gap-1.5 px-2 text-xs"264 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
247 onClick={() =>265 placeholder="VARIABLE_NAME"
248 setRows([266 spellCheck={false}
249 ...rows,267 aria-label={`Environment variable name ${index + 1}`}
250 {268 />
251 id: nextEnvironmentVariableDraftId(),269 <span className="text-xs text-muted-foreground" aria-hidden>
252 name: "",270 =
253 value: "",271 </span>
254 sensitive: true,272 <DraftInput
255 },273 size="compact"
256 ])274 className="min-w-0 flex-1 font-mono"
257 }275 value={variable.valueRedacted ? "" : variable.value}
258 >276 onCommit={(value) => updateVariable(variable.id, { value })}
277 type={variable.sensitive ? "password" : undefined}
278 autoComplete="off"
279 placeholder={
280 variable.valueRedacted ? "Stored secret, enter a new value to replace" : "value"
281 }
282 spellCheck={false}
283 aria-label={`Environment variable value ${index + 1}`}
284 />
285 <Tooltip>
286 <TooltipTrigger
287 render={
288 <Button
289 type="button"
290 size="icon-micro"
291 variant="ghost-muted"
292 className={cn(
293 "[--control-icon-color:currentColor]",
294 variable.sensitive && "text-foreground",
295 )}
296 onClick={() => {
297 const sensitive = !variable.sensitive;
298 updateVariable(variable.id, {
299 sensitive,
300 ...(sensitive && variable.valueRedacted === undefined
301 ? {}
302 : { valueRedacted: sensitive ? variable.valueRedacted : false }),
303 });
304 }}
305 aria-pressed={variable.sensitive}
306 aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
307 >
308 {variable.sensitive ? (
309 <LockIcon className="size-3" />
310 ) : (
311 <LockOpenIcon className="size-3" />
312 )}
313 </Button>
314 }
315 />
316 <TooltipPopup side="top">
317 {variable.sensitive ? "Sensitive, stored separately" : "Plain text"}
318 </TooltipPopup>
319 </Tooltip>
320 <Button
321 type="button"
322 size="icon-micro"
323 variant="ghost-muted"
324 className="[--control-icon-color:currentColor] hover:text-destructive"
325 onClick={() => removeVariable(variable.id)}
326 aria-label={`Remove environment variable ${variable.name || index + 1}`}
327 >
328 <XIcon className="size-3" />
329 </Button>
330 </div>
331 ))}
332 <div className="flex min-h-[1.875rem] flex-wrap items-center gap-x-3 gap-y-1">
333 <Button type="button" size="xs" variant="ghost-muted" onClick={addVariable}>
259 <PlusIcon className="size-3" />334 <PlusIcon className="size-3" />
260 Add335 Add variable
261 </Button>336 </Button>
337 <span className="text-xs text-muted-foreground">
338 {rows.length === 0
339 ? "API keys, base URLs, or other per-instance CLI settings."
340 : "Sensitive values are stored separately and never returned to the app."}
341 </span>
262 </div>342 </div>
263 {rows.length === 0 ? (
264 <p className="text-xs text-muted-foreground">
265 Add variables to pass API keys, base URLs, or other per-instance CLI settings.
266 </p>
267 ) : (
268 <div className="overflow-hidden rounded-md border border-border/70">
269 <Table>
270 <TableHeader className="bg-muted/25 text-[11px] text-muted-foreground">
271 <TableRow className="hover:bg-transparent">
272 <TableHead>Variable</TableHead>
273 <TableHead>Value</TableHead>
274 <TableHead className="w-20">Sensitive</TableHead>
275 <TableHead className="w-12 text-right">
276 <span className="sr-only">Options</span>
277 </TableHead>
278 </TableRow>
279 </TableHeader>
280 <TableBody>
281 {rows.map((variable, index) => (
282 <TableRow
283 key={variable.id}
284 className="border-border/60 odd:bg-muted/20 even:bg-background/20"
285 >
286 <TableCell>
287 <DraftInput
288 value={variable.name}
289 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
290 placeholder="VARIABLE_NAME"
291 spellCheck={false}
292 aria-label={`Environment variable name ${index + 1}`}
293 />
294 </TableCell>
295 <TableCell>
296 <DraftInput
297 value={variable.valueRedacted ? "" : variable.value}
298 onCommit={(value) => updateVariable(variable.id, { value })}
299 type={variable.sensitive ? "password" : undefined}
300 autoComplete="off"
301 placeholder={
302 variable.valueRedacted
303 ? "Stored secret - enter a new value to replace"
304 : "Value"
305 }
306 spellCheck={false}
307 aria-label={`Environment variable value ${index + 1}`}
308 />
309 </TableCell>
310 <TableCell className="w-20">
311 <div className="flex h-8 items-center justify-center">
312 <Checkbox
313 checked={variable.sensitive}
314 onCheckedChange={(checked) => {
315 const sensitive = Boolean(checked);
316 updateVariable(variable.id, {
317 sensitive,
318 ...(sensitive && variable.valueRedacted === undefined
319 ? {}
320 : { valueRedacted: sensitive ? variable.valueRedacted : false }),
321 });
322 }}
323 aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
324 />
325 </div>
326 </TableCell>
327 <TableCell className="w-12">
328 <div className="flex justify-end">
329 <Button
330 type="button"
331 size="icon-sm"
332 variant="ghost"
333 className="size-8 text-muted-foreground hover:text-destructive"
334 onClick={() => removeVariable(variable.id)}
335 aria-label={`Remove environment variable ${variable.name || index + 1}`}
336 >
337 <XIcon className="size-3.5" />
338 </Button>
339 </div>
340 </TableCell>
341 </TableRow>
342 ))}
343 </TableBody>
344 </Table>
345 </div>
346 )}
347 <span className="text-xs text-muted-foreground">
348 Sensitive values are stored separately and are not returned to the app after saving.
349 </span>
350 </div>343 </div>
351 );344 );
352}345}
...@@ -362,7 +355,7 @@ interface ProviderInstanceCardProps {...@@ -362,7 +355,7 @@ interface ProviderInstanceCardProps {
362 readonly readOnly?: boolean | undefined;355 readonly readOnly?: boolean | undefined;
363 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;356 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
364 /**357 /**
365 * Pass `undefined` to hide the delete button entirely. Built-in default358 * Pass `undefined` to hide the delete footer entirely. Built-in default
366 * instance slots use `undefined` — they can't be deleted without losing359 * instance slots use `undefined` — they can't be deleted without losing
367 * the slot, and their "reset to defaults" affordance lives on an outer360 * the slot, and their "reset to defaults" affordance lives on an outer
368 * reset button instead. Explicit `| undefined` in the type accommodates361 * reset button instead. Explicit `| undefined` in the type accommodates
...@@ -489,6 +482,9 @@ export function ProviderInstanceCard({...@@ -489,6 +482,9 @@ export function ProviderInstanceCard({
489 liveModels: liveProvider?.models,482 liveModels: liveProvider?.models,
490 customModels,483 customModels,
491 });484 });
485 const hiddenModelCount = modelsForDisplay.filter(
486 (model) => !model.isCustom && hiddenModels.includes(model.slug),
487 ).length;
492488
493 const updateDisplayName = (value: string) => {489 const updateDisplayName = (value: string) => {
494 const trimmed = value.trim();490 const trimmed = value.trim();
...@@ -581,35 +577,9 @@ export function ProviderInstanceCard({...@@ -581,35 +577,9 @@ export function ProviderInstanceCard({
581 </>577 </>
582 );578 );
583579
584 const titleTailNode = (580 const titleTailNode = headerAction ? (
585 <>581 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span>
586 {headerAction ? (582 ) : null;
587 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
588 {headerAction}
589 </span>
590 ) : null}
591 {onDelete ? (
592 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
593 <Tooltip>
594 <TooltipTrigger
595 render={
596 <Button
597 size="icon-micro"
598 variant="ghost"
599 className="text-muted-foreground hover:text-destructive"
600 onClick={onDelete}
601 aria-label={`Delete provider instance ${instanceId}`}
602 >
603 <Trash2Icon className="size-3" />
604 </Button>
605 }
606 />
607 <TooltipPopup side="top">Delete instance</TooltipPopup>
608 </Tooltip>
609 </span>
610 ) : null}
611 </>
612 );
613583
614 const versionCodeNode = versionLabel ? (584 const versionCodeNode = versionLabel ? (
615 <code className="text-xs text-muted-foreground">{versionLabel}</code>585 <code className="text-xs text-muted-foreground">{versionLabel}</code>
...@@ -634,7 +604,7 @@ export function ProviderInstanceCard({...@@ -634,7 +604,7 @@ export function ProviderInstanceCard({
634 className={cn(604 className={cn(
635 // Sidebar-style selection with a fixed row height so the list stays605 // Sidebar-style selection with a fixed row height so the list stays
636 // even; the status line clamps to two lines instead of growing.606 // even; the status line clamps to two lines instead of growing.
637 "group flex h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors",607 "group flex min-h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors",
638 // Foreground-alpha tint so the fill reads the same in light and dark themes.608 // Foreground-alpha tint so the fill reads the same in light and dark themes.
639 selected ? "bg-foreground/8" : "hover:bg-foreground/4",609 selected ? "bg-foreground/8" : "hover:bg-foreground/4",
640 )}610 )}
...@@ -712,7 +682,7 @@ export function ProviderInstanceCard({...@@ -712,7 +682,7 @@ export function ProviderInstanceCard({
712 size="icon-xs"682 size="icon-xs"
713 variant="ghost"683 variant="ghost"
714 className={cn(684 className={cn(
715 "size-5 rounded-sm p-0",685 "size-5 rounded-sm p-0 [--control-icon-color:currentColor]",
716 versionAdvisory.emphasis === "strong"686 versionAdvisory.emphasis === "strong"
717 ? "text-warning hover:text-warning"687 ? "text-warning hover:text-warning"
718 : "text-muted-foreground hover:text-foreground",688 : "text-muted-foreground hover:text-foreground",
...@@ -820,6 +790,25 @@ export function ProviderInstanceCard({...@@ -820,6 +790,25 @@ export function ProviderInstanceCard({
820 </p>790 </p>
821 ) : null}791 ) : null}
822 </div>792 </div>
793 {onDelete ? (
794 <span
795 inert={readOnly}
796 aria-disabled={readOnly || undefined}
797 className={cn("shrink-0", readOnly && "opacity-50")}
798 >
799 <Button
800 type="button"
801 size="xs"
802 variant="ghost"
803 className="[--control-icon-color:currentColor] text-muted-foreground hover:text-destructive"
804 onClick={onDelete}
805 aria-label={`Delete instance ${instanceId}`}
806 >
807 <Trash2Icon className="size-3" />
808 Delete instance
809 </Button>
810 </span>
811 ) : null}
823 </div>812 </div>
824813
825 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">814 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">
...@@ -835,10 +824,14 @@ export function ProviderInstanceCard({...@@ -835,10 +824,14 @@ export function ProviderInstanceCard({
835 <button824 <button
836 type="button"825 type="button"
837 aria-pressed={visibleTab === "models"}826 aria-pressed={visibleTab === "models"}
838 className={providerSettingsTabClassName(visibleTab === "models")}827 className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")}
839 onClick={() => setActiveTab("models")}828 onClick={() => setActiveTab("models")}
840 >829 >
841 Models830 Models
831 <span className="font-normal text-muted-foreground/70">
832 {modelsForDisplay.length}
833 {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""}
834 </span>
842 </button>835 </button>
843 ) : null}836 ) : null}
844 </div>837 </div>
...@@ -853,62 +846,60 @@ export function ProviderInstanceCard({...@@ -853,62 +846,60 @@ export function ProviderInstanceCard({
853 <div846 <div
854 inert={readOnly}847 inert={readOnly}
855 aria-disabled={readOnly || undefined}848 aria-disabled={readOnly || undefined}
856 className={cn("space-y-5 px-4 py-5", readOnly && "opacity-50 select-none")}849 className={cn("px-4 py-4", readOnly && "opacity-50 select-none")}
857 >850 >
858 <div>851 <div className={PROVIDER_FIELD_GRID_CLASS_NAME}>
859 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">852 <label
860 <span className="text-xs font-medium text-foreground">Display name</span>853 htmlFor={`provider-instance-${instanceId}-display-name`}
854 className="pt-1.5 text-xs font-medium text-foreground"
855 >
856 Display name
857 </label>
858 <div className="flex min-w-0 flex-wrap items-center gap-3">
861 <DraftInput859 <DraftInput
862 id={`provider-instance-${instanceId}-display-name`}860 id={`provider-instance-${instanceId}-display-name`}
863 className="mt-1.5"861 className="w-44"
864 value={instance.displayName ?? ""}862 value={instance.displayName ?? ""}
865 onCommit={updateDisplayName}863 onCommit={updateDisplayName}
866 placeholder={driverOption?.label ?? "Instance label"}864 placeholder={driverOption?.label ?? "Instance label"}
867 spellCheck={false}865 spellCheck={false}
868 />866 />
869 <span className="mt-1 block text-xs text-muted-foreground">867 <ProviderAccentColorPicker
870 Optional label shown in the provider list.868 layout="inline"
871 </span>869 displayName={displayName}
872 </label>870 value={accentColor}
873 </div>871 onCommit={updateAccentColor}
874872 commitDelayMs={120}
875 <div>873 />
876 <ProviderAccentColorPicker
877 displayName={displayName}
878 value={accentColor}
879 onCommit={updateAccentColor}
880 commitDelayMs={120}
881 description="Used to distinguish this instance in picker rails and model lists."
882 />
883 </div>
884
885 <div>
886 <ProviderEnvironmentSection
887 environment={instance.environment ?? []}
888 onChange={updateEnvironment}
889 />
890 </div>
891
892 {driverOption ? (
893 <ProviderSettingsForm
894 definition={driverOption}
895 value={instance.config}
896 idPrefix={`provider-instance-${instanceId}`}
897 variant="card"
898 onChange={updateConfig}
899 />
900 ) : null}
901
902 {driverOption === undefined ? (
903 <div>
904 <p className="text-xs text-muted-foreground">
905 This instance uses a driver (
906 <code className="text-foreground">{String(instance.driver)}</code>) that is not
907 shipped with the current build. Configuration values are preserved but cannot be
908 edited from this surface.
909 </p>
910 </div>874 </div>
911 ) : null}875 <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Runtime</div>
876 {driverOption ? (
877 <ProviderSettingsForm
878 definition={driverOption}
879 value={instance.config}
880 idPrefix={`provider-instance-${instanceId}`}
881 variant="grid"
882 onChange={updateConfig}
883 />
884 ) : (
885 <>
886 <span className="pt-1.5 text-xs font-medium text-foreground">Driver</span>
887 <p className="text-xs text-muted-foreground">
888 This instance uses a driver (
889 <code className="text-foreground">{String(instance.driver)}</code>) that is not
890 shipped with the current build. Configuration values are preserved but cannot be
891 edited from this surface.
892 </p>
893 </>
894 )}
895 <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Environment</div>
896 <div className="col-span-full">
897 <ProviderEnvironmentSection
898 environment={instance.environment ?? []}
899 onChange={updateEnvironment}
900 />
901 </div>
902 </div>
912 </div>903 </div>
913 </ScrollArea>904 </ScrollArea>
914 {driverOption !== undefined ? (905 {driverOption !== undefined ? (
apps/web/src/components/settings/ProviderModelsSection.test.ts created+23
...@@ -0,0 +1,23 @@
1import { describe, expect, it } from "vite-plus/test";
2import type { ServerProviderModel } from "@t3tools/contracts";
3
4import { groupModelsForDisplay } from "./ProviderModelsSection";
5
6function model(slug: string, isCustom = false): ServerProviderModel {
7 return { slug, name: slug, isCustom, capabilities: null };
8}
9
10describe("groupModelsForDisplay", () => {
11 it("lists favorites first, then visible models in user order, then hidden ones", () => {
12 const models = [model("a"), model("b"), model("c"), model("d"), model("custom", true)];
13
14 const display = groupModelsForDisplay(models, {
15 favoriteModels: new Set(["c"]),
16 hiddenModels: new Set(["a", "custom"]),
17 modelOrder: ["d", "b"],
18 });
19
20 // A custom model is never hidden, even if its slug is in the hidden set.
21 expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]);
22 });
23});
apps/web/src/components/settings/ProviderModelsSection.tsx+397-252
...@@ -1,16 +1,7 @@...@@ -1,16 +1,7 @@
1"use client";1"use client";
22
3import {3import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
4 ArrowDownIcon,4import { useEffect, useMemo, useRef, useState } from "react";
5 ArrowUpIcon,
6 EyeIcon,
7 EyeOffIcon,
8 InfoIcon,
9 PlusIcon,
10 StarIcon,
11 XIcon,
12} from "lucide-react";
13import { useMemo, useRef, useState } from "react";
14import {5import {
15 ProviderDriverKind,6 ProviderDriverKind,
16 type ProviderInstanceId,7 type ProviderInstanceId,
...@@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering";...@@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering";
23import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection";14import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection";
24import { Button } from "../ui/button";15import { Button } from "../ui/button";
25import { Input } from "../ui/input";16import { Input } from "../ui/input";
26import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";17import { Switch } from "../ui/switch";
27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";18import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2819
29/**20/**
...@@ -38,6 +29,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, strin...@@ -38,6 +29,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, strin
38 [ProviderDriverKind.make("opencode")]: "openai/gpt-5",29 [ProviderDriverKind.make("opencode")]: "openai/gpt-5",
39};30};
4031
32/** Above this many models the list gets a filter input. */
33const FILTER_THRESHOLD = 8;
34
35/**
36 * Short capability words shown after a model's slug. Claude and Cursor report
37 * fast mode as a boolean `fastMode` option; Codex reports it as a
38 * `serviceTier` select whose fast tier is labelled "Fast" (catalog id
39 * `priority`, or `fast` from the speed-tier fallback), matching the composer.
40 */
41function describeModelCapabilities(model: ServerProviderModel): string[] {
42 const descriptors = model.capabilities?.optionDescriptors ?? [];
43 const labels: string[] = [];
44 const hasFastMode = descriptors.some(
45 (descriptor) =>
46 descriptor.id === "fastMode" ||
47 (descriptor.id === "serviceTier" &&
48 descriptor.type === "select" &&
49 descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")),
50 );
51 if (hasFastMode) labels.push("Fast mode");
52 if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking");
53 if (
54 descriptors.some(
55 (descriptor) =>
56 descriptor.type === "select" &&
57 (descriptor.id === "reasoningEffort" ||
58 descriptor.id === "effort" ||
59 descriptor.id === "reasoning" ||
60 descriptor.id === "variant"),
61 )
62 ) {
63 labels.push("Reasoning");
64 }
65 return labels;
66}
67
68/**
69 * Display order for the models list: favorites first (in user order), then
70 * visible models, then hidden ones. Hidden models sink so the list reads
71 * top-down as "what the picker shows"; moves only swap rows within the same
72 * group, and the resulting display order is what gets persisted as
73 * `modelOrder`.
74 */
75export function groupModelsForDisplay<
76 T extends { readonly slug: string; readonly isCustom: boolean },
77>(
78 models: ReadonlyArray<T>,
79 options: {
80 readonly favoriteModels: ReadonlySet<string>;
81 readonly hiddenModels: ReadonlySet<string>;
82 readonly modelOrder: ReadonlyArray<string>;
83 },
84): T[] {
85 const ordered = sortModelsForProviderInstance(models, {
86 favoriteModels: options.favoriteModels,
87 groupFavorites: true,
88 modelOrder: options.modelOrder,
89 });
90 const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug);
91 return [
92 ...ordered.filter((model) => options.favoriteModels.has(model.slug)),
93 ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)),
94 ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)),
95 ];
96}
97
41interface ProviderModelsSectionProps {98interface ProviderModelsSectionProps {
42 /** Identifier used to namespace input ids within the DOM. */99 /** Identifier used to namespace input ids within the DOM. */
43 readonly instanceId: ProviderInstanceId;100 readonly instanceId: ProviderInstanceId;
...@@ -99,17 +156,50 @@ export function ProviderModelsSection({...@@ -99,17 +156,50 @@ export function ProviderModelsSection({
99 onModelOrderChange,156 onModelOrderChange,
100}: ProviderModelsSectionProps) {157}: ProviderModelsSectionProps) {
101 const [input, setInput] = useState("");158 const [input, setInput] = useState("");
159 const [isAdding, setIsAdding] = useState(false);
160 const [filter, setFilter] = useState("");
102 const [error, setError] = useState<string | null>(null);161 const [error, setError] = useState<string | null>(null);
103 const listRef = useRef<HTMLDivElement | null>(null);162 const listRef = useRef<HTMLDivElement>(null);
163 // Slug of a just-added custom model, scrolled into view once its row exists.
164 const scrollToSlugRef = useRef<string | null>(null);
104 const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]);165 const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]);
105 const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);166 const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);
106 const orderedModels = useMemo(() => {167 const displayModels = useMemo(
107 return sortModelsForProviderInstance(models, {168 () =>
108 favoriteModels: favoriteModelSet,169 groupModelsForDisplay(models, {
109 groupFavorites: true,170 favoriteModels: favoriteModelSet,
110 modelOrder,171 hiddenModels: hiddenModelSet,
111 });172 modelOrder,
112 }, [favoriteModelSet, modelOrder, models]);173 }),
174 [favoriteModelSet, hiddenModelSet, modelOrder, models],
175 );
176 const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length;
177 const hiddenCount = displayModels.filter(
178 (model) => !model.isCustom && hiddenModelSet.has(model.slug),
179 ).length;
180 const showFilter = models.length > FILTER_THRESHOLD;
181 const normalizedFilter = filter.trim().toLowerCase();
182 const isFiltering = showFilter && normalizedFilter.length > 0;
183 const visibleModels = isFiltering
184 ? displayModels.filter(
185 (model) =>
186 model.name.toLowerCase().includes(normalizedFilter) ||
187 model.slug.toLowerCase().includes(normalizedFilter),
188 )
189 : displayModels;
190
191 // The parent commits the new custom model and hands back an updated
192 // `models` list, so the row can only be scrolled to after that render.
193 useEffect(() => {
194 const slug = scrollToSlugRef.current;
195 if (slug === null) return;
196 const row = listRef.current?.querySelector<HTMLElement>(
197 `[data-model-slug="${CSS.escape(slug)}"]`,
198 );
199 if (!row) return;
200 scrollToSlugRef.current = null;
201 row.scrollIntoView({ block: "nearest" });
202 }, [displayModels]);
113203
114 const handleAdd = () => {204 const handleAdd = () => {
115 const normalized = normalizeCustomModelSlug(input);205 const normalized = normalizeCustomModelSlug(input);
...@@ -130,24 +220,20 @@ export function ProviderModelsSection({...@@ -130,24 +220,20 @@ export function ProviderModelsSection({
130 return;220 return;
131 }221 }
132222
223 // Clear the filter so the new row renders even when it does not match,
224 // which is also what lets the pending scroll target resolve and clear.
225 scrollToSlugRef.current = normalized;
226 setFilter("");
133 onChange([...customModels, normalized]);227 onChange([...customModels, normalized]);
134 setInput("");228 setInput("");
135 setError(null);229 setError(null);
230 setIsAdding(false);
231 };
136232
137 // Scroll the new row into view once the DOM reflects the commit.233 const cancelAdd = () => {
138 // `MutationObserver` handles the one-frame gap between `onChange` and234 setInput("");
139 // the `models` prop update; the `requestAnimationFrame` covers the235 setError(null);
140 // common case where the parent updates synchronously.236 setIsAdding(false);
141 const el = listRef.current;
142 if (!el) return;
143 const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
144 requestAnimationFrame(scrollToEnd);
145 const observer = new MutationObserver(() => {
146 scrollToEnd();
147 observer.disconnect();
148 });
149 observer.observe(el, { childList: true, subtree: true });
150 setTimeout(() => observer.disconnect(), 2_000);
151 };237 };
152238
153 const handleRemove = (slug: string) => {239 const handleRemove = (slug: string) => {
...@@ -157,12 +243,11 @@ export function ProviderModelsSection({...@@ -157,12 +243,11 @@ export function ProviderModelsSection({
157 setError(null);243 setError(null);
158 };244 };
159245
160 const handleToggleHidden = (slug: string) => {246 const setHidden = (slug: string, hidden: boolean) => {
161 if (hiddenModelSet.has(slug)) {247 if (hidden === hiddenModelSet.has(slug)) return;
162 onHiddenModelsChange(hiddenModels.filter((model) => model !== slug));248 onHiddenModelsChange(
163 return;249 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),
164 }250 );
165 onHiddenModelsChange([...hiddenModels, slug]);
166 };251 };
167252
168 const handleToggleFavorite = (slug: string) => {253 const handleToggleFavorite = (slug: string) => {
...@@ -173,237 +258,297 @@ export function ProviderModelsSection({...@@ -173,237 +258,297 @@ export function ProviderModelsSection({
173 onFavoriteModelsChange([...favoriteModels, slug]);258 onFavoriteModelsChange([...favoriteModels, slug]);
174 };259 };
175260
261 // Rows only trade places with a neighbour in the same group (favorites,
262 // visible, hidden), and the display order is persisted as the new order.
263 const groupOf = (model: (typeof displayModels)[number]) =>
264 favoriteModelSet.has(model.slug)
265 ? "favorite"
266 : !model.isCustom && hiddenModelSet.has(model.slug)
267 ? "hidden"
268 : "visible";
176 const handleMove = (slug: string, direction: -1 | 1) => {269 const handleMove = (slug: string, direction: -1 | 1) => {
177 const slugs = orderedModels.map((model) => model.slug);270 const index = displayModels.findIndex((model) => model.slug === slug);
178 const index = slugs.indexOf(slug);
179 const nextIndex = index + direction;271 const nextIndex = index + direction;
180 if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) {272 if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return;
181 return;273 if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return;
182 }274 const next = displayModels.map((model) => model.slug);
183 const next = [...slugs];
184 [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!];275 [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!];
185 onModelOrderChange(next);276 onModelOrderChange(next);
186 };277 };
187278
279 type DisplayModel = (typeof displayModels)[number];
280
281 const starButton = (model: DisplayModel, isFavorite: boolean) => (
282 <Tooltip>
283 <TooltipTrigger
284 render={
285 <Button
286 size="icon-micro"
287 variant="ghost"
288 className={cn(
289 "[--control-icon-color:currentColor]",
290 isFavorite
291 ? "text-yellow-500 hover:text-yellow-600"
292 : "text-muted-foreground/40 hover:text-muted-foreground",
293 )}
294 onClick={() => handleToggleFavorite(model.slug)}
295 aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${
296 isFavorite ? "from" : "to"
297 } favorites`}
298 />
299 }
300 >
301 <StarIcon className={cn("size-3", isFavorite && "fill-current")} />
302 </TooltipTrigger>
303 <TooltipPopup side="top">
304 {isFavorite ? "Remove from favorites" : "Add to favorites"}
305 </TooltipPopup>
306 </Tooltip>
307 );
308
309 // Reorder and remove stay in the row at all times (dimmed when unavailable)
310 // so ordering is discoverable without hovering.
311 const rowActions = (
312 model: DisplayModel,
313 options: {
314 readonly isHidden: boolean;
315 readonly canMoveUp: boolean;
316 readonly canMoveDown: boolean;
317 },
318 ) => (
319 <span className="flex shrink-0 items-center justify-end gap-0.5">
320 {!options.isHidden && !isFiltering ? (
321 <>
322 <Tooltip>
323 <TooltipTrigger
324 render={
325 <Button
326 size="icon-micro"
327 variant="ghost-muted"
328 disabled={!options.canMoveUp}
329 onClick={() => handleMove(model.slug, -1)}
330 aria-label={`Move ${model.name} up`}
331 />
332 }
333 >
334 <ArrowUpIcon className="size-3" />
335 </TooltipTrigger>
336 <TooltipPopup side="top">Move up</TooltipPopup>
337 </Tooltip>
338 <Tooltip>
339 <TooltipTrigger
340 render={
341 <Button
342 size="icon-micro"
343 variant="ghost-muted"
344 disabled={!options.canMoveDown}
345 onClick={() => handleMove(model.slug, 1)}
346 aria-label={`Move ${model.name} down`}
347 />
348 }
349 >
350 <ArrowDownIcon className="size-3" />
351 </TooltipTrigger>
352 <TooltipPopup side="top">Move down</TooltipPopup>
353 </Tooltip>
354 </>
355 ) : null}
356 {model.isCustom ? (
357 <Tooltip>
358 <TooltipTrigger
359 render={
360 <Button
361 size="icon-micro"
362 variant="ghost-muted"
363 aria-label={`Remove ${model.slug}`}
364 onClick={() => handleRemove(model.slug)}
365 />
366 }
367 >
368 <XIcon className="size-3" />
369 </TooltipTrigger>
370 <TooltipPopup side="top">Remove custom model</TooltipPopup>
371 </Tooltip>
372 ) : null}
373 </span>
374 );
375
376 const pickerTooltip = (model: DisplayModel, isHidden: boolean) =>
377 model.isCustom
378 ? "Custom models are always shown in the picker"
379 : isHidden
380 ? "Hidden from picker"
381 : "Shown in picker";
382
383 // The trigger is a wrapper span: a disabled switch gets no pointer events,
384 // so it could not open the tooltip itself.
385 const pickerSwitch = (model: DisplayModel, isHidden: boolean) => (
386 <Tooltip>
387 <TooltipTrigger render={<span className="flex shrink-0 items-center" />}>
388 <Switch
389 className="sm:[--thumb-size:--spacing(3.5)]"
390 checked={!isHidden}
391 disabled={model.isCustom}
392 onCheckedChange={(checked) => setHidden(model.slug, !checked)}
393 aria-label={`Show ${model.name} in the model picker`}
394 />
395 </TooltipTrigger>
396 <TooltipPopup side="top">{pickerTooltip(model, isHidden)}</TooltipPopup>
397 </Tooltip>
398 );
399
400 const renderRow = (model: DisplayModel) => {
401 const capLabels = describeModelCapabilities(model);
402 const group = groupOf(model);
403 // Hidden is read from the preference itself: a favorited model can still be
404 // hidden, and its switch must say so even though it sits in the favorites group.
405 const isHidden = !model.isCustom && hiddenModelSet.has(model.slug);
406 const isFavorite = group === "favorite";
407 const index = displayModels.indexOf(model);
408 const previousModel = displayModels[index - 1];
409 const nextModel = displayModels[index + 1];
410 // Reordering a filtered view would be ambiguous, so arrows only show on
411 // the full list.
412 const canMoveUp =
413 !isFiltering && previousModel !== undefined && groupOf(previousModel) === group;
414 const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group;
415 const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90");
416
417 return (
418 <div
419 key={`${instanceId}:${model.slug}`}
420 data-model-slug={model.slug}
421 className={cn(
422 "grid h-7 grid-cols-[1.5rem_minmax(0,1fr)_auto_4rem_auto] items-center gap-2 rounded-md px-2 transition-colors hover:bg-muted/30",
423 isHidden && "opacity-50",
424 )}
425 >
426 {starButton(model, isFavorite)}
427 <span className="flex min-w-0 items-baseline gap-2">
428 <span className={cn(nameClassName, "truncate")}>{model.name}</span>
429 {model.isCustom ? (
430 <span className="text-[11px] text-muted-foreground/70">custom</span>
431 ) : model.name !== model.slug ? (
432 <code className="truncate font-mono text-[11px] text-muted-foreground/70">
433 {model.slug}
434 </code>
435 ) : null}
436 </span>
437 {/*
438 Always a grid item so the columns line up across rows; the text
439 itself drops out on phone widths where it would starve the name.
440 */}
441 <span className="text-[11px] text-muted-foreground/70">
442 {capLabels.length > 0 ? (
443 <span className="hidden sm:inline">{capLabels.join(" · ")}</span>
444 ) : null}
445 </span>
446 {rowActions(model, { isHidden, canMoveUp, canMoveDown })}
447 {pickerSwitch(model, isHidden)}
448 </div>
449 );
450 };
451
452 const groupLabel = (label: string, isFirst: boolean) => (
453 <div className={cn("px-2 pb-1.5 text-[11px] text-muted-foreground", isFirst ? "pt-1" : "pt-5")}>
454 {label}
455 </div>
456 );
457
188 return (458 return (
189 <div className="lg:flex lg:h-full lg:min-h-0 lg:flex-col">459 <div className="lg:flex lg:h-full lg:min-h-0 lg:flex-col">
190 <div className="text-xs font-medium text-foreground">Models</div>460 <div className="flex flex-wrap items-center justify-between gap-2">
191 <div className="mt-1 text-xs text-muted-foreground">461 {showFilter ? (
192 {models.length} model{models.length === 1 ? "" : "s"} available.462 <Input
463 value={filter}
464 onChange={(event) => setFilter(event.target.value)}
465 placeholder="Filter models"
466 size="compact"
467 className="w-56"
468 spellCheck={false}
469 aria-label="Filter models"
470 />
471 ) : null}
472 <span className="text-xs text-muted-foreground">
473 {models.length} model{models.length === 1 ? "" : "s"}
474 {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""}
475 {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""}
476 </span>
193 </div>477 </div>
194 <div478 <div
195 ref={listRef}479 ref={listRef}
196 className="mt-2 max-h-40 overflow-y-auto pb-1 lg:min-h-0 lg:max-h-none lg:flex-1"480 className="mt-2 -mx-2 max-h-64 overflow-y-auto lg:max-h-none lg:min-h-0 lg:flex-1"
197 >481 >
198 {orderedModels.map((model, index) => {482 {visibleModels.length === 0 ? (
199 const caps = model.capabilities;483 <p className="px-2 py-2 text-xs text-muted-foreground">
200 const capLabels: string[] = [];484 {isFiltering ? "No models match." : "No models reported for this provider yet."}
201 const isHidden = !model.isCustom && hiddenModelSet.has(model.slug);485 </p>
202 const isFavorite = favoriteModelSet.has(model.slug);486 ) : null}
203 const previousModel = orderedModels[index - 1];487 {visibleModels.map((model, index) => {
204 const nextModel = orderedModels[index + 1];488 const group = groupOf(model);
205 const canMoveUp =489 const previous = visibleModels[index - 1];
206 previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite;490 const startsGroup = previous === undefined || groupOf(previous) !== group;
207 const canMoveDown =
208 nextModel !== undefined && favoriteModelSet.has(nextModel.slug) === isFavorite;
209 const descriptors = caps?.optionDescriptors ?? [];
210 if (descriptors.some((descriptor) => descriptor.id === "fastMode")) {
211 capLabels.push("Fast mode");
212 }
213 if (descriptors.some((descriptor) => descriptor.id === "thinking")) {
214 capLabels.push("Thinking");
215 }
216 if (
217 descriptors.some(
218 (descriptor) =>
219 descriptor.type === "select" &&
220 (descriptor.id === "reasoningEffort" ||
221 descriptor.id === "effort" ||
222 descriptor.id === "reasoning" ||
223 descriptor.id === "variant"),
224 )
225 ) {
226 capLabels.push("Reasoning");
227 }
228 const hasDetails = capLabels.length > 0 || model.name !== model.slug;
229
230 return (491 return (
231 <div492 <div key={`${instanceId}:${model.slug}:group`}>
232 key={`${instanceId}:${model.slug}`}493 {startsGroup && favoriteCount > 0 && group === "favorite"
233 className={cn(494 ? groupLabel("Favorites", index === 0)
234 "grid min-h-7 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-1",495 : null}
235 isHidden && "text-muted-foreground",496 {startsGroup && favoriteCount > 0 && group === "visible"
236 )}497 ? groupLabel("All", index === 0)
237 >498 : null}
238 <div className="flex min-w-0 items-center gap-1">499 {startsGroup && group === "hidden"
239 <span500 ? groupLabel("Hidden from picker", index === 0)
240 className={cn(501 : null}
241 "min-w-0 truncate text-xs",502 {renderRow(model)}
242 isHidden ? "text-muted-foreground line-through" : "text-foreground/90",
243 )}
244 >
245 {model.name}
246 </span>
247 {hasDetails ? (
248 <Popover>
249 <PopoverTrigger
250 openOnHover
251 delay={250}
252 closeDelay={100}
253 render={
254 <Button
255 size="icon-micro"
256 variant="ghost"
257 className="text-muted-foreground/60 hover:text-muted-foreground"
258 aria-label={`Details for ${model.name}`}
259 />
260 }
261 >
262 <InfoIcon className="size-3" />
263 </PopoverTrigger>
264 <PopoverPopup side="top" tooltipStyle className="max-w-56">
265 <div className="space-y-1">
266 <code className="block text-[11px] text-foreground">{model.slug}</code>
267 {capLabels.length > 0 ? (
268 <div className="flex flex-wrap gap-x-2 gap-y-0.5">
269 {capLabels.map((label) => (
270 <span key={label} className="text-[10px] text-muted-foreground">
271 {label}
272 </span>
273 ))}
274 </div>
275 ) : null}
276 </div>
277 </PopoverPopup>
278 </Popover>
279 ) : null}
280 {isHidden ? (
281 <span className="text-[10px] text-muted-foreground">hidden</span>
282 ) : null}
283 {model.isCustom ? (
284 <span className="text-[10px] text-muted-foreground">custom</span>
285 ) : null}
286 </div>
287 <div className="flex shrink-0 items-center gap-0.5">
288 <Tooltip>
289 <TooltipTrigger
290 render={
291 <Button
292 size="icon-micro"
293 variant="ghost-muted"
294 className={cn(isFavorite && "text-yellow-500 hover:text-yellow-600")}
295 onClick={() => handleToggleFavorite(model.slug)}
296 aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${
297 isFavorite ? "from" : "to"
298 } favorites`}
299 />
300 }
301 >
302 <StarIcon className={cn("size-3", isFavorite && "fill-current")} />
303 </TooltipTrigger>
304 <TooltipPopup side="top">
305 {isFavorite ? "Remove from favorites" : "Add to favorites"}
306 </TooltipPopup>
307 </Tooltip>
308 <Tooltip>
309 <TooltipTrigger
310 render={
311 <Button
312 size="icon-micro"
313 variant="ghost-muted"
314 disabled={!canMoveUp}
315 onClick={() => handleMove(model.slug, -1)}
316 aria-label={`Move ${model.name} up`}
317 />
318 }
319 >
320 <ArrowUpIcon className="size-3" />
321 </TooltipTrigger>
322 <TooltipPopup side="top">Move up</TooltipPopup>
323 </Tooltip>
324 <Tooltip>
325 <TooltipTrigger
326 render={
327 <Button
328 size="icon-micro"
329 variant="ghost-muted"
330 disabled={!canMoveDown}
331 onClick={() => handleMove(model.slug, 1)}
332 aria-label={`Move ${model.name} down`}
333 />
334 }
335 >
336 <ArrowDownIcon className="size-3" />
337 </TooltipTrigger>
338 <TooltipPopup side="top">Move down</TooltipPopup>
339 </Tooltip>
340 {!model.isCustom ? (
341 <Tooltip>
342 <TooltipTrigger
343 render={
344 <Button
345 size="icon-micro"
346 variant="ghost-muted"
347 onClick={() => handleToggleHidden(model.slug)}
348 aria-label={`${isHidden ? "Show" : "Hide"} ${model.name}`}
349 />
350 }
351 >
352 {isHidden ? (
353 <EyeIcon className="size-3" />
354 ) : (
355 <EyeOffIcon className="size-3" />
356 )}
357 </TooltipTrigger>
358 <TooltipPopup side="top">
359 {isHidden ? "Show in picker" : "Hide from picker"}
360 </TooltipPopup>
361 </Tooltip>
362 ) : null}
363 {model.isCustom ? (
364 <Tooltip>
365 <TooltipTrigger
366 render={
367 <Button
368 size="icon-micro"
369 variant="ghost-muted"
370 aria-label={`Remove ${model.slug}`}
371 onClick={() => handleRemove(model.slug)}
372 />
373 }
374 >
375 <XIcon className="size-3" />
376 </TooltipTrigger>
377 <TooltipPopup side="top">Remove custom model</TooltipPopup>
378 </Tooltip>
379 ) : null}
380 </div>
381 </div>503 </div>
382 );504 );
383 })}505 })}
384 </div>506 </div>
385507
386 <div className="mt-3 flex flex-col gap-2 sm:flex-row">508 {isAdding ? (
387 <Input509 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
388 id={`provider-instance-${instanceId}-custom-model`}510 <Input
389 value={input}511 id={`provider-instance-${instanceId}-custom-model`}
390 onChange={(event) => {512 autoFocus
391 setInput(event.target.value);513 value={input}
392 if (error) setError(null);514 onChange={(event) => {
393 }}515 setInput(event.target.value);
394 onKeyDown={(event) => {516 if (error) setError(null);
395 if (event.key !== "Enter") return;517 }}
396 event.preventDefault();518 onKeyDown={(event) => {
397 handleAdd();519 if (event.key === "Escape") {
398 }}520 event.preventDefault();
399 placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"}521 cancelAdd();
400 spellCheck={false}522 return;
401 />523 }
402 <Button className="shrink-0" variant="outline" onClick={handleAdd}>524 if (event.key !== "Enter") return;
403 <PlusIcon className="size-3.5" />525 event.preventDefault();
404 Add526 handleAdd();
527 }}
528 placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"}
529 spellCheck={false}
530 />
531 <div className="flex shrink-0 gap-2">
532 <Button variant="outline" onClick={handleAdd}>
533 Add
534 </Button>
535 <Button variant="ghost" onClick={cancelAdd}>
536 Cancel
537 </Button>
538 </div>
539 </div>
540 ) : (
541 <Button
542 type="button"
543 size="xs"
544 variant="ghost-muted"
545 className="mt-2 -ml-2"
546 onClick={() => setIsAdding(true)}
547 >
548 <PlusIcon className="size-3" />
549 Add custom model
405 </Button>550 </Button>
406 </div>551 )}
407552
408 {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null}553 {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null}
409 </div>554 </div>
apps/web/src/components/settings/ProviderSettingsForm.tsx+71-4
...@@ -158,7 +158,13 @@ interface ProviderSettingsFormProps {...@@ -158,7 +158,13 @@ interface ProviderSettingsFormProps {
158 readonly definition: ProviderClientDefinition;158 readonly definition: ProviderClientDefinition;
159 readonly value: unknown;159 readonly value: unknown;
160 readonly idPrefix: string;160 readonly idPrefix: string;
161 readonly variant: "card" | "dialog";161 /**
162 * `card` stacks label over control, `dialog` is the compact wizard layout,
163 * `grid` emits a label cell and a control cell per field for a parent
164 * two-column grid (label column left, control right), with the description
165 * beside a fixed-width control so each field stays on one line.
166 */
167 readonly variant: "card" | "dialog" | "grid";
162 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;168 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;
163}169}
164170
...@@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({...@@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({
189}: ProviderSettingsFieldRowProps) {195}: ProviderSettingsFieldRowProps) {
190 const inputId = `${idPrefix}-${field.key}`;196 const inputId = `${idPrefix}-${field.key}`;
191 const descriptionClassName =197 const descriptionClassName =
192 variant === "card"198 variant === "dialog"
193 ? "mt-1 block text-xs text-muted-foreground"199 ? "text-[11px] text-muted-foreground"
194 : "text-[11px] text-muted-foreground";200 : "mt-1 block text-xs text-muted-foreground";
195 const label = <span className="text-xs font-medium text-foreground">{field.label}</span>;201 const label = <span className="text-xs font-medium text-foreground">{field.label}</span>;
196 const description = field.description ? (202 const description = field.description ? (
197 <span className={descriptionClassName}>{field.description}</span>203 <span className={descriptionClassName}>{field.description}</span>
198 ) : null;204 ) : null;
199205
206 if (variant === "grid") {
207 // Label cell, then a control cell where the description sits beside a
208 // fixed-width control and wraps under it when the pane is narrow. The
209 // description is outside the label, so the control points at it instead.
210 const descriptionId = field.description ? `${inputId}-description` : undefined;
211 return (
212 <>
213 {field.control === "switch" ? (
214 <span className="pt-1.5 text-xs font-medium text-foreground">{field.label}</span>
215 ) : (
216 <label htmlFor={inputId} className="pt-1.5 text-xs font-medium text-foreground">
217 {field.label}
218 </label>
219 )}
220 <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
221 {field.control === "switch" ? (
222 <span className="flex h-8 items-center">
223 <Switch
224 checked={readProviderConfigBoolean(value, field.key, field.defaultBooleanValue)}
225 onCheckedChange={(checked) =>
226 onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked)))
227 }
228 aria-label={field.label}
229 aria-describedby={descriptionId}
230 />
231 </span>
232 ) : field.control === "textarea" ? (
233 <Textarea
234 id={inputId}
235 aria-describedby={descriptionId}
236 className="w-full max-w-sm"
237 value={readProviderConfigString(value, field.key)}
238 onChange={(event) =>
239 onChange(nextProviderConfigWithFieldValue(value, field, event.target.value))
240 }
241 placeholder={field.placeholder}
242 spellCheck={false}
243 />
244 ) : (
245 <DraftInput
246 id={inputId}
247 aria-describedby={descriptionId}
248 className="w-56"
249 type={field.control === "password" ? "password" : undefined}
250 autoComplete={field.control === "password" ? "off" : undefined}
251 value={readProviderConfigString(value, field.key)}
252 onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))}
253 placeholder={field.placeholder}
254 spellCheck={false}
255 />
256 )}
257 {field.description ? (
258 <span id={descriptionId} className="text-[11px] leading-snug text-muted-foreground">
259 {field.description}
260 </span>
261 ) : null}
262 </div>
263 </>
264 );
265 }
266
200 if (field.control === "switch") {267 if (field.control === "switch") {
201 return (268 return (
202 <FieldFrame variant={variant}>269 <FieldFrame variant={variant}>
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+18-10
...@@ -141,6 +141,21 @@ function renderPanel(options?: {...@@ -141,6 +141,21 @@ function renderPanel(options?: {
141 }) as ReactElement<Record<string, unknown>>;141 }) as ReactElement<Record<string, unknown>>;
142}142}
143143
144function isRefreshButton(element: ReactElement<Record<string, unknown>>): boolean {
145 const children = element.props.children;
146 return (
147 Array.isArray(children) &&
148 children.some(
149 (child) =>
150 typeof child === "object" &&
151 child !== null &&
152 (child as ReactElement<Record<string, unknown>>).props?.className === "sr-only" &&
153 (child as ReactElement<Record<string, unknown>>).props?.children ===
154 "Refresh provider status",
155 )
156 );
157}
158
144function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean {159function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean {
145 return element.props["aria-label"] === "Add provider";160 return element.props["aria-label"] === "Add provider";
146}161}
...@@ -184,10 +199,7 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -184,10 +199,7 @@ describe("EnvironmentProviderSettings routing", () => {
184 it("routes refresh and provider update commands to the selected environment", async () => {199 it("routes refresh and provider update commands to the selected environment", async () => {
185 atoms.providers = [provider()];200 atoms.providers = [provider()];
186 const panel = renderPanel();201 const panel = renderPanel();
187 const refreshButton = visitElements(202 const refreshButton = visitElements(panel, isRefreshButton);
188 panel,
189 (element) => element.props["aria-label"] === "Refresh provider status",
190 );
191 expect(refreshButton).not.toBeNull();203 expect(refreshButton).not.toBeNull();
192 (refreshButton?.props.onClick as (() => void) | undefined)?.();204 (refreshButton?.props.onClick as (() => void) | undefined)?.();
193 await flushPromises();205 await flushPromises();
...@@ -243,9 +255,7 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -243,9 +255,7 @@ describe("EnvironmentProviderSettings routing", () => {
243 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");255 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
244 expect(notice).not.toBeNull();256 expect(notice).not.toBeNull();
245257
246 expect(258 expect(visitElements(panel, isRefreshButton)).toBeNull();
247 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
248 ).toBeNull();
249 expect(visitElements(panel, isAddProviderButton)).toBeNull();259 expect(visitElements(panel, isAddProviderButton)).toBeNull();
250 });260 });
251261
...@@ -256,9 +266,7 @@ describe("EnvironmentProviderSettings routing", () => {...@@ -256,9 +266,7 @@ describe("EnvironmentProviderSettings routing", () => {
256 expect(266 expect(
257 visitElements(panel, (element) => element.props.title === "Limited permissions"),267 visitElements(panel, (element) => element.props.title === "Limited permissions"),
258 ).toBeNull();268 ).toBeNull();
259 expect(269 expect(visitElements(panel, isRefreshButton)).not.toBeNull();
260 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
261 ).not.toBeNull();
262 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();270 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
263 });271 });
264272
apps/web/src/components/settings/ProviderSettingsPanel.tsx+76-74
...@@ -135,11 +135,11 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }...@@ -135,11 +135,11 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }
135 }135 }
136136
137 if (lastCheckedRelative.status === "invalid") {137 if (lastCheckedRelative.status === "invalid") {
138 return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;138 return <span>Checked unavailable</span>;
139 }139 }
140140
141 return (141 return (
142 <span className="text-[11px] text-muted-foreground/60">142 <span>
143 {lastCheckedRelative.suffix ? (143 {lastCheckedRelative.suffix ? (
144 <>144 <>
145 Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "}145 Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "}
...@@ -858,33 +858,31 @@ export function EnvironmentProviderSettings({...@@ -858,33 +858,31 @@ export function EnvironmentProviderSettings({
858 <SettingsSection858 <SettingsSection
859 {...searchableSetting("providers")}859 {...searchableSetting("providers")}
860 headerAction={860 headerAction={
861 <div className="flex min-w-0 items-center gap-1.5">861 <div className="flex min-w-0 items-center gap-2">
862 {/*862 {readOnly ? (
863 The 11px size must sit on this flex item, not just the span863 <span className="min-w-0 truncate text-xs text-muted-foreground">
864 inside: the item's line box is struck from its own font size,864 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
865 and an inherited 16px strut hangs the smaller text below the865 </span>
866 vertical center of the row.866 ) : (
867 */}
868 <span className="hidden min-w-0 truncate text-[11px] sm:inline">
869 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
870 </span>
871 {!readOnly ? (
872 <>867 <>
873 <Tooltip>868 <Tooltip>
874 <TooltipTrigger869 <TooltipTrigger
875 render={870 render={
876 <Button871 <Button
877 size="icon-micro"872 size="compact"
878 variant="ghost-muted"873 variant="ghost-muted"
879 disabled={isRefreshingProviders}874 disabled={isRefreshingProviders}
880 onClick={() => void refreshProviders()}875 onClick={() => void refreshProviders()}
881 aria-label="Refresh provider status"
882 >876 >
883 {isRefreshingProviders ? (877 {isRefreshingProviders ? (
884 <LoaderIcon className="size-3 animate-spin" />878 <LoaderIcon className="animate-spin" />
885 ) : (879 ) : (
886 <RefreshCwIcon className="size-3" />880 <RefreshCwIcon />
887 )}881 )}
882 <span className="sr-only">Refresh provider status</span>
883 <span className="hidden min-w-0 truncate sm:inline">
884 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
885 </span>
888 </Button>886 </Button>
889 }887 }
890 />888 />
...@@ -906,7 +904,7 @@ export function EnvironmentProviderSettings({...@@ -906,7 +904,7 @@ export function EnvironmentProviderSettings({
906 <TooltipPopup side="top">Add provider</TooltipPopup>904 <TooltipPopup side="top">Add provider</TooltipPopup>
907 </Tooltip>905 </Tooltip>
908 </>906 </>
909 ) : null}907 )}
910 </div>908 </div>
911 }909 }
912 >910 >
...@@ -948,27 +946,24 @@ export function EnvironmentProviderSettings({...@@ -948,27 +946,24 @@ export function EnvironmentProviderSettings({
948 Advanced946 Advanced
949 </CollapsibleTrigger>947 </CollapsibleTrigger>
950 <CollapsibleContent>948 <CollapsibleContent>
951 <div949 {/* Only the write controls go inert; the title and its policy tooltip stay readable. */}
952 inert={readOnly}950 <SettingsRow
953 aria-disabled={readOnly || undefined}951 id={searchableSetting("provider-health-check-interval").id}
954 className={readOnly ? "opacity-50 select-none" : undefined}952 title={
955 >953 <span className="inline-flex items-center gap-1.5">
956 <SettingsRow954 {searchableSetting("provider-health-check-interval").title}
957 id={searchableSetting("provider-health-check-interval").id}955 <PolicyTooltip>
958 title={956 This interval is configured here, then the shared Background activity policy
959 <span className="inline-flex items-center gap-1.5">957 decides whether provider probes may run when the timer fires. Custom intervals
960 {searchableSetting("provider-health-check-interval").title}958 appear as Advanced in General settings.
961 <PolicyTooltip>959 </PolicyTooltip>
962 This interval is configured here, then the shared Background activity policy960 </span>
963 decides whether provider probes may run when the timer fires. Custom961 }
964 intervals appear as Advanced in General settings.962 description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off."
965 </PolicyTooltip>963 resetAction={
966 </span>964 providerHealthRefreshIntervalSeconds !==
967 }965 defaultProviderHealthRefreshIntervalSeconds ? (
968 description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off."966 <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}>
969 resetAction={
970 providerHealthRefreshIntervalSeconds !==
971 defaultProviderHealthRefreshIntervalSeconds ? (
972 <SettingResetButton967 <SettingResetButton
973 label="provider health check interval"968 label="provider health check interval"
974 onClick={() =>969 onClick={() =>
...@@ -981,41 +976,48 @@ export function EnvironmentProviderSettings({...@@ -981,41 +976,48 @@ export function EnvironmentProviderSettings({
981 )976 )
982 }977 }
983 />978 />
984 ) : null979 </span>
985 }980 ) : null
986 control={981 }
987 <div className="flex shrink-0 items-center gap-2">982 control={
988 <NumberField983 <div
989 value={providerHealthRefreshIntervalSeconds}984 inert={readOnly}
990 min={0}985 aria-disabled={readOnly || undefined}
991 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}986 className={cn(
992 size="sm"987 "flex shrink-0 items-center gap-2",
993 className="w-32"988 readOnly && "opacity-50 select-none",
994 onValueChange={(value) =>989 )}
995 updateSettings(990 >
996 backgroundActivityOverrideSettings(991 <NumberField
997 settings.backgroundActivity,992 value={providerHealthRefreshIntervalSeconds}
998 resolvedBackgroundActivity,993 min={0}
999 {994 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
1000 providerHealthRefreshInterval: Duration.seconds(995 size="sm"
1001 normalizeIntervalSeconds(value),996 className="w-32"
1002 ),997 onValueChange={(value) =>
1003 },998 updateSettings(
1004 ),999 backgroundActivityOverrideSettings(
1005 )1000 settings.backgroundActivity,
1006 }1001 resolvedBackgroundActivity,
1007 >1002 {
1008 <NumberFieldGroup>1003 providerHealthRefreshInterval: Duration.seconds(
1009 <NumberFieldDecrement aria-label="Decrease provider health check interval" />1004 normalizeIntervalSeconds(value),
1010 <NumberFieldInput aria-label="Provider health check interval in seconds" />1005 ),
1011 <NumberFieldIncrement aria-label="Increase provider health check interval" />1006 },
1012 </NumberFieldGroup>1007 ),
1013 </NumberField>1008 )
1014 <span className="text-xs text-muted-foreground">seconds</span>1009 }
1015 </div>1010 >
1016 }1011 <NumberFieldGroup>
1017 />1012 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
1018 </div>1013 <NumberFieldInput aria-label="Provider health check interval in seconds" />
1014 <NumberFieldIncrement aria-label="Increase provider health check interval" />
1015 </NumberFieldGroup>
1016 </NumberField>
1017 <span className="text-xs text-muted-foreground">seconds</span>
1018 </div>
1019 }
1020 />
1019 </CollapsibleContent>1021 </CollapsibleContent>
1020 </Collapsible>1022 </Collapsible>
1021 </div>1023 </div>