diff --git a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx index d352257257a0..b6544d46db78 100644 --- a/apps/web/src/components/settings/ProviderAccentColorPicker.tsx +++ b/apps/web/src/components/settings/ProviderAccentColorPicker.tsx @@ -228,8 +228,17 @@ export function ProviderAccentColorPicker(props: { readonly onCommit: (value: string) => void; readonly description?: string; readonly commitDelayMs?: number; + /** `inline` renders only the swatch row, for callers that supply their own label. */ + readonly layout?: "stacked" | "inline"; }) { - const { commitDelayMs = 0, description, displayName, onCommit, value } = props; + const { + commitDelayMs = 0, + description, + displayName, + layout = "stacked", + onCommit, + value, + } = props; const [optimisticValue, setOptimisticValue] = useState(() => value ?? ""); const commitTimeoutRef = useRef | null>(null); const pendingCommitRef = useRef(null); @@ -295,40 +304,52 @@ export function ProviderAccentColorPicker(props: { : ""; const customSelected = Boolean(normalized && selectedValue === ""); + const swatchRow = ( +
+ + + +
+ ); + + if (layout === "inline") { + return swatchRow; + } + return (
Accent color -
- - - -
+ {swatchRow} {description ? {description} : null}
); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 75c0361e9c6a..14cac060e10c 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -5,6 +5,8 @@ import { CopyIcon, DownloadIcon, LoaderIcon, + LockIcon, + LockOpenIcon, PlusIcon, Trash2Icon, XIcon, @@ -28,12 +30,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { normalizeProviderAccentColor } from "../../providerInstances"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; -import { Checkbox } from "../ui/checkbox"; import { DraftInput } from "../ui/draft-input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { ScrollArea } from "../ui/scroll-area"; import { Switch } from "../ui/switch"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; @@ -53,6 +53,13 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +/** Label-left field grid for the Configuration tab: one row per field. */ +const PROVIDER_FIELD_GRID_CLASS_NAME = + "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start"; +/** Full-width divider row that names the group of fields below it. */ +const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME = + "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground"; + let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -235,118 +242,104 @@ function ProviderEnvironmentSection(props: { publishRows(nextRows); }; + const addVariable = () => + setRows([ + ...rows, + { + id: nextEnvironmentVariableDraftId(), + name: "", + value: "", + sensitive: true, + }, + ]); + return ( -
-
- Environment variables - + } + /> + + {variable.sensitive ? "Sensitive, stored separately" : "Plain text"} + + + +
+ ))} +
+ + + {rows.length === 0 + ? "API keys, base URLs, or other per-instance CLI settings." + : "Sensitive values are stored separately and never returned to the app."} +
- {rows.length === 0 ? ( -

- Add variables to pass API keys, base URLs, or other per-instance CLI settings. -

- ) : ( -
- - - - Variable - Value - Sensitive - - Options - - - - - {rows.map((variable, index) => ( - - - updateVariable(variable.id, { name: name.trim() })} - placeholder="VARIABLE_NAME" - spellCheck={false} - aria-label={`Environment variable name ${index + 1}`} - /> - - - updateVariable(variable.id, { value })} - type={variable.sensitive ? "password" : undefined} - autoComplete="off" - placeholder={ - variable.valueRedacted - ? "Stored secret - enter a new value to replace" - : "Value" - } - spellCheck={false} - aria-label={`Environment variable value ${index + 1}`} - /> - - -
- { - const sensitive = Boolean(checked); - updateVariable(variable.id, { - sensitive, - ...(sensitive && variable.valueRedacted === undefined - ? {} - : { valueRedacted: sensitive ? variable.valueRedacted : false }), - }); - }} - aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`} - /> -
-
- -
- -
-
-
- ))} -
-
-
- )} - - Sensitive values are stored separately and are not returned to the app after saving. -
); } @@ -362,7 +355,7 @@ interface ProviderInstanceCardProps { readonly readOnly?: boolean | undefined; readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; /** - * Pass `undefined` to hide the delete button entirely. Built-in default + * Pass `undefined` to hide the delete footer entirely. Built-in default * instance slots use `undefined` — they can't be deleted without losing * the slot, and their "reset to defaults" affordance lives on an outer * reset button instead. Explicit `| undefined` in the type accommodates @@ -489,6 +482,9 @@ export function ProviderInstanceCard({ liveModels: liveProvider?.models, customModels, }); + const hiddenModelCount = modelsForDisplay.filter( + (model) => !model.isCustom && hiddenModels.includes(model.slug), + ).length; const updateDisplayName = (value: string) => { const trimmed = value.trim(); @@ -581,35 +577,9 @@ export function ProviderInstanceCard({ ); - const titleTailNode = ( - <> - {headerAction ? ( - - {headerAction} - - ) : null} - {onDelete ? ( - - - - - - } - /> - Delete instance - - - ) : null} - - ); + const titleTailNode = headerAction ? ( + {headerAction} + ) : null; const versionCodeNode = versionLabel ? ( {versionLabel} @@ -634,7 +604,7 @@ export function ProviderInstanceCard({ className={cn( // Sidebar-style selection with a fixed row height so the list stays // even; the status line clamps to two lines instead of growing. - "group flex h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors", + "group flex min-h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors", // Foreground-alpha tint so the fill reads the same in light and dark themes. selected ? "bg-foreground/8" : "hover:bg-foreground/4", )} @@ -712,7 +682,7 @@ export function ProviderInstanceCard({ size="icon-xs" variant="ghost" className={cn( - "size-5 rounded-sm p-0", + "size-5 rounded-sm p-0 [--control-icon-color:currentColor]", versionAdvisory.emphasis === "strong" ? "text-warning hover:text-warning" : "text-muted-foreground hover:text-foreground", @@ -820,6 +790,25 @@ export function ProviderInstanceCard({

) : null} + {onDelete ? ( + + + + ) : null}
@@ -835,10 +824,14 @@ export function ProviderInstanceCard({ ) : null}
@@ -853,62 +846,60 @@ export function ProviderInstanceCard({
-
-
{driverOption !== undefined ? ( diff --git a/apps/web/src/components/settings/ProviderModelsSection.test.ts b/apps/web/src/components/settings/ProviderModelsSection.test.ts new file mode 100644 index 000000000000..83adbeb97320 --- /dev/null +++ b/apps/web/src/components/settings/ProviderModelsSection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { ServerProviderModel } from "@t3tools/contracts"; + +import { groupModelsForDisplay } from "./ProviderModelsSection"; + +function model(slug: string, isCustom = false): ServerProviderModel { + return { slug, name: slug, isCustom, capabilities: null }; +} + +describe("groupModelsForDisplay", () => { + it("lists favorites first, then visible models in user order, then hidden ones", () => { + const models = [model("a"), model("b"), model("c"), model("d"), model("custom", true)]; + + const display = groupModelsForDisplay(models, { + favoriteModels: new Set(["c"]), + hiddenModels: new Set(["a", "custom"]), + modelOrder: ["d", "b"], + }); + + // A custom model is never hidden, even if its slug is in the hidden set. + expect(display.map((entry) => entry.slug)).toEqual(["c", "d", "b", "custom", "a"]); + }); +}); diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 007abea2b844..375d1ca419f9 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -1,16 +1,7 @@ "use client"; -import { - ArrowDownIcon, - ArrowUpIcon, - EyeIcon, - EyeOffIcon, - InfoIcon, - PlusIcon, - StarIcon, - XIcon, -} from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { ArrowDownIcon, ArrowUpIcon, PlusIcon, StarIcon, XIcon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ProviderDriverKind, type ProviderInstanceId, @@ -23,7 +14,7 @@ import { sortModelsForProviderInstance } from "../../modelOrdering"; import { MAX_CUSTOM_MODEL_LENGTH } from "../../modelSelection"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; -import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; /** @@ -38,6 +29,72 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial + descriptor.id === "fastMode" || + (descriptor.id === "serviceTier" && + descriptor.type === "select" && + descriptor.options.some((option) => option.id === "fast" || option.label === "Fast")), + ); + if (hasFastMode) labels.push("Fast mode"); + if (descriptors.some((descriptor) => descriptor.id === "thinking")) labels.push("Thinking"); + if ( + descriptors.some( + (descriptor) => + descriptor.type === "select" && + (descriptor.id === "reasoningEffort" || + descriptor.id === "effort" || + descriptor.id === "reasoning" || + descriptor.id === "variant"), + ) + ) { + labels.push("Reasoning"); + } + return labels; +} + +/** + * Display order for the models list: favorites first (in user order), then + * visible models, then hidden ones. Hidden models sink so the list reads + * top-down as "what the picker shows"; moves only swap rows within the same + * group, and the resulting display order is what gets persisted as + * `modelOrder`. + */ +export function groupModelsForDisplay< + T extends { readonly slug: string; readonly isCustom: boolean }, +>( + models: ReadonlyArray, + options: { + readonly favoriteModels: ReadonlySet; + readonly hiddenModels: ReadonlySet; + readonly modelOrder: ReadonlyArray; + }, +): T[] { + const ordered = sortModelsForProviderInstance(models, { + favoriteModels: options.favoriteModels, + groupFavorites: true, + modelOrder: options.modelOrder, + }); + const isHidden = (model: T) => !model.isCustom && options.hiddenModels.has(model.slug); + return [ + ...ordered.filter((model) => options.favoriteModels.has(model.slug)), + ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)), + ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && isHidden(model)), + ]; +} + interface ProviderModelsSectionProps { /** Identifier used to namespace input ids within the DOM. */ readonly instanceId: ProviderInstanceId; @@ -99,17 +156,50 @@ export function ProviderModelsSection({ onModelOrderChange, }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); + const [isAdding, setIsAdding] = useState(false); + const [filter, setFilter] = useState(""); const [error, setError] = useState(null); - const listRef = useRef(null); + const listRef = useRef(null); + // Slug of a just-added custom model, scrolled into view once its row exists. + const scrollToSlugRef = useRef(null); const hiddenModelSet = useMemo(() => new Set(hiddenModels), [hiddenModels]); const favoriteModelSet = useMemo(() => new Set(favoriteModels), [favoriteModels]); - const orderedModels = useMemo(() => { - return sortModelsForProviderInstance(models, { - favoriteModels: favoriteModelSet, - groupFavorites: true, - modelOrder, - }); - }, [favoriteModelSet, modelOrder, models]); + const displayModels = useMemo( + () => + groupModelsForDisplay(models, { + favoriteModels: favoriteModelSet, + hiddenModels: hiddenModelSet, + modelOrder, + }), + [favoriteModelSet, hiddenModelSet, modelOrder, models], + ); + const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length; + const hiddenCount = displayModels.filter( + (model) => !model.isCustom && hiddenModelSet.has(model.slug), + ).length; + const showFilter = models.length > FILTER_THRESHOLD; + const normalizedFilter = filter.trim().toLowerCase(); + const isFiltering = showFilter && normalizedFilter.length > 0; + const visibleModels = isFiltering + ? displayModels.filter( + (model) => + model.name.toLowerCase().includes(normalizedFilter) || + model.slug.toLowerCase().includes(normalizedFilter), + ) + : displayModels; + + // The parent commits the new custom model and hands back an updated + // `models` list, so the row can only be scrolled to after that render. + useEffect(() => { + const slug = scrollToSlugRef.current; + if (slug === null) return; + const row = listRef.current?.querySelector( + `[data-model-slug="${CSS.escape(slug)}"]`, + ); + if (!row) return; + scrollToSlugRef.current = null; + row.scrollIntoView({ block: "nearest" }); + }, [displayModels]); const handleAdd = () => { const normalized = normalizeCustomModelSlug(input); @@ -130,24 +220,20 @@ export function ProviderModelsSection({ return; } + // Clear the filter so the new row renders even when it does not match, + // which is also what lets the pending scroll target resolve and clear. + scrollToSlugRef.current = normalized; + setFilter(""); onChange([...customModels, normalized]); setInput(""); setError(null); + setIsAdding(false); + }; - // Scroll the new row into view once the DOM reflects the commit. - // `MutationObserver` handles the one-frame gap between `onChange` and - // the `models` prop update; the `requestAnimationFrame` covers the - // common case where the parent updates synchronously. - const el = listRef.current; - if (!el) return; - const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - requestAnimationFrame(scrollToEnd); - const observer = new MutationObserver(() => { - scrollToEnd(); - observer.disconnect(); - }); - observer.observe(el, { childList: true, subtree: true }); - setTimeout(() => observer.disconnect(), 2_000); + const cancelAdd = () => { + setInput(""); + setError(null); + setIsAdding(false); }; const handleRemove = (slug: string) => { @@ -157,12 +243,11 @@ export function ProviderModelsSection({ setError(null); }; - const handleToggleHidden = (slug: string) => { - if (hiddenModelSet.has(slug)) { - onHiddenModelsChange(hiddenModels.filter((model) => model !== slug)); - return; - } - onHiddenModelsChange([...hiddenModels, slug]); + const setHidden = (slug: string, hidden: boolean) => { + if (hidden === hiddenModelSet.has(slug)) return; + onHiddenModelsChange( + hidden ? [...hiddenModels, slug] : hiddenModels.filter((model) => model !== slug), + ); }; const handleToggleFavorite = (slug: string) => { @@ -173,237 +258,297 @@ export function ProviderModelsSection({ onFavoriteModelsChange([...favoriteModels, slug]); }; + // Rows only trade places with a neighbour in the same group (favorites, + // visible, hidden), and the display order is persisted as the new order. + const groupOf = (model: (typeof displayModels)[number]) => + favoriteModelSet.has(model.slug) + ? "favorite" + : !model.isCustom && hiddenModelSet.has(model.slug) + ? "hidden" + : "visible"; const handleMove = (slug: string, direction: -1 | 1) => { - const slugs = orderedModels.map((model) => model.slug); - const index = slugs.indexOf(slug); + const index = displayModels.findIndex((model) => model.slug === slug); const nextIndex = index + direction; - if (index < 0 || nextIndex < 0 || nextIndex >= slugs.length) { - return; - } - const next = [...slugs]; + if (index < 0 || nextIndex < 0 || nextIndex >= displayModels.length) return; + if (groupOf(displayModels[index]!) !== groupOf(displayModels[nextIndex]!)) return; + const next = displayModels.map((model) => model.slug); [next[index], next[nextIndex]] = [next[nextIndex]!, next[index]!]; onModelOrderChange(next); }; + type DisplayModel = (typeof displayModels)[number]; + + const starButton = (model: DisplayModel, isFavorite: boolean) => ( + + handleToggleFavorite(model.slug)} + aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ + isFavorite ? "from" : "to" + } favorites`} + /> + } + > + + + + {isFavorite ? "Remove from favorites" : "Add to favorites"} + + + ); + + // Reorder and remove stay in the row at all times (dimmed when unavailable) + // so ordering is discoverable without hovering. + const rowActions = ( + model: DisplayModel, + options: { + readonly isHidden: boolean; + readonly canMoveUp: boolean; + readonly canMoveDown: boolean; + }, + ) => ( + + {!options.isHidden && !isFiltering ? ( + <> + + handleMove(model.slug, -1)} + aria-label={`Move ${model.name} up`} + /> + } + > + + + Move up + + + handleMove(model.slug, 1)} + aria-label={`Move ${model.name} down`} + /> + } + > + + + Move down + + + ) : null} + {model.isCustom ? ( + + handleRemove(model.slug)} + /> + } + > + + + Remove custom model + + ) : null} + + ); + + const pickerTooltip = (model: DisplayModel, isHidden: boolean) => + model.isCustom + ? "Custom models are always shown in the picker" + : isHidden + ? "Hidden from picker" + : "Shown in picker"; + + // The trigger is a wrapper span: a disabled switch gets no pointer events, + // so it could not open the tooltip itself. + const pickerSwitch = (model: DisplayModel, isHidden: boolean) => ( + + }> + setHidden(model.slug, !checked)} + aria-label={`Show ${model.name} in the model picker`} + /> + + {pickerTooltip(model, isHidden)} + + ); + + const renderRow = (model: DisplayModel) => { + const capLabels = describeModelCapabilities(model); + const group = groupOf(model); + // Hidden is read from the preference itself: a favorited model can still be + // hidden, and its switch must say so even though it sits in the favorites group. + const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); + const isFavorite = group === "favorite"; + const index = displayModels.indexOf(model); + const previousModel = displayModels[index - 1]; + const nextModel = displayModels[index + 1]; + // Reordering a filtered view would be ambiguous, so arrows only show on + // the full list. + const canMoveUp = + !isFiltering && previousModel !== undefined && groupOf(previousModel) === group; + const canMoveDown = !isFiltering && nextModel !== undefined && groupOf(nextModel) === group; + const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90"); + + return ( +
+ {starButton(model, isFavorite)} + + {model.name} + {model.isCustom ? ( + custom + ) : model.name !== model.slug ? ( + + {model.slug} + + ) : null} + + {/* + Always a grid item so the columns line up across rows; the text + itself drops out on phone widths where it would starve the name. + */} + + {capLabels.length > 0 ? ( + {capLabels.join(" · ")} + ) : null} + + {rowActions(model, { isHidden, canMoveUp, canMoveDown })} + {pickerSwitch(model, isHidden)} +
+ ); + }; + + const groupLabel = (label: string, isFirst: boolean) => ( +
+ {label} +
+ ); + return (
-
Models
-
- {models.length} model{models.length === 1 ? "" : "s"} available. +
+ {showFilter ? ( + setFilter(event.target.value)} + placeholder="Filter models" + size="compact" + className="w-56" + spellCheck={false} + aria-label="Filter models" + /> + ) : null} + + {models.length} model{models.length === 1 ? "" : "s"} + {favoriteCount > 0 ? ` · ${favoriteCount} favorite${favoriteCount === 1 ? "" : "s"}` : ""} + {hiddenCount > 0 ? ` · ${hiddenCount} hidden` : ""} +
- {orderedModels.map((model, index) => { - const caps = model.capabilities; - const capLabels: string[] = []; - const isHidden = !model.isCustom && hiddenModelSet.has(model.slug); - const isFavorite = favoriteModelSet.has(model.slug); - const previousModel = orderedModels[index - 1]; - const nextModel = orderedModels[index + 1]; - const canMoveUp = - previousModel !== undefined && favoriteModelSet.has(previousModel.slug) === isFavorite; - const canMoveDown = - nextModel !== undefined && favoriteModelSet.has(nextModel.slug) === isFavorite; - const descriptors = caps?.optionDescriptors ?? []; - if (descriptors.some((descriptor) => descriptor.id === "fastMode")) { - capLabels.push("Fast mode"); - } - if (descriptors.some((descriptor) => descriptor.id === "thinking")) { - capLabels.push("Thinking"); - } - if ( - descriptors.some( - (descriptor) => - descriptor.type === "select" && - (descriptor.id === "reasoningEffort" || - descriptor.id === "effort" || - descriptor.id === "reasoning" || - descriptor.id === "variant"), - ) - ) { - capLabels.push("Reasoning"); - } - const hasDetails = capLabels.length > 0 || model.name !== model.slug; - + {visibleModels.length === 0 ? ( +

+ {isFiltering ? "No models match." : "No models reported for this provider yet."} +

+ ) : null} + {visibleModels.map((model, index) => { + const group = groupOf(model); + const previous = visibleModels[index - 1]; + const startsGroup = previous === undefined || groupOf(previous) !== group; return ( -
-
- - {model.name} - - {hasDetails ? ( - - - } - > - - - -
- {model.slug} - {capLabels.length > 0 ? ( -
- {capLabels.map((label) => ( - - {label} - - ))} -
- ) : null} -
-
-
- ) : null} - {isHidden ? ( - hidden - ) : null} - {model.isCustom ? ( - custom - ) : null} -
-
- - handleToggleFavorite(model.slug)} - aria-label={`${isFavorite ? "Remove" : "Add"} ${model.name} ${ - isFavorite ? "from" : "to" - } favorites`} - /> - } - > - - - - {isFavorite ? "Remove from favorites" : "Add to favorites"} - - - - handleMove(model.slug, -1)} - aria-label={`Move ${model.name} up`} - /> - } - > - - - Move up - - - handleMove(model.slug, 1)} - aria-label={`Move ${model.name} down`} - /> - } - > - - - Move down - - {!model.isCustom ? ( - - handleToggleHidden(model.slug)} - aria-label={`${isHidden ? "Show" : "Hide"} ${model.name}`} - /> - } - > - {isHidden ? ( - - ) : ( - - )} - - - {isHidden ? "Show in picker" : "Hide from picker"} - - - ) : null} - {model.isCustom ? ( - - handleRemove(model.slug)} - /> - } - > - - - Remove custom model - - ) : null} -
+
+ {startsGroup && favoriteCount > 0 && group === "favorite" + ? groupLabel("Favorites", index === 0) + : null} + {startsGroup && favoriteCount > 0 && group === "visible" + ? groupLabel("All", index === 0) + : null} + {startsGroup && group === "hidden" + ? groupLabel("Hidden from picker", index === 0) + : null} + {renderRow(model)}
); })}
-
- { - setInput(event.target.value); - if (error) setError(null); - }} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - handleAdd(); - }} - placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} - spellCheck={false} - /> - + +
+
+ ) : ( + -
+ )} {error ?

{error}

: null}
diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index cd34bb35c6b2..988ac9160412 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -158,7 +158,13 @@ interface ProviderSettingsFormProps { readonly definition: ProviderClientDefinition; readonly value: unknown; readonly idPrefix: string; - readonly variant: "card" | "dialog"; + /** + * `card` stacks label over control, `dialog` is the compact wizard layout, + * `grid` emits a label cell and a control cell per field for a parent + * two-column grid (label column left, control right), with the description + * beside a fixed-width control so each field stays on one line. + */ + readonly variant: "card" | "dialog" | "grid"; readonly onChange: (nextConfig: Record | undefined) => void; } @@ -189,14 +195,75 @@ function ProviderSettingsFieldRow({ }: ProviderSettingsFieldRowProps) { const inputId = `${idPrefix}-${field.key}`; const descriptionClassName = - variant === "card" - ? "mt-1 block text-xs text-muted-foreground" - : "text-[11px] text-muted-foreground"; + variant === "dialog" + ? "text-[11px] text-muted-foreground" + : "mt-1 block text-xs text-muted-foreground"; const label = {field.label}; const description = field.description ? ( {field.description} ) : null; + if (variant === "grid") { + // Label cell, then a control cell where the description sits beside a + // fixed-width control and wraps under it when the pane is narrow. The + // description is outside the label, so the control points at it instead. + const descriptionId = field.description ? `${inputId}-description` : undefined; + return ( + <> + {field.control === "switch" ? ( + {field.label} + ) : ( + + )} +
+ {field.control === "switch" ? ( + + + onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) + } + aria-label={field.label} + aria-describedby={descriptionId} + /> + + ) : field.control === "textarea" ? ( +