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: {
228228 readonly onCommit: (value: string) => void;
229229 readonly description?: string;
230230 readonly commitDelayMs?: number;
231 /** `inline` renders only the swatch row, for callers that supply their own label. */
232 readonly layout?: "stacked" | "inline";
231233}) {
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;
233242 const [optimisticValue, setOptimisticValue] = useState(() => value ?? "");
234243 const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
235244 const pendingCommitRef = useRef<string | null>(null);
......@@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: {
295304 : "";
296305 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
298349 return (
299350 <div className="grid gap-2">
300351 <span className="text-xs font-medium text-foreground">Accent color</span>
301 <div className="flex min-w-0 flex-wrap items-center gap-2">
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>
352 {swatchRow}
332353 {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
333354 </div>
334355 );
apps/web/src/components/settings/ProviderInstanceCard.tsx+181-190
......@@ -5,6 +5,8 @@ import {
55 CopyIcon,
66 DownloadIcon,
77 LoaderIcon,
8 LockIcon,
9 LockOpenIcon,
810 PlusIcon,
911 Trash2Icon,
1012 XIcon,
......@@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
2830import { normalizeProviderAccentColor } from "../../providerInstances";
2931import { Badge } from "../ui/badge";
3032import { Button } from "../ui/button";
31import { Checkbox } from "../ui/checkbox";
3233import { DraftInput } from "../ui/draft-input";
3334import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
3435import { ScrollArea } from "../ui/scroll-area";
3536import { Switch } from "../ui/switch";
36import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
3737import { stackedThreadToast, toastManager } from "../ui/toast";
3838import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3939import type { DriverOption } from "./providerDriverMeta";
......@@ -53,6 +53,13 @@ import {
5353
5454const 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
5663let environmentVariableDraftId = 0;
5764const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
5865
......@@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: {
235242 publishRows(nextRows);
236243 };
237244
245 const addVariable = () =>
246 setRows([
247 ...rows,
248 {
249 id: nextEnvironmentVariableDraftId(),
250 name: "",
251 value: "",
252 sensitive: true,
253 },
254 ]);
255
238256 return (
239 <div className="grid gap-2">
240 <div className="flex items-center justify-between gap-3">
241 <span className="text-xs font-medium text-foreground">Environment variables</span>
242 <Button
243 type="button"
244 size="sm"
245 variant="outline"
246 className="h-7 gap-1.5 px-2 text-xs"
247 onClick={() =>
248 setRows([
249 ...rows,
250 {
251 id: nextEnvironmentVariableDraftId(),
252 name: "",
253 value: "",
254 sensitive: true,
255 },
256 ])
257 }
258 >
257 <div className="min-w-0 space-y-1.5">
258 {rows.map((variable, index) => (
259 <div key={variable.id} className="flex min-w-0 items-center gap-1.5">
260 <DraftInput
261 size="compact"
262 className="w-44 shrink-0 font-mono"
263 value={variable.name}
264 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
265 placeholder="VARIABLE_NAME"
266 spellCheck={false}
267 aria-label={`Environment variable name ${index + 1}`}
268 />
269 <span className="text-xs text-muted-foreground" aria-hidden>
270 =
271 </span>
272 <DraftInput
273 size="compact"
274 className="min-w-0 flex-1 font-mono"
275 value={variable.valueRedacted ? "" : variable.value}
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}>
259334 <PlusIcon className="size-3" />
260 Add
335 Add variable
261336 </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>
262342 </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>
350343 </div>
351344 );
352345}
......@@ -362,7 +355,7 @@ interface ProviderInstanceCardProps {
362355 readonly readOnly?: boolean | undefined;
363356 readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void;
364357 /**
365 * Pass `undefined` to hide the delete button entirely. Built-in default
358 * Pass `undefined` to hide the delete footer entirely. Built-in default
366359 * instance slots use `undefined` — they can't be deleted without losing
367360 * the slot, and their "reset to defaults" affordance lives on an outer
368361 * reset button instead. Explicit `| undefined` in the type accommodates
......@@ -489,6 +482,9 @@ export function ProviderInstanceCard({
489482 liveModels: liveProvider?.models,
490483 customModels,
491484 });
485 const hiddenModelCount = modelsForDisplay.filter(
486 (model) => !model.isCustom && hiddenModels.includes(model.slug),
487 ).length;
492488
493489 const updateDisplayName = (value: string) => {
494490 const trimmed = value.trim();
......@@ -581,35 +577,9 @@ export function ProviderInstanceCard({
581577 </>
582578 );
583579
584 const titleTailNode = (
585 <>
586 {headerAction ? (
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 );
580 const titleTailNode = headerAction ? (
581 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span>
582 ) : null;
613583
614584 const versionCodeNode = versionLabel ? (
615585 <code className="text-xs text-muted-foreground">{versionLabel}</code>
......@@ -634,7 +604,7 @@ export function ProviderInstanceCard({
634604 className={cn(
635605 // Sidebar-style selection with a fixed row height so the list stays
636606 // 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",
638608 // Foreground-alpha tint so the fill reads the same in light and dark themes.
639609 selected ? "bg-foreground/8" : "hover:bg-foreground/4",
640610 )}
......@@ -712,7 +682,7 @@ export function ProviderInstanceCard({
712682 size="icon-xs"
713683 variant="ghost"
714684 className={cn(
715 "size-5 rounded-sm p-0",
685 "size-5 rounded-sm p-0 [--control-icon-color:currentColor]",
716686 versionAdvisory.emphasis === "strong"
717687 ? "text-warning hover:text-warning"
718688 : "text-muted-foreground hover:text-foreground",
......@@ -820,6 +790,25 @@ export function ProviderInstanceCard({
820790 </p>
821791 ) : null}
822792 </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}
823812 </div>
824813
825814 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">
......@@ -835,10 +824,14 @@ export function ProviderInstanceCard({
835824 <button
836825 type="button"
837826 aria-pressed={visibleTab === "models"}
838 className={providerSettingsTabClassName(visibleTab === "models")}
827 className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")}
839828 onClick={() => setActiveTab("models")}
840829 >
841830 Models
831 <span className="font-normal text-muted-foreground/70">
832 {modelsForDisplay.length}
833 {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""}
834 </span>
842835 </button>
843836 ) : null}
844837 </div>
......@@ -853,62 +846,60 @@ export function ProviderInstanceCard({
853846 <div
854847 inert={readOnly}
855848 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")}
857850 >
858 <div>
859 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
860 <span className="text-xs font-medium text-foreground">Display name</span>
851 <div className={PROVIDER_FIELD_GRID_CLASS_NAME}>
852 <label
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">
861859 <DraftInput
862860 id={`provider-instance-${instanceId}-display-name`}
863 className="mt-1.5"
861 className="w-44"
864862 value={instance.displayName ?? ""}
865863 onCommit={updateDisplayName}
866864 placeholder={driverOption?.label ?? "Instance label"}
867865 spellCheck={false}
868866 />
869 <span className="mt-1 block text-xs text-muted-foreground">
870 Optional label shown in the provider list.
871 </span>
872 </label>
873 </div>
874
875 <div>
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>
867 <ProviderAccentColorPicker
868 layout="inline"
869 displayName={displayName}
870 value={accentColor}
871 onCommit={updateAccentColor}
872 commitDelayMs={120}
873 />
910874 </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>
912903 </div>
913904 </ScrollArea>
914905 {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 @@
11"use client";
22
3import {
4 ArrowDownIcon,
5 ArrowUpIcon,
6 EyeIcon,
7 EyeOffIcon,
8 InfoIcon,
9 PlusIcon,
10 StarIcon,
11 XIcon,
12} from "lucide-react";
13import { useMemo, useRef, useState } from "react";
3import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, useState } from "react";
145import {
156 ProviderDriverKind,
167 type ProviderInstanceId,
......@@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering";
2314import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection";
2415import { Button } from "../ui/button";
2516import { Input } from "../ui/input";
26import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
17import { Switch } from "../ui/switch";
2718import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2819
2920/**
......@@ -38,6 +29,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial<Record<ProviderDriverKind, strin
3829 [ProviderDriverKind.make("opencode")]: "openai/gpt-5",
3930};
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
4198interface ProviderModelsSectionProps {
4299 /** Identifier used to namespace input ids within the DOM. */
43100 readonly instanceId: ProviderInstanceId;
......@@ -99,17 +156,50 @@ export function ProviderModelsSection({
99156 onModelOrderChange,
100157}: ProviderModelsSectionProps) {
101158 const [input, setInput] = useState("");
159 const [isAdding, setIsAdding] = useState(false);
160 const [filter, setFilter] = useState("");
102161 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);
104165 const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]);
105166 const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);
106 const orderedModels = useMemo(() => {
107 return sortModelsForProviderInstance(models, {
108 favoriteModels: favoriteModelSet,
109 groupFavorites: true,
110 modelOrder,
111 });
112 }, [favoriteModelSet, modelOrder, models]);
167 const displayModels = useMemo(
168 () =>
169 groupModelsForDisplay(models, {
170 favoriteModels: favoriteModelSet,
171 hiddenModels: hiddenModelSet,
172 modelOrder,
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
114204 const handleAdd = () => {
115205 const normalized = normalizeCustomModelSlug(input);
......@@ -130,24 +220,20 @@ export function ProviderModelsSection({
130220 return;
131221 }
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("");
133227 onChange([...customModels, normalized]);
134228 setInput("");
135229 setError(null);
230 setIsAdding(false);
231 };
136232
137 // Scroll the new row into view once the DOM reflects the commit.
138 // `MutationObserver` handles the one-frame gap between `onChange` and
139 // the `models` prop update; the `requestAnimationFrame` covers the
140 // common case where the parent updates synchronously.
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);
233 const cancelAdd = () => {
234 setInput("");
235 setError(null);
236 setIsAdding(false);
151237 };
152238
153239 const handleRemove = (slug: string) => {
......@@ -157,12 +243,11 @@ export function ProviderModelsSection({
157243 setError(null);
158244 };
159245
160 const handleToggleHidden = (slug: string) => {
161 if (hiddenModelSet.has(slug)) {
162 onHiddenModelsChange(hiddenModels.filter((model) => model !== slug));
163 return;
164 }
165 onHiddenModelsChange([...hiddenModels, slug]);
246 const setHidden = (slug: string, hidden: boolean) => {
247 if (hidden === hiddenModelSet.has(slug)) return;
248 onHiddenModelsChange(
249 hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug),
250 );
166251 };
167252
168253 const handleToggleFavorite = (slug: string) => {
......@@ -173,237 +258,297 @@ export function ProviderModelsSection({
173258 onFavoriteModelsChange([...favoriteModels, slug]);
174259 };
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";
176269 const handleMove = (slug: string, direction: -1 | 1) => {
177 const slugs = orderedModels.map((model) => model.slug);
178 const index = slugs.indexOf(slug);
270 const index = displayModels.findIndex((model) => model.slug === slug);
179271 const nextIndex = index + direction;
180 if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) {
181 return;
182 }
183 const next = [...slugs];
272 if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return;
273 if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return;
274 const next = displayModels.map((model) => model.slug);
184275 [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!];
185276 onModelOrderChange(next);
186277 };
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
188458 return (
189459 <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>
191 <div className="mt-1 text-xs text-muted-foreground">
192 {models.length} model{models.length === 1 ? "" : "s"} available.
460 <div className="flex flex-wrap items-center justify-between gap-2">
461 {showFilter ? (
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>
193477 </div>
194478 <div
195479 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"
197481 >
198 {orderedModels.map((model, index) => {
199 const caps = model.capabilities;
200 const capLabels: string[] = [];
201 const isHidden = !model.isCustom && hiddenModelSet.has(model.slug);
202 const isFavorite = favoriteModelSet.has(model.slug);
203 const previousModel = orderedModels[index - 1];
204 const nextModel = orderedModels[index + 1];
205 const canMoveUp =
206 previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite;
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
482 {visibleModels.length === 0 ? (
483 <p className="px-2 py-2 text-xs text-muted-foreground">
484 {isFiltering ? "No models match." : "No models reported for this provider yet."}
485 </p>
486 ) : null}
487 {visibleModels.map((model, index) => {
488 const group = groupOf(model);
489 const previous = visibleModels[index - 1];
490 const startsGroup = previous === undefined || groupOf(previous) !== group;
230491 return (
231 <div
232 key={`${instanceId}:${model.slug}`}
233 className={cn(
234 "grid min-h-7 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-1",
235 isHidden && "text-muted-foreground",
236 )}
237 >
238 <div className="flex min-w-0 items-center gap-1">
239 <span
240 className={cn(
241 "min-w-0 truncate text-xs",
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>
492 <div key={`${instanceId}:${model.slug}:group`}>
493 {startsGroup && favoriteCount > 0 && group === "favorite"
494 ? groupLabel("Favorites", index === 0)
495 : null}
496 {startsGroup && favoriteCount > 0 && group === "visible"
497 ? groupLabel("All", index === 0)
498 : null}
499 {startsGroup && group === "hidden"
500 ? groupLabel("Hidden from picker", index === 0)
501 : null}
502 {renderRow(model)}
381503 </div>
382504 );
383505 })}
384506 </div>
385507
386 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
387 <Input
388 id={`provider-instance-${instanceId}-custom-model`}
389 value={input}
390 onChange={(event) => {
391 setInput(event.target.value);
392 if (error) setError(null);
393 }}
394 onKeyDown={(event) => {
395 if (event.key !== "Enter") return;
396 event.preventDefault();
397 handleAdd();
398 }}
399 placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"}
400 spellCheck={false}
401 />
402 <Button className="shrink-0" variant="outline" onClick={handleAdd}>
403 <PlusIcon className="size-3.5" />
404 Add
508 {isAdding ? (
509 <div className="mt-3 flex flex-col gap-2 sm:flex-row">
510 <Input
511 id={`provider-instance-${instanceId}-custom-model`}
512 autoFocus
513 value={input}
514 onChange={(event) => {
515 setInput(event.target.value);
516 if (error) setError(null);
517 }}
518 onKeyDown={(event) => {
519 if (event.key === "Escape") {
520 event.preventDefault();
521 cancelAdd();
522 return;
523 }
524 if (event.key !== "Enter") return;
525 event.preventDefault();
526 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
405550 </Button>
406 </div>
551 )}
407552
408553 {error ? <p className="mt-2 text-xs text-destructive">{error}</p> : null}
409554 </div>
apps/web/src/components/settings/ProviderSettingsForm.tsx+71-4
......@@ -158,7 +158,13 @@ interface ProviderSettingsFormProps {
158158 readonly definition: ProviderClientDefinition;
159159 readonly value: unknown;
160160 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";
162168 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;
163169}
164170
......@@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({
189195}: ProviderSettingsFieldRowProps) {
190196 const inputId = `${idPrefix}-${field.key}`;
191197 const descriptionClassName =
192 variant === "card"
193 ? "mt-1 block text-xs text-muted-foreground"
194 : "text-[11px] text-muted-foreground";
198 variant === "dialog"
199 ? "text-[11px] text-muted-foreground"
200 : "mt-1 block text-xs text-muted-foreground";
195201 const label = <span className="text-xs font-medium text-foreground">{field.label}</span>;
196202 const description = field.description ? (
197203 <span className={descriptionClassName}>{field.description}</span>
198204 ) : 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
200267 if (field.control === "switch") {
201268 return (
202269 <FieldFrame variant={variant}>
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+18-10
......@@ -141,6 +141,21 @@ function renderPanel(options?: {
141141 }) as ReactElement<Record<string, unknown>>;
142142}
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
144159function isAddProviderButton(element: ReactElement<Record<string, unknown>>): boolean {
145160 return element.props["aria-label"] === "Add provider";
146161}
......@@ -184,10 +199,7 @@ describe("EnvironmentProviderSettings routing", () => {
184199 it("routes refresh and provider update commands to the selected environment", async () => {
185200 atoms.providers = [provider()];
186201 const panel = renderPanel();
187 const refreshButton = visitElements(
188 panel,
189 (element) => element.props["aria-label"] === "Refresh provider status",
190 );
202 const refreshButton = visitElements(panel, isRefreshButton);
191203 expect(refreshButton).not.toBeNull();
192204 (refreshButton?.props.onClick as (() => void) | undefined)?.();
193205 await flushPromises();
......@@ -243,9 +255,7 @@ describe("EnvironmentProviderSettings routing", () => {
243255 const notice = visitElements(panel, (element) => element.props.title === "Limited permissions");
244256 expect(notice).not.toBeNull();
245257
246 expect(
247 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
248 ).toBeNull();
258 expect(visitElements(panel, isRefreshButton)).toBeNull();
249259 expect(visitElements(panel, isAddProviderButton)).toBeNull();
250260 });
251261
......@@ -256,9 +266,7 @@ describe("EnvironmentProviderSettings routing", () => {
256266 expect(
257267 visitElements(panel, (element) => element.props.title === "Limited permissions"),
258268 ).toBeNull();
259 expect(
260 visitElements(panel, (element) => element.props["aria-label"] === "Refresh provider status"),
261 ).not.toBeNull();
269 expect(visitElements(panel, isRefreshButton)).not.toBeNull();
262270 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
263271 });
264272
apps/web/src/components/settings/ProviderSettingsPanel.tsx+76-74
......@@ -135,11 +135,11 @@ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }
135135 }
136136
137137 if (lastCheckedRelative.status === "invalid") {
138 return <span className="text-[11px] text-muted-foreground/50">Checked unavailable</span>;
138 return <span>Checked unavailable</span>;
139139 }
140140
141141 return (
142 <span className="text-[11px] text-muted-foreground/60">
142 <span>
143143 {lastCheckedRelative.suffix ? (
144144 <>
145145 Checked <span className="font-mono tabular-nums">{lastCheckedRelative.value}</span>{" "}
......@@ -858,33 +858,31 @@ export function EnvironmentProviderSettings({
858858 <SettingsSection
859859 {...searchableSetting("providers")}
860860 headerAction={
861 <div className="flex min-w-0 items-center gap-1.5">
862 {/*
863 The 11px size must sit on this flex item, not just the span
864 inside: the item's line box is struck from its own font size,
865 and an inherited 16px strut hangs the smaller text below the
866 vertical center of the row.
867 */}
868 <span className="hidden min-w-0 truncate text-[11px] sm:inline">
869 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
870 </span>
871 {!readOnly ? (
861 <div className="flex min-w-0 items-center gap-2">
862 {readOnly ? (
863 <span className="min-w-0 truncate text-xs text-muted-foreground">
864 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
865 </span>
866 ) : (
872867 <>
873868 <Tooltip>
874869 <TooltipTrigger
875870 render={
876871 <Button
877 size="icon-micro"
872 size="compact"
878873 variant="ghost-muted"
879874 disabled={isRefreshingProviders}
880875 onClick={() => void refreshProviders()}
881 aria-label="Refresh provider status"
882876 >
883877 {isRefreshingProviders ? (
884 <LoaderIcon className="size-3 animate-spin" />
878 <LoaderIcon className="animate-spin" />
885879 ) : (
886 <RefreshCwIcon className="size-3" />
880 <RefreshCwIcon />
887881 )}
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>
888886 </Button>
889887 }
890888 />
......@@ -906,7 +904,7 @@ export function EnvironmentProviderSettings({
906904 <TooltipPopup side="top">Add provider</TooltipPopup>
907905 </Tooltip>
908906 </>
909 ) : null}
907 )}
910908 </div>
911909 }
912910 >
......@@ -948,27 +946,24 @@ export function EnvironmentProviderSettings({
948946 Advanced
949947 </CollapsibleTrigger>
950948 <CollapsibleContent>
951 <div
952 inert={readOnly}
953 aria-disabled={readOnly || undefined}
954 className={readOnly ? "opacity-50 select-none" : undefined}
955 >
956 <SettingsRow
957 id={searchableSetting("provider-health-check-interval").id}
958 title={
959 <span className="inline-flex items-center gap-1.5">
960 {searchableSetting("provider-health-check-interval").title}
961 <PolicyTooltip>
962 This interval is configured here, then the shared Background activity policy
963 decides whether provider probes may run when the timer fires. Custom
964 intervals appear as Advanced in General settings.
965 </PolicyTooltip>
966 </span>
967 }
968 description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off."
969 resetAction={
970 providerHealthRefreshIntervalSeconds !==
971 defaultProviderHealthRefreshIntervalSeconds ? (
949 {/* Only the write controls go inert; the title and its policy tooltip stay readable. */}
950 <SettingsRow
951 id={searchableSetting("provider-health-check-interval").id}
952 title={
953 <span className="inline-flex items-center gap-1.5">
954 {searchableSetting("provider-health-check-interval").title}
955 <PolicyTooltip>
956 This interval is configured here, then the shared Background activity policy
957 decides whether provider probes may run when the timer fires. Custom intervals
958 appear as Advanced in General settings.
959 </PolicyTooltip>
960 </span>
961 }
962 description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off."
963 resetAction={
964 providerHealthRefreshIntervalSeconds !==
965 defaultProviderHealthRefreshIntervalSeconds ? (
966 <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}>
972967 <SettingResetButton
973968 label="provider health check interval"
974969 onClick={() =>
......@@ -981,41 +976,48 @@ export function EnvironmentProviderSettings({
981976 )
982977 }
983978 />
984 ) : null
985 }
986 control={
987 <div className="flex shrink-0 items-center gap-2">
988 <NumberField
989 value={providerHealthRefreshIntervalSeconds}
990 min={0}
991 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
992 size="sm"
993 className="w-32"
994 onValueChange={(value) =>
995 updateSettings(
996 backgroundActivityOverrideSettings(
997 settings.backgroundActivity,
998 resolvedBackgroundActivity,
999 {
1000 providerHealthRefreshInterval: Duration.seconds(
1001 normalizeIntervalSeconds(value),
1002 ),
1003 },
1004 ),
1005 )
1006 }
1007 >
1008 <NumberFieldGroup>
1009 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
1010 <NumberFieldInput aria-label="Provider health check interval in seconds" />
1011 <NumberFieldIncrement aria-label="Increase provider health check interval" />
1012 </NumberFieldGroup>
1013 </NumberField>
1014 <span className="text-xs text-muted-foreground">seconds</span>
1015 </div>
1016 }
1017 />
1018 </div>
979 </span>
980 ) : null
981 }
982 control={
983 <div
984 inert={readOnly}
985 aria-disabled={readOnly || undefined}
986 className={cn(
987 "flex shrink-0 items-center gap-2",
988 readOnly && "opacity-50 select-none",
989 )}
990 >
991 <NumberField
992 value={providerHealthRefreshIntervalSeconds}
993 min={0}
994 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
995 size="sm"
996 className="w-32"
997 onValueChange={(value) =>
998 updateSettings(
999 backgroundActivityOverrideSettings(
1000 settings.backgroundActivity,
1001 resolvedBackgroundActivity,
1002 {
1003 providerHealthRefreshInterval: Duration.seconds(
1004 normalizeIntervalSeconds(value),
1005 ),
1006 },
1007 ),
1008 )
1009 }
1010 >
1011 <NumberFieldGroup>
1012 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
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 />
10191021 </CollapsibleContent>
10201022 </Collapsible>
10211023 </div>