authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 20:48:03-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 20:48:03-04:00
log343db2c328af56fe0fa7672f3af055550832689b
tree55d3b5f1b3ff9a4e15011a589cecb7b65e0b1fc6
parent19d8ab2ae9fc562ee7b216a0d72903fbfafa9572
signature Signed by PGP key B5690EEEBB952194

feat(web): reorganize settings pages (#9354)

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 <SettingsRow1711 <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 <CloudLinkSwitch1715 <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 <SettingsRow2908 <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 <SettingsRow2946 <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 <SettingsRow2995 <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 <Switch3000 <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 <SettingsRow263 <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 <SettingResetButton268 <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 <SettingsRow461 <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 <SettingResetButton466 <SettingResetButton
...@@ -557,7 +557,7 @@ function AgentBrowserAccessSetting() {...@@ -557,7 +557,7 @@ function AgentBrowserAccessSetting() {
557 <SettingsRow557 <SettingsRow
558 serverScoped558 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.enableAgentBrowserAccess562 settings.enableAgentBrowserAccess
563 ? undefined563 ? undefined
...@@ -595,7 +595,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled...@@ -595,7 +595,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled
595 return (595 return (
596 <SettingsRow596 <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 <SettingResetButton601 <SettingResetButton
...@@ -638,7 +638,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled...@@ -638,7 +638,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled
638 */638 */
639function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode }) {639function 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 <SettingsRow934 <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 <MenuTrigger939 <MenuTrigger
apps/web/src/components/settings/ProviderAccentColorPicker.tsx+36-71
...@@ -3,22 +3,12 @@...@@ -3,22 +3,12 @@
3import { PipetteIcon, XIcon } from "lucide-react";3import { PipetteIcon, XIcon } from "lucide-react";
4import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";4import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
55
6import { ColorSelector } from "../color-selector";
7import { Button } from "../ui/button";6import { Button } from "../ui/button";
8import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";7import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
9import { normalizeProviderAccentColor } from "../../providerInstances";8import { normalizeProviderAccentColor } from "../../providerInstances";
10import { cn } from "../../lib/utils";9import { cn } from "../../lib/utils";
1110
12const PROVIDER_ACCENT_SWATCHES = [11const FALLBACK_ACCENT_COLOR = "#2563eb";
13 "#2563eb",
14 "#16a34a",
15 "#ea580c",
16 "#dc2626",
17 "#7c3aed",
18 "#0891b2",
19] as const;
20
21const FALLBACK_ACCENT_COLOR = PROVIDER_ACCENT_SWATCHES[0];
2212
23function clamp(value: number, min = 0, max = 1) {13function 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);
9182
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 <input155 <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: {
181function ProviderCustomColorPicker(props: {174function 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;
188181
...@@ -193,20 +186,13 @@ function ProviderCustomColorPicker(props: {...@@ -193,20 +186,13 @@ function ProviderCustomColorPicker(props: {
193 <button186 <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 );
298299
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 ? normalized304 value={normalized}
304 : "";305 onCommit={commitAccentColor}
305 const customSelected = Boolean(normalized && selectedValue === "");306 onClear={() => commitAccentColor("")}
306307 />
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 );
344309
345 if (layout === "inline") {310 if (layout === "inline") {
346 return swatchRow;311 return picker;
347 }312 }
348313
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";
32import { Button } from "../ui/button";32import { Button } from "../ui/button";
33import { DraftInput } from "../ui/draft-input";33import { DraftInput } from "../ui/draft-input";
34import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";34import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
35import { ScrollArea } from "../ui/scroll-area";
36import { Switch } from "../ui/switch";35import { Switch } from "../ui/switch";
37import { stackedThreadToast, toastManager } from "../ui/toast";36import { stackedThreadToast, toastManager } from "../ui/toast";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";37import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
39import type { DriverOption } from "./providerDriverMeta";38import type { DriverOption } from "./providerDriverMeta";
40import { providerSettingsTabClassName } from "./providerSettingsTabs";
41import { ProviderSettingsForm } from "./ProviderSettingsForm";39import { ProviderSettingsForm } from "./ProviderSettingsForm";
42import { ProviderModelsSection } from "./ProviderModelsSection";40import { ProviderModelsSection } from "./ProviderModelsSection";
43import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon";41import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon";
44import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";42import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
45import { RedactedSensitiveText } from "./RedactedSensitiveText";43import { RedactedSensitiveText } from "./RedactedSensitiveText";
44import { SettingsRow, SettingsSection } from "./settingsLayout";
46import {45import {
47 getProviderVersionAdvisoryPresentation,46 getProviderVersionAdvisoryPresentation,
48 PROVIDER_STATUS_STYLES,47 PROVIDER_STATUS_STYLES,
...@@ -53,13 +52,6 @@ import {...@@ -53,13 +52,6 @@ import {
5352
54const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;53const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
5554
56/** Label-left field grid for the Configuration tab: one row per field. */
57const PROVIDER_FIELD_GRID_CLASS_NAME =
58 "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start";
59/** Full-width divider row that names the group of fields below it. */
60const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME =
61 "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground";
62
63let environmentVariableDraftId = 0;55let environmentVariableDraftId = 0;
64const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;56const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
6557
...@@ -254,7 +246,7 @@ function ProviderEnvironmentSection(props: {...@@ -254,7 +246,7 @@ function ProviderEnvironmentSection(props: {
254 ]);246 ]);
255247
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 <DraftInput252 <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 variable332 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 its417 // 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.driver463 ? 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 probe467 // 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 );
563545
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 shadow559 // 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 the560 // 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 ? (
603565 <>
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 <div585 <div
586 data-slot="settings-row"
607 className={cn(587 className={cn(
608 // Sidebar-style selection with a fixed row height so the list stays588 "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 <button592 <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 }
662639
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; the648 <span
672 status line below keeps its email reveal clickable.649 inert={readOnly}
673 */}650 aria-disabled={readOnly || undefined}
674 <span651 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 <PopoverTrigger680 <p className="text-[13px] font-semibold leading-tight text-foreground">
682 render={681 Update available
683 <Button682 </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 available694 {onRunUpdate ? (
708 </p>695 <Button
709 <p696 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 <Button708 <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 using723 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 <TooltipTrigger735 />
749 render={736 <TooltipPopup side="top">Copy command</TooltipPopup>
750 <Button737 </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}
816745 {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 Models755 <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 );
841761
842 <div className="lg:min-h-0 lg:flex-1">762 return (
843 <ScrollArea763 <>
844 scrollFade764 <SettingsSection
845 chainVerticalScroll765 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 <div770 <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 <label776 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 <ProviderEnvironmentSection782 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 <div801
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 <ProviderModelsSection861 <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 "../
17import { Switch } from "../ui/switch";17import { Switch } from "../ui/switch";
18import { Textarea } from "../ui/textarea";18import { Textarea } from "../ui/textarea";
19import type { ProviderClientDefinition } from "./providerDriverMeta";19import type { ProviderClientDefinition } from "./providerDriverMeta";
20import { SettingsRow } from "./settingsLayout";
2021
21export interface ProviderSettingsFieldModel {22export 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 parent171 * 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}
177176
...@@ -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;
254253
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 }
325314
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}
166166
167function 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
174function flushEffects(): void {
175 for (const effect of settingsSearchState.effects.splice(0)) effect();
176}
177
178async function flushPromises(): Promise<void> {167async 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 });
299288
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();
303295
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 });
310303
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";
24import * as Duration from "effect/Duration";24import * as Duration from "effect/Duration";
25import * as Equal from "effect/Equal";25import * as Equal from "effect/Equal";
26import * as Result from "effect/Result";26import * as Result from "effect/Result";
27import { ChevronDownIcon, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";27import { PlusIcon, RefreshCwIcon } from "lucide-react";
28import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";28import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2929
30import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";30import { 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";
58import { Button } from "../ui/button";58import { Button } from "../ui/button";
59import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
60import {59import {
61 NumberField,60 NumberField,
62 NumberFieldDecrement,61 NumberFieldDecrement,
...@@ -196,7 +195,7 @@ interface ProviderSettingsTarget {...@@ -196,7 +195,7 @@ interface ProviderSettingsTarget {
196195
197export function ProviderSettingsPanel(target: ProviderSettingsTarget) {196export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
198 return (197 return (
199 <SettingsPageContainer className="gap-8">198 <SettingsPageContainer width="wide" className="gap-8">
200 <ProviderSettingsPanelContent199 <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 <div256 <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 credential481 * Grey out and freeze every write control when this session's credential
487 * lacks `orchestration:operate` on the environment. Selecting providers and482 * 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 is484 * 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());
516508
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 <SettingsSection911 <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 <SettingsRow968 <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>
999983
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 {targetInstanceMissing989 ) : (
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>
1012999
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 <ChevronDownIcon1002 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 Advanced1005 {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 <SettingsRow1009 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 policy1015 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
1029 decides whether provider probes may run when the timer fires. Custom intervals1016 <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 <SettingResetButton1026 )
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 ) : null1039 )}
1053 }1040 >
1054 control={1041 <NumberField
1055 <div1042 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 <NumberField1050 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>
10971072
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";
159import { searchableSetting } from "./settingsSearch";160import { 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};
204205
205const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile, string> = {206const 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};
211211
212const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION =212const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom intervals.";
213 "Uses custom background intervals with the selected shared power policy.";
214213
215const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");214const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
216const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{215const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{
...@@ -422,7 +421,7 @@ function AboutVersionSection() {...@@ -422,7 +421,7 @@ function AboutVersionSection() {
422 {hasDesktopBridge ? (421 {hasDesktopBridge ? (
423 <SettingsRow422 <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 <Select426 <Select
428 value={selectedUpdateChannel}427 value={selectedUpdateChannel}
...@@ -1091,7 +1090,7 @@ export function AppearanceSettingsPanel() {...@@ -1091,7 +1090,7 @@ export function AppearanceSettingsPanel() {
10911090
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 <ThemeLibrary1095 <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>
11101110
1111 <SettingsSection id="appearance-interface" title="Interface">
1111 <SettingsRow1112 <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() {
11581159
1159 <SettingsRow1160 <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 <SettingResetButton1165 <SettingResetButton
...@@ -1479,7 +1480,7 @@ function FontSmoothingRow() {...@@ -1479,7 +1480,7 @@ function FontSmoothingRow() {
1479 return (1480 return (
1480 <SettingsRow1481 <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 <SettingResetButton1486 <SettingResetButton
...@@ -1610,6 +1611,7 @@ function TypographySection() {...@@ -1610,6 +1611,7 @@ function TypographySection() {
1610 }, [searchTargetId, setAdvanced]);1611 }, [searchTargetId, setAdvanced]);
1611 return (1612 return (
1612 <SettingsSection1613 <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 fold1895 // 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]);
19071910
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 features1916 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 <SettingsRow1922 <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 <Switch1926 <Switch
1924 checked={settings.planModeEnabled}1927 checked={settings.planModeEnabled}
...@@ -1967,7 +1970,7 @@ function LegacyFeaturesSection() {...@@ -1967,7 +1970,7 @@ function LegacyFeaturesSection() {
1967 <SettingsRow1970 <SettingsRow
1968 serverScoped1971 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 <Switch1975 <Switch
1973 checked={settings.enableLegacyTokenStreaming}1976 checked={settings.enableLegacyTokenStreaming}
...@@ -1993,7 +1996,7 @@ function LegacyFeaturesSection() {...@@ -1993,7 +1996,7 @@ function LegacyFeaturesSection() {
1993 />1996 />
1994 <SettingsRow1997 <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 <Switch2001 <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 <SettingsRow2094 <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>
22022206
2207 <SettingsSection id="behavior" title="Behavior">
2203 <SettingsRow2208 <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 <SettingsRow2249 <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 <SettingsRow2274 <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() {
23052308
2306 <SettingsRow2309 <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 <SettingResetButton2314 <SettingResetButton
...@@ -2548,7 +2551,9 @@ export function GeneralSettingsPanel() {...@@ -2548,7 +2551,9 @@ export function GeneralSettingsPanel() {
2548 </>2551 </>
2549 }2552 }
2550 />2553 />
2554 </SettingsSection>
25512555
2556 <SettingsSection id="projects-and-threads" title="Projects & threads">
2552 <SettingsRow2557 <SettingsRow
2553 serverScoped2558 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 <SettingsRow2634 <SettingsRow
2631 serverScoped2635 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>
26592664
2665 <SettingsSection id="confirmations" title="Confirmations">
2660 <SettingsRow2666 <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>
27792786
2787 <SettingsSection id="text-generation" title="Text generation">
2780 <SettingsRow2788 <SettingsRow
2781 serverScoped2789 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 <SettingResetButton2794 <SettingResetButton
...@@ -2872,7 +2880,7 @@ export function GeneralSettingsPanel() {...@@ -2872,7 +2880,7 @@ export function GeneralSettingsPanel() {
2872 />2880 />
2873 </SettingsSection>2881 </SettingsSection>
28742882
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";
36import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";39import { 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}));
6972
73const 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
70function SettingsSectionIcon({ to }: { to: SettingsPath }) {100function 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 found298 No settings found
240 </p>299 </p>
241 ) : null}300 ) : null}
242 <SidebarMenu301 {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 {isSearching307 >
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 <SidebarMenuButton342 <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 credentials393 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 };
5049
...@@ -90,7 +89,7 @@ export function SourceControlWritingSettingsSection() {...@@ -90,7 +89,7 @@ export function SourceControlWritingSettingsSection() {
90 );89 );
9190
92 return (91 return (
93 <SettingsSection title="Text generation">92 <SettingsSection id="source-control-text-generation" title="Text generation">
94 <SettingsRow93 <SettingsRow
95 serverScoped94 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 <SettingsRow162 <SettingsRow
164 serverScoped163 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 <SettingResetButton168 <SettingResetButton
...@@ -196,7 +195,7 @@ export function SourceControlWritingSettingsSection() {...@@ -196,7 +195,7 @@ export function SourceControlWritingSettingsSection() {
196 <SettingsRow195 <SettingsRow
197 serverScoped196 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 );
720720
721 const renderModeTiles = () => (721 const renderModeTiles = () => (
722 <div722 <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 <div775 <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({
889885
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
20import { Button } from "../ui/button";20import { Button } from "../ui/button";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2222
23declare module "@tanstack/react-router" {
24 interface HistoryState {
25 settingsTargetHighlight?: boolean;
26 }
27}
28
23interface SettingsSearchTargetContextValue {29interface 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}
2734
28const noop = () => undefined;35const noop = () => undefined;
29const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({36const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({
30 targetId: null,37 targetId: null,
38 highlightTarget: true,
31 onTargetHandled: noop,39 onTargetHandled: noop,
32});40});
3341
34export function SettingsSearchTargetProvider({42export 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}
4659
47function scrollAndFocusSettingsTarget(target: HTMLElement): void {60function 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.firstElementChild67 markedScrollTarget ??
68 (target.tagName === "SECTION" && target.firstElementChild
51 ? (target.firstElementChild as HTMLElement)69 ? (target.firstElementChild as HTMLElement)
52 : target;70 : target);
5371
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 destination81 // 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}
7492
75function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {93export 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 );
87105
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) {
139157
140export function SettingsSection({158export 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 ...sectionProps167 ...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]);
319377
320 return (378 return (
321 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>379 <SettingsSearchTargetProvider
380 targetId={targetId}
381 highlightTarget={highlightTarget}
382 onTargetHandled={clearTargetHash}
383 >
322 <div384 <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-scroll386 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}
333395
334export function scrollToSettingsTarget(targetId: string): boolean {396export 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 });
203203
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",