| author | |
| committer | |
| log | 343db2c328af56fe0fa7672f3af055550832689b |
| tree | 55d3b5f1b3ff9a4e15011a589cecb7b65e0b1fc6 |
| parent | 19d8ab2ae9fc562ee7b216a0d72903fbfafa9572 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>15 files changed, 724 insertions(+), 714 deletions(-)
apps/web/src/components/settings/ConnectionsSettings.tsx+6-6| ... | @@ -1710,7 +1710,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b | ... | @@ -1710,7 +1710,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b |
| 1710 | ) : null} | 1710 | ) : null} |
| 1711 | <SettingsRow | 1711 | <SettingsRow |
| 1712 | title={searchableSetting("publish-agent-activity").title} | 1712 | title={searchableSetting("publish-agent-activity").title} |
| 1713 | description="Send activity from this environment to your mobile clients for push notifications and Live Activities. Works without a T3 Connect tunnel." | 1713 | description="Send activity to mobile notifications and Live Activities without T3 Connect." |
| 1714 | control={ | 1714 | control={ |
| 1715 | <CloudLinkSwitch | 1715 | <CloudLinkSwitch |
| 1716 | ariaLabel="Publish agent activity to mobile clients" | 1716 | ariaLabel="Publish agent activity to mobile clients" |
| ... | @@ -2907,7 +2907,7 @@ export function ConnectionsSettings() { | ... | @@ -2907,7 +2907,7 @@ export function ConnectionsSettings() { |
| 2907 | return ( | 2907 | return ( |
| 2908 | <SettingsRow | 2908 | <SettingsRow |
| 2909 | {...searchableSetting("wsl-backend")} | 2909 | {...searchableSetting("wsl-backend")} |
| 2910 | description="WSL is no longer available, so the Windows backend is running instead. Switch off the WSL backend to clear this preference." | 2910 | description="WSL is unavailable, so Windows is running instead. Turn WSL off to clear this preference." |
| 2911 | status={ | 2911 | status={ |
| 2912 | desktopWslError ? ( | 2912 | desktopWslError ? ( |
| 2913 | <span className="block text-destructive">{desktopWslError}</span> | 2913 | <span className="block text-destructive">{desktopWslError}</span> |
| ... | @@ -2945,7 +2945,7 @@ export function ConnectionsSettings() { | ... | @@ -2945,7 +2945,7 @@ export function ConnectionsSettings() { |
| 2945 | <> | 2945 | <> |
| 2946 | <SettingsRow | 2946 | <SettingsRow |
| 2947 | {...searchableSetting("wsl-backend")} | 2947 | {...searchableSetting("wsl-backend")} |
| 2948 | description="Run a second backend inside a WSL distro alongside the Windows one. Pick a distro to start it; pick Off to stop it. Projects opened against the WSL backend live on the Linux side; Windows projects stay where they are." | 2948 | description="Run the selected WSL distro alongside Windows. Projects remain on their current filesystem." |
| 2949 | status={ | 2949 | status={ |
| 2950 | desktopWslError ? ( | 2950 | desktopWslError ? ( |
| 2951 | <span className="block text-destructive">{desktopWslError}</span> | 2951 | <span className="block text-destructive">{desktopWslError}</span> |
| ... | @@ -2994,7 +2994,7 @@ export function ConnectionsSettings() { | ... | @@ -2994,7 +2994,7 @@ export function ConnectionsSettings() { |
| 2994 | {desktopWslState.enabled ? ( | 2994 | {desktopWslState.enabled ? ( |
| 2995 | <SettingsRow | 2995 | <SettingsRow |
| 2996 | title="WSL only" | 2996 | title="WSL only" |
| 2997 | description="Stop the Windows backend and run only the WSL backend. Useful if you develop entirely inside WSL and don't want a second backend process. T3 Code restarts when you change this." | 2997 | description="Run only the WSL backend. T3 Code restarts when this changes." |
| 2998 | className="bg-muted/20 pl-7 sm:pl-8" | 2998 | className="bg-muted/20 pl-7 sm:pl-8" |
| 2999 | control={ | 2999 | control={ |
| 3000 | <Switch | 3000 | <Switch |
| ... | @@ -3098,8 +3098,8 @@ export function ConnectionsSettings() { | ... | @@ -3098,8 +3098,8 @@ export function ConnectionsSettings() { |
| 3098 | title={searchableSetting("network-access").title} | 3098 | title={searchableSetting("network-access").title} |
| 3099 | description={ | 3099 | description={ |
| 3100 | currentAuthPolicy === "remote-reachable" | 3100 | currentAuthPolicy === "remote-reachable" |
| 3101 | ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched." | 3101 | ? "Remote access is already configured. Change network exposure where the server starts." |
| 3102 | : "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing." | 3102 | : "Only this machine can connect. Restart with a non-loopback host for remote pairing." |
| 3103 | } | 3103 | } |
| 3104 | control={ | 3104 | control={ |
| 3105 | <Tooltip> | 3105 | <Tooltip> |
apps/web/src/components/settings/IntegrationsSettings.tsx+6-6| ... | @@ -262,7 +262,7 @@ function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -262,7 +262,7 @@ function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) { |
| 262 | return ( | 262 | return ( |
| 263 | <SettingsRow | 263 | <SettingsRow |
| 264 | {...searchableSetting("browser-default-viewport")} | 264 | {...searchableSetting("browser-default-viewport")} |
| 265 | description="The viewport a browser tab opens at, for both you and agents. Fill sizes the page to the panel; any other choice opens the device toolbar at that size." | 265 | description="Tab size for you and agents. Fill fits the panel; other sizes show the device toolbar." |
| 266 | resetAction={ | 266 | resetAction={ |
| 267 | !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? ( | 267 | !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? ( |
| 268 | <SettingResetButton | 268 | <SettingResetButton |
| ... | @@ -460,7 +460,7 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo | ... | @@ -460,7 +460,7 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo |
| 460 | return ( | 460 | return ( |
| 461 | <SettingsRow | 461 | <SettingsRow |
| 462 | {...searchableSetting("browser-recording-frame-rate")} | 462 | {...searchableSetting("browser-recording-frame-rate")} |
| 463 | description="Maximum frame rate for browser recordings. 30 fps is the default and uses less CPU and storage; 60 fps captures smoother motion." | 463 | description="Maximum recording rate. 30 fps saves CPU and storage; 60 fps is smoother." |
| 464 | resetAction={ | 464 | resetAction={ |
| 465 | !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? ( | 465 | !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? ( |
| 466 | <SettingResetButton | 466 | <SettingResetButton |
| ... | @@ -557,7 +557,7 @@ function AgentBrowserAccessSetting() { | ... | @@ -557,7 +557,7 @@ function AgentBrowserAccessSetting() { |
| 557 | <SettingsRow | 557 | <SettingsRow |
| 558 | serverScoped | 558 | serverScoped |
| 559 | {...searchableSetting("agent-browser-access")} | 559 | {...searchableSetting("agent-browser-access")} |
| 560 | description="Let agents open and drive the preview browser. When off, the browser tools and the instructions describing them are withheld from agent sessions. Your own browser panel is unaffected." | 560 | description="Allow agents to use the preview browser. Off hides browser tools from agents, not you." |
| 561 | status={ | 561 | status={ |
| 562 | settings.enableAgentBrowserAccess | 562 | settings.enableAgentBrowserAccess |
| 563 | ? undefined | 563 | ? undefined |
| ... | @@ -595,7 +595,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled | ... | @@ -595,7 +595,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled |
| 595 | return ( | 595 | return ( |
| 596 | <SettingsRow | 596 | <SettingsRow |
| 597 | {...searchableSetting("browser-auto-show-floating-preview")} | 597 | {...searchableSetting("browser-auto-show-floating-preview")} |
| 598 | description="Pop the floating preview into view when an agent uses a browser. An agent that explicitly asks to show or hide its preview still gets what it asked for." | 598 | description="Show the floating preview when an agent opens a browser unless the agent says otherwise." |
| 599 | resetAction={ | 599 | resetAction={ |
| 600 | !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? ( | 600 | !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? ( |
| 601 | <SettingResetButton | 601 | <SettingResetButton |
| ... | @@ -638,7 +638,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled | ... | @@ -638,7 +638,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled |
| 638 | */ | 638 | */ |
| 639 | function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode }) { | 639 | function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode }) { |
| 640 | return ( | 640 | return ( |
| 641 | <div className="rounded-xl border border-border/60 bg-muted/20 py-1.5"> | 641 | <div className="border-border/60 bg-muted/20 py-1.5"> |
| 642 | <div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4"> | 642 | <div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4"> |
| 643 | <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" /> | 643 | <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" /> |
| 644 | <p>Only available in the desktop app.</p> | 644 | <p>Only available in the desktop app.</p> |
| ... | @@ -933,7 +933,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -933,7 +933,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 933 | return ( | 933 | return ( |
| 934 | <SettingsRow | 934 | <SettingsRow |
| 935 | {...searchableSetting("browser-profiles")} | 935 | {...searchableSetting("browser-profiles")} |
| 936 | description="Each profile has its own cookies and logins, so you can stay signed in to different accounts at once." | 936 | description="Profiles separate cookies and logins. Incognito data is cleared when the app closes." |
| 937 | control={ | 937 | control={ |
| 938 | <Menu onOpenChange={(open) => open && loadSources()}> | 938 | <Menu onOpenChange={(open) => open && loadSources()}> |
| 939 | <MenuTrigger | 939 | <MenuTrigger |
apps/web/src/components/settings/ProviderAccentColorPicker.tsx+36-71| ... | @@ -3,22 +3,12 @@ | ... | @@ -3,22 +3,12 @@ |
| 3 | import { PipetteIcon, XIcon } from "lucide-react"; | 3 | import { PipetteIcon, XIcon } from "lucide-react"; |
| 4 | import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; | 4 | import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react"; |
| 5 | 5 | ||
| 6 | import { ColorSelector } from "../color-selector"; | ||
| 7 | import { Button } from "../ui/button"; | 6 | import { Button } from "../ui/button"; |
| 8 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 7 | import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 9 | import { normalizeProviderAccentColor } from "../../providerInstances"; | 8 | import { normalizeProviderAccentColor } from "../../providerInstances"; |
| 10 | import { cn } from "../../lib/utils"; | 9 | import { cn } from "../../lib/utils"; |
| 11 | 10 | ||
| 12 | const PROVIDER_ACCENT_SWATCHES = [ | 11 | const FALLBACK_ACCENT_COLOR = "#2563eb"; |
| 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 | 12 | ||
| 23 | function clamp(value: number, min = 0, max = 1) { | 13 | function clamp(value: number, min = 0, max = 1) { |
| 24 | return Math.min(max, Math.max(min, value)); | 14 | return Math.min(max, Math.max(min, value)); |
| ... | @@ -88,6 +78,7 @@ function ProviderCustomColorPanel(props: { | ... | @@ -88,6 +78,7 @@ function ProviderCustomColorPanel(props: { |
| 88 | const initialHsv = useMemo(() => hexToHsv(props.value), [props.value]); | 78 | const initialHsv = useMemo(() => hexToHsv(props.value), [props.value]); |
| 89 | const [hsv, setHsv] = useState(initialHsv); | 79 | const [hsv, setHsv] = useState(initialHsv); |
| 90 | const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v); | 80 | const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v); |
| 81 | const [hexDraft, setHexDraft] = useState<string | null>(null); | ||
| 91 | 82 | ||
| 92 | const commitHsv = useCallback( | 83 | const commitHsv = useCallback( |
| 93 | (nextHsv: typeof hsv) => { | 84 | (nextHsv: typeof hsv) => { |
| ... | @@ -162,13 +153,15 @@ function ProviderCustomColorPanel(props: { | ... | @@ -162,13 +153,15 @@ function ProviderCustomColorPanel(props: { |
| 162 | /> | 153 | /> |
| 163 | </div> | 154 | </div> |
| 164 | <input | 155 | <input |
| 165 | value={currentColor} | 156 | value={hexDraft ?? currentColor} |
| 166 | onChange={(event) => { | 157 | onChange={(event) => { |
| 167 | const nextColor = event.currentTarget.value; | 158 | const nextColor = event.currentTarget.value; |
| 159 | setHexDraft(nextColor); | ||
| 168 | if (!/^#[\da-f]{6}$/i.test(nextColor)) return; | 160 | if (!/^#[\da-f]{6}$/i.test(nextColor)) return; |
| 169 | setHsv(hexToHsv(nextColor)); | 161 | setHsv(hexToHsv(nextColor)); |
| 170 | props.onCommit(nextColor); | 162 | props.onCommit(nextColor); |
| 171 | }} | 163 | }} |
| 164 | onBlur={() => setHexDraft(null)} | ||
| 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" | 165 | 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" | 166 | aria-label="Custom hex accent color" |
| 174 | spellCheck={false} | 167 | spellCheck={false} |
| ... | @@ -181,8 +174,8 @@ function ProviderCustomColorPanel(props: { | ... | @@ -181,8 +174,8 @@ function ProviderCustomColorPanel(props: { |
| 181 | function ProviderCustomColorPicker(props: { | 174 | function ProviderCustomColorPicker(props: { |
| 182 | readonly displayName: string; | 175 | readonly displayName: string; |
| 183 | readonly value: string | undefined; | 176 | readonly value: string | undefined; |
| 184 | readonly selected: boolean; | ||
| 185 | readonly onCommit: (value: string) => void; | 177 | readonly onCommit: (value: string) => void; |
| 178 | readonly onClear: () => void; | ||
| 186 | }) { | 179 | }) { |
| 187 | const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR; | 180 | const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR; |
| 188 | 181 | ||
| ... | @@ -193,20 +186,13 @@ function ProviderCustomColorPicker(props: { | ... | @@ -193,20 +186,13 @@ function ProviderCustomColorPicker(props: { |
| 193 | <button | 186 | <button |
| 194 | type="button" | 187 | type="button" |
| 195 | className={cn( | 188 | className={cn( |
| 196 | "flex size-6 cursor-pointer items-center justify-center rounded-full text-white transition-transform duration-200 active:scale-90", | 189 | "flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md border border-input text-white shadow-xs transition-transform duration-200 active:scale-95", |
| 197 | "hover:scale-105", | 190 | "hover:scale-105 hover:border-ring/60", |
| 198 | )} | 191 | )} |
| 199 | style={{ | 192 | style={{ backgroundColor: normalized }} |
| 200 | backgroundColor: normalized, | 193 | aria-label={`Choose accent color for ${props.displayName}`} |
| 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 | > | 194 | > |
| 209 | <PipetteIcon className="size-3 text-foreground/25" aria-hidden /> | 195 | <PipetteIcon className="size-3 text-white/70 drop-shadow-sm" aria-hidden /> |
| 210 | </button> | 196 | </button> |
| 211 | } | 197 | } |
| 212 | /> | 198 | /> |
| ... | @@ -217,6 +203,21 @@ function ProviderCustomColorPicker(props: { | ... | @@ -217,6 +203,21 @@ function ProviderCustomColorPicker(props: { |
| 217 | className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0" | 203 | className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0" |
| 218 | > | 204 | > |
| 219 | <ProviderCustomColorPanel value={normalized} onCommit={props.onCommit} /> | 205 | <ProviderCustomColorPanel value={normalized} onCommit={props.onCommit} /> |
| 206 | <PopoverClose | ||
| 207 | render={ | ||
| 208 | <Button | ||
| 209 | type="button" | ||
| 210 | size="sm" | ||
| 211 | variant="ghost" | ||
| 212 | className="h-8 w-full justify-start rounded-none border-t border-border/60 px-3 text-xs text-muted-foreground [--control-icon-color:currentColor]" | ||
| 213 | onClick={props.onClear} | ||
| 214 | disabled={!props.value} | ||
| 215 | > | ||
| 216 | <XIcon className="size-3.5" aria-hidden /> | ||
| 217 | Clear color | ||
| 218 | </Button> | ||
| 219 | } | ||
| 220 | /> | ||
| 220 | </PopoverPopup> | 221 | </PopoverPopup> |
| 221 | </Popover> | 222 | </Popover> |
| 222 | ); | 223 | ); |
| ... | @@ -297,59 +298,23 @@ export function ProviderAccentColorPicker(props: { | ... | @@ -297,59 +298,23 @@ export function ProviderAccentColorPicker(props: { |
| 297 | ); | 298 | ); |
| 298 | 299 | ||
| 299 | const normalized = normalizeProviderAccentColor(optimisticValue); | 300 | const normalized = normalizeProviderAccentColor(optimisticValue); |
| 300 | const selectedValue = | 301 | const picker = ( |
| 301 | normalized && | 302 | <ProviderCustomColorPicker |
| 302 | PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number]) | 303 | displayName={displayName} |
| 303 | ? normalized | 304 | value={normalized} |
| 304 | : ""; | 305 | onCommit={commitAccentColor} |
| 305 | const customSelected = Boolean(normalized && selectedValue === ""); | 306 | onClear={() => commitAccentColor("")} |
| 306 | 307 | /> | |
| 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 | ); | 308 | ); |
| 344 | 309 | ||
| 345 | if (layout === "inline") { | 310 | if (layout === "inline") { |
| 346 | return swatchRow; | 311 | return picker; |
| 347 | } | 312 | } |
| 348 | 313 | ||
| 349 | return ( | 314 | return ( |
| 350 | <div className="grid gap-2"> | 315 | <div className="grid gap-2"> |
| 351 | <span className="text-xs font-medium text-foreground">Accent color</span> | 316 | <span className="text-xs font-medium text-foreground">Accent color</span> |
| 352 | {swatchRow} | 317 | {picker} |
| 353 | {description ? <span className="text-xs text-muted-foreground">{description}</span> : null} | 318 | {description ? <span className="text-xs text-muted-foreground">{description}</span> : null} |
| 354 | </div> | 319 | </div> |
| 355 | ); | 320 | ); |
apps/web/src/components/settings/ProviderInstanceCard.tsx+247-303| ... | @@ -32,17 +32,16 @@ import { Badge } from "../ui/badge"; | ... | @@ -32,17 +32,16 @@ import { Badge } from "../ui/badge"; |
| 32 | import { Button } from "../ui/button"; | 32 | import { Button } from "../ui/button"; |
| 33 | import { DraftInput } from "../ui/draft-input"; | 33 | import { DraftInput } from "../ui/draft-input"; |
| 34 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 34 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 35 | import { ScrollArea } from "../ui/scroll-area"; | ||
| 36 | import { Switch } from "../ui/switch"; | 35 | import { Switch } from "../ui/switch"; |
| 37 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 36 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 37 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 39 | import type { DriverOption } from "./providerDriverMeta"; | 38 | import type { DriverOption } from "./providerDriverMeta"; |
| 40 | import { providerSettingsTabClassName } from "./providerSettingsTabs"; | ||
| 41 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; | 39 | import { ProviderSettingsForm } from "./ProviderSettingsForm"; |
| 42 | import { ProviderModelsSection } from "./ProviderModelsSection"; | 40 | import { ProviderModelsSection } from "./ProviderModelsSection"; |
| 43 | import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon"; | 41 | import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon"; |
| 44 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; | 42 | import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; |
| 45 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 43 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 44 | import { SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 46 | import { | 45 | import { |
| 47 | getProviderVersionAdvisoryPresentation, | 46 | getProviderVersionAdvisoryPresentation, |
| 48 | PROVIDER_STATUS_STYLES, | 47 | PROVIDER_STATUS_STYLES, |
| ... | @@ -53,13 +52,6 @@ import { | ... | @@ -53,13 +52,6 @@ import { |
| 53 | 52 | ||
| 54 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; | 53 | const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; |
| 55 | 54 | ||
| 56 | /** Label-left field grid for the Configuration tab: one row per field. */ | ||
| 57 | const 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. */ | ||
| 60 | const 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 | |||
| 63 | let environmentVariableDraftId = 0; | 55 | let environmentVariableDraftId = 0; |
| 64 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; | 56 | const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; |
| 65 | 57 | ||
| ... | @@ -254,7 +246,7 @@ function ProviderEnvironmentSection(props: { | ... | @@ -254,7 +246,7 @@ function ProviderEnvironmentSection(props: { |
| 254 | ]); | 246 | ]); |
| 255 | 247 | ||
| 256 | return ( | 248 | return ( |
| 257 | <div className="min-w-0 space-y-1.5"> | 249 | <div className="mt-3 min-w-0 space-y-2"> |
| 258 | {rows.map((variable, index) => ( | 250 | {rows.map((variable, index) => ( |
| 259 | <div key={variable.id} className="flex min-w-0 items-center gap-1.5"> | 251 | <div key={variable.id} className="flex min-w-0 items-center gap-1.5"> |
| 260 | <DraftInput | 252 | <DraftInput |
| ... | @@ -329,16 +321,16 @@ function ProviderEnvironmentSection(props: { | ... | @@ -329,16 +321,16 @@ function ProviderEnvironmentSection(props: { |
| 329 | </Button> | 321 | </Button> |
| 330 | </div> | 322 | </div> |
| 331 | ))} | 323 | ))} |
| 332 | <div className="flex min-h-[1.875rem] flex-wrap items-center gap-x-3 gap-y-1"> | 324 | <div className="flex min-h-[1.875rem] flex-wrap items-center justify-end gap-x-3 gap-y-1"> |
| 325 | {rows.length > 0 ? ( | ||
| 326 | <span className="mr-auto text-xs text-muted-foreground"> | ||
| 327 | Sensitive values are stored separately and never returned to the app. | ||
| 328 | </span> | ||
| 329 | ) : null} | ||
| 333 | <Button type="button" size="xs" variant="ghost-muted" onClick={addVariable}> | 330 | <Button type="button" size="xs" variant="ghost-muted" onClick={addVariable}> |
| 334 | <PlusIcon className="size-3" /> | 331 | <PlusIcon className="size-3" /> |
| 335 | Add variable | 332 | Add variable |
| 336 | </Button> | 333 | </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> | ||
| 342 | </div> | 334 | </div> |
| 343 | </div> | 335 | </div> |
| 344 | ); | 336 | ); |
| ... | @@ -421,7 +413,6 @@ export function ProviderInstanceCard({ | ... | @@ -421,7 +413,6 @@ export function ProviderInstanceCard({ |
| 421 | onRunUpdate, | 413 | onRunUpdate, |
| 422 | isUpdating = false, | 414 | isUpdating = false, |
| 423 | }: ProviderInstanceCardProps) { | 415 | }: ProviderInstanceCardProps) { |
| 424 | const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration"); | ||
| 425 | const enabled = resolveProviderInstanceEnabled(instance); | 416 | const enabled = resolveProviderInstanceEnabled(instance); |
| 426 | // A locally disabled provider reads "Disabled" with a muted dot even if its | 417 | // A locally disabled provider reads "Disabled" with a muted dot even if its |
| 427 | // last server status is stale. Enabled providers use the server status. | 418 | // last server status is stale. Enabled providers use the server status. |
| ... | @@ -433,9 +424,6 @@ export function ProviderInstanceCard({ | ... | @@ -433,9 +424,6 @@ export function ProviderInstanceCard({ |
| 433 | ? getProviderSummary(liveProvider) | 424 | ? getProviderSummary(liveProvider) |
| 434 | : { headline: "Disabled", detail: null }; | 425 | : { headline: "Disabled", detail: null }; |
| 435 | const authEmail = liveProvider?.auth.email?.trim(); | 426 | const authEmail = liveProvider?.auth.email?.trim(); |
| 436 | // The editor header folds the account email into the status line — | ||
| 437 | // "Authenticated as <email> · <plan>" — with the email redacted until its | ||
| 438 | // reveal toggle is clicked. | ||
| 439 | const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; | 427 | const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; |
| 440 | const authLabel = | 428 | const authLabel = |
| 441 | enabled && liveProvider?.auth.status === "authenticated" | 429 | enabled && liveProvider?.auth.status === "authenticated" |
| ... | @@ -474,8 +462,6 @@ export function ProviderInstanceCard({ | ... | @@ -474,8 +462,6 @@ export function ProviderInstanceCard({ |
| 474 | const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) | 462 | const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) |
| 475 | ? instance.driver | 463 | ? instance.driver |
| 476 | : null; | 464 | : null; |
| 477 | const visibleTab = driverOption === undefined ? "configuration" : activeTab; | ||
| 478 | |||
| 479 | const customModels = | 465 | const customModels = |
| 480 | instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); | 466 | instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); |
| 481 | // Server-returned models may lag behind settings writes. Treat probe | 467 | // Server-returned models may lag behind settings writes. Treat probe |
| ... | @@ -485,10 +471,6 @@ export function ProviderInstanceCard({ | ... | @@ -485,10 +471,6 @@ export function ProviderInstanceCard({ |
| 485 | liveModels: liveProvider?.models, | 471 | liveModels: liveProvider?.models, |
| 486 | customModels, | 472 | customModels, |
| 487 | }); | 473 | }); |
| 488 | const hiddenModelCount = modelsForDisplay.filter( | ||
| 489 | (model) => !model.isCustom && hiddenModels.includes(model.slug), | ||
| 490 | ).length; | ||
| 491 | |||
| 492 | const updateDisplayName = (value: string) => { | 474 | const updateDisplayName = (value: string) => { |
| 493 | const trimmed = value.trim(); | 475 | const trimmed = value.trim(); |
| 494 | const { displayName: _omit, ...rest } = instance; | 476 | const { displayName: _omit, ...rest } = instance; |
| ... | @@ -561,25 +543,6 @@ export function ProviderInstanceCard({ | ... | @@ -561,25 +543,6 @@ export function ProviderInstanceCard({ |
| 561 | </span> | 543 | </span> |
| 562 | ); | 544 | ); |
| 563 | 545 | ||
| 564 | const titleHeadNode = ( | ||
| 565 | <> | ||
| 566 | {titleIconNode} | ||
| 567 | <h3 className="truncate text-sm font-medium tracking-[-0.005em] text-foreground"> | ||
| 568 | {displayName} | ||
| 569 | </h3> | ||
| 570 | {String(instanceId) !== String(instance.driver) ? ( | ||
| 571 | <code className="truncate rounded bg-muted/60 px-1 py-0.5 text-[10px] text-muted-foreground"> | ||
| 572 | {instanceId} | ||
| 573 | </code> | ||
| 574 | ) : null} | ||
| 575 | {driverOption?.badgeLabel ? ( | ||
| 576 | <Badge variant="warning" size="sm" className="shrink-0"> | ||
| 577 | {driverOption.badgeLabel} | ||
| 578 | </Badge> | ||
| 579 | ) : null} | ||
| 580 | </> | ||
| 581 | ); | ||
| 582 | |||
| 583 | const titleTailNode = headerAction ? ( | 546 | const titleTailNode = headerAction ? ( |
| 584 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span> | 547 | <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span> |
| 585 | ) : null; | 548 | ) : null; |
| ... | @@ -593,29 +556,43 @@ export function ProviderInstanceCard({ | ... | @@ -593,29 +556,43 @@ export function ProviderInstanceCard({ |
| 593 | statusKey === "warning" || statusKey === "error" ? ( | 556 | statusKey === "warning" || statusKey === "error" ? ( |
| 594 | <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden /> | 557 | <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden /> |
| 595 | ) : null; | 558 | ) : null; |
| 596 | const statusHeadlineNode = <span>{summary.headline}</span>; | ||
| 597 | // Trouble states carry the server's explanation (a failed probe, a shadow | 559 | // Trouble states carry the server's explanation (a failed probe, a shadow |
| 598 | // home entry that is not a symlink, a missing binary). Show it wherever the | 560 | // home entry that is not a symlink, a missing binary). Show it wherever the |
| 599 | // headline shows so the user can act without opening the editor. | 561 | // headline shows so the user can act without opening the editor. |
| 600 | const needsAttention = statusKey === "warning" || statusKey === "error"; | 562 | const needsAttention = statusKey === "warning" || statusKey === "error"; |
| 601 | const statusLineClassName = | 563 | const editorStatusNode = |
| 602 | "flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80"; | 564 | isAuthenticated && authEmail ? ( |
| 603 | 565 | <> | |
| 566 | {needsAttention ? statusDotNode : null} | ||
| 567 | <span>Authenticated as</span> | ||
| 568 | <ProviderAuthEmail email={authEmail} /> | ||
| 569 | {authLabel ? <span>· {authLabel}</span> : null} | ||
| 570 | {summary.detail ? ( | ||
| 571 | <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span> | ||
| 572 | ) : null} | ||
| 573 | </> | ||
| 574 | ) : ( | ||
| 575 | <> | ||
| 576 | {statusDotNode} | ||
| 577 | <span>{summary.headline}</span> | ||
| 578 | {summary.detail ? ( | ||
| 579 | <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span> | ||
| 580 | ) : null} | ||
| 581 | </> | ||
| 582 | ); | ||
| 604 | if (mode === "list") { | 583 | if (mode === "list") { |
| 605 | return ( | 584 | return ( |
| 606 | <div | 585 | <div |
| 586 | data-slot="settings-row" | ||
| 607 | className={cn( | 587 | className={cn( |
| 608 | // Sidebar-style selection with a fixed row height so the list stays | 588 | "group flex min-h-18 items-center gap-3 px-3 py-3 transition-colors sm:px-4", |
| 609 | // even; the status line clamps to two lines instead of growing. | 589 | selected ? "bg-muted/45" : "hover:bg-muted/25", |
| 610 | "group flex min-h-19 items-start gap-3 rounded-md px-3 py-2 transition-colors", | ||
| 611 | // Foreground-alpha tint so the fill reads the same in light and dark themes. | ||
| 612 | selected ? "bg-foreground/8" : "hover:bg-foreground/4", | ||
| 613 | )} | 590 | )} |
| 614 | > | 591 | > |
| 615 | <button | 592 | <button |
| 616 | type="button" | 593 | type="button" |
| 617 | className={cn( | 594 | className={cn( |
| 618 | "flex min-w-0 flex-1 cursor-pointer items-start gap-3 rounded-sm text-left outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring", | 595 | "flex min-w-0 flex-1 cursor-pointer items-start gap-3 rounded-md text-left outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring", |
| 619 | !enabled && !selected && "opacity-60 group-hover:opacity-100", | 596 | !enabled && !selected && "opacity-60 group-hover:opacity-100", |
| 620 | )} | 597 | )} |
| 621 | onClick={onSelect} | 598 | onClick={onSelect} |
| ... | @@ -660,260 +637,227 @@ export function ProviderInstanceCard({ | ... | @@ -660,260 +637,227 @@ export function ProviderInstanceCard({ |
| 660 | ); | 637 | ); |
| 661 | } | 638 | } |
| 662 | 639 | ||
| 663 | return ( | 640 | const editorHeaderAction = ( |
| 664 | <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col"> | 641 | <div className="flex min-w-0 items-center gap-1.5"> |
| 665 | <div className="flex min-h-16 shrink-0 items-start justify-between gap-3 border-b border-border/70 px-4 py-3"> | 642 | {driverOption?.badgeLabel ? ( |
| 666 | <div className="min-w-0 flex-1 space-y-1"> | 643 | <Badge variant="warning" size="sm" className="shrink-0"> |
| 667 | <div className="flex min-w-0 flex-wrap items-center gap-2"> | 644 | {driverOption.badgeLabel} |
| 668 | {titleHeadNode} | 645 | </Badge> |
| 669 | {versionCodeNode} | 646 | ) : null} |
| 670 | {/* | 647 | {versionCodeNode} |
| 671 | Only the write actions go inert on read-only sessions; the | 648 | <span |
| 672 | status line below keeps its email reveal clickable. | 649 | inert={readOnly} |
| 673 | */} | 650 | aria-disabled={readOnly || undefined} |
| 674 | <span | 651 | className={cn("inline-flex items-center gap-1", readOnly && "opacity-50")} |
| 675 | inert={readOnly} | 652 | > |
| 676 | aria-disabled={readOnly || undefined} | 653 | {versionAdvisory ? ( |
| 677 | className={cn("inline-flex items-center gap-2", readOnly && "opacity-50")} | 654 | <Popover> |
| 655 | <PopoverTrigger | ||
| 656 | render={ | ||
| 657 | <Button | ||
| 658 | type="button" | ||
| 659 | size="icon-micro" | ||
| 660 | variant="ghost" | ||
| 661 | className={cn( | ||
| 662 | "[--control-icon-color:currentColor]", | ||
| 663 | versionAdvisory.emphasis === "strong" | ||
| 664 | ? "text-warning hover:text-warning" | ||
| 665 | : "text-muted-foreground hover:text-foreground", | ||
| 666 | )} | ||
| 667 | aria-label="Update available — view details" | ||
| 668 | > | ||
| 669 | <ArrowUpCircleIcon className="size-3.5" /> | ||
| 670 | </Button> | ||
| 671 | } | ||
| 672 | /> | ||
| 673 | <PopoverPopup | ||
| 674 | side="bottom" | ||
| 675 | align="end" | ||
| 676 | className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]" | ||
| 678 | > | 677 | > |
| 679 | {versionAdvisory ? ( | 678 | <div className="grid min-w-0 gap-3"> |
| 680 | <Popover> | 679 | <div className="grid gap-0.5"> |
| 681 | <PopoverTrigger | 680 | <p className="text-[13px] font-semibold leading-tight text-foreground"> |
| 682 | render={ | 681 | Update available |
| 683 | <Button | 682 | </p> |
| 684 | type="button" | 683 | <p |
| 685 | size="icon-micro" | 684 | className={cn( |
| 686 | variant="ghost" | 685 | "text-xs leading-snug", |
| 687 | className={cn( | 686 | versionAdvisory.emphasis === "strong" |
| 688 | "[--control-icon-color:currentColor]", | 687 | ? "text-warning" |
| 689 | versionAdvisory.emphasis === "strong" | 688 | : "text-muted-foreground", |
| 690 | ? "text-warning hover:text-warning" | 689 | )} |
| 691 | : "text-muted-foreground hover:text-foreground", | ||
| 692 | )} | ||
| 693 | aria-label="Update available — view details" | ||
| 694 | > | ||
| 695 | <ArrowUpCircleIcon className="size-3.5" /> | ||
| 696 | </Button> | ||
| 697 | } | ||
| 698 | /> | ||
| 699 | <PopoverPopup | ||
| 700 | side="bottom" | ||
| 701 | align="start" | ||
| 702 | className="w-[min(21rem,calc(100vw-1.5rem))] [--popup-width:min(21rem,calc(100vw-1.5rem))]" | ||
| 703 | > | 690 | > |
| 704 | <div className="grid min-w-0 gap-3"> | 691 | {versionAdvisory.detail} |
| 705 | <div className="grid gap-0.5"> | 692 | </p> |
| 706 | <p className="text-[13px] font-semibold leading-tight text-foreground"> | 693 | </div> |
| 707 | Update available | 694 | {onRunUpdate ? ( |
| 708 | </p> | 695 | <Button |
| 709 | <p | 696 | type="button" |
| 710 | className={cn( | 697 | size="xs" |
| 711 | "text-xs leading-snug", | 698 | variant="outline" |
| 712 | versionAdvisory.emphasis === "strong" | 699 | className="w-full" |
| 713 | ? "text-warning" | 700 | disabled={isUpdating} |
| 714 | : "text-muted-foreground", | 701 | onClick={onRunUpdate} |
| 715 | )} | 702 | > |
| 716 | > | 703 | {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />} |
| 717 | {versionAdvisory.detail} | 704 | {isUpdating ? "Updating" : "Update now"} |
| 718 | </p> | 705 | </Button> |
| 719 | </div> | 706 | ) : null} |
| 720 | {onRunUpdate ? ( | 707 | {onRunUpdate && updateCommand ? ( |
| 721 | <Button | 708 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> |
| 722 | type="button" | 709 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| 723 | size="xs" | 710 | or, update manually using |
| 724 | variant="outline" | 711 | <span aria-hidden className="h-px flex-1 bg-border" /> |
| 725 | className="w-full" | 712 | </div> |
| 726 | disabled={isUpdating} | 713 | ) : null} |
| 727 | onClick={onRunUpdate} | 714 | {updateCommand ? ( |
| 728 | > | 715 | <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2"> |
| 729 | {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />} | 716 | <code className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground"> |
| 730 | {isUpdating ? "Updating" : "Update now"} | 717 | {updateCommand} |
| 731 | </Button> | 718 | </code> |
| 732 | ) : null} | 719 | <Tooltip> |
| 733 | {onRunUpdate && updateCommand ? ( | 720 | <TooltipTrigger |
| 734 | <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground"> | 721 | render={ |
| 735 | <span aria-hidden className="h-px flex-1 bg-border" /> | 722 | <Button |
| 736 | or, update manually using | 723 | type="button" |
| 737 | <span aria-hidden className="h-px flex-1 bg-border" /> | 724 | size="icon-xs" |
| 738 | </div> | 725 | variant="ghost" |
| 739 | ) : null} | 726 | className="shrink-0 text-muted-foreground hover:text-foreground" |
| 740 | {updateCommand ? ( | 727 | onClick={() => |
| 741 | <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/70 bg-muted/40 py-0.5 pr-0.5 pl-2"> | 728 | copyToClipboard(updateCommand, { providerName: displayName }) |
| 742 | <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none"> | 729 | } |
| 743 | <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground"> | 730 | aria-label="Copy update command" |
| 744 | {updateCommand} | 731 | > |
| 745 | </code> | 732 | <CopyIcon className="size-3" /> |
| 746 | </ScrollArea> | 733 | </Button> |
| 747 | <Tooltip> | 734 | } |
| 748 | <TooltipTrigger | 735 | /> |
| 749 | render={ | 736 | <TooltipPopup side="top">Copy command</TooltipPopup> |
| 750 | <Button | 737 | </Tooltip> |
| 751 | type="button" | 738 | </div> |
| 752 | size="icon-xs" | 739 | ) : null} |
| 753 | variant="ghost" | 740 | </div> |
| 754 | className="shrink-0 text-muted-foreground hover:text-foreground" | 741 | </PopoverPopup> |
| 755 | onClick={() => | 742 | </Popover> |
| 756 | copyToClipboard(updateCommand, { | ||
| 757 | providerName: displayName, | ||
| 758 | }) | ||
| 759 | } | ||
| 760 | aria-label="Copy update command" | ||
| 761 | > | ||
| 762 | <CopyIcon className="size-3" /> | ||
| 763 | </Button> | ||
| 764 | } | ||
| 765 | /> | ||
| 766 | <TooltipPopup side="top">Copy command</TooltipPopup> | ||
| 767 | </Tooltip> | ||
| 768 | </div> | ||
| 769 | ) : null} | ||
| 770 | </div> | ||
| 771 | </PopoverPopup> | ||
| 772 | </Popover> | ||
| 773 | ) : null} | ||
| 774 | {titleTailNode} | ||
| 775 | </span> | ||
| 776 | </div> | ||
| 777 | <p className={statusLineClassName}> | ||
| 778 | {statusDotNode} | ||
| 779 | {isAuthenticated && authEmail ? ( | ||
| 780 | <> | ||
| 781 | <span>Authenticated as</span> | ||
| 782 | <ProviderAuthEmail email={authEmail} /> | ||
| 783 | {authLabel ? <span>· {authLabel}</span> : null} | ||
| 784 | </> | ||
| 785 | ) : ( | ||
| 786 | statusHeadlineNode | ||
| 787 | )} | ||
| 788 | {summary.detail && !needsAttention ? <span>· {summary.detail}</span> : null} | ||
| 789 | </p> | ||
| 790 | {summary.detail && needsAttention ? ( | ||
| 791 | <p className="text-[13px] leading-[1.45] text-muted-foreground/80 [overflow-wrap:anywhere]"> | ||
| 792 | {summary.detail} | ||
| 793 | </p> | ||
| 794 | ) : null} | ||
| 795 | </div> | ||
| 796 | {onDelete ? ( | ||
| 797 | <span | ||
| 798 | inert={readOnly} | ||
| 799 | aria-disabled={readOnly || undefined} | ||
| 800 | className={cn("shrink-0", readOnly && "opacity-50")} | ||
| 801 | > | ||
| 802 | <Button | ||
| 803 | type="button" | ||
| 804 | size="xs" | ||
| 805 | variant="ghost" | ||
| 806 | className="[--control-icon-color:currentColor] text-muted-foreground hover:text-destructive" | ||
| 807 | onClick={onDelete} | ||
| 808 | aria-label={`Delete instance ${instanceId}`} | ||
| 809 | > | ||
| 810 | <Trash2Icon className="size-3" /> | ||
| 811 | Delete instance | ||
| 812 | </Button> | ||
| 813 | </span> | ||
| 814 | ) : null} | 743 | ) : null} |
| 815 | </div> | 744 | {titleTailNode} |
| 816 | 745 | {onDelete ? ( | |
| 817 | <div className="flex h-11 shrink-0 border-b border-border/70 px-1"> | 746 | <Button |
| 818 | <button | ||
| 819 | type="button" | ||
| 820 | aria-pressed={visibleTab === "configuration"} | ||
| 821 | className={providerSettingsTabClassName(visibleTab === "configuration")} | ||
| 822 | onClick={() => setActiveTab("configuration")} | ||
| 823 | > | ||
| 824 | Configuration | ||
| 825 | </button> | ||
| 826 | {driverOption !== undefined ? ( | ||
| 827 | <button | ||
| 828 | type="button" | 747 | type="button" |
| 829 | aria-pressed={visibleTab === "models"} | 748 | size="icon-micro" |
| 830 | className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")} | 749 | variant="ghost-muted" |
| 831 | onClick={() => setActiveTab("models")} | 750 | disabled={readOnly} |
| 751 | className="[--control-icon-color:currentColor] hover:text-destructive" | ||
| 752 | onClick={onDelete} | ||
| 753 | aria-label={`Delete instance ${instanceId}`} | ||
| 832 | > | 754 | > |
| 833 | Models | 755 | <Trash2Icon className="size-3" /> |
| 834 | <span className="font-normal text-muted-foreground/70"> | 756 | </Button> |
| 835 | {modelsForDisplay.length} | ||
| 836 | {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""} | ||
| 837 | </span> | ||
| 838 | </button> | ||
| 839 | ) : null} | 757 | ) : null} |
| 840 | </div> | 758 | </span> |
| 759 | </div> | ||
| 760 | ); | ||
| 841 | 761 | ||
| 842 | <div className="lg:min-h-0 lg:flex-1"> | 762 | return ( |
| 843 | <ScrollArea | 763 | <> |
| 844 | scrollFade | 764 | <SettingsSection |
| 845 | chainVerticalScroll | 765 | title={displayName} |
| 846 | className="lg:h-full" | 766 | description={editorStatusNode} |
| 847 | hidden={visibleTab !== "configuration"} | 767 | icon={titleIconNode} |
| 848 | > | 768 | headerAction={editorHeaderAction} |
| 849 | {setup ? <div className="border-b border-border/60 px-4 py-3">{setup}</div> : null} | 769 | > |
| 850 | <div | 770 | <SettingsRow |
| 851 | inert={readOnly} | 771 | title="Display name" |
| 852 | aria-disabled={readOnly || undefined} | 772 | control={ |
| 853 | className={cn("px-4 py-4", readOnly && "opacity-50 select-none")} | 773 | <div |
| 854 | > | 774 | inert={readOnly} |
| 855 | <div className={PROVIDER_FIELD_GRID_CLASS_NAME}> | 775 | aria-disabled={readOnly || undefined} |
| 856 | <label | 776 | className={cn( |
| 857 | htmlFor={`provider-instance-${instanceId}-display-name`} | 777 | "flex w-full items-center justify-end gap-2 sm:w-auto", |
| 858 | className="pt-1.5 text-xs font-medium text-foreground" | 778 | readOnly && "opacity-50 select-none", |
| 859 | > | ||
| 860 | Display name | ||
| 861 | </label> | ||
| 862 | <div className="flex min-w-0 flex-wrap items-center gap-3"> | ||
| 863 | <DraftInput | ||
| 864 | id={`provider-instance-${instanceId}-display-name`} | ||
| 865 | size="sm" | ||
| 866 | className="w-44" | ||
| 867 | value={instance.displayName ?? ""} | ||
| 868 | onCommit={updateDisplayName} | ||
| 869 | placeholder={driverOption?.label ?? "Instance label"} | ||
| 870 | spellCheck={false} | ||
| 871 | /> | ||
| 872 | <ProviderAccentColorPicker | ||
| 873 | layout="inline" | ||
| 874 | displayName={displayName} | ||
| 875 | value={accentColor} | ||
| 876 | onCommit={updateAccentColor} | ||
| 877 | commitDelayMs={120} | ||
| 878 | /> | ||
| 879 | </div> | ||
| 880 | <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Runtime</div> | ||
| 881 | {driverOption ? ( | ||
| 882 | <ProviderSettingsForm | ||
| 883 | definition={driverOption} | ||
| 884 | value={instance.config} | ||
| 885 | idPrefix={`provider-instance-${instanceId}`} | ||
| 886 | variant="grid" | ||
| 887 | onChange={updateConfig} | ||
| 888 | /> | ||
| 889 | ) : ( | ||
| 890 | <> | ||
| 891 | <span className="pt-1.5 text-xs font-medium text-foreground">Driver</span> | ||
| 892 | <p className="text-xs text-muted-foreground"> | ||
| 893 | This instance uses a driver ( | ||
| 894 | <code className="text-foreground">{String(instance.driver)}</code>) that is not | ||
| 895 | shipped with the current build. Configuration values are preserved but cannot be | ||
| 896 | edited from this surface. | ||
| 897 | </p> | ||
| 898 | </> | ||
| 899 | )} | 779 | )} |
| 900 | <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Environment</div> | 780 | > |
| 901 | <div className="col-span-full"> | 781 | <ProviderAccentColorPicker |
| 902 | <ProviderEnvironmentSection | 782 | layout="inline" |
| 903 | environment={instance.environment ?? []} | 783 | displayName={displayName} |
| 904 | onChange={updateEnvironment} | 784 | value={accentColor} |
| 905 | /> | 785 | onCommit={updateAccentColor} |
| 906 | </div> | 786 | commitDelayMs={120} |
| 787 | /> | ||
| 788 | <DraftInput | ||
| 789 | id={`provider-instance-${instanceId}-display-name`} | ||
| 790 | size="sm" | ||
| 791 | className="min-w-0 flex-1 sm:w-56 sm:flex-none" | ||
| 792 | value={instance.displayName ?? ""} | ||
| 793 | onCommit={updateDisplayName} | ||
| 794 | placeholder={driverOption?.label ?? "Instance label"} | ||
| 795 | spellCheck={false} | ||
| 796 | /> | ||
| 907 | </div> | 797 | </div> |
| 908 | </div> | 798 | } |
| 909 | </ScrollArea> | 799 | /> |
| 910 | {driverOption !== undefined ? ( | 800 | </SettingsSection> |
| 911 | <div | 801 | |
| 912 | inert={readOnly} | 802 | {setup ? ( |
| 913 | aria-disabled={readOnly || undefined} | 803 | <SettingsSection title="Setup"> |
| 914 | className={cn("px-4 py-5 lg:h-full lg:min-h-0", readOnly && "opacity-50 select-none")} | 804 | <div className="px-3 py-3 sm:px-4">{setup}</div> |
| 915 | hidden={visibleTab !== "models"} | 805 | </SettingsSection> |
| 916 | > | 806 | ) : null} |
| 807 | |||
| 808 | <SettingsSection | ||
| 809 | title="Runtime" | ||
| 810 | inert={readOnly} | ||
| 811 | aria-disabled={readOnly || undefined} | ||
| 812 | className={readOnly ? "opacity-50 select-none" : undefined} | ||
| 813 | > | ||
| 814 | {driverOption ? ( | ||
| 815 | <ProviderSettingsForm | ||
| 816 | definition={driverOption} | ||
| 817 | value={instance.config} | ||
| 818 | idPrefix={`provider-instance-${instanceId}`} | ||
| 819 | variant="settings" | ||
| 820 | onChange={updateConfig} | ||
| 821 | /> | ||
| 822 | ) : ( | ||
| 823 | <SettingsRow | ||
| 824 | title="Driver" | ||
| 825 | description={ | ||
| 826 | <span> | ||
| 827 | This instance uses{" "} | ||
| 828 | <code className="text-foreground">{String(instance.driver)}</code>, which is not | ||
| 829 | available in this build. Its configuration is preserved. | ||
| 830 | </span> | ||
| 831 | } | ||
| 832 | /> | ||
| 833 | )} | ||
| 834 | </SettingsSection> | ||
| 835 | |||
| 836 | <SettingsSection | ||
| 837 | title="Environment" | ||
| 838 | inert={readOnly} | ||
| 839 | aria-disabled={readOnly || undefined} | ||
| 840 | className={readOnly ? "opacity-50 select-none" : undefined} | ||
| 841 | > | ||
| 842 | <SettingsRow | ||
| 843 | title="Variables" | ||
| 844 | description="API keys, base URLs, and other per-instance CLI settings." | ||
| 845 | > | ||
| 846 | <ProviderEnvironmentSection | ||
| 847 | environment={instance.environment ?? []} | ||
| 848 | onChange={updateEnvironment} | ||
| 849 | /> | ||
| 850 | </SettingsRow> | ||
| 851 | </SettingsSection> | ||
| 852 | |||
| 853 | {driverOption !== undefined ? ( | ||
| 854 | <SettingsSection | ||
| 855 | title="Models" | ||
| 856 | inert={readOnly} | ||
| 857 | aria-disabled={readOnly || undefined} | ||
| 858 | className={readOnly ? "opacity-50 select-none" : undefined} | ||
| 859 | > | ||
| 860 | <div className="px-3 py-3 sm:px-4"> | ||
| 917 | <ProviderModelsSection | 861 | <ProviderModelsSection |
| 918 | instanceId={instanceId} | 862 | instanceId={instanceId} |
| 919 | driverKind={driverKind} | 863 | driverKind={driverKind} |
| ... | @@ -928,8 +872,8 @@ export function ProviderInstanceCard({ | ... | @@ -928,8 +872,8 @@ export function ProviderInstanceCard({ |
| 928 | onModelOrderChange={onModelOrderChange} | 872 | onModelOrderChange={onModelOrderChange} |
| 929 | /> | 873 | /> |
| 930 | </div> | 874 | </div> |
| 931 | ) : null} | 875 | </SettingsSection> |
| 932 | </div> | 876 | ) : null} |
| 933 | </div> | 877 | </> |
| 934 | ); | 878 | ); |
| 935 | } | 879 | } |
apps/web/src/components/settings/ProviderSettingsForm.tsx+59-70| ... | @@ -17,6 +17,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ | ... | @@ -17,6 +17,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ |
| 17 | import { Switch } from "../ui/switch"; | 17 | import { Switch } from "../ui/switch"; |
| 18 | import { Textarea } from "../ui/textarea"; | 18 | import { Textarea } from "../ui/textarea"; |
| 19 | import type { ProviderClientDefinition } from "./providerDriverMeta"; | 19 | import type { ProviderClientDefinition } from "./providerDriverMeta"; |
| 20 | import { SettingsRow } from "./settingsLayout"; | ||
| 20 | 21 | ||
| 21 | export interface ProviderSettingsFieldModel { | 22 | export interface ProviderSettingsFieldModel { |
| 22 | readonly key: string; | 23 | readonly key: string; |
| ... | @@ -167,11 +168,9 @@ interface ProviderSettingsFormProps { | ... | @@ -167,11 +168,9 @@ interface ProviderSettingsFormProps { |
| 167 | readonly idPrefix: string; | 168 | readonly idPrefix: string; |
| 168 | /** | 169 | /** |
| 169 | * `card` stacks label over control, `dialog` is the compact wizard layout, | 170 | * `card` stacks label over control, `dialog` is the compact wizard layout, |
| 170 | * `grid` emits a label cell and a control cell per field for a parent | 171 | * and `settings` renders the shared settings row treatment. |
| 171 | * two-column grid (label column left, control right), with the description | ||
| 172 | * beside a fixed-width control so each field stays on one line. | ||
| 173 | */ | 172 | */ |
| 174 | readonly variant: "card" | "dialog" | "grid"; | 173 | readonly variant: "card" | "dialog" | "settings"; |
| 175 | readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void; | 174 | readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void; |
| 176 | } | 175 | } |
| 177 | 176 | ||
| ... | @@ -252,74 +251,64 @@ function ProviderSettingsFieldRow({ | ... | @@ -252,74 +251,64 @@ function ProviderSettingsFieldRow({ |
| 252 | <span className={descriptionClassName}>{field.description}</span> | 251 | <span className={descriptionClassName}>{field.description}</span> |
| 253 | ) : null; | 252 | ) : null; |
| 254 | 253 | ||
| 255 | if (variant === "grid") { | 254 | if (variant === "settings") { |
| 256 | // Label cell, then a control cell where the description sits beside a | ||
| 257 | // fixed-width control and wraps under it when the pane is narrow. The | ||
| 258 | // description is outside the label, so the control points at it instead. | ||
| 259 | const descriptionId = field.description ? `${inputId}-description` : undefined; | 255 | const descriptionId = field.description ? `${inputId}-description` : undefined; |
| 256 | const control = | ||
| 257 | field.control === "switch" ? ( | ||
| 258 | <Switch | ||
| 259 | checked={readProviderConfigBoolean(value, field.key, field.defaultBooleanValue)} | ||
| 260 | onCheckedChange={(checked) => | ||
| 261 | onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) | ||
| 262 | } | ||
| 263 | aria-label={field.label} | ||
| 264 | aria-describedby={descriptionId} | ||
| 265 | /> | ||
| 266 | ) : field.control === "select" ? ( | ||
| 267 | <ProviderSettingsSelect | ||
| 268 | field={field} | ||
| 269 | value={value} | ||
| 270 | inputId={inputId} | ||
| 271 | size="sm" | ||
| 272 | className="w-full sm:w-56" | ||
| 273 | onChange={onChange} | ||
| 274 | /> | ||
| 275 | ) : field.control === "textarea" ? ( | ||
| 276 | <Textarea | ||
| 277 | id={inputId} | ||
| 278 | aria-describedby={descriptionId} | ||
| 279 | className="w-full sm:w-96" | ||
| 280 | value={readProviderConfigString(value, field.key)} | ||
| 281 | onChange={(event) => | ||
| 282 | onChange(nextProviderConfigWithFieldValue(value, field, event.target.value)) | ||
| 283 | } | ||
| 284 | placeholder={field.placeholder} | ||
| 285 | spellCheck={false} | ||
| 286 | /> | ||
| 287 | ) : ( | ||
| 288 | <DraftInput | ||
| 289 | id={inputId} | ||
| 290 | aria-describedby={descriptionId} | ||
| 291 | size="sm" | ||
| 292 | className="w-full sm:w-56" | ||
| 293 | type={field.control === "password" ? "password" : undefined} | ||
| 294 | autoComplete={field.control === "password" ? "off" : undefined} | ||
| 295 | value={readProviderConfigString(value, field.key)} | ||
| 296 | onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))} | ||
| 297 | placeholder={field.placeholder} | ||
| 298 | spellCheck={false} | ||
| 299 | /> | ||
| 300 | ); | ||
| 301 | |||
| 260 | return ( | 302 | return ( |
| 261 | <> | 303 | <SettingsRow |
| 262 | {field.control === "switch" ? ( | 304 | title={ |
| 263 | <span className="pt-1.5 text-xs font-medium text-foreground">{field.label}</span> | 305 | field.control === "switch" ? field.label : <label htmlFor={inputId}>{field.label}</label> |
| 264 | ) : ( | 306 | } |
| 265 | <label htmlFor={inputId} className="pt-1.5 text-xs font-medium text-foreground"> | 307 | description={ |
| 266 | {field.label} | 308 | field.description ? <span id={descriptionId}>{field.description}</span> : undefined |
| 267 | </label> | 309 | } |
| 268 | )} | 310 | control={control} |
| 269 | <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> | 311 | /> |
| 270 | {field.control === "switch" ? ( | ||
| 271 | <span className="flex h-8 items-center sm:h-7"> | ||
| 272 | <Switch | ||
| 273 | checked={readProviderConfigBoolean(value, field.key, field.defaultBooleanValue)} | ||
| 274 | onCheckedChange={(checked) => | ||
| 275 | onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) | ||
| 276 | } | ||
| 277 | aria-label={field.label} | ||
| 278 | aria-describedby={descriptionId} | ||
| 279 | /> | ||
| 280 | </span> | ||
| 281 | ) : field.control === "select" ? ( | ||
| 282 | <ProviderSettingsSelect | ||
| 283 | field={field} | ||
| 284 | value={value} | ||
| 285 | inputId={inputId} | ||
| 286 | size="xs" | ||
| 287 | className="w-56" | ||
| 288 | onChange={onChange} | ||
| 289 | /> | ||
| 290 | ) : field.control === "textarea" ? ( | ||
| 291 | <Textarea | ||
| 292 | id={inputId} | ||
| 293 | aria-describedby={descriptionId} | ||
| 294 | className="w-full max-w-sm" | ||
| 295 | value={readProviderConfigString(value, field.key)} | ||
| 296 | onChange={(event) => | ||
| 297 | onChange(nextProviderConfigWithFieldValue(value, field, event.target.value)) | ||
| 298 | } | ||
| 299 | placeholder={field.placeholder} | ||
| 300 | spellCheck={false} | ||
| 301 | /> | ||
| 302 | ) : ( | ||
| 303 | <DraftInput | ||
| 304 | id={inputId} | ||
| 305 | aria-describedby={descriptionId} | ||
| 306 | size="sm" | ||
| 307 | className="w-56" | ||
| 308 | type={field.control === "password" ? "password" : undefined} | ||
| 309 | autoComplete={field.control === "password" ? "off" : undefined} | ||
| 310 | value={readProviderConfigString(value, field.key)} | ||
| 311 | onCommit={(next) => onChange(nextProviderConfigWithFieldValue(value, field, next))} | ||
| 312 | placeholder={field.placeholder} | ||
| 313 | spellCheck={false} | ||
| 314 | /> | ||
| 315 | )} | ||
| 316 | {field.description ? ( | ||
| 317 | <span id={descriptionId} className="text-[11px] leading-snug text-muted-foreground"> | ||
| 318 | {field.description} | ||
| 319 | </span> | ||
| 320 | ) : null} | ||
| 321 | </div> | ||
| 322 | </> | ||
| 323 | ); | 312 | ); |
| 324 | } | 313 | } |
| 325 | 314 |
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+10-17| ... | @@ -164,17 +164,6 @@ function isAddProviderButton(element: ReactElement<Record<string, unknown>>): bo | ... | @@ -164,17 +164,6 @@ function isAddProviderButton(element: ReactElement<Record<string, unknown>>): bo |
| 164 | return element.props["aria-label"] === "Add provider"; | 164 | return element.props["aria-label"] === "Add provider"; |
| 165 | } | 165 | } |
| 166 | 166 | ||
| 167 | function findAdvancedPanel(panel: ReactElement<Record<string, unknown>>) { | ||
| 168 | return visitElements( | ||
| 169 | panel, | ||
| 170 | (element) => element.props.className === "mt-1" && typeof element.props.open === "boolean", | ||
| 171 | ); | ||
| 172 | } | ||
| 173 | |||
| 174 | function flushEffects(): void { | ||
| 175 | for (const effect of settingsSearchState.effects.splice(0)) effect(); | ||
| 176 | } | ||
| 177 | |||
| 178 | async function flushPromises(): Promise<void> { | 167 | async function flushPromises(): Promise<void> { |
| 179 | await Promise.resolve(); | 168 | await Promise.resolve(); |
| 180 | await Promise.resolve(); | 169 | await Promise.resolve(); |
| ... | @@ -297,15 +286,19 @@ describe("EnvironmentProviderSettings routing", () => { | ... | @@ -297,15 +286,19 @@ describe("EnvironmentProviderSettings routing", () => { |
| 297 | expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); | 286 | expect(visitElements(panel, isAddProviderButton)).not.toBeNull(); |
| 298 | }); | 287 | }); |
| 299 | 288 | ||
| 300 | it("opens Advanced when search targets the provider health interval", () => { | 289 | it("keeps Advanced visible when search targets the provider health interval", () => { |
| 301 | settingsSearchState.targetId = "provider-health-check-interval"; | ||
| 302 | let panel = renderPanel(); | 290 | let panel = renderPanel(); |
| 291 | expect(visitElements(panel, (element) => element.props.title === "Advanced")).not.toBeNull(); | ||
| 292 | expect( | ||
| 293 | visitElements(panel, (element) => element.props.id === "provider-health-check-interval"), | ||
| 294 | ).not.toBeNull(); | ||
| 303 | 295 | ||
| 304 | expect(findAdvancedPanel(panel)?.props.open).toBe(false); | 296 | settingsSearchState.targetId = "provider-health-check-interval"; |
| 305 | flushEffects(); | ||
| 306 | |||
| 307 | panel = renderPanel(); | 297 | panel = renderPanel(); |
| 308 | expect(findAdvancedPanel(panel)?.props.open).toBe(true); | 298 | expect(visitElements(panel, (element) => element.props.title === "Advanced")).not.toBeNull(); |
| 299 | expect( | ||
| 300 | visitElements(panel, (element) => element.props.id === "provider-health-check-interval"), | ||
| 301 | ).not.toBeNull(); | ||
| 309 | }); | 302 | }); |
| 310 | 303 | ||
| 311 | it("deletes and resets provider configuration without erasing shared preferences", () => { | 304 | it("deletes and resets provider configuration without erasing shared preferences", () => { |
apps/web/src/components/settings/ProviderSettingsPanel.tsx+105-130| ... | @@ -24,7 +24,7 @@ import * as Arr from "effect/Array"; | ... | @@ -24,7 +24,7 @@ import * as Arr from "effect/Array"; |
| 24 | import * as Duration from "effect/Duration"; | 24 | import * as Duration from "effect/Duration"; |
| 25 | import * as Equal from "effect/Equal"; | 25 | import * as Equal from "effect/Equal"; |
| 26 | import * as Result from "effect/Result"; | 26 | import * as Result from "effect/Result"; |
| 27 | import { ChevronDownIcon, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; | 27 | import { PlusIcon, RefreshCwIcon } from "lucide-react"; |
| 28 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; | 28 | import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; |
| 29 | 29 | ||
| 30 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; | 30 | import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; |
| ... | @@ -56,7 +56,6 @@ import { | ... | @@ -56,7 +56,6 @@ import { |
| 56 | type ProviderUpdateCandidate, | 56 | type ProviderUpdateCandidate, |
| 57 | } from "../ProviderUpdateLaunchNotification.logic"; | 57 | } from "../ProviderUpdateLaunchNotification.logic"; |
| 58 | import { Button } from "../ui/button"; | 58 | import { Button } from "../ui/button"; |
| 59 | import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; | ||
| 60 | import { | 59 | import { |
| 61 | NumberField, | 60 | NumberField, |
| 62 | NumberFieldDecrement, | 61 | NumberFieldDecrement, |
| ... | @@ -196,7 +195,7 @@ interface ProviderSettingsTarget { | ... | @@ -196,7 +195,7 @@ interface ProviderSettingsTarget { |
| 196 | 195 | ||
| 197 | export function ProviderSettingsPanel(target: ProviderSettingsTarget) { | 196 | export function ProviderSettingsPanel(target: ProviderSettingsTarget) { |
| 198 | return ( | 197 | return ( |
| 199 | <SettingsPageContainer className="gap-8"> | 198 | <SettingsPageContainer width="wide" className="gap-8"> |
| 200 | <ProviderSettingsPanelContent | 199 | <ProviderSettingsPanelContent |
| 201 | key={`${target.environmentId ?? ""}:${target.instanceId ?? ""}`} | 200 | key={`${target.environmentId ?? ""}:${target.instanceId ?? ""}`} |
| 202 | {...target} | 201 | {...target} |
| ... | @@ -254,11 +253,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { | ... | @@ -254,11 +253,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) { |
| 254 | const deviceTabs = | 253 | const deviceTabs = |
| 255 | !onlyPrimaryDevice && options.length > 0 ? ( | 254 | !onlyPrimaryDevice && options.length > 0 ? ( |
| 256 | <ScrollArea hideScrollbars scrollFade className="mx-3 h-11 min-w-0 rounded-none sm:mx-4"> | 255 | <ScrollArea hideScrollbars scrollFade className="mx-3 h-11 min-w-0 rounded-none sm:mx-4"> |
| 257 | <div | 256 | <div role="group" aria-label="Devices" className="flex h-full w-max min-w-full px-1"> |
| 258 | role="group" | ||
| 259 | aria-label="Devices" | ||
| 260 | className="flex h-full w-max min-w-full border-b border-border/70 px-1" | ||
| 261 | > | ||
| 262 | {options.map((environment) => { | 257 | {options.map((environment) => { |
| 263 | const machine = resolveEnvironmentMachineKind(environment.serverConfig); | 258 | const machine = resolveEnvironmentMachineKind(environment.serverConfig); |
| 264 | const selected = environment.environmentId === effectiveEnvironmentId; | 259 | const selected = environment.environmentId === effectiveEnvironmentId; |
| ... | @@ -484,10 +479,9 @@ export function EnvironmentProviderSettings({ | ... | @@ -484,10 +479,9 @@ export function EnvironmentProviderSettings({ |
| 484 | readonly targetInstanceId?: ProviderInstanceId | undefined; | 479 | readonly targetInstanceId?: ProviderInstanceId | undefined; |
| 485 | /** | 480 | /** |
| 486 | * Grey out and freeze every write control when this session's credential | 481 | * Grey out and freeze every write control when this session's credential |
| 487 | * lacks `orchestration:operate` on the environment. Selecting providers and | 482 | * lacks `orchestration:operate` on the environment. Selecting providers |
| 488 | * opening Advanced still work so the real configuration stays readable; | 483 | * still works so the real configuration stays readable; switches, forms, |
| 489 | * switches, forms, and the health interval are inert so no write is | 484 | * and the health interval are inert so no write is offered and then rejected. |
| 490 | * offered and then rejected. | ||
| 491 | */ | 485 | */ |
| 492 | readonly readOnly?: boolean; | 486 | readonly readOnly?: boolean; |
| 493 | }) { | 487 | }) { |
| ... | @@ -506,20 +500,12 @@ export function EnvironmentProviderSettings({ | ... | @@ -506,20 +500,12 @@ export function EnvironmentProviderSettings({ |
| 506 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>( | 500 | const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>( |
| 507 | targetInstanceId ?? null, | 501 | targetInstanceId ?? null, |
| 508 | ); | 502 | ); |
| 509 | const [advancedOpen, setAdvancedOpen] = useState(false); | ||
| 510 | const searchTargetId = useSettingsSearchTargetId(); | ||
| 511 | const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< | 503 | const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< |
| 512 | ReadonlySet<ProviderDriverKind> | 504 | ReadonlySet<ProviderDriverKind> |
| 513 | >(() => new Set()); | 505 | >(() => new Set()); |
| 514 | const refreshingRef = useRef(false); | 506 | const refreshingRef = useRef(false); |
| 515 | const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set()); | 507 | const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set()); |
| 516 | 508 | ||
| 517 | useEffect(() => { | ||
| 518 | if (searchTargetId === searchableSetting("provider-health-check-interval").id) { | ||
| 519 | setAdvancedOpen(true); | ||
| 520 | } | ||
| 521 | }, [searchTargetId]); | ||
| 522 | |||
| 523 | const providerUpdateCandidates = useMemo( | 509 | const providerUpdateCandidates = useMemo( |
| 524 | () => collectProviderUpdateCandidates(serverProviders), | 510 | () => collectProviderUpdateCandidates(serverProviders), |
| 525 | [serverProviders], | 511 | [serverProviders], |
| ... | @@ -924,6 +910,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -924,6 +910,7 @@ export function EnvironmentProviderSettings({ |
| 924 | <> | 910 | <> |
| 925 | <SettingsSection | 911 | <SettingsSection |
| 926 | {...searchableSetting("providers")} | 912 | {...searchableSetting("providers")} |
| 913 | variant="plain" | ||
| 927 | headerAction={ | 914 | headerAction={ |
| 928 | <div className="flex min-w-0 items-center gap-2"> | 915 | <div className="flex min-w-0 items-center gap-2"> |
| 929 | {readOnly ? ( | 916 | {readOnly ? ( |
| ... | @@ -978,121 +965,109 @@ export function EnvironmentProviderSettings({ | ... | @@ -978,121 +965,109 @@ export function EnvironmentProviderSettings({ |
| 978 | > | 965 | > |
| 979 | {deviceTabs} | 966 | {deviceTabs} |
| 980 | {readOnly ? ( | 967 | {readOnly ? ( |
| 981 | <SettingsRow | 968 | <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5"> |
| 982 | title="Limited permissions" | 969 | <SettingsRow |
| 983 | description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`} | 970 | title="Limited permissions" |
| 984 | /> | 971 | description={`This session can view ${environmentLabel}'s providers but can't change their settings.`} |
| 972 | /> | ||
| 973 | </div> | ||
| 985 | ) : null} | 974 | ) : null} |
| 986 | <div className="space-y-1"> | 975 | <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5 lg:grid lg:h-[min(44rem,calc(100dvh-11rem))] lg:min-h-[32rem] lg:grid-cols-[17rem_minmax(0,1fr)]"> |
| 987 | <div className="mx-3 overflow-hidden rounded-lg border border-border/70 sm:mx-4 lg:grid lg:h-[min(38rem,calc(100dvh-16rem))] lg:min-h-[30rem] lg:grid-cols-[20rem_minmax(0,1fr)]"> | 976 | <div className="border-b border-border/60 bg-muted/10 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0"> |
| 988 | <div className="border-b border-border/70 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0"> | 977 | <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1"> |
| 989 | <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1"> | 978 | <div className="divide-y divide-border/50"> |
| 990 | <div className="divide-y divide-border/60"> | 979 | {rows.map((row) => renderProviderInstance(row, "list"))} |
| 991 | {rows.map((row) => ( | 980 | </div> |
| 992 | <div key={row.instanceId} className="p-1"> | 981 | </ScrollArea> |
| 993 | {renderProviderInstance(row, "list")} | 982 | </div> |
| 994 | </div> | ||
| 995 | ))} | ||
| 996 | </div> | ||
| 997 | </ScrollArea> | ||
| 998 | </div> | ||
| 999 | 983 | ||
| 1000 | <div className="min-w-0 lg:min-h-0"> | 984 | <div className="min-w-0 lg:min-h-0"> |
| 1001 | {selectedRow ? ( | 985 | {selectedRow ? ( |
| 1002 | renderProviderInstance(selectedRow, "editor") | 986 | <ScrollArea scrollFade chainVerticalScroll className="lg:h-full"> |
| 1003 | ) : ( | 987 | <div className="space-y-6 p-4">{renderProviderInstance(selectedRow, "editor")}</div> |
| 1004 | <div className="p-6 text-sm text-muted-foreground"> | 988 | </ScrollArea> |
| 1005 | {targetInstanceMissing | 989 | ) : ( |
| 1006 | ? "This provider instance is no longer available on this device." | 990 | <div className="p-6 text-sm text-muted-foreground"> |
| 1007 | : "No providers configured."} | 991 | {targetInstanceMissing |
| 1008 | </div> | 992 | ? "This provider instance is no longer available on this device." |
| 1009 | )} | 993 | : "No providers configured."} |
| 1010 | </div> | 994 | </div> |
| 995 | )} | ||
| 1011 | </div> | 996 | </div> |
| 997 | </div> | ||
| 998 | </SettingsSection> | ||
| 1012 | 999 | ||
| 1013 | <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-1"> | 1000 | <SettingsSection title="Advanced"> |
| 1014 | <CollapsibleTrigger className="flex h-10 w-full items-center gap-2 px-3 text-xs text-muted-foreground hover:text-foreground sm:px-4"> | 1001 | <SettingsRow |
| 1015 | <ChevronDownIcon | 1002 | id={searchableSetting("provider-health-check-interval").id} |
| 1016 | className={cn("size-3 transition-transform", advancedOpen && "rotate-180")} | 1003 | title={ |
| 1017 | /> | 1004 | <span className="inline-flex items-center gap-1.5"> |
| 1018 | Advanced | 1005 | {searchableSetting("provider-health-check-interval").title} |
| 1019 | </CollapsibleTrigger> | 1006 | <PolicyTooltip> |
| 1020 | <CollapsibleContent> | 1007 | This interval is configured here, then the shared Background activity policy decides |
| 1021 | {/* Only the write controls go inert; the title and its policy tooltip stay readable. */} | 1008 | whether provider probes may run when the timer fires. Custom intervals appear as |
| 1022 | <SettingsRow | 1009 | Advanced in General settings. |
| 1023 | id={searchableSetting("provider-health-check-interval").id} | 1010 | </PolicyTooltip> |
| 1024 | title={ | 1011 | </span> |
| 1025 | <span className="inline-flex items-center gap-1.5"> | 1012 | } |
| 1026 | {searchableSetting("provider-health-check-interval").title} | 1013 | description="Refresh provider status, versions, and models in the background. Set to 0 to disable." |
| 1027 | <PolicyTooltip> | 1014 | resetAction={ |
| 1028 | This interval is configured here, then the shared Background activity policy | 1015 | providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? ( |
| 1029 | decides whether provider probes may run when the timer fires. Custom intervals | 1016 | <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}> |
| 1030 | appear as Advanced in General settings. | 1017 | <SettingResetButton |
| 1031 | </PolicyTooltip> | 1018 | label="provider health check interval" |
| 1032 | </span> | 1019 | onClick={() => |
| 1033 | } | 1020 | updateSettings( |
| 1034 | description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off." | 1021 | backgroundActivityOverrideSettings( |
| 1035 | resetAction={ | 1022 | settings.backgroundActivity, |
| 1036 | providerHealthRefreshIntervalSeconds !== | 1023 | resolvedBackgroundActivity, |
| 1037 | defaultProviderHealthRefreshIntervalSeconds ? ( | 1024 | { providerHealthRefreshInterval: undefined }, |
| 1038 | <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}> | 1025 | ), |
| 1039 | <SettingResetButton | 1026 | ) |
| 1040 | label="provider health check interval" | 1027 | } |
| 1041 | onClick={() => | 1028 | /> |
| 1042 | updateSettings( | 1029 | </span> |
| 1043 | backgroundActivityOverrideSettings( | 1030 | ) : null |
| 1044 | settings.backgroundActivity, | 1031 | } |
| 1045 | resolvedBackgroundActivity, | 1032 | control={ |
| 1046 | { providerHealthRefreshInterval: undefined }, | 1033 | <div |
| 1047 | ), | 1034 | inert={readOnly} |
| 1048 | ) | 1035 | aria-disabled={readOnly || undefined} |
| 1049 | } | 1036 | className={cn( |
| 1050 | /> | 1037 | "flex shrink-0 items-center gap-2", |
| 1051 | </span> | 1038 | readOnly && "opacity-50 select-none", |
| 1052 | ) : null | 1039 | )} |
| 1053 | } | 1040 | > |
| 1054 | control={ | 1041 | <NumberField |
| 1055 | <div | 1042 | value={providerHealthRefreshIntervalSeconds} |
| 1056 | inert={readOnly} | 1043 | min={0} |
| 1057 | aria-disabled={readOnly || undefined} | 1044 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} |
| 1058 | className={cn( | 1045 | size="sm" |
| 1059 | "flex shrink-0 items-center gap-2", | 1046 | className="w-32" |
| 1060 | readOnly && "opacity-50 select-none", | 1047 | onValueChange={(value) => |
| 1061 | )} | 1048 | updateSettings( |
| 1062 | > | 1049 | backgroundActivityOverrideSettings( |
| 1063 | <NumberField | 1050 | settings.backgroundActivity, |
| 1064 | value={providerHealthRefreshIntervalSeconds} | 1051 | resolvedBackgroundActivity, |
| 1065 | min={0} | 1052 | { |
| 1066 | step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} | 1053 | providerHealthRefreshInterval: Duration.seconds( |
| 1067 | size="sm" | 1054 | normalizeIntervalSeconds(value), |
| 1068 | className="w-32" | 1055 | ), |
| 1069 | onValueChange={(value) => | 1056 | }, |
| 1070 | updateSettings( | 1057 | ), |
| 1071 | backgroundActivityOverrideSettings( | 1058 | ) |
| 1072 | settings.backgroundActivity, | ||
| 1073 | resolvedBackgroundActivity, | ||
| 1074 | { | ||
| 1075 | providerHealthRefreshInterval: Duration.seconds( | ||
| 1076 | normalizeIntervalSeconds(value), | ||
| 1077 | ), | ||
| 1078 | }, | ||
| 1079 | ), | ||
| 1080 | ) | ||
| 1081 | } | ||
| 1082 | > | ||
| 1083 | <NumberFieldGroup> | ||
| 1084 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> | ||
| 1085 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> | ||
| 1086 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | ||
| 1087 | </NumberFieldGroup> | ||
| 1088 | </NumberField> | ||
| 1089 | <span className="text-xs text-muted-foreground">seconds</span> | ||
| 1090 | </div> | ||
| 1091 | } | 1059 | } |
| 1092 | /> | 1060 | > |
| 1093 | </CollapsibleContent> | 1061 | <NumberFieldGroup> |
| 1094 | </Collapsible> | 1062 | <NumberFieldDecrement aria-label="Decrease provider health check interval" /> |
| 1095 | </div> | 1063 | <NumberFieldInput aria-label="Provider health check interval in seconds" /> |
| 1064 | <NumberFieldIncrement aria-label="Increase provider health check interval" /> | ||
| 1065 | </NumberFieldGroup> | ||
| 1066 | </NumberField> | ||
| 1067 | <span className="text-xs text-muted-foreground">seconds</span> | ||
| 1068 | </div> | ||
| 1069 | } | ||
| 1070 | /> | ||
| 1096 | </SettingsSection> | 1071 | </SettingsSection> |
| 1097 | 1072 | ||
| 1098 | {isAddInstanceDialogOpen ? ( | 1073 | {isAddInstanceDialogOpen ? ( |
apps/web/src/components/settings/SettingsPanels.tsx+30-22| ... | @@ -154,6 +154,7 @@ import { | ... | @@ -154,6 +154,7 @@ import { |
| 154 | SettingsPageContainer, | 154 | SettingsPageContainer, |
| 155 | SettingsRow, | 155 | SettingsRow, |
| 156 | SettingsSection, | 156 | SettingsSection, |
| 157 | useSettingsSearchTarget, | ||
| 157 | useSettingsSearchTargetId, | 158 | useSettingsSearchTargetId, |
| 158 | } from "./settingsLayout"; | 159 | } from "./settingsLayout"; |
| 159 | import { searchableSetting } from "./settingsSearch"; | 160 | import { searchableSetting } from "./settingsSearch"; |
| ... | @@ -203,14 +204,12 @@ const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfil | ... | @@ -203,14 +204,12 @@ const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfil |
| 203 | }; | 204 | }; |
| 204 | 205 | ||
| 205 | const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile, string> = { | 206 | const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile, string> = { |
| 206 | balanced: | 207 | balanced: "Pauses probes for idle clients, locked hosts, or low power mode.", |
| 207 | "Pauses background probes when clients are idle, the host is locked, or low power mode is active.", | ||
| 208 | performance: "Allows scoped background probes while any subscribed client remains connected.", | 208 | performance: "Allows scoped background probes while any subscribed client remains connected.", |
| 209 | "battery-saver": "Also pauses background probes when the host or client is on battery.", | 209 | "battery-saver": "Also pauses background probes when the host or client is on battery.", |
| 210 | }; | 210 | }; |
| 211 | 211 | ||
| 212 | const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = | 212 | const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom intervals."; |
| 213 | "Uses custom background intervals with the selected shared power policy."; | ||
| 214 | 213 | ||
| 215 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); | 214 | const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); |
| 216 | const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ | 215 | const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{ |
| ... | @@ -422,7 +421,7 @@ function AboutVersionSection() { | ... | @@ -422,7 +421,7 @@ function AboutVersionSection() { |
| 422 | {hasDesktopBridge ? ( | 421 | {hasDesktopBridge ? ( |
| 423 | <SettingsRow | 422 | <SettingsRow |
| 424 | title="Update track" | 423 | title="Update track" |
| 425 | description="Stable follows full releases. Nightly follows the nightly desktop channel and can switch back to stable immediately." | 424 | description="Use stable releases or nightly builds. Switch back anytime." |
| 426 | control={ | 425 | control={ |
| 427 | <Select | 426 | <Select |
| 428 | value={selectedUpdateChannel} | 427 | value={selectedUpdateChannel} |
| ... | @@ -1091,7 +1090,7 @@ export function AppearanceSettingsPanel() { | ... | @@ -1091,7 +1090,7 @@ export function AppearanceSettingsPanel() { |
| 1091 | 1090 | ||
| 1092 | return ( | 1091 | return ( |
| 1093 | <SettingsPageContainer> | 1092 | <SettingsPageContainer> |
| 1094 | <SettingsSection id="appearance" title="Appearance"> | 1093 | <SettingsSection id="appearance" title="Colors & themes" variant="plain" hideTitle> |
| 1095 | <div id={searchableSetting("theme").id}> | 1094 | <div id={searchableSetting("theme").id}> |
| 1096 | <ThemeLibrary | 1095 | <ThemeLibrary |
| 1097 | appearanceMode={appearanceMode} | 1096 | appearanceMode={appearanceMode} |
| ... | @@ -1107,7 +1106,9 @@ export function AppearanceSettingsPanel() { | ... | @@ -1107,7 +1106,9 @@ export function AppearanceSettingsPanel() { |
| 1107 | onImportOpenChange={setIsImportThemeOpen} | 1106 | onImportOpenChange={setIsImportThemeOpen} |
| 1108 | /> | 1107 | /> |
| 1109 | </div> | 1108 | </div> |
| 1109 | </SettingsSection> | ||
| 1110 | 1110 | ||
| 1111 | <SettingsSection id="appearance-interface" title="Interface"> | ||
| 1111 | <SettingsRow | 1112 | <SettingsRow |
| 1112 | {...searchableSetting("setting-appearance-contrast")} | 1113 | {...searchableSetting("setting-appearance-contrast")} |
| 1113 | description="Adjust the contrast of colors and borders across the interface." | 1114 | description="Adjust the contrast of colors and borders across the interface." |
| ... | @@ -1158,7 +1159,7 @@ export function AppearanceSettingsPanel() { | ... | @@ -1158,7 +1159,7 @@ export function AppearanceSettingsPanel() { |
| 1158 | 1159 | ||
| 1159 | <SettingsRow | 1160 | <SettingsRow |
| 1160 | {...searchableSetting("setting-glass-opacity")} | 1161 | {...searchableSetting("setting-glass-opacity")} |
| 1161 | description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid." | 1162 | description="Higher values make menus, dialogs, and the composer more solid." |
| 1162 | resetAction={ | 1163 | resetAction={ |
| 1163 | settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ( | 1164 | settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ( |
| 1164 | <SettingResetButton | 1165 | <SettingResetButton |
| ... | @@ -1479,7 +1480,7 @@ function FontSmoothingRow() { | ... | @@ -1479,7 +1480,7 @@ function FontSmoothingRow() { |
| 1479 | return ( | 1480 | return ( |
| 1480 | <SettingsRow | 1481 | <SettingsRow |
| 1481 | {...searchableSetting("font-smoothing")} | 1482 | {...searchableSetting("font-smoothing")} |
| 1482 | description="Render text with thinner grayscale anti-aliasing instead of macOS's heavier default." | 1483 | description="Use thinner grayscale text smoothing instead of the macOS default." |
| 1483 | resetAction={ | 1484 | resetAction={ |
| 1484 | settings.fontSmoothing !== DEFAULT_UNIFIED_SETTINGS.fontSmoothing ? ( | 1485 | settings.fontSmoothing !== DEFAULT_UNIFIED_SETTINGS.fontSmoothing ? ( |
| 1485 | <SettingResetButton | 1486 | <SettingResetButton |
| ... | @@ -1610,6 +1611,7 @@ function TypographySection() { | ... | @@ -1610,6 +1611,7 @@ function TypographySection() { |
| 1610 | }, [searchTargetId, setAdvanced]); | 1611 | }, [searchTargetId, setAdvanced]); |
| 1611 | return ( | 1612 | return ( |
| 1612 | <SettingsSection | 1613 | <SettingsSection |
| 1614 | id="typography" | ||
| 1613 | title="Typography" | 1615 | title="Typography" |
| 1614 | headerAction={ | 1616 | headerAction={ |
| 1615 | <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground"> | 1617 | <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground"> |
| ... | @@ -1889,6 +1891,7 @@ function LegacyFeaturesSection() { | ... | @@ -1889,6 +1891,7 @@ function LegacyFeaturesSection() { |
| 1889 | const updateSettings = useUpdatePrimarySettings(); | 1891 | const updateSettings = useUpdatePrimarySettings(); |
| 1890 | const [open, setOpen] = useState(false); | 1892 | const [open, setOpen] = useState(false); |
| 1891 | const searchTargetId = useSettingsSearchTargetId(); | 1893 | const searchTargetId = useSettingsSearchTargetId(); |
| 1894 | const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features"); | ||
| 1892 | // Unfold once per search jump; tracking the handled id lets the user fold | 1895 | // Unfold once per search jump; tracking the handled id lets the user fold |
| 1893 | // the section back up without the still-set target immediately reopening it. | 1896 | // the section back up without the still-set target immediately reopening it. |
| 1894 | const lastExpandedTargetRef = useRef<string | null>(null); | 1897 | const lastExpandedTargetRef = useRef<string | null>(null); |
| ... | @@ -1906,19 +1909,19 @@ function LegacyFeaturesSection() { | ... | @@ -1906,19 +1909,19 @@ function LegacyFeaturesSection() { |
| 1906 | }, [searchTargetId]); | 1909 | }, [searchTargetId]); |
| 1907 | 1910 | ||
| 1908 | return ( | 1911 | return ( |
| 1909 | <section className="space-y-3"> | 1912 | <section id="legacy-features" ref={targetRef} tabIndex={-1} className="space-y-2.5"> |
| 1910 | <Collapsible open={open} onOpenChange={setOpen}> | 1913 | <Collapsible open={open} onOpenChange={setOpen}> |
| 1911 | <CollapsibleTrigger className="group flex min-h-8 w-full items-center gap-2 px-3 sm:px-4"> | 1914 | <CollapsibleTrigger className="group flex min-h-8 w-full items-center gap-2 px-3 sm:px-4"> |
| 1912 | <h2 className="text-lg font-semibold tracking-[-0.025em] text-muted-foreground transition-colors group-hover:text-foreground"> | 1915 | <h2 className="text-sm font-normal tracking-[-0.005em] text-foreground/70 transition-colors group-hover:text-foreground"> |
| 1913 | Legacy features | 1916 | Legacy features |
| 1914 | </h2> | 1917 | </h2> |
| 1915 | <ChevronRightIcon className="size-4 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90" /> | 1918 | <ChevronRightIcon className="size-4 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90" /> |
| 1916 | </CollapsibleTrigger> | 1919 | </CollapsibleTrigger> |
| 1917 | <CollapsiblePanel> | 1920 | <CollapsiblePanel> |
| 1918 | <div className="relative space-y-1 overflow-visible pt-3 text-foreground"> | 1921 | <div className="relative overflow-visible rounded-xl border border-border/60 bg-card/40 text-foreground shadow-xs/5 [&>*+*]:border-t [&>*+*]:border-border/50 [&>[data-slot=settings-row]]:rounded-none"> |
| 1919 | <SettingsRow | 1922 | <SettingsRow |
| 1920 | {...searchableSetting("legacy-plan-mode")} | 1923 | {...searchableSetting("legacy-plan-mode")} |
| 1921 | description="Brings back the Build/Plan toggle in the composer along with the /plan and /default commands and the Shift+Tab shortcut. While off, every thread runs in build mode." | 1924 | description="Restore Build/Plan, /plan, /default, and Shift+Tab. Off uses build mode." |
| 1922 | control={ | 1925 | control={ |
| 1923 | <Switch | 1926 | <Switch |
| 1924 | checked={settings.planModeEnabled} | 1927 | checked={settings.planModeEnabled} |
| ... | @@ -1967,7 +1970,7 @@ function LegacyFeaturesSection() { | ... | @@ -1967,7 +1970,7 @@ function LegacyFeaturesSection() { |
| 1967 | <SettingsRow | 1970 | <SettingsRow |
| 1968 | serverScoped | 1971 | serverScoped |
| 1969 | {...searchableSetting("legacy-token-streaming")} | 1972 | {...searchableSetting("legacy-token-streaming")} |
| 1970 | description="Paints assistant output token by token instead of in complete chunks. Not recommended: it is significantly slower, and long responses become harder to follow. Kept only for compatibility with the old behavior." | 1973 | description="Stream output token by token. This legacy mode is slower and harder to follow." |
| 1971 | control={ | 1974 | control={ |
| 1972 | <Switch | 1975 | <Switch |
| 1973 | checked={settings.enableLegacyTokenStreaming} | 1976 | checked={settings.enableLegacyTokenStreaming} |
| ... | @@ -1993,7 +1996,7 @@ function LegacyFeaturesSection() { | ... | @@ -1993,7 +1996,7 @@ function LegacyFeaturesSection() { |
| 1993 | /> | 1996 | /> |
| 1994 | <SettingsRow | 1997 | <SettingsRow |
| 1995 | {...searchableSetting("legacy-sidebar")} | 1998 | {...searchableSetting("legacy-sidebar")} |
| 1996 | description="Brings back the original sidebar with per-project thread trees. The default sidebar shows one flat list: active work as rich cards, settled threads as compact rows." | 1999 | description="Restore per-project thread trees instead of the default flat sidebar." |
| 1997 | control={ | 2000 | control={ |
| 1998 | <Switch | 2001 | <Switch |
| 1999 | checked={settings.legacySidebarEnabled} | 2002 | checked={settings.legacySidebarEnabled} |
| ... | @@ -2075,7 +2078,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2075,7 +2078,7 @@ export function GeneralSettingsPanel() { |
| 2075 | const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); | 2078 | const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings); |
| 2076 | const backgroundActivityDescription = | 2079 | const backgroundActivityDescription = |
| 2077 | backgroundActivityProfileOption === "advanced" | 2080 | backgroundActivityProfileOption === "advanced" |
| 2078 | ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Current shared policy: ${ | 2081 | ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${ |
| 2079 | BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] | 2082 | BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile] |
| 2080 | }.` | 2083 | }.` |
| 2081 | : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; | 2084 | : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile]; |
| ... | @@ -2087,7 +2090,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2087,7 +2090,7 @@ export function GeneralSettingsPanel() { |
| 2087 | return ( | 2090 | return ( |
| 2088 | <SettingsPageContainer> | 2091 | <SettingsPageContainer> |
| 2089 | <SharedSettingsMismatchAlert /> | 2092 | <SharedSettingsMismatchAlert /> |
| 2090 | <SettingsSection title="General"> | 2093 | <SettingsSection id="organization" title="Organization"> |
| 2091 | <SettingsRow | 2094 | <SettingsRow |
| 2092 | {...searchableSetting("project-grouping")} | 2095 | {...searchableSetting("project-grouping")} |
| 2093 | description="Combine matching repositories across environments." | 2096 | description="Combine matching repositories across environments." |
| ... | @@ -2199,7 +2202,9 @@ export function GeneralSettingsPanel() { | ... | @@ -2199,7 +2202,9 @@ export function GeneralSettingsPanel() { |
| 2199 | ) : null} | 2202 | ) : null} |
| 2200 | </> | 2203 | </> |
| 2201 | ) : null} | 2204 | ) : null} |
| 2205 | </SettingsSection> | ||
| 2202 | 2206 | ||
| 2207 | <SettingsSection id="behavior" title="Behavior"> | ||
| 2203 | <SettingsRow | 2208 | <SettingsRow |
| 2204 | {...searchableSetting("time-format")} | 2209 | {...searchableSetting("time-format")} |
| 2205 | description="System default follows your browser or OS clock preference." | 2210 | description="System default follows your browser or OS clock preference." |
| ... | @@ -2241,7 +2246,6 @@ export function GeneralSettingsPanel() { | ... | @@ -2241,7 +2246,6 @@ export function GeneralSettingsPanel() { |
| 2241 | </Select> | 2246 | </Select> |
| 2242 | } | 2247 | } |
| 2243 | /> | 2248 | /> |
| 2244 | |||
| 2245 | <SettingsRow | 2249 | <SettingsRow |
| 2246 | {...searchableSetting("hide-whitespace-changes")} | 2250 | {...searchableSetting("hide-whitespace-changes")} |
| 2247 | description="Set whether the diff panel ignores whitespace-only edits by default." | 2251 | description="Set whether the diff panel ignores whitespace-only edits by default." |
| ... | @@ -2267,7 +2271,6 @@ export function GeneralSettingsPanel() { | ... | @@ -2267,7 +2271,6 @@ export function GeneralSettingsPanel() { |
| 2267 | /> | 2271 | /> |
| 2268 | } | 2272 | } |
| 2269 | /> | 2273 | /> |
| 2270 | |||
| 2271 | <SettingsRow | 2274 | <SettingsRow |
| 2272 | {...searchableSetting("diff-layout")} | 2275 | {...searchableSetting("diff-layout")} |
| 2273 | description="Show diffs stacked or side by side. The toggle in the diff toolbar changes this too." | 2276 | description="Show diffs stacked or side by side. The toggle in the diff toolbar changes this too." |
| ... | @@ -2305,7 +2308,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2305,7 +2308,7 @@ export function GeneralSettingsPanel() { |
| 2305 | 2308 | ||
| 2306 | <SettingsRow | 2309 | <SettingsRow |
| 2307 | {...searchableSetting("proactive-panels")} | 2310 | {...searchableSetting("proactive-panels")} |
| 2308 | description="Automatically open the linked pull request when it appears and the turn diff when agent work finishes." | 2311 | description="Open linked pull requests when found and turn diffs when work finishes." |
| 2309 | resetAction={ | 2312 | resetAction={ |
| 2310 | settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? ( | 2313 | settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? ( |
| 2311 | <SettingResetButton | 2314 | <SettingResetButton |
| ... | @@ -2548,7 +2551,9 @@ export function GeneralSettingsPanel() { | ... | @@ -2548,7 +2551,9 @@ export function GeneralSettingsPanel() { |
| 2548 | </> | 2551 | </> |
| 2549 | } | 2552 | } |
| 2550 | /> | 2553 | /> |
| 2554 | </SettingsSection> | ||
| 2551 | 2555 | ||
| 2556 | <SettingsSection id="projects-and-threads" title="Projects & threads"> | ||
| 2552 | <SettingsRow | 2557 | <SettingsRow |
| 2553 | serverScoped | 2558 | serverScoped |
| 2554 | {...searchableSetting("new-threads")} | 2559 | {...searchableSetting("new-threads")} |
| ... | @@ -2626,7 +2631,6 @@ export function GeneralSettingsPanel() { | ... | @@ -2626,7 +2631,6 @@ export function GeneralSettingsPanel() { |
| 2626 | } | 2631 | } |
| 2627 | /> | 2632 | /> |
| 2628 | ) : null} | 2633 | ) : null} |
| 2629 | |||
| 2630 | <SettingsRow | 2634 | <SettingsRow |
| 2631 | serverScoped | 2635 | serverScoped |
| 2632 | {...searchableSetting("add-project-starts-in")} | 2636 | {...searchableSetting("add-project-starts-in")} |
| ... | @@ -2656,7 +2660,9 @@ export function GeneralSettingsPanel() { | ... | @@ -2656,7 +2660,9 @@ export function GeneralSettingsPanel() { |
| 2656 | /> | 2660 | /> |
| 2657 | } | 2661 | } |
| 2658 | /> | 2662 | /> |
| 2663 | </SettingsSection> | ||
| 2659 | 2664 | ||
| 2665 | <SettingsSection id="confirmations" title="Confirmations"> | ||
| 2660 | <SettingsRow | 2666 | <SettingsRow |
| 2661 | {...searchableSetting("unpin-confirmation")} | 2667 | {...searchableSetting("unpin-confirmation")} |
| 2662 | description="Ask before unpinning a thread from the pinned section." | 2668 | description="Ask before unpinning a thread from the pinned section." |
| ... | @@ -2776,11 +2782,13 @@ export function GeneralSettingsPanel() { | ... | @@ -2776,11 +2782,13 @@ export function GeneralSettingsPanel() { |
| 2776 | } | 2782 | } |
| 2777 | /> | 2783 | /> |
| 2778 | ) : null} | 2784 | ) : null} |
| 2785 | </SettingsSection> | ||
| 2779 | 2786 | ||
| 2787 | <SettingsSection id="text-generation" title="Text generation"> | ||
| 2780 | <SettingsRow | 2788 | <SettingsRow |
| 2781 | serverScoped | 2789 | serverScoped |
| 2782 | {...searchableSetting("text-generation-model")} | 2790 | {...searchableSetting("text-generation-model")} |
| 2783 | description="Default model for generated text like thread titles and source control content. Source control settings can override it with a dedicated source control writer model." | 2791 | description="Used for thread titles and other generated text. Source control can override it." |
| 2784 | resetAction={ | 2792 | resetAction={ |
| 2785 | isTextGenerationModelDirty ? ( | 2793 | isTextGenerationModelDirty ? ( |
| 2786 | <SettingResetButton | 2794 | <SettingResetButton |
| ... | @@ -2872,7 +2880,7 @@ export function GeneralSettingsPanel() { | ... | @@ -2872,7 +2880,7 @@ export function GeneralSettingsPanel() { |
| 2872 | /> | 2880 | /> |
| 2873 | </SettingsSection> | 2881 | </SettingsSection> |
| 2874 | 2882 | ||
| 2875 | <SettingsSection title="About"> | 2883 | <SettingsSection id="about" title="About"> |
| 2876 | {isElectron || HOSTED_APP_CHANNEL ? ( | 2884 | {isElectron || HOSTED_APP_CHANNEL ? ( |
| 2877 | <AboutVersionSection /> | 2885 | <AboutVersionSection /> |
| 2878 | ) : ( | 2886 | ) : ( |
apps/web/src/components/settings/SettingsSidebarNav.tsx+126-46| ... | @@ -31,6 +31,9 @@ import { | ... | @@ -31,6 +31,9 @@ import { |
| 31 | SidebarMenu, | 31 | SidebarMenu, |
| 32 | SidebarMenuButton, | 32 | SidebarMenuButton, |
| 33 | SidebarMenuItem, | 33 | SidebarMenuItem, |
| 34 | SidebarMenuSub, | ||
| 35 | SidebarMenuSubButton, | ||
| 36 | SidebarMenuSubItem, | ||
| 34 | useSidebar, | 37 | useSidebar, |
| 35 | } from "../ui/sidebar"; | 38 | } from "../ui/sidebar"; |
| 36 | import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; | 39 | import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; |
| ... | @@ -67,6 +70,33 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | ... | @@ -67,6 +70,33 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 67 | icon: SETTINGS_SECTION_ICONS[to], | 70 | icon: SETTINGS_SECTION_ICONS[to], |
| 68 | })); | 71 | })); |
| 69 | 72 | ||
| 73 | const SETTINGS_PAGE_SECTIONS: Partial< | ||
| 74 | Readonly<Record<SettingsPath, ReadonlyArray<{ label: string; targetId: string }>>> | ||
| 75 | > = { | ||
| 76 | "/settings/general": [ | ||
| 77 | { label: "Organization", targetId: "organization" }, | ||
| 78 | { label: "Behavior", targetId: "behavior" }, | ||
| 79 | { label: "Projects & threads", targetId: "projects-and-threads" }, | ||
| 80 | { label: "Confirmations", targetId: "confirmations" }, | ||
| 81 | { label: "Text generation", targetId: "text-generation" }, | ||
| 82 | { label: "About", targetId: "about" }, | ||
| 83 | { label: "Legacy features", targetId: "legacy-features" }, | ||
| 84 | ], | ||
| 85 | "/settings/appearance": [ | ||
| 86 | { label: "Colors & themes", targetId: "appearance" }, | ||
| 87 | { label: "Interface", targetId: "appearance-interface" }, | ||
| 88 | { label: "Typography", targetId: "typography" }, | ||
| 89 | ], | ||
| 90 | "/settings/source-control": [ | ||
| 91 | { label: "Version control", targetId: "source-control" }, | ||
| 92 | { label: "Text generation", targetId: "source-control-text-generation" }, | ||
| 93 | ], | ||
| 94 | "/settings/connections": [ | ||
| 95 | { label: "This environment", targetId: "connections-environment" }, | ||
| 96 | { label: "Remote environments", targetId: "remote-environments" }, | ||
| 97 | ], | ||
| 98 | }; | ||
| 99 | |||
| 70 | function SettingsSectionIcon({ to }: { to: SettingsPath }) { | 100 | function SettingsSectionIcon({ to }: { to: SettingsPath }) { |
| 71 | const Icon = SETTINGS_SECTION_ICONS[to]; | 101 | const Icon = SETTINGS_SECTION_ICONS[to]; |
| 72 | return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />; | 102 | return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />; |
| ... | @@ -133,10 +163,33 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -133,10 +163,33 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 133 | if (isMobile) { | 163 | if (isMobile) { |
| 134 | setOpenMobile(false); | 164 | setOpenMobile(false); |
| 135 | } | 165 | } |
| 136 | void navigate({ to, hash: "", replace: true, hashScrollIntoView: false }); | 166 | void navigate({ |
| 167 | to, | ||
| 168 | hash: "", | ||
| 169 | replace: true, | ||
| 170 | hashScrollIntoView: false, | ||
| 171 | }); | ||
| 137 | }, | 172 | }, |
| 138 | [isMobile, navigate, setOpenMobile], | 173 | [isMobile, navigate, setOpenMobile], |
| 139 | ); | 174 | ); |
| 175 | const handlePageSectionClick = useCallback( | ||
| 176 | (to: SettingsPath, targetId: string) => { | ||
| 177 | if (isMobile) { | ||
| 178 | setOpenMobile(false); | ||
| 179 | } | ||
| 180 | if (pathname === to && scrollToSettingsTarget(targetId, { highlight: false })) { | ||
| 181 | return; | ||
| 182 | } | ||
| 183 | void navigate({ | ||
| 184 | to, | ||
| 185 | hash: targetId, | ||
| 186 | replace: true, | ||
| 187 | hashScrollIntoView: false, | ||
| 188 | state: { settingsTargetHighlight: false }, | ||
| 189 | }); | ||
| 190 | }, | ||
| 191 | [isMobile, navigate, pathname, setOpenMobile], | ||
| 192 | ); | ||
| 140 | const clearSearch = useCallback(() => { | 193 | const clearSearch = useCallback(() => { |
| 141 | setQuery(""); | 194 | setQuery(""); |
| 142 | setActiveResultIndex(0); | 195 | setActiveResultIndex(0); |
| ... | @@ -152,7 +205,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -152,7 +205,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 152 | scrollToSettingsTarget(targetId); | 205 | scrollToSettingsTarget(targetId); |
| 153 | return; | 206 | return; |
| 154 | } | 207 | } |
| 155 | void navigate({ to: item.to, hash: targetId, replace: true, hashScrollIntoView: false }); | 208 | void navigate({ |
| 209 | to: item.to, | ||
| 210 | hash: targetId, | ||
| 211 | replace: true, | ||
| 212 | hashScrollIntoView: false, | ||
| 213 | state: { settingsTargetHighlight: true }, | ||
| 214 | }); | ||
| 156 | }, | 215 | }, |
| 157 | [clearSearch, currentHash, isMobile, navigate, pathname, setOpenMobile], | 216 | [clearSearch, currentHash, isMobile, navigate, pathname, setOpenMobile], |
| 158 | ); | 217 | ); |
| ... | @@ -239,54 +298,75 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { | ... | @@ -239,54 +298,75 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 239 | No settings found | 298 | No settings found |
| 240 | </p> | 299 | </p> |
| 241 | ) : null} | 300 | ) : null} |
| 242 | <SidebarMenu | 301 | {isSearching ? ( |
| 243 | className="ps-px" | 302 | <SidebarMenu |
| 244 | id={isSearching && hasResults ? "settings-search-results" : undefined} | 303 | className="ps-px" |
| 245 | role={isSearching && hasResults ? "listbox" : undefined} | 304 | id={hasResults ? "settings-search-results" : undefined} |
| 246 | aria-label={isSearching && hasResults ? "Settings search results" : undefined} | 305 | role={hasResults ? "listbox" : undefined} |
| 247 | > | 306 | aria-label={hasResults ? "Settings search results" : undefined} |
| 248 | {isSearching | 307 | > |
| 249 | ? results.map((item, index) => ( | 308 | {results.map((item, index) => ( |
| 250 | <SidebarMenuItem key={item.id} role="presentation"> | 309 | <SidebarMenuItem key={item.id} role="presentation"> |
| 310 | <SidebarMenuButton | ||
| 311 | id={`settings-search-result-${item.id}`} | ||
| 312 | role="option" | ||
| 313 | aria-selected={index === activeResultIndex} | ||
| 314 | tabIndex={-1} | ||
| 315 | size="sm" | ||
| 316 | isActive={index === activeResultIndex} | ||
| 317 | className="h-auto min-h-10 items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-row-hover hover:text-sidebar-foreground" | ||
| 318 | onMouseMove={() => setActiveResultIndex(index)} | ||
| 319 | onClick={() => handleSearchResultClick(item)} | ||
| 320 | > | ||
| 321 | <SettingsSectionIcon to={item.to} /> | ||
| 322 | <span className="min-w-0 flex-1"> | ||
| 323 | <span className="block truncate text-sm font-medium text-sidebar-foreground"> | ||
| 324 | {item.title} | ||
| 325 | </span> | ||
| 326 | <span className="block truncate text-[11px] text-sidebar-muted-foreground/75"> | ||
| 327 | {SETTINGS_SECTION_LABELS[item.to]} | ||
| 328 | </span> | ||
| 329 | </span> | ||
| 330 | </SidebarMenuButton> | ||
| 331 | </SidebarMenuItem> | ||
| 332 | ))} | ||
| 333 | </SidebarMenu> | ||
| 334 | ) : ( | ||
| 335 | <SidebarMenu className="ps-px"> | ||
| 336 | {SETTINGS_NAV_ITEMS.map((item) => { | ||
| 337 | const Icon = item.icon; | ||
| 338 | const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); | ||
| 339 | const pageSections = SETTINGS_PAGE_SECTIONS[item.to]; | ||
| 340 | return ( | ||
| 341 | <SidebarMenuItem key={item.to}> | ||
| 251 | <SidebarMenuButton | 342 | <SidebarMenuButton |
| 252 | id={`settings-search-result-${item.id}`} | 343 | isActive={isActive} |
| 253 | role="option" | 344 | onClick={() => handleSectionClick(item.to)} |
| 254 | aria-selected={index === activeResultIndex} | ||
| 255 | tabIndex={-1} | ||
| 256 | size="sm" | ||
| 257 | isActive={index === activeResultIndex} | ||
| 258 | className="h-auto min-h-10 items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-row-hover hover:text-sidebar-foreground" | ||
| 259 | onMouseMove={() => setActiveResultIndex(index)} | ||
| 260 | onClick={() => handleSearchResultClick(item)} | ||
| 261 | > | 345 | > |
| 262 | <SettingsSectionIcon to={item.to} /> | 346 | <Icon /> |
| 263 | <span className="min-w-0 flex-1"> | 347 | <span className="truncate">{item.label}</span> |
| 264 | <span className="block truncate text-sm font-medium text-sidebar-foreground"> | ||
| 265 | {item.title} | ||
| 266 | </span> | ||
| 267 | <span className="block truncate text-[11px] text-sidebar-muted-foreground/75"> | ||
| 268 | {SETTINGS_SECTION_LABELS[item.to]} | ||
| 269 | </span> | ||
| 270 | </span> | ||
| 271 | </SidebarMenuButton> | 348 | </SidebarMenuButton> |
| 349 | {isActive && pageSections ? ( | ||
| 350 | <SidebarMenuSub className="border-l-0"> | ||
| 351 | {pageSections.map((section) => ( | ||
| 352 | <SidebarMenuSubItem key={section.targetId}> | ||
| 353 | <SidebarMenuSubButton | ||
| 354 | render={<button type="button" />} | ||
| 355 | size="sm" | ||
| 356 | className="text-sidebar-muted-foreground/65" | ||
| 357 | onClick={() => handlePageSectionClick(item.to, section.targetId)} | ||
| 358 | > | ||
| 359 | <span className="ms-0.5">{section.label}</span> | ||
| 360 | </SidebarMenuSubButton> | ||
| 361 | </SidebarMenuSubItem> | ||
| 362 | ))} | ||
| 363 | </SidebarMenuSub> | ||
| 364 | ) : null} | ||
| 272 | </SidebarMenuItem> | 365 | </SidebarMenuItem> |
| 273 | )) | 366 | ); |
| 274 | : SETTINGS_NAV_ITEMS.map((item) => { | 367 | })} |
| 275 | const Icon = item.icon; | 368 | </SidebarMenu> |
| 276 | const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); | 369 | )} |
| 277 | return ( | ||
| 278 | <SidebarMenuItem key={item.to}> | ||
| 279 | <SidebarMenuButton | ||
| 280 | isActive={isActive} | ||
| 281 | onClick={() => handleSectionClick(item.to)} | ||
| 282 | > | ||
| 283 | <Icon /> | ||
| 284 | <span className="truncate">{item.label}</span> | ||
| 285 | </SidebarMenuButton> | ||
| 286 | </SidebarMenuItem> | ||
| 287 | ); | ||
| 288 | })} | ||
| 289 | </SidebarMenu> | ||
| 290 | </SidebarGroup> | 370 | </SidebarGroup> |
| 291 | </SidebarContent> | 371 | </SidebarContent> |
| 292 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> | 372 | <SidebarFooter className="p-[var(--sidebar-content-inset)]"> |
apps/web/src/components/settings/SourceControlSettings.tsx+1-2| ... | @@ -390,8 +390,7 @@ function GitFetchIntervalSettings() { | ... | @@ -390,8 +390,7 @@ function GitFetchIntervalSettings() { |
| 390 | </span> | 390 | </span> |
| 391 | </div> | 391 | </div> |
| 392 | <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground"> | 392 | <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground"> |
| 393 | Refresh remote branch status in the background. Set this to 0 seconds if Git credentials | 393 | Refresh remote branches in the background. Set to 0 to avoid automatic Git prompts. |
| 394 | or security keys should only be prompted by explicit Git actions. | ||
| 395 | </p> | 394 | </p> |
| 396 | </div> | 395 | </div> |
| 397 | <div className="flex shrink-0 items-center gap-2"> | 396 | <div className="flex shrink-0 items-center gap-2"> |
apps/web/src/components/settings/SourceControlWritingSettings.tsx+5-6| ... | @@ -38,13 +38,12 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr | ... | @@ -38,13 +38,12 @@ const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; descr |
| 38 | }, | 38 | }, |
| 39 | conventional_commits: { | 39 | conventional_commits: { |
| 40 | label: "Conventional Commits", | 40 | label: "Conventional Commits", |
| 41 | description: | 41 | description: "Use Conventional Commit prefixes and keep change request text concise.", |
| 42 | "Uses Conventional Commit prefixes for change descriptions; change request titles and descriptions stay concise.", | ||
| 43 | }, | 42 | }, |
| 44 | custom: { | 43 | custom: { |
| 45 | label: "Custom instructions", | 44 | label: "Custom instructions", |
| 46 | description: | 45 | description: |
| 47 | "Applies your instructions to change descriptions and change request titles and descriptions in every project.", | 46 | "Use your instructions for change descriptions and change requests in every project.", |
| 48 | }, | 47 | }, |
| 49 | }; | 48 | }; |
| 50 | 49 | ||
| ... | @@ -90,7 +89,7 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -90,7 +89,7 @@ export function SourceControlWritingSettingsSection() { |
| 90 | ); | 89 | ); |
| 91 | 90 | ||
| 92 | return ( | 91 | return ( |
| 93 | <SettingsSection title="Text generation"> | 92 | <SettingsSection id="source-control-text-generation" title="Text generation"> |
| 94 | <SettingsRow | 93 | <SettingsRow |
| 95 | serverScoped | 94 | serverScoped |
| 96 | {...searchableSetting("source-control-writing-style")} | 95 | {...searchableSetting("source-control-writing-style")} |
| ... | @@ -163,7 +162,7 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -163,7 +162,7 @@ export function SourceControlWritingSettingsSection() { |
| 163 | <SettingsRow | 162 | <SettingsRow |
| 164 | serverScoped | 163 | serverScoped |
| 165 | {...searchableSetting("follow-change-request-templates")} | 164 | {...searchableSetting("follow-change-request-templates")} |
| 166 | description="Structures change request descriptions using the current repository's template when one is available." | 165 | description="Use the repository's template for change request descriptions when available." |
| 167 | resetAction={ | 166 | resetAction={ |
| 168 | style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? ( | 167 | style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? ( |
| 169 | <SettingResetButton | 168 | <SettingResetButton |
| ... | @@ -196,7 +195,7 @@ export function SourceControlWritingSettingsSection() { | ... | @@ -196,7 +195,7 @@ export function SourceControlWritingSettingsSection() { |
| 196 | <SettingsRow | 195 | <SettingsRow |
| 197 | serverScoped | 196 | serverScoped |
| 198 | {...searchableSetting("source-control-writer-model")} | 197 | {...searchableSetting("source-control-writer-model")} |
| 199 | description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model." | 198 | description="Model for source control text and branch or bookmark names. Off uses the global default." |
| 200 | control={ | 199 | control={ |
| 201 | <div className="flex flex-wrap items-center justify-end gap-2"> | 200 | <div className="flex flex-wrap items-center justify-end gap-2"> |
| 202 | {usesDedicatedModel && !canEnableDedicatedModel ? ( | 201 | {usesDedicatedModel && !canEnableDedicatedModel ? ( |
apps/web/src/components/settings/ThemeSettings.tsx+4-11| ... | @@ -719,11 +719,7 @@ export function ThemeLibrary({ | ... | @@ -719,11 +719,7 @@ export function ThemeLibrary({ |
| 719 | ); | 719 | ); |
| 720 | 720 | ||
| 721 | const renderModeTiles = () => ( | 721 | const renderModeTiles = () => ( |
| 722 | <div | 722 | <div aria-label="Appearance mode" className="grid w-full grid-cols-3 gap-3" role="group"> |
| 723 | aria-label="Appearance mode" | ||
| 724 | className="mx-auto grid w-full max-w-[56rem] grid-cols-3 gap-3 px-3 sm:px-4" | ||
| 725 | role="group" | ||
| 726 | > | ||
| 727 | {(["system", "light", "dark"] as const).map((mode) => { | 723 | {(["system", "light", "dark"] as const).map((mode) => { |
| 728 | const isActive = appearanceMode === mode; | 724 | const isActive = appearanceMode === mode; |
| 729 | return ( | 725 | return ( |
| ... | @@ -777,7 +773,7 @@ export function ThemeLibrary({ | ... | @@ -777,7 +773,7 @@ export function ThemeLibrary({ |
| 777 | // accepted — scoping the group tighter makes the handoffs feel sluggish. | 773 | // accepted — scoping the group tighter makes the handoffs feel sluggish. |
| 778 | <TooltipProvider> | 774 | <TooltipProvider> |
| 779 | <div | 775 | <div |
| 780 | className="mx-auto grid w-full max-w-[56rem] gap-2 px-3 sm:px-4" | 776 | className="grid w-full gap-2" |
| 781 | style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }} | 777 | style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }} |
| 782 | > | 778 | > |
| 783 | {STANDARD_THEME_CARDS.map((standardTheme) => ( | 779 | {STANDARD_THEME_CARDS.map((standardTheme) => ( |
| ... | @@ -889,15 +885,12 @@ export function ThemeLibrary({ | ... | @@ -889,15 +885,12 @@ export function ThemeLibrary({ |
| 889 | 885 | ||
| 890 | return ( | 886 | return ( |
| 891 | <div className="space-y-3"> | 887 | <div className="space-y-3"> |
| 892 | <p className="px-3 text-[13px] leading-[1.45] text-muted-foreground/80 sm:px-4"> | 888 | <h3 className="px-3 text-sm font-normal tracking-[-0.005em] text-foreground/70 sm:px-4"> |
| 893 | Choose how T3 Code looks. Use a built-in theme or make your own. | ||
| 894 | </p> | ||
| 895 | <h3 className="px-3 text-sm font-medium tracking-[-0.005em] text-foreground sm:px-4"> | ||
| 896 | {searchableSetting("color-scheme").title} | 889 | {searchableSetting("color-scheme").title} |
| 897 | </h3> | 890 | </h3> |
| 898 | {renderModeTiles()} | 891 | {renderModeTiles()} |
| 899 | <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4"> | 892 | <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4"> |
| 900 | <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground"> | 893 | <h3 className="text-sm font-normal tracking-[-0.005em] text-foreground/70"> |
| 901 | {searchableSetting("theme").title} | 894 | {searchableSetting("theme").title} |
| 902 | </h3> | 895 | </h3> |
| 903 | <div className="flex flex-wrap items-center justify-end gap-2"> | 896 | <div className="flex flex-wrap items-center justify-end gap-2"> |
apps/web/src/components/settings/settingsLayout.tsx+87-22| ... | @@ -20,36 +20,54 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag | ... | @@ -20,36 +20,54 @@ import { WorkspacePageContainer, type WorkspacePageWidth } from "../WorkspacePag |
| 20 | import { Button } from "../ui/button"; | 20 | import { Button } from "../ui/button"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 22 | 22 | ||
| 23 | declare module "@tanstack/react-router" { | ||
| 24 | interface HistoryState { | ||
| 25 | settingsTargetHighlight?: boolean; | ||
| 26 | } | ||
| 27 | } | ||
| 28 | |||
| 23 | interface SettingsSearchTargetContextValue { | 29 | interface SettingsSearchTargetContextValue { |
| 24 | readonly targetId: string | null; | 30 | readonly targetId: string | null; |
| 31 | readonly highlightTarget: boolean; | ||
| 25 | readonly onTargetHandled: () => void; | 32 | readonly onTargetHandled: () => void; |
| 26 | } | 33 | } |
| 27 | 34 | ||
| 28 | const noop = () => undefined; | 35 | const noop = () => undefined; |
| 29 | const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({ | 36 | const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({ |
| 30 | targetId: null, | 37 | targetId: null, |
| 38 | highlightTarget: true, | ||
| 31 | onTargetHandled: noop, | 39 | onTargetHandled: noop, |
| 32 | }); | 40 | }); |
| 33 | 41 | ||
| 34 | export function SettingsSearchTargetProvider({ | 42 | export function SettingsSearchTargetProvider({ |
| 35 | targetId, | 43 | targetId, |
| 44 | highlightTarget = true, | ||
| 36 | onTargetHandled = noop, | 45 | onTargetHandled = noop, |
| 37 | children, | 46 | children, |
| 38 | }: { | 47 | }: { |
| 39 | targetId: string | null; | 48 | targetId: string | null; |
| 49 | highlightTarget?: boolean; | ||
| 40 | onTargetHandled?: () => void; | 50 | onTargetHandled?: () => void; |
| 41 | children: ReactNode; | 51 | children: ReactNode; |
| 42 | }) { | 52 | }) { |
| 43 | const value = useMemo(() => ({ targetId, onTargetHandled }), [onTargetHandled, targetId]); | 53 | const value = useMemo( |
| 54 | () => ({ targetId, highlightTarget, onTargetHandled }), | ||
| 55 | [highlightTarget, onTargetHandled, targetId], | ||
| 56 | ); | ||
| 44 | return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>; | 57 | return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>; |
| 45 | } | 58 | } |
| 46 | 59 | ||
| 47 | function scrollAndFocusSettingsTarget(target: HTMLElement): void { | 60 | function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void { |
| 48 | const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; | 61 | const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; |
| 62 | const markedScrollTarget = | ||
| 63 | typeof target.querySelector === "function" | ||
| 64 | ? target.querySelector<HTMLElement>(":scope > [data-settings-scroll-target]") | ||
| 65 | : null; | ||
| 49 | const scrollTarget = | 66 | const scrollTarget = |
| 50 | target.tagName === "SECTION" && target.firstElementChild | 67 | markedScrollTarget ?? |
| 68 | (target.tagName === "SECTION" && target.firstElementChild | ||
| 51 | ? (target.firstElementChild as HTMLElement) | 69 | ? (target.firstElementChild as HTMLElement) |
| 52 | : target; | 70 | : target); |
| 53 | 71 | ||
| 54 | scrollTarget.scrollIntoView({ | 72 | scrollTarget.scrollIntoView({ |
| 55 | behavior: prefersReducedMotion ? "auto" : "smooth", | 73 | behavior: prefersReducedMotion ? "auto" : "smooth", |
| ... | @@ -57,7 +75,7 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void { | ... | @@ -57,7 +75,7 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void { |
| 57 | }); | 75 | }); |
| 58 | target.focus({ preventScroll: true }); | 76 | target.focus({ preventScroll: true }); |
| 59 | target.classList.remove("settings-search-target-pulse"); | 77 | target.classList.remove("settings-search-target-pulse"); |
| 60 | if (prefersReducedMotion) return; | 78 | if (!highlight || prefersReducedMotion) return; |
| 61 | void target.offsetWidth; | 79 | void target.offsetWidth; |
| 62 | target.classList.add("settings-search-target-pulse"); | 80 | target.classList.add("settings-search-target-pulse"); |
| 63 | // The class also suppresses the focus outline (the pulse is the destination | 81 | // The class also suppresses the focus outline (the pulse is the destination |
| ... | @@ -72,17 +90,17 @@ export function useSettingsSearchTargetId(): string | null { | ... | @@ -72,17 +90,17 @@ export function useSettingsSearchTargetId(): string | null { |
| 72 | return useContext(SettingsSearchTargetContext).targetId; | 90 | return useContext(SettingsSearchTargetContext).targetId; |
| 73 | } | 91 | } |
| 74 | 92 | ||
| 75 | function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) { | 93 | export function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) { |
| 76 | const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext); | 94 | const { targetId, highlightTarget, onTargetHandled } = useContext(SettingsSearchTargetContext); |
| 77 | const isSearchTarget = id !== undefined && id === targetId; | 95 | const isSearchTarget = id !== undefined && id === targetId; |
| 78 | const targetRef = useCallback( | 96 | const targetRef = useCallback( |
| 79 | (target: T | null) => { | 97 | (target: T | null) => { |
| 80 | if (target && isSearchTarget) { | 98 | if (target && isSearchTarget) { |
| 81 | scrollAndFocusSettingsTarget(target); | 99 | scrollAndFocusSettingsTarget(target, highlightTarget); |
| 82 | onTargetHandled(); | 100 | onTargetHandled(); |
| 83 | } | 101 | } |
| 84 | }, | 102 | }, |
| 85 | [isSearchTarget, onTargetHandled], | 103 | [highlightTarget, isSearchTarget, onTargetHandled], |
| 86 | ); | 104 | ); |
| 87 | 105 | ||
| 88 | return targetRef; | 106 | return targetRef; |
| ... | @@ -139,15 +157,21 @@ export function useRelativeTimeTick(intervalMs = 1_000) { | ... | @@ -139,15 +157,21 @@ export function useRelativeTimeTick(intervalMs = 1_000) { |
| 139 | 157 | ||
| 140 | export function SettingsSection({ | 158 | export function SettingsSection({ |
| 141 | title, | 159 | title, |
| 160 | description, | ||
| 161 | hideTitle = false, | ||
| 142 | icon, | 162 | icon, |
| 143 | headerAction, | 163 | headerAction, |
| 164 | variant = "grouped", | ||
| 144 | children, | 165 | children, |
| 145 | className, | 166 | className, |
| 146 | ...sectionProps | 167 | ...sectionProps |
| 147 | }: ComponentPropsWithoutRef<"section"> & { | 168 | }: ComponentPropsWithoutRef<"section"> & { |
| 148 | title: string; | 169 | title: string; |
| 170 | description?: ReactNode; | ||
| 171 | hideTitle?: boolean; | ||
| 149 | icon?: ReactNode; | 172 | icon?: ReactNode; |
| 150 | headerAction?: ReactNode; | 173 | headerAction?: ReactNode; |
| 174 | variant?: "grouped" | "plain"; | ||
| 151 | children: ReactNode; | 175 | children: ReactNode; |
| 152 | }) { | 176 | }) { |
| 153 | const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id); | 177 | const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id); |
| ... | @@ -157,16 +181,40 @@ export function SettingsSection({ | ... | @@ -157,16 +181,40 @@ export function SettingsSection({ |
| 157 | {...sectionProps} | 181 | {...sectionProps} |
| 158 | ref={targetRef} | 182 | ref={targetRef} |
| 159 | tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex} | 183 | tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex} |
| 160 | className={cn("space-y-3", className)} | 184 | className={cn(!hideTitle && "space-y-2.5", className)} |
| 161 | > | 185 | > |
| 162 | <div className="flex min-h-8 items-center justify-between gap-4 px-3 sm:px-4"> | 186 | {hideTitle ? ( |
| 163 | <h2 className="flex items-center gap-2 text-lg font-semibold tracking-[-0.025em] text-foreground"> | 187 | <h2 className="sr-only">{title}</h2> |
| 164 | {icon} | 188 | ) : ( |
| 165 | {title} | 189 | <div |
| 166 | </h2> | 190 | data-settings-scroll-target |
| 167 | <div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div> | 191 | className="flex min-h-7 items-start justify-between gap-4 px-3 sm:px-4" |
| 192 | > | ||
| 193 | <div className="min-w-0"> | ||
| 194 | <h2 className="flex min-h-7 items-center gap-2 text-sm font-normal tracking-[-0.005em] text-foreground/70"> | ||
| 195 | {icon} | ||
| 196 | {title} | ||
| 197 | </h2> | ||
| 198 | {description ? ( | ||
| 199 | <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80"> | ||
| 200 | {description} | ||
| 201 | </div> | ||
| 202 | ) : null} | ||
| 203 | </div> | ||
| 204 | <div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div> | ||
| 205 | </div> | ||
| 206 | )} | ||
| 207 | <div | ||
| 208 | data-settings-scroll-target={hideTitle ? "" : undefined} | ||
| 209 | className={cn( | ||
| 210 | "relative overflow-visible text-foreground", | ||
| 211 | variant === "grouped" | ||
| 212 | ? "rounded-xl border border-border/60 bg-card/40 shadow-xs/5 [&>*+*]:border-t [&>*+*]:border-border/50 [&>[data-slot=settings-row]]:rounded-none" | ||
| 213 | : "space-y-1", | ||
| 214 | )} | ||
| 215 | > | ||
| 216 | {children} | ||
| 168 | </div> | 217 | </div> |
| 169 | <div className="relative space-y-1 overflow-visible text-foreground">{children}</div> | ||
| 170 | </section> | 218 | </section> |
| 171 | ); | 219 | ); |
| 172 | } | 220 | } |
| ... | @@ -235,6 +283,7 @@ export function SettingsRow({ | ... | @@ -235,6 +283,7 @@ export function SettingsRow({ |
| 235 | {...rowProps} | 283 | {...rowProps} |
| 236 | ref={targetRef} | 284 | ref={targetRef} |
| 237 | tabIndex={rowProps.id ? -1 : rowProps.tabIndex} | 285 | tabIndex={rowProps.id ? -1 : rowProps.tabIndex} |
| 286 | data-slot="settings-row" | ||
| 238 | className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} | 287 | className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} |
| 239 | > | 288 | > |
| 240 | <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8"> | 289 | <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8"> |
| ... | @@ -312,18 +361,31 @@ export function SettingsPageContainer({ | ... | @@ -312,18 +361,31 @@ export function SettingsPageContainer({ |
| 312 | }) { | 361 | }) { |
| 313 | const navigate = useNavigate(); | 362 | const navigate = useNavigate(); |
| 314 | const hash = useLocation({ select: (location) => location.hash }); | 363 | const hash = useLocation({ select: (location) => location.hash }); |
| 364 | const highlightTarget = useLocation({ | ||
| 365 | select: (location) => location.state.settingsTargetHighlight !== false, | ||
| 366 | }); | ||
| 315 | const targetId = hash.replace(/^#/, "") || null; | 367 | const targetId = hash.replace(/^#/, "") || null; |
| 316 | const clearTargetHash = useCallback(() => { | 368 | const clearTargetHash = useCallback(() => { |
| 317 | void navigate({ hash: "", replace: true, resetScroll: false, hashScrollIntoView: false }); | 369 | void navigate({ |
| 370 | hash: "", | ||
| 371 | replace: true, | ||
| 372 | resetScroll: false, | ||
| 373 | hashScrollIntoView: false, | ||
| 374 | state: { settingsTargetHighlight: true }, | ||
| 375 | }); | ||
| 318 | }, [navigate]); | 376 | }, [navigate]); |
| 319 | 377 | ||
| 320 | return ( | 378 | return ( |
| 321 | <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}> | 379 | <SettingsSearchTargetProvider |
| 380 | targetId={targetId} | ||
| 381 | highlightTarget={highlightTarget} | ||
| 382 | onTargetHandled={clearTargetHash} | ||
| 383 | > | ||
| 322 | <div | 384 | <div |
| 323 | className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto" | 385 | className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto" |
| 324 | data-settings-page-scroll | 386 | data-settings-page-scroll |
| 325 | > | 387 | > |
| 326 | <WorkspacePageContainer width={width} className={cn("gap-12", className)}> | 388 | <WorkspacePageContainer width={width} className={cn("gap-8", className)}> |
| 327 | {children} | 389 | {children} |
| 328 | </WorkspacePageContainer> | 390 | </WorkspacePageContainer> |
| 329 | </div> | 391 | </div> |
| ... | @@ -331,9 +393,12 @@ export function SettingsPageContainer({ | ... | @@ -331,9 +393,12 @@ export function SettingsPageContainer({ |
| 331 | ); | 393 | ); |
| 332 | } | 394 | } |
| 333 | 395 | ||
| 334 | export function scrollToSettingsTarget(targetId: string): boolean { | 396 | export function scrollToSettingsTarget( |
| 397 | targetId: string, | ||
| 398 | { highlight = true }: { readonly highlight?: boolean } = {}, | ||
| 399 | ): boolean { | ||
| 335 | const target = document.getElementById(targetId); | 400 | const target = document.getElementById(targetId); |
| 336 | if (!target) return false; | 401 | if (!target) return false; |
| 337 | scrollAndFocusSettingsTarget(target); | 402 | scrollAndFocusSettingsTarget(target, highlight); |
| 338 | return true; | 403 | return true; |
| 339 | } | 404 | } |
apps/web/src/components/settings/settingsSearch.test.ts+1-1| ... | @@ -197,7 +197,7 @@ describe("searchSettings", () => { | ... | @@ -197,7 +197,7 @@ describe("searchSettings", () => { |
| 197 | expect(searchSettings("environment identification")[0]).toMatchObject({ | 197 | expect(searchSettings("environment identification")[0]).toMatchObject({ |
| 198 | id: "environment-identification", | 198 | id: "environment-identification", |
| 199 | to: "/settings/appearance", | 199 | to: "/settings/appearance", |
| 200 | targetId: "appearance", | 200 | targetId: "appearance-interface", |
| 201 | }); | 201 | }); |
| 202 | }); | 202 | }); |
| 203 | 203 |
apps/web/src/components/settings/settingsSearch.ts+1-1| ... | @@ -105,7 +105,7 @@ export const SETTINGS_SEARCH_ITEMS = [ | ... | @@ -105,7 +105,7 @@ export const SETTINGS_SEARCH_ITEMS = [ |
| 105 | to: "/settings/appearance", | 105 | to: "/settings/appearance", |
| 106 | searchTerms: ["dev nightly artwork pill label hide none"], | 106 | searchTerms: ["dev nightly artwork pill label hide none"], |
| 107 | // The setting is stage-dependent, so its parent section is the stable destination. | 107 | // The setting is stage-dependent, so its parent section is the stable destination. |
| 108 | targetId: "appearance", | 108 | targetId: "appearance-interface", |
| 109 | }, | 109 | }, |
| 110 | { | 110 | { |
| 111 | id: "interface-font", | 111 | id: "interface-font", |