authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-09 23:27:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-09 23:27:59-07:00
loge2db800f7c7d7b2cba4da9d48883bf332a7f187b
tree6cd845dd2d148c89ab007645c3e440886d8c8546
parent31533466b09ba12ec520e61c7908841c0b55de01
signature Signed by PGP key B5690EEEBB952194

Provider env vars table and reworked accent color picker (#3026)

Co-authored-by: ss <ss@barekey.dev> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

7 files changed, 612 insertions(+), 155 deletions(-)

apps/web/components.json+2-1
...@@ -22,6 +22,7 @@...@@ -22,6 +22,7 @@
22 "hooks": "~/hooks"22 "hooks": "~/hooks"
23 },23 },
24 "registries": {24 "registries": {
25 "@coss": "https://coss.com/ui/r/{name}.json"25 "@coss": "https://coss.com/ui/r/{name}.json",
26 "@spell": "https://spell.sh/r/{name}.json"
26 }27 }
27}28}
apps/web/src/components/color-selector.tsx created+101
...@@ -0,0 +1,101 @@
1"use client";
2
3import { useState } from "react";
4import { cn } from "~/lib/utils";
5
6interface ColorSelectorProps {
7 colors: string[];
8 size?: "default" | "sm" | "lg";
9 defaultValue: string;
10 name?: string;
11 onColorSelect?: (color: string) => void;
12 className?: string;
13}
14
15const colorMap = {
16 default: "var(--foreground)",
17 red: "var(--color-red-500)",
18 green: "var(--color-green-500)",
19 blue: "var(--color-blue-500)",
20 yellow: "var(--color-yellow-500)",
21 purple: "var(--color-purple-500)",
22 pink: "var(--color-pink-500)",
23 indigo: "var(--color-indigo-500)",
24 orange: "var(--color-orange-500)",
25 teal: "var(--color-teal-500)",
26 cyan: "var(--color-cyan-500)",
27 lime: "var(--color-lime-500)",
28 emerald: "var(--color-emerald-500)",
29 violet: "var(--color-violet-500)",
30 fuchsia: "var(--color-fuchsia-500)",
31 rose: "var(--color-rose-500)",
32 sky: "var(--color-sky-500)",
33 amber: "var(--color-amber-500)",
34} as const;
35
36function getSizeClass(size: "default" | "sm" | "lg") {
37 switch (size) {
38 case "sm":
39 return "size-4";
40 case "default":
41 return "size-5";
42 case "lg":
43 return "size-6";
44 default:
45 return "size-5";
46 }
47}
48
49function getColorValue(color: string): string {
50 return colorMap[color as keyof typeof colorMap] || color;
51}
52
53export function ColorSelector({
54 colors,
55 size = "default",
56 defaultValue,
57 name,
58 onColorSelect,
59 className,
60}: ColorSelectorProps) {
61 const [selectedColor, setSelectedColor] = useState<string>(defaultValue);
62
63 const handleColorSelect = (color: string) => {
64 setSelectedColor(color);
65 onColorSelect?.(color);
66 };
67
68 const sizeClass = getSizeClass(size);
69
70 return (
71 <div className={cn("flex gap-2", className)}>
72 {name && <input type="hidden" name={name} value={selectedColor} />}
73 {colors.map((color) => {
74 const colorValue = getColorValue(color);
75 return (
76 <div
77 key={color}
78 className={`${sizeClass} cursor-pointer rounded-full transition-transform duration-200 active:scale-90`}
79 style={{
80 backgroundColor: colorValue,
81 ...(selectedColor === color && {
82 boxShadow: `inset 0 0 0 2px var(--card), 0 0 0 2px ${colorValue}`,
83 }),
84 }}
85 onClick={() => handleColorSelect(color)}
86 onKeyDown={(e) => {
87 if (e.key === "Enter" || e.key === " ") {
88 e.preventDefault();
89 handleColorSelect(color);
90 }
91 }}
92 tabIndex={0}
93 role="button"
94 aria-label={`Select ${color} color`}
95 aria-pressed={selectedColor === color}
96 />
97 );
98 })}
99 </div>
100 );
101}
apps/web/src/components/settings/ProviderAccentColorPicker.tsx created+335
...@@ -0,0 +1,335 @@
1"use client";
2
3import { PipetteIcon, XIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
5
6import { ColorSelector } from "../color-selector";
7import { Button } from "../ui/button";
8import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
9import { normalizeProviderAccentColor } from "../../providerInstances";
10import { cn } from "../../lib/utils";
11
12const PROVIDER_ACCENT_SWATCHES = [
13 "#2563eb",
14 "#16a34a",
15 "#ea580c",
16 "#dc2626",
17 "#7c3aed",
18 "#0891b2",
19] as const;
20
21const FALLBACK_ACCENT_COLOR = PROVIDER_ACCENT_SWATCHES[0];
22
23function clamp(value: number, min = 0, max = 1) {
24 return Math.min(max, Math.max(min, value));
25}
26
27function hexToHsv(hex: string) {
28 const normalized = normalizeProviderAccentColor(hex) ?? FALLBACK_ACCENT_COLOR;
29 const numeric = Number.parseInt(normalized.slice(1), 16);
30 const red = ((numeric >> 16) & 255) / 255;
31 const green = ((numeric >> 8) & 255) / 255;
32 const blue = (numeric & 255) / 255;
33 const max = Math.max(red, green, blue);
34 const min = Math.min(red, green, blue);
35 const delta = max - min;
36
37 let hue = 0;
38 if (delta !== 0) {
39 if (max === red) {
40 hue = ((green - blue) / delta) % 6;
41 } else if (max === green) {
42 hue = (blue - red) / delta + 2;
43 } else {
44 hue = (red - green) / delta + 4;
45 }
46 hue *= 60;
47 if (hue < 0) hue += 360;
48 }
49
50 return {
51 h: hue,
52 s: max === 0 ? 0 : delta / max,
53 v: max,
54 };
55}
56
57function hsvToHex(hue: number, saturation: number, value: number) {
58 const chroma = value * saturation;
59 const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
60 const match = value - chroma;
61 const [red, green, blue] =
62 hue < 60
63 ? [chroma, x, 0]
64 : hue < 120
65 ? [x, chroma, 0]
66 : hue < 180
67 ? [0, chroma, x]
68 : hue < 240
69 ? [0, x, chroma]
70 : hue < 300
71 ? [x, 0, chroma]
72 : [chroma, 0, x];
73
74 return `#${[red, green, blue]
75 .map((channel) =>
76 Math.round((channel + match) * 255)
77 .toString(16)
78 .padStart(2, "0"),
79 )
80 .join("")}`;
81}
82
83function ProviderCustomColorPanel(props: {
84 readonly value: string;
85 readonly onCommit: (value: string) => void;
86}) {
87 const { onCommit } = props;
88 const initialHsv = useMemo(() => hexToHsv(props.value), [props.value]);
89 const [hsv, setHsv] = useState(initialHsv);
90 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
91
92 const commitHsv = useCallback(
93 (nextHsv: typeof hsv) => {
94 setHsv(nextHsv);
95 onCommit(hsvToHex(nextHsv.h, nextHsv.s, nextHsv.v));
96 },
97 [onCommit],
98 );
99
100 const updateFromPlane = useCallback(
101 (event: PointerEvent<HTMLDivElement>) => {
102 const bounds = event.currentTarget.getBoundingClientRect();
103 const saturation = clamp((event.clientX - bounds.left) / bounds.width);
104 const value = 1 - clamp((event.clientY - bounds.top) / bounds.height);
105 commitHsv({ ...hsv, s: saturation, v: value });
106 },
107 [commitHsv, hsv],
108 );
109
110 const updateFromHue = useCallback(
111 (event: PointerEvent<HTMLDivElement>) => {
112 const bounds = event.currentTarget.getBoundingClientRect();
113 commitHsv({ ...hsv, h: clamp((event.clientX - bounds.left) / bounds.width) * 360 });
114 },
115 [commitHsv, hsv],
116 );
117
118 const handlePointerDown = (handler: (event: PointerEvent<HTMLDivElement>) => void) => {
119 return (event: PointerEvent<HTMLDivElement>) => {
120 event.currentTarget.setPointerCapture(event.pointerId);
121 handler(event);
122 };
123 };
124
125 return (
126 <div className="w-56 bg-popover">
127 <div
128 className="relative h-36 cursor-crosshair touch-none"
129 style={{
130 backgroundColor: `hsl(${hsv.h} 100% 50%)`,
131 backgroundImage:
132 "linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent)",
133 }}
134 onPointerDown={handlePointerDown(updateFromPlane)}
135 onPointerMove={(event) => {
136 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
137 updateFromPlane(event);
138 }
139 }}
140 >
141 <span
142 className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]"
143 style={{ left: `${hsv.s * 100}%`, top: `${(1 - hsv.v) * 100}%` }}
144 />
145 </div>
146 <div className="grid gap-3 p-3">
147 <div
148 className="relative h-3 cursor-pointer touch-none rounded-full"
149 style={{
150 background: "linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00)",
151 }}
152 onPointerDown={handlePointerDown(updateFromHue)}
153 onPointerMove={(event) => {
154 if (event.currentTarget.hasPointerCapture(event.pointerId)) {
155 updateFromHue(event);
156 }
157 }}
158 >
159 <span
160 className="pointer-events-none absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgb(0_0_0/0.35)]"
161 style={{ left: `${(hsv.h / 360) * 100}%`, backgroundColor: currentColor }}
162 />
163 </div>
164 <input
165 value={currentColor}
166 onChange={(event) => {
167 const nextColor = event.currentTarget.value;
168 if (!/^#[\da-f]{6}$/i.test(nextColor)) return;
169 setHsv(hexToHsv(nextColor));
170 props.onCommit(nextColor);
171 }}
172 className="h-8 rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground outline-none transition-colors focus:border-ring"
173 aria-label="Custom hex accent color"
174 spellCheck={false}
175 />
176 </div>
177 </div>
178 );
179}
180
181function ProviderCustomColorPicker(props: {
182 readonly displayName: string;
183 readonly value: string | undefined;
184 readonly selected: boolean;
185 readonly onCommit: (value: string) => void;
186}) {
187 const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR;
188
189 return (
190 <Popover>
191 <PopoverTrigger
192 render={
193 <button
194 type="button"
195 className={cn(
196 "flex size-6 cursor-pointer items-center justify-center rounded-full text-white transition-transform duration-200 active:scale-90",
197 "hover:scale-105",
198 )}
199 style={{
200 backgroundColor: normalized,
201 ...(props.selected
202 ? {
203 boxShadow: `inset 0 0 0 2px var(--card), 0 0 0 2px ${normalized}`,
204 }
205 : {}),
206 }}
207 aria-label={`Choose custom accent color for ${props.displayName}`}
208 >
209 <PipetteIcon className="size-3 text-foreground/25" aria-hidden />
210 </button>
211 }
212 />
213 <PopoverPopup
214 side="bottom"
215 align="start"
216 sideOffset={6}
217 className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0"
218 >
219 <ProviderCustomColorPanel value={normalized} onCommit={props.onCommit} />
220 </PopoverPopup>
221 </Popover>
222 );
223}
224
225export function ProviderAccentColorPicker(props: {
226 readonly displayName: string;
227 readonly value: string | undefined;
228 readonly onCommit: (value: string) => void;
229 readonly description?: string;
230 readonly commitDelayMs?: number;
231}) {
232 const { commitDelayMs = 0, description, displayName, onCommit, value } = props;
233 const [optimisticValue, setOptimisticValue] = useState(() => value ?? "");
234 const commitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
235 const pendingCommitRef = useRef<string | null>(null);
236 const onCommitRef = useRef(onCommit);
237
238 useEffect(() => {
239 onCommitRef.current = onCommit;
240 }, [onCommit]);
241
242 useEffect(() => {
243 if (pendingCommitRef.current !== null) return;
244 setOptimisticValue(value ?? "");
245 }, [value]);
246
247 useEffect(() => {
248 return () => {
249 if (commitTimeoutRef.current !== null) {
250 clearTimeout(commitTimeoutRef.current);
251 }
252 const pendingCommit = pendingCommitRef.current;
253 if (pendingCommit !== null) {
254 onCommitRef.current(pendingCommit);
255 }
256 };
257 }, []);
258
259 const commitAccentColor = useCallback(
260 (value: string) => {
261 const normalizedValue = normalizeProviderAccentColor(value) ?? "";
262 setOptimisticValue(normalizedValue);
263
264 if (commitDelayMs <= 0) {
265 pendingCommitRef.current = null;
266 if (commitTimeoutRef.current !== null) {
267 clearTimeout(commitTimeoutRef.current);
268 commitTimeoutRef.current = null;
269 }
270 onCommit(normalizedValue);
271 return;
272 }
273
274 pendingCommitRef.current = normalizedValue;
275 if (commitTimeoutRef.current !== null) {
276 clearTimeout(commitTimeoutRef.current);
277 }
278 commitTimeoutRef.current = setTimeout(() => {
279 commitTimeoutRef.current = null;
280 const pendingCommit = pendingCommitRef.current;
281 pendingCommitRef.current = null;
282 if (pendingCommit !== null) {
283 onCommitRef.current(pendingCommit);
284 }
285 }, commitDelayMs);
286 },
287 [commitDelayMs, onCommit],
288 );
289
290 const normalized = normalizeProviderAccentColor(optimisticValue);
291 const selectedValue =
292 normalized &&
293 PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number])
294 ? normalized
295 : "";
296 const customSelected = Boolean(normalized && selectedValue === "");
297
298 return (
299 <div className="grid gap-2">
300 <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>
332 {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
333 </div>
334 );
335}
apps/web/src/components/settings/ProviderInstanceCard.tsx+85-152
...@@ -28,17 +28,20 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";...@@ -28,17 +28,20 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
28import { normalizeProviderAccentColor } from "../../providerInstances";28import { normalizeProviderAccentColor } from "../../providerInstances";
29import { Badge } from "../ui/badge";29import { Badge } from "../ui/badge";
30import { Button } from "../ui/button";30import { Button } from "../ui/button";
31import { Checkbox } from "../ui/checkbox";
31import { Collapsible, CollapsibleContent } from "../ui/collapsible";32import { Collapsible, CollapsibleContent } from "../ui/collapsible";
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";
37import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
36import { stackedThreadToast, toastManager } from "../ui/toast";38import { stackedThreadToast, toastManager } from "../ui/toast";
37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";39import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
38import type { DriverOption } from "./providerDriverMeta";40import type { DriverOption } from "./providerDriverMeta";
39import { ProviderSettingsForm } from "./ProviderSettingsForm";41import { ProviderSettingsForm } from "./ProviderSettingsForm";
40import { ProviderModelsSection } from "./ProviderModelsSection";42import { ProviderModelsSection } from "./ProviderModelsSection";
41import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";43import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
44import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
42import { RedactedSensitiveText } from "./RedactedSensitiveText";45import { RedactedSensitiveText } from "./RedactedSensitiveText";
43import {46import {
44 getProviderVersionAdvisoryPresentation,47 getProviderVersionAdvisoryPresentation,
...@@ -48,15 +51,6 @@ import {...@@ -48,15 +51,6 @@ import {
48 type ProviderStatusKey,51 type ProviderStatusKey,
49} from "./providerStatus";52} from "./providerStatus";
5053
51const PROVIDER_ACCENT_SWATCHES = [
52 "#2563eb",
53 "#16a34a",
54 "#ea580c",
55 "#dc2626",
56 "#7c3aed",
57 "#0891b2",
58] as const;
59
60const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;54const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
6155
62let environmentVariableDraftId = 0;56let environmentVariableDraftId = 0;
...@@ -159,94 +153,6 @@ function ProviderAuthEmail(props: {...@@ -159,94 +153,6 @@ function ProviderAuthEmail(props: {
159 );153 );
160}154}
161155
162function ProviderAccentColorPicker(props: {
163 readonly displayName: string;
164 readonly value: string | undefined;
165 readonly onCommit: (value: string) => void;
166}) {
167 const [draft, setDraft] = useState(props.value ?? "");
168 const [isEditing, setIsEditing] = useState(false);
169 const draftColor = normalizeProviderAccentColor(draft);
170
171 useEffect(() => {
172 if (isEditing) return;
173 setDraft(props.value ?? "");
174 }, [isEditing, props.value]);
175
176 const commitDraft = () => {
177 setIsEditing(false);
178 props.onCommit(draftColor ?? "");
179 };
180
181 const commitSwatch = (swatch: string) => {
182 setIsEditing(false);
183 setDraft(swatch);
184 props.onCommit(swatch);
185 };
186
187 return (
188 <div className="grid gap-2">
189 <span className="text-xs font-medium text-foreground">Accent color</span>
190 <div className="flex min-w-0 flex-wrap items-center gap-2">
191 <input
192 type="color"
193 value={draftColor ?? PROVIDER_ACCENT_SWATCHES[0]}
194 onFocus={() => setIsEditing(true)}
195 onInput={(event) => {
196 setIsEditing(true);
197 setDraft(event.currentTarget.value);
198 }}
199 onChange={(event) => {
200 setIsEditing(true);
201 setDraft(event.currentTarget.value);
202 }}
203 onBlur={commitDraft}
204 aria-label={`Accent color for ${props.displayName}`}
205 className="h-8 w-10 cursor-pointer rounded border border-input bg-background p-0.5"
206 />
207 <div className="flex flex-wrap gap-1.5">
208 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
209 const selected = draftColor?.toLowerCase() === swatch;
210 return (
211 <button
212 key={swatch}
213 type="button"
214 className={cn(
215 "size-6 cursor-pointer rounded-full border transition",
216 selected
217 ? "border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
218 : "border-black/10 hover:scale-105 dark:border-white/20",
219 )}
220 style={{ backgroundColor: swatch }}
221 onClick={() => commitSwatch(swatch)}
222 aria-label={`Use ${swatch} accent`}
223 />
224 );
225 })}
226 </div>
227 {draftColor ? (
228 <Button
229 type="button"
230 size="sm"
231 variant="ghost"
232 className="h-7 px-2 text-xs text-muted-foreground"
233 onClick={() => {
234 setIsEditing(false);
235 setDraft("");
236 props.onCommit("");
237 }}
238 >
239 Clear
240 </Button>
241 ) : null}
242 </div>
243 <span className="text-xs text-muted-foreground">
244 Used to distinguish this instance in picker rails and model lists.
245 </span>
246 </div>
247 );
248}
249
250function ProviderEnvironmentSection(props: {156function ProviderEnvironmentSection(props: {
251 readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;157 readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>;
252 readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;158 readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void;
...@@ -330,59 +236,83 @@ function ProviderEnvironmentSection(props: {...@@ -330,59 +236,83 @@ function ProviderEnvironmentSection(props: {
330 Add variables to pass API keys, base URLs, or other per-instance CLI settings.236 Add variables to pass API keys, base URLs, or other per-instance CLI settings.
331 </p>237 </p>
332 ) : (238 ) : (
333 <div className="grid gap-2">239 <div className="overflow-hidden rounded-md border border-border/70">
334 {rows.map((variable, index) => (240 <Table>
335 <div241 <TableHeader className="bg-muted/25 text-[11px] text-muted-foreground">
336 key={variable.id}242 <TableRow className="hover:bg-transparent">
337 className="grid gap-2 rounded-md border border-border/70 bg-muted/20 p-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_auto_auto] sm:items-center"243 <TableHead>Variable</TableHead>
338 >244 <TableHead>Value</TableHead>
339 <DraftInput245 <TableHead className="w-20">Sensitive</TableHead>
340 value={variable.name}246 <TableHead className="w-12 text-right">
341 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}247 <span className="sr-only">Options</span>
342 placeholder="VARIABLE_NAME"248 </TableHead>
343 spellCheck={false}249 </TableRow>
344 aria-label={`Environment variable name ${index + 1}`}250 </TableHeader>
345 />251 <TableBody>
346 <DraftInput252 {rows.map((variable, index) => (
347 value={variable.valueRedacted ? "" : variable.value}253 <TableRow
348 onCommit={(value) => updateVariable(variable.id, { value })}254 key={variable.id}
349 type={variable.sensitive ? "password" : undefined}255 className="border-border/60 odd:bg-muted/20 even:bg-background/20"
350 autoComplete="off"256 >
351 placeholder={257 <TableCell>
352 variable.valueRedacted ? "Stored secret - enter a new value to replace" : "Value"258 <DraftInput
353 }259 value={variable.name}
354 spellCheck={false}260 onCommit={(name) => updateVariable(variable.id, { name: name.trim() })}
355 aria-label={`Environment variable value ${index + 1}`}261 placeholder="VARIABLE_NAME"
356 />262 spellCheck={false}
357 <label className="inline-flex h-8 items-center gap-2 text-xs text-muted-foreground">263 aria-label={`Environment variable name ${index + 1}`}
358 <input264 />
359 type="checkbox"265 </TableCell>
360 className="size-3.5"266 <TableCell>
361 checked={variable.sensitive}267 <DraftInput
362 onChange={(event) => {268 value={variable.valueRedacted ? "" : variable.value}
363 const sensitive = event.currentTarget.checked;269 onCommit={(value) => updateVariable(variable.id, { value })}
364 updateVariable(variable.id, {270 type={variable.sensitive ? "password" : undefined}
365 sensitive,271 autoComplete="off"
366 ...(sensitive && variable.valueRedacted === undefined272 placeholder={
367 ? {}273 variable.valueRedacted
368 : { valueRedacted: sensitive ? variable.valueRedacted : false }),274 ? "Stored secret - enter a new value to replace"
369 });275 : "Value"
370 }}276 }
371 />277 spellCheck={false}
372 Sensitive278 aria-label={`Environment variable value ${index + 1}`}
373 </label>279 />
374 <Button280 </TableCell>
375 type="button"281 <TableCell className="w-20">
376 size="icon-sm"282 <div className="flex h-8 items-center justify-center">
377 variant="ghost"283 <Checkbox
378 className="size-8 justify-self-start text-muted-foreground hover:text-destructive sm:justify-self-end"284 checked={variable.sensitive}
379 onClick={() => removeVariable(variable.id)}285 onCheckedChange={(checked) => {
380 aria-label={`Remove environment variable ${variable.name || index + 1}`}286 const sensitive = Boolean(checked);
381 >287 updateVariable(variable.id, {
382 <XIcon className="size-3.5" />288 sensitive,
383 </Button>289 ...(sensitive && variable.valueRedacted === undefined
384 </div>290 ? {}
385 ))}291 : { valueRedacted: sensitive ? variable.valueRedacted : false }),
292 });
293 }}
294 aria-label={`Mark environment variable ${variable.name || index + 1} as sensitive`}
295 />
296 </div>
297 </TableCell>
298 <TableCell className="w-12">
299 <div className="flex justify-end">
300 <Button
301 type="button"
302 size="icon-sm"
303 variant="ghost"
304 className="size-8 text-muted-foreground hover:text-destructive"
305 onClick={() => removeVariable(variable.id)}
306 aria-label={`Remove environment variable ${variable.name || index + 1}`}
307 >
308 <XIcon className="size-3.5" />
309 </Button>
310 </div>
311 </TableCell>
312 </TableRow>
313 ))}
314 </TableBody>
315 </Table>
386 </div>316 </div>
387 )}317 )}
388 <span className="text-xs text-muted-foreground">318 <span className="text-xs text-muted-foreground">
...@@ -582,16 +512,17 @@ export function ProviderInstanceCard({...@@ -582,16 +512,17 @@ export function ProviderInstanceCard({
582 accentColor={accentColor}512 accentColor={accentColor}
583 showBadge={Boolean(accentColor)}513 showBadge={Boolean(accentColor)}
584 statusDotClassName={statusStyle.dot}514 statusDotClassName={statusStyle.dot}
515 indicatorBackground="var(--card)"
585 className="size-5"516 className="size-5"
586 iconClassName="size-4 text-foreground/80"517 iconClassName="size-4 text-foreground/80"
587 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 text-[7px]"518 badgeClassName="right-[-0.125rem] bottom-[-0.125rem] h-3 min-w-3 px-0.5 text-[7px]"
588 />519 />
589 ) : FallbackIconComponent ? (520 ) : FallbackIconComponent ? (
590 <span className="relative inline-flex size-5 shrink-0 items-center justify-center">521 <span className="relative inline-flex size-5 shrink-0 items-center justify-center">
591 <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />522 <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden />
592 <span523 <span
593 className={cn(524 className={cn(
594 "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background",525 "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-card",
595 statusStyle.dot,526 statusStyle.dot,
596 )}527 )}
597 aria-hidden528 aria-hidden
...@@ -825,6 +756,8 @@ export function ProviderInstanceCard({...@@ -825,6 +756,8 @@ export function ProviderInstanceCard({
825 displayName={displayName}756 displayName={displayName}
826 value={accentColor}757 value={accentColor}
827 onCommit={updateAccentColor}758 onCommit={updateAccentColor}
759 commitDelayMs={120}
760 description="Used to distinguish this instance in picker rails and model lists."
828 />761 />
829 </div>762 </div>
830763
apps/web/src/components/settings/settingsLayout.tsx+1-1
...@@ -38,7 +38,7 @@ export function SettingsSection({...@@ -38,7 +38,7 @@ export function SettingsSection({
38 </h2>38 </h2>
39 <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div>39 <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div>
40 </div>40 </div>
41 <div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">41 <div className="relative overflow-visible rounded-2xl border bg-card text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
42 {children}42 {children}
43 </div>43 </div>
44 </section>44 </section>
apps/web/src/components/ui/table.tsx created+87
...@@ -0,0 +1,87 @@
1import * as React from "react";
2
3import { cn } from "~/lib/utils";
4
5function Table({ className, ...props }: React.ComponentProps<"table">) {
6 return (
7 <div data-slot="table-container" className="relative w-full overflow-x-auto">
8 <table
9 data-slot="table"
10 className={cn("w-full caption-bottom text-xs", className)}
11 {...props}
12 />
13 </div>
14 );
15}
16
17function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
18 return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
19}
20
21function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
22 return (
23 <tbody
24 data-slot="table-body"
25 className={cn("[&_tr:last-child]:border-0", className)}
26 {...props}
27 />
28 );
29}
30
31function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
32 return (
33 <tfoot
34 data-slot="table-footer"
35 className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
36 {...props}
37 />
38 );
39}
40
41function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
42 return (
43 <tr
44 data-slot="table-row"
45 className={cn(
46 "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
47 className,
48 )}
49 {...props}
50 />
51 );
52}
53
54function TableHead({ className, ...props }: React.ComponentProps<"th">) {
55 return (
56 <th
57 data-slot="table-head"
58 className={cn(
59 "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
60 className,
61 )}
62 {...props}
63 />
64 );
65}
66
67function TableCell({ className, ...props }: React.ComponentProps<"td">) {
68 return (
69 <td
70 data-slot="table-cell"
71 className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
72 {...props}
73 />
74 );
75}
76
77function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
78 return (
79 <caption
80 data-slot="table-caption"
81 className={cn("mt-4 text-xs text-muted-foreground", className)}
82 {...props}
83 />
84 );
85}
86
87export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
apps/web/src/routes/settings.tsx+1-1
...@@ -24,7 +24,7 @@ function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {...@@ -24,7 +24,7 @@ function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) {
24 disabled={changedSettingLabels.length === 0}24 disabled={changedSettingLabels.length === 0}
25 onClick={() => void restoreDefaults()}25 onClick={() => void restoreDefaults()}
26 >26 >
27 <RotateCcwIcon className="size-3.5" />27 <RotateCcwIcon className="mx-1 size-3.5" />
28 Restore defaults28 Restore defaults
29 </Button>29 </Button>
30 );30 );