| author | |
| committer | |
| log | e2db800f7c7d7b2cba4da9d48883bf332a7f187b |
| tree | 6cd845dd2d148c89ab007645c3e440886d8c8546 |
| parent | 31533466b09ba12ec520e61c7908841c0b55de01 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 22 | "hooks": "~/hooks" |
| 23 | 23 | }, |
| 24 | 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 | ||
| 3 | import { useState } from "react"; | |
| 4 | import { cn } from "~/lib/utils"; | |
| 5 | ||
| 6 | interface 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 | ||
| 15 | const 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 | ||
| 36 | function 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 | ||
| 49 | function getColorValue(color: string): string { | |
| 50 | return colorMap[color as keyof typeof colorMap] || color; | |
| 51 | } | |
| 52 | ||
| 53 | export 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 | ||
| 3 | import { PipetteIcon, XIcon } from "lucide-react"; | |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; | |
| 5 | ||
| 6 | import { ColorSelector } from "../color-selector"; | |
| 7 | import { Button } from "../ui/button"; | |
| 8 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | |
| 9 | import { normalizeProviderAccentColor } from "../../providerInstances"; | |
| 10 | import { cn } from "../../lib/utils"; | |
| 11 | ||
| 12 | const PROVIDER_ACCENT_SWATCHES = [ | |
| 13 | "#2563eb", | |
| 14 | "#16a34a", | |
| 15 | "#ea580c", | |
| 16 | "#dc2626", | |
| 17 | "#7c3aed", | |
| 18 | "#0891b2", | |
| 19 | ] as const; | |
| 20 | ||
| 21 | const FALLBACK_ACCENT_COLOR = PROVIDER_ACCENT_SWATCHES[0]; | |
| 22 | ||
| 23 | function clamp(value: number, min = 0, max = 1) { | |
| 24 | return Math.min(max, Math.max(min, value)); | |
| 25 | } | |
| 26 | ||
| 27 | function 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 | ||
| 57 | function 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 | ||
| 83 | function 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 | ||
| 181 | function 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 | ||
| 225 | export 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 | 28 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 29 | 29 | import { Badge } from "../ui/badge"; |
| 30 | 30 | import { Button } from "../ui/button"; |
| 31 | import { Checkbox } from "../ui/checkbox"; | |
| 31 | 32 | import { Collapsible, CollapsibleContent } from "../ui/collapsible"; |
| 32 | 33 | import { DraftInput } from "../ui/draft-input"; |
| 33 | 34 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 34 | 35 | import { ScrollArea } from "../ui/scroll-area"; |
| 35 | 36 | import { Switch } from "../ui/switch"; |
| 37 | import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"; | |
| 36 | 38 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 37 | 39 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 38 | 40 | import type { DriverOption } from "./providerDriverMeta"; |
| 39 | 41 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; |
| 40 | 42 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 41 | 43 | import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; |
| 44 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; | |
| 42 | 45 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 43 | 46 | import { |
| 44 | 47 | getProviderVersionAdvisoryPresentation, |
| ... | ... | @@ -48,15 +51,6 @@ import { |
| 48 | 51 | type ProviderStatusKey, |
| 49 | 52 | } from "./providerStatus"; |
| 50 | 53 | |
| 51 | const PROVIDER_ACCENT_SWATCHES = [ | |
| 52 | "#2563eb", | |
| 53 | "#16a34a", | |
| 54 | "#ea580c", | |
| 55 | "#dc2626", | |
| 56 | "#7c3aed", | |
| 57 | "#0891b2", | |
| 58 | ] as const; | |
| 59 | ||
| 60 | 54 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 61 | 55 | |
| 62 | 56 | let environmentVariableDraftId = 0; |
| ... | ... | @@ -159,94 +153,6 @@ function ProviderAuthEmail(props: { |
| 159 | 153 | ); |
| 160 | 154 | } |
| 161 | 155 | |
| 162 | function 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 | ||
| 250 | 156 | function ProviderEnvironmentSection(props: { |
| 251 | 157 | readonly environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>; |
| 252 | 158 | readonly onChange: (environment: ReadonlyArray<ProviderInstanceEnvironmentVariable>) => void; |
| ... | ... | @@ -330,59 +236,83 @@ function ProviderEnvironmentSection(props: { |
| 330 | 236 | Add variables to pass API keys, base URLs, or other per-instance CLI settings. |
| 331 | 237 | </p> |
| 332 | 238 | ) : ( |
| 333 | <div className="grid gap-2"> | |
| 334 | {rows.map((variable, index) => ( | |
| 335 | <div | |
| 336 | key={variable.id} | |
| 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" | |
| 338 | > | |
| 339 | <DraftInput | |
| 340 | value={variable.name} | |
| 341 | onCommit={(name) => updateVariable(variable.id, { name: name.trim() })} | |
| 342 | placeholder="VARIABLE_NAME" | |
| 343 | spellCheck={false} | |
| 344 | aria-label={`Environment variable name ${index + 1}`} | |
| 345 | /> | |
| 346 | <DraftInput | |
| 347 | value={variable.valueRedacted ? "" : variable.value} | |
| 348 | onCommit={(value) => updateVariable(variable.id, { value })} | |
| 349 | type={variable.sensitive ? "password" : undefined} | |
| 350 | autoComplete="off" | |
| 351 | placeholder={ | |
| 352 | variable.valueRedacted ? "Stored secret - enter a new value to replace" : "Value" | |
| 353 | } | |
| 354 | spellCheck={false} | |
| 355 | aria-label={`Environment variable value ${index + 1}`} | |
| 356 | /> | |
| 357 | <label className="inline-flex h-8 items-center gap-2 text-xs text-muted-foreground"> | |
| 358 | <input | |
| 359 | type="checkbox" | |
| 360 | className="size-3.5" | |
| 361 | checked={variable.sensitive} | |
| 362 | onChange={(event) => { | |
| 363 | const sensitive = event.currentTarget.checked; | |
| 364 | updateVariable(variable.id, { | |
| 365 | sensitive, | |
| 366 | ...(sensitive && variable.valueRedacted === undefined | |
| 367 | ? {} | |
| 368 | : { valueRedacted: sensitive ? variable.valueRedacted : false }), | |
| 369 | }); | |
| 370 | }} | |
| 371 | /> | |
| 372 | Sensitive | |
| 373 | </label> | |
| 374 | <Button | |
| 375 | type="button" | |
| 376 | size="icon-sm" | |
| 377 | variant="ghost" | |
| 378 | className="size-8 justify-self-start text-muted-foreground hover:text-destructive sm:justify-self-end" | |
| 379 | onClick={() => removeVariable(variable.id)} | |
| 380 | aria-label={`Remove environment variable ${variable.name || index + 1}`} | |
| 381 | > | |
| 382 | <XIcon className="size-3.5" /> | |
| 383 | </Button> | |
| 384 | </div> | |
| 385 | ))} | |
| 239 | <div className="overflow-hidden rounded-md border border-border/70"> | |
| 240 | <Table> | |
| 241 | <TableHeader className="bg-muted/25 text-[11px] text-muted-foreground"> | |
| 242 | <TableRow className="hover:bg-transparent"> | |
| 243 | <TableHead>Variable</TableHead> | |
| 244 | <TableHead>Value</TableHead> | |
| 245 | <TableHead className="w-20">Sensitive</TableHead> | |
| 246 | <TableHead className="w-12 text-right"> | |
| 247 | <span className="sr-only">Options</span> | |
| 248 | </TableHead> | |
| 249 | </TableRow> | |
| 250 | </TableHeader> | |
| 251 | <TableBody> | |
| 252 | {rows.map((variable, index) => ( | |
| 253 | <TableRow | |
| 254 | key={variable.id} | |
| 255 | className="border-border/60 odd:bg-muted/20 even:bg-background/20" | |
| 256 | > | |
| 257 | <TableCell> | |
| 258 | <DraftInput | |
| 259 | value={variable.name} | |
| 260 | onCommit={(name) => updateVariable(variable.id, { name: name.trim() })} | |
| 261 | placeholder="VARIABLE_NAME" | |
| 262 | spellCheck={false} | |
| 263 | aria-label={`Environment variable name ${index + 1}`} | |
| 264 | /> | |
| 265 | </TableCell> | |
| 266 | <TableCell> | |
| 267 | <DraftInput | |
| 268 | value={variable.valueRedacted ? "" : variable.value} | |
| 269 | onCommit={(value) => updateVariable(variable.id, { value })} | |
| 270 | type={variable.sensitive ? "password" : undefined} | |
| 271 | autoComplete="off" | |
| 272 | placeholder={ | |
| 273 | variable.valueRedacted | |
| 274 | ? "Stored secret - enter a new value to replace" | |
| 275 | : "Value" | |
| 276 | } | |
| 277 | spellCheck={false} | |
| 278 | aria-label={`Environment variable value ${index + 1}`} | |
| 279 | /> | |
| 280 | </TableCell> | |
| 281 | <TableCell className="w-20"> | |
| 282 | <div className="flex h-8 items-center justify-center"> | |
| 283 | <Checkbox | |
| 284 | checked={variable.sensitive} | |
| 285 | onCheckedChange={(checked) => { | |
| 286 | const sensitive = Boolean(checked); | |
| 287 | updateVariable(variable.id, { | |
| 288 | sensitive, | |
| 289 | ...(sensitive && variable.valueRedacted === undefined | |
| 290 | ? {} | |
| 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 | 316 | </div> |
| 387 | 317 | )} |
| 388 | 318 | <span className="text-xs text-muted-foreground"> |
| ... | ... | @@ -582,16 +512,17 @@ export function ProviderInstanceCard({ |
| 582 | 512 | accentColor={accentColor} |
| 583 | 513 | showBadge={Boolean(accentColor)} |
| 584 | 514 | statusDotClassName={statusStyle.dot} |
| 515 | indicatorBackground="var(--card)" | |
| 585 | 516 | className="size-5" |
| 586 | 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 | 520 | ) : FallbackIconComponent ? ( |
| 590 | 521 | <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> |
| 591 | 522 | <FallbackIconComponent className="size-4 text-foreground/80" aria-hidden /> |
| 592 | 523 | <span |
| 593 | 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 | 526 | statusStyle.dot, |
| 596 | 527 | )} |
| 597 | 528 | aria-hidden |
| ... | ... | @@ -825,6 +756,8 @@ export function ProviderInstanceCard({ |
| 825 | 756 | displayName={displayName} |
| 826 | 757 | value={accentColor} |
| 827 | 758 | onCommit={updateAccentColor} |
| 759 | commitDelayMs={120} | |
| 760 | description="Used to distinguish this instance in picker rails and model lists." | |
| 828 | 761 | /> |
| 829 | 762 | </div> |
| 830 | 763 |
apps/web/src/components/settings/settingsLayout.tsx+1-1| ... | ... | @@ -38,7 +38,7 @@ export function SettingsSection({ |
| 38 | 38 | </h2> |
| 39 | 39 | <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div> |
| 40 | 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 | 42 | {children} |
| 43 | 43 | </div> |
| 44 | 44 | </section> |
apps/web/src/components/ui/table.tsx created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import * as React from "react"; | |
| 2 | ||
| 3 | import { cn } from "~/lib/utils"; | |
| 4 | ||
| 5 | function 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 | ||
| 17 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { | |
| 18 | return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />; | |
| 19 | } | |
| 20 | ||
| 21 | function 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 | ||
| 31 | function 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 | ||
| 41 | function 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 | ||
| 54 | function 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 | ||
| 67 | function 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 | ||
| 77 | function 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 | ||
| 87 | export { 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 | 24 | disabled={changedSettingLabels.length === 0} |
| 25 | 25 | onClick={() => void restoreDefaults()} |
| 26 | 26 | > |
| 27 | <RotateCcwIcon className="size-3.5" /> | |
| 27 | <RotateCcwIcon className="mx-1 size-3.5" /> | |
| 28 | 28 | Restore defaults |
| 29 | 29 | </Button> |
| 30 | 30 | ); |