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
17101710 ) : null}
17111711 <SettingsRow
17121712 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."
17141714 control={
17151715 <CloudLinkSwitch
17161716 ariaLabel="Publish agent activity to mobile clients"
......@@ -2907,7 +2907,7 @@ export function ConnectionsSettings() {
29072907 return (
29082908 <SettingsRow
29092909 {...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."
29112911 status={
29122912 desktopWslError ? (
29132913 <span className="block text-destructive">{desktopWslError}</span>
......@@ -2945,7 +2945,7 @@ export function ConnectionsSettings() {
29452945 <>
29462946 <SettingsRow
29472947 {...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."
29492949 status={
29502950 desktopWslError ? (
29512951 <span className="block text-destructive">{desktopWslError}</span>
......@@ -2994,7 +2994,7 @@ export function ConnectionsSettings() {
29942994 {desktopWslState.enabled ? (
29952995 <SettingsRow
29962996 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."
29982998 className="bg-muted/20 pl-7 sm:pl-8"
29992999 control={
30003000 <Switch
......@@ -3098,8 +3098,8 @@ export function ConnectionsSettings() {
30983098 title={searchableSetting("network-access").title}
30993099 description={
31003100 currentAuthPolicy === "remote-reachable"
3101 ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched."
3102 : "This backend is only reachable on this machine. Restart it with a non-loopback host to enable remote pairing."
3101 ? "Remote access is already configured. Change network exposure where the server starts."
3102 : "Only this machine can connect. Restart with a non-loopback host for remote pairing."
31033103 }
31043104 control={
31053105 <Tooltip>
apps/web/src/components/settings/IntegrationsSettings.tsx+6-6
......@@ -262,7 +262,7 @@ function BrowserViewportSetting({ disabled }: { readonly disabled: boolean }) {
262262 return (
263263 <SettingsRow
264264 {...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."
266266 resetAction={
267267 !disabled && viewport._tag !== DEFAULT_BROWSER_VIEWPORT._tag ? (
268268 <SettingResetButton
......@@ -460,7 +460,7 @@ function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boo
460460 return (
461461 <SettingsRow
462462 {...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."
464464 resetAction={
465465 !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? (
466466 <SettingResetButton
......@@ -557,7 +557,7 @@ function AgentBrowserAccessSetting() {
557557 <SettingsRow
558558 serverScoped
559559 {...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."
561561 status={
562562 settings.enableAgentBrowserAccess
563563 ? undefined
......@@ -595,7 +595,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled
595595 return (
596596 <SettingsRow
597597 {...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."
599599 resetAction={
600600 !disabled && autoShow !== DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW ? (
601601 <SettingResetButton
......@@ -638,7 +638,7 @@ function BrowserAutoShowFloatingPreviewSetting({ disabled }: { readonly disabled
638638 */
639639function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode }) {
640640 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">
642642 <div className="flex items-start gap-2 px-3 py-2 text-[12px] leading-relaxed text-muted-foreground sm:px-4">
643643 <InfoIcon className="mt-0.5 size-3.5 shrink-0 text-warning" />
644644 <p>Only available in the desktop app.</p>
......@@ -933,7 +933,7 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
933933 return (
934934 <SettingsRow
935935 {...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."
937937 control={
938938 <Menu onOpenChange={(open) => open && loadSources()}>
939939 <MenuTrigger
apps/web/src/components/settings/ProviderAccentColorPicker.tsx+36-71
......@@ -3,22 +3,12 @@
33import { PipetteIcon, XIcon } from "lucide-react";
44import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
55
6import { ColorSelector } from "../color-selector";
76import { Button } from "../ui/button";
8import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover";
98import { normalizeProviderAccentColor } from "../../providerInstances";
109import { cn } from "../../lib/utils";
1110
12const PROVIDER_ACCENT_SWATCHES = [
13 "#2563eb",
14 "#16a34a",
15 "#ea580c",
16 "#dc2626",
17 "#7c3aed",
18 "#0891b2",
19] as const;
20
21const FALLBACK_ACCENT_COLOR = PROVIDER_ACCENT_SWATCHES[0];
11const FALLBACK_ACCENT_COLOR = "#2563eb";
2212
2313function clamp(value: number, min = 0, max = 1) {
2414 return Math.min(max, Math.max(min, value));
......@@ -88,6 +78,7 @@ function ProviderCustomColorPanel(props: {
8878 const initialHsv = useMemo(() => hexToHsv(props.value), [props.value]);
8979 const [hsv, setHsv] = useState(initialHsv);
9080 const currentColor = hsvToHex(hsv.h, hsv.s, hsv.v);
81 const [hexDraft, setHexDraft] = useState<string | null>(null);
9182
9283 const commitHsv = useCallback(
9384 (nextHsv: typeof hsv) => {
......@@ -162,13 +153,15 @@ function ProviderCustomColorPanel(props: {
162153 />
163154 </div>
164155 <input
165 value={currentColor}
156 value={hexDraft ?? currentColor}
166157 onChange={(event) => {
167158 const nextColor = event.currentTarget.value;
159 setHexDraft(nextColor);
168160 if (!/^#[\da-f]{6}$/i.test(nextColor)) return;
169161 setHsv(hexToHsv(nextColor));
170162 props.onCommit(nextColor);
171163 }}
164 onBlur={() => setHexDraft(null)}
172165 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"
173166 aria-label="Custom hex accent color"
174167 spellCheck={false}
......@@ -181,8 +174,8 @@ function ProviderCustomColorPanel(props: {
181174function ProviderCustomColorPicker(props: {
182175 readonly displayName: string;
183176 readonly value: string | undefined;
184 readonly selected: boolean;
185177 readonly onCommit: (value: string) => void;
178 readonly onClear: () => void;
186179}) {
187180 const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR;
188181
......@@ -193,20 +186,13 @@ function ProviderCustomColorPicker(props: {
193186 <button
194187 type="button"
195188 className={cn(
196 "flex size-6 cursor-pointer items-center justify-center rounded-full text-white transition-transform duration-200 active:scale-90",
197 "hover:scale-105",
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",
190 "hover:scale-105 hover:border-ring/60",
198191 )}
199 style={{
200 backgroundColor: normalized,
201 ...(props.selected
202 ? {
203 boxShadow: `inset 0 0 0 2px var(--card), 0 0 0 2px ${normalized}`,
204 }
205 : {}),
206 }}
207 aria-label={`Choose custom accent color for ${props.displayName}`}
192 style={{ backgroundColor: normalized }}
193 aria-label={`Choose accent color for ${props.displayName}`}
208194 >
209 <PipetteIcon className="size-3 text-foreground/25" aria-hidden />
195 <PipetteIcon className="size-3 text-white/70 drop-shadow-sm" aria-hidden />
210196 </button>
211197 }
212198 />
......@@ -217,6 +203,21 @@ function ProviderCustomColorPicker(props: {
217203 className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0"
218204 >
219205 <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 />
220221 </PopoverPopup>
221222 </Popover>
222223 );
......@@ -297,59 +298,23 @@ export function ProviderAccentColorPicker(props: {
297298 );
298299
299300 const normalized = normalizeProviderAccentColor(optimisticValue);
300 const selectedValue =
301 normalized &&
302 PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number])
303 ? normalized
304 : "";
305 const customSelected = Boolean(normalized && selectedValue === "");
306
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>
301 const picker = (
302 <ProviderCustomColorPicker
303 displayName={displayName}
304 value={normalized}
305 onCommit={commitAccentColor}
306 onClear={() => commitAccentColor("")}
307 />
343308 );
344309
345310 if (layout === "inline") {
346 return swatchRow;
311 return picker;
347312 }
348313
349314 return (
350315 <div className="grid gap-2">
351316 <span className="text-xs font-medium text-foreground">Accent color</span>
352 {swatchRow}
317 {picker}
353318 {description ? <span className="text-xs text-muted-foreground">{description}</span> : null}
354319 </div>
355320 );
apps/web/src/components/settings/ProviderInstanceCard.tsx+247-303
......@@ -32,17 +32,16 @@ import { Badge } from "../ui/badge";
3232import { Button } from "../ui/button";
3333import { DraftInput } from "../ui/draft-input";
3434import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
35import { ScrollArea } from "../ui/scroll-area";
3635import { Switch } from "../ui/switch";
3736import { stackedThreadToast, toastManager } from "../ui/toast";
3837import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3938import type { DriverOption } from "./providerDriverMeta";
40import { providerSettingsTabClassName } from "./providerSettingsTabs";
4139import { ProviderSettingsForm } from "./ProviderSettingsForm";
4240import { ProviderModelsSection } from "./ProviderModelsSection";
4341import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon";
4442import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
4543import { RedactedSensitiveText } from "./RedactedSensitiveText";
44import { SettingsRow, SettingsSection } from "./settingsLayout";
4645import {
4746 getProviderVersionAdvisoryPresentation,
4847 PROVIDER_STATUS_STYLES,
......@@ -53,13 +52,6 @@ import {
5352
5453const 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
6355let environmentVariableDraftId = 0;
6456const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
6557
......@@ -254,7 +246,7 @@ function ProviderEnvironmentSection(props: {
254246 ]);
255247
256248 return (
257 <div className="min-w-0 space-y-1.5">
249 <div className="mt-3 min-w-0 space-y-2">
258250 {rows.map((variable, index) => (
259251 <div key={variable.id} className="flex min-w-0 items-center gap-1.5">
260252 <DraftInput
......@@ -329,16 +321,16 @@ function ProviderEnvironmentSection(props: {
329321 </Button>
330322 </div>
331323 ))}
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}
333330 <Button type="button" size="xs" variant="ghost-muted" onClick={addVariable}>
334331 <PlusIcon className="size-3" />
335332 Add variable
336333 </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>
342334 </div>
343335 </div>
344336 );
......@@ -421,7 +413,6 @@ export function ProviderInstanceCard({
421413 onRunUpdate,
422414 isUpdating = false,
423415}: ProviderInstanceCardProps) {
424 const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration");
425416 const enabled = resolveProviderInstanceEnabled(instance);
426417 // A locally disabled provider reads "Disabled" with a muted dot even if its
427418 // last server status is stale. Enabled providers use the server status.
......@@ -433,9 +424,6 @@ export function ProviderInstanceCard({
433424 ? getProviderSummary(liveProvider)
434425 : { headline: "Disabled", detail: null };
435426 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.
439427 const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated";
440428 const authLabel =
441429 enabled && liveProvider?.auth.status === "authenticated"
......@@ -474,8 +462,6 @@ export function ProviderInstanceCard({
474462 const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver)
475463 ? instance.driver
476464 : null;
477 const visibleTab = driverOption === undefined ? "configuration" : activeTab;
478
479465 const customModels =
480466 instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels");
481467 // Server-returned models may lag behind settings writes. Treat probe
......@@ -485,10 +471,6 @@ export function ProviderInstanceCard({
485471 liveModels: liveProvider?.models,
486472 customModels,
487473 });
488 const hiddenModelCount = modelsForDisplay.filter(
489 (model) => !model.isCustom && hiddenModels.includes(model.slug),
490 ).length;
491
492474 const updateDisplayName = (value: string) => {
493475 const trimmed = value.trim();
494476 const { displayName: _omit, ...rest } = instance;
......@@ -561,25 +543,6 @@ export function ProviderInstanceCard({
561543 </span>
562544 );
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
583546 const titleTailNode = headerAction ? (
584547 <span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">{headerAction}</span>
585548 ) : null;
......@@ -593,29 +556,43 @@ export function ProviderInstanceCard({
593556 statusKey === "warning" || statusKey === "error" ? (
594557 <span className={cn("size-1.5 shrink-0 rounded-full", statusStyle.dot)} aria-hidden />
595558 ) : null;
596 const statusHeadlineNode = <span>{summary.headline}</span>;
597559 // Trouble states carry the server's explanation (a failed probe, a shadow
598560 // home entry that is not a symlink, a missing binary). Show it wherever the
599561 // headline shows so the user can act without opening the editor.
600562 const needsAttention = statusKey === "warning" || statusKey === "error";
601 const statusLineClassName =
602 "flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80";
603
563 const editorStatusNode =
564 isAuthenticated && authEmail ? (
565 <>
566 {needsAttention ? statusDotNode : null}
567 <span>Authenticated as</span>
568 <ProviderAuthEmail email={authEmail} />
569 {authLabel ? <span>· {authLabel}</span> : null}
570 {summary.detail ? (
571 <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span>
572 ) : null}
573 </>
574 ) : (
575 <>
576 {statusDotNode}
577 <span>{summary.headline}</span>
578 {summary.detail ? (
579 <span className="min-w-0 [overflow-wrap:anywhere]">· {summary.detail}</span>
580 ) : null}
581 </>
582 );
604583 if (mode === "list") {
605584 return (
606585 <div
586 data-slot="settings-row"
607587 className={cn(
608 // Sidebar-style selection with a fixed row height so the list stays
609 // even; the status line clamps to two lines instead of growing.
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",
588 "group flex min-h-18 items-center gap-3 px-3 py-3 transition-colors sm:px-4",
589 selected ? "bg-muted/45" : "hover:bg-muted/25",
613590 )}
614591 >
615592 <button
616593 type="button"
617594 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",
619596 !enabled && !selected && "opacity-60 group-hover:opacity-100",
620597 )}
621598 onClick={onSelect}
......@@ -660,260 +637,227 @@ export function ProviderInstanceCard({
660637 );
661638 }
662639
663 return (
664 <div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col">
665 <div className="flex min-h-16 shrink-0 items-start justify-between gap-3 border-b border-border/70 px-4 py-3">
666 <div className="min-w-0 flex-1 space-y-1">
667 <div className="flex min-w-0 flex-wrap items-center gap-2">
668 {titleHeadNode}
669 {versionCodeNode}
670 {/*
671 Only the write actions go inert on read-only sessions; the
672 status line below keeps its email reveal clickable.
673 */}
674 <span
675 inert={readOnly}
676 aria-disabled={readOnly || undefined}
677 className={cn("inline-flex items-center gap-2", readOnly && "opacity-50")}
640 const editorHeaderAction = (
641 <div className="flex min-w-0 items-center gap-1.5">
642 {driverOption?.badgeLabel ? (
643 <Badge variant="warning" size="sm" className="shrink-0">
644 {driverOption.badgeLabel}
645 </Badge>
646 ) : null}
647 {versionCodeNode}
648 <span
649 inert={readOnly}
650 aria-disabled={readOnly || undefined}
651 className={cn("inline-flex items-center gap-1", readOnly && "opacity-50")}
652 >
653 {versionAdvisory ? (
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))]"
678677 >
679 {versionAdvisory ? (
680 <Popover>
681 <PopoverTrigger
682 render={
683 <Button
684 type="button"
685 size="icon-micro"
686 variant="ghost"
687 className={cn(
688 "[--control-icon-color:currentColor]",
689 versionAdvisory.emphasis === "strong"
690 ? "text-warning hover:text-warning"
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))]"
678 <div className="grid min-w-0 gap-3">
679 <div className="grid gap-0.5">
680 <p className="text-[13px] font-semibold leading-tight text-foreground">
681 Update available
682 </p>
683 <p
684 className={cn(
685 "text-xs leading-snug",
686 versionAdvisory.emphasis === "strong"
687 ? "text-warning"
688 : "text-muted-foreground",
689 )}
703690 >
704 <div className="grid min-w-0 gap-3">
705 <div className="grid gap-0.5">
706 <p className="text-[13px] font-semibold leading-tight text-foreground">
707 Update available
708 </p>
709 <p
710 className={cn(
711 "text-xs leading-snug",
712 versionAdvisory.emphasis === "strong"
713 ? "text-warning"
714 : "text-muted-foreground",
715 )}
716 >
717 {versionAdvisory.detail}
718 </p>
719 </div>
720 {onRunUpdate ? (
721 <Button
722 type="button"
723 size="xs"
724 variant="outline"
725 className="w-full"
726 disabled={isUpdating}
727 onClick={onRunUpdate}
728 >
729 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
730 {isUpdating ? "Updating" : "Update now"}
731 </Button>
732 ) : null}
733 {onRunUpdate && updateCommand ? (
734 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
735 <span aria-hidden className="h-px flex-1 bg-border" />
736 or, update manually using
737 <span aria-hidden className="h-px flex-1 bg-border" />
738 </div>
739 ) : null}
740 {updateCommand ? (
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">
742 <ScrollArea scrollFade className="h-8 min-w-0 flex-1 rounded-none">
743 <code className="flex h-full w-max items-center whitespace-nowrap pr-3 font-mono text-[11px] text-foreground">
744 {updateCommand}
745 </code>
746 </ScrollArea>
747 <Tooltip>
748 <TooltipTrigger
749 render={
750 <Button
751 type="button"
752 size="icon-xs"
753 variant="ghost"
754 className="shrink-0 text-muted-foreground hover:text-foreground"
755 onClick={() =>
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>
691 {versionAdvisory.detail}
692 </p>
693 </div>
694 {onRunUpdate ? (
695 <Button
696 type="button"
697 size="xs"
698 variant="outline"
699 className="w-full"
700 disabled={isUpdating}
701 onClick={onRunUpdate}
702 >
703 {isUpdating ? <LoaderIcon className="animate-spin" /> : <DownloadIcon />}
704 {isUpdating ? "Updating" : "Update now"}
705 </Button>
706 ) : null}
707 {onRunUpdate && updateCommand ? (
708 <div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
709 <span aria-hidden className="h-px flex-1 bg-border" />
710 or, update manually using
711 <span aria-hidden className="h-px flex-1 bg-border" />
712 </div>
713 ) : null}
714 {updateCommand ? (
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">
716 <code className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground">
717 {updateCommand}
718 </code>
719 <Tooltip>
720 <TooltipTrigger
721 render={
722 <Button
723 type="button"
724 size="icon-xs"
725 variant="ghost"
726 className="shrink-0 text-muted-foreground hover:text-foreground"
727 onClick={() =>
728 copyToClipboard(updateCommand, { providerName: displayName })
729 }
730 aria-label="Copy update command"
731 >
732 <CopyIcon className="size-3" />
733 </Button>
734 }
735 />
736 <TooltipPopup side="top">Copy command</TooltipPopup>
737 </Tooltip>
738 </div>
739 ) : null}
740 </div>
741 </PopoverPopup>
742 </Popover>
814743 ) : null}
815 </div>
816
817 <div className="flex h-11 shrink-0 border-b border-border/70 px-1">
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
744 {titleTailNode}
745 {onDelete ? (
746 <Button
828747 type="button"
829 aria-pressed={visibleTab === "models"}
830 className={cn(providerSettingsTabClassName(visibleTab === "models"), "gap-1.5")}
831 onClick={() => setActiveTab("models")}
748 size="icon-micro"
749 variant="ghost-muted"
750 disabled={readOnly}
751 className="[--control-icon-color:currentColor] hover:text-destructive"
752 onClick={onDelete}
753 aria-label={`Delete instance ${instanceId}`}
832754 >
833 Models
834 <span className="font-normal text-muted-foreground/70">
835 {modelsForDisplay.length}
836 {hiddenModelCount > 0 ? ` · ${hiddenModelCount} hidden` : ""}
837 </span>
838 </button>
755 <Trash2Icon className="size-3" />
756 </Button>
839757 ) : null}
840 </div>
758 </span>
759 </div>
760 );
841761
842 <div className="lg:min-h-0 lg:flex-1">
843 <ScrollArea
844 scrollFade
845 chainVerticalScroll
846 className="lg:h-full"
847 hidden={visibleTab !== "configuration"}
848 >
849 {setup ? <div className="border-b border-border/60 px-4 py-3">{setup}</div> : null}
850 <div
851 inert={readOnly}
852 aria-disabled={readOnly || undefined}
853 className={cn("px-4 py-4", readOnly && "opacity-50 select-none")}
854 >
855 <div className={PROVIDER_FIELD_GRID_CLASS_NAME}>
856 <label
857 htmlFor={`provider-instance-${instanceId}-display-name`}
858 className="pt-1.5 text-xs font-medium text-foreground"
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 </>
762 return (
763 <>
764 <SettingsSection
765 title={displayName}
766 description={editorStatusNode}
767 icon={titleIconNode}
768 headerAction={editorHeaderAction}
769 >
770 <SettingsRow
771 title="Display name"
772 control={
773 <div
774 inert={readOnly}
775 aria-disabled={readOnly || undefined}
776 className={cn(
777 "flex w-full items-center justify-end gap-2 sm:w-auto",
778 readOnly && "opacity-50 select-none",
899779 )}
900 <div className={PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME}>Environment</div>
901 <div className="col-span-full">
902 <ProviderEnvironmentSection
903 environment={instance.environment ?? []}
904 onChange={updateEnvironment}
905 />
906 </div>
780 >
781 <ProviderAccentColorPicker
782 layout="inline"
783 displayName={displayName}
784 value={accentColor}
785 onCommit={updateAccentColor}
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 />
907797 </div>
908 </div>
909 </ScrollArea>
910 {driverOption !== undefined ? (
911 <div
912 inert={readOnly}
913 aria-disabled={readOnly || undefined}
914 className={cn("px-4 py-5 lg:h-full lg:min-h-0", readOnly && "opacity-50 select-none")}
915 hidden={visibleTab !== "models"}
916 >
798 }
799 />
800 </SettingsSection>
801
802 {setup ? (
803 <SettingsSection title="Setup">
804 <div className="px-3 py-3 sm:px-4">{setup}</div>
805 </SettingsSection>
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">
917861 <ProviderModelsSection
918862 instanceId={instanceId}
919863 driverKind={driverKind}
......@@ -928,8 +872,8 @@ export function ProviderInstanceCard({
928872 onModelOrderChange={onModelOrderChange}
929873 />
930874 </div>
931 ) : null}
932 </div>
933 </div>
875 </SettingsSection>
876 ) : null}
877 </>
934878 );
935879}
apps/web/src/components/settings/ProviderSettingsForm.tsx+59-70
......@@ -17,6 +17,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
1717import { Switch } from "../ui/switch";
1818import { Textarea } from "../ui/textarea";
1919import type { ProviderClientDefinition } from "./providerDriverMeta";
20import { SettingsRow } from "./settingsLayout";
2021
2122export interface ProviderSettingsFieldModel {
2223 readonly key: string;
......@@ -167,11 +168,9 @@ interface ProviderSettingsFormProps {
167168 readonly idPrefix: string;
168169 /**
169170 * `card` stacks label over control, `dialog` is the compact wizard layout,
170 * `grid` emits a label cell and a control cell per field for a parent
171 * two-column grid (label column left, control right), with the description
172 * beside a fixed-width control so each field stays on one line.
171 * and `settings` renders the shared settings row treatment.
173172 */
174 readonly variant: "card" | "dialog" | "grid";
173 readonly variant: "card" | "dialog" | "settings";
175174 readonly onChange: (nextConfig: Record<string, unknown> | undefined) => void;
176175}
177176
......@@ -252,74 +251,64 @@ function ProviderSettingsFieldRow({
252251 <span className={descriptionClassName}>{field.description}</span>
253252 ) : null;
254253
255 if (variant === "grid") {
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.
254 if (variant === "settings") {
259255 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
260302 return (
261 <>
262 {field.control === "switch" ? (
263 <span className="pt-1.5 text-xs font-medium text-foreground">{field.label}</span>
264 ) : (
265 <label htmlFor={inputId} className="pt-1.5 text-xs font-medium text-foreground">
266 {field.label}
267 </label>
268 )}
269 <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
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 </>
303 <SettingsRow
304 title={
305 field.control === "switch" ? field.label : <label htmlFor={inputId}>{field.label}</label>
306 }
307 description={
308 field.description ? <span id={descriptionId}>{field.description}</span> : undefined
309 }
310 control={control}
311 />
323312 );
324313 }
325314
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+10-17
......@@ -164,17 +164,6 @@ function isAddProviderButton(element: ReactElement<Record<string, unknown>>): bo
164164 return element.props["aria-label"] === "Add provider";
165165}
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
178167async function flushPromises(): Promise<void> {
179168 await Promise.resolve();
180169 await Promise.resolve();
......@@ -297,15 +286,19 @@ describe("EnvironmentProviderSettings routing", () => {
297286 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
298287 });
299288
300 it("opens Advanced when search targets the provider health interval", () => {
301 settingsSearchState.targetId = "provider-health-check-interval";
289 it("keeps Advanced visible when search targets the provider health interval", () => {
302290 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);
305 flushEffects();
306
296 settingsSearchState.targetId = "provider-health-check-interval";
307297 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();
309302 });
310303
311304 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";
2424import * as Duration from "effect/Duration";
2525import * as Equal from "effect/Equal";
2626import * as Result from "effect/Result";
27import { ChevronDownIcon, LoaderIcon, PlusIcon, RefreshCwIcon } from "lucide-react";
27import { PlusIcon, RefreshCwIcon } from "lucide-react";
2828import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2929
3030import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
......@@ -56,7 +56,6 @@ import {
5656 type ProviderUpdateCandidate,
5757} from "../ProviderUpdateLaunchNotification.logic";
5858import { Button } from "../ui/button";
59import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible";
6059import {
6160 NumberField,
6261 NumberFieldDecrement,
......@@ -196,7 +195,7 @@ interface ProviderSettingsTarget {
196195
197196export function ProviderSettingsPanel(target: ProviderSettingsTarget) {
198197 return (
199 <SettingsPageContainer className="gap-8">
198 <SettingsPageContainer width="wide" className="gap-8">
200199 <ProviderSettingsPanelContent
201200 key={`${target.environmentId ?? ""}:${target.instanceId ?? ""}`}
202201 {...target}
......@@ -254,11 +253,7 @@ function ProviderSettingsPanelContent(target: ProviderSettingsTarget) {
254253 const deviceTabs =
255254 !onlyPrimaryDevice && options.length > 0 ? (
256255 <ScrollArea hideScrollbars scrollFade className="mx-3 h-11 min-w-0 rounded-none sm:mx-4">
257 <div
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 >
256 <div role="group" aria-label="Devices" className="flex h-full w-max min-w-full px-1">
262257 {options.map((environment) => {
263258 const machine = resolveEnvironmentMachineKind(environment.serverConfig);
264259 const selected = environment.environmentId === effectiveEnvironmentId;
......@@ -484,10 +479,9 @@ export function EnvironmentProviderSettings({
484479 readonly targetInstanceId?: ProviderInstanceId | undefined;
485480 /**
486481 * Grey out and freeze every write control when this session's credential
487 * lacks `orchestration:operate` on the environment. Selecting providers and
488 * opening Advanced still work so the real configuration stays readable;
489 * switches, forms, and the health interval are inert so no write is
490 * offered and then rejected.
482 * lacks `orchestration:operate` on the environment. Selecting providers
483 * still works so the real configuration stays readable; switches, forms,
484 * and the health interval are inert so no write is offered and then rejected.
491485 */
492486 readonly readOnly?: boolean;
493487}) {
......@@ -506,20 +500,12 @@ export function EnvironmentProviderSettings({
506500 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(
507501 targetInstanceId ?? null,
508502 );
509 const [advancedOpen, setAdvancedOpen] = useState(false);
510 const searchTargetId = useSettingsSearchTargetId();
511503 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
512504 ReadonlySet<ProviderDriverKind>
513505 >(() => new Set());
514506 const refreshingRef = useRef(false);
515507 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
523509 const providerUpdateCandidates = useMemo(
524510 () => collectProviderUpdateCandidates(serverProviders),
525511 [serverProviders],
......@@ -924,6 +910,7 @@ export function EnvironmentProviderSettings({
924910 <>
925911 <SettingsSection
926912 {...searchableSetting("providers")}
913 variant="plain"
927914 headerAction={
928915 <div className="flex min-w-0 items-center gap-2">
929916 {readOnly ? (
......@@ -978,121 +965,109 @@ export function EnvironmentProviderSettings({
978965 >
979966 {deviceTabs}
980967 {readOnly ? (
981 <SettingsRow
982 title="Limited permissions"
983 description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`}
984 />
968 <div className="overflow-hidden rounded-xl border border-border/60 bg-card/40 shadow-xs/5">
969 <SettingsRow
970 title="Limited permissions"
971 description={`This session can view ${environmentLabel}'s providers but can't change their settings.`}
972 />
973 </div>
985974 ) : null}
986 <div className="space-y-1">
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)]">
988 <div className="border-b border-border/70 lg:flex lg:min-h-0 lg:flex-col lg:border-r lg:border-b-0">
989 <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1">
990 <div className="divide-y divide-border/60">
991 {rows.map((row) => (
992 <div key={row.instanceId} className="p-1">
993 {renderProviderInstance(row, "list")}
994 </div>
995 ))}
996 </div>
997 </ScrollArea>
998 </div>
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)]">
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">
977 <ScrollArea scrollFade chainVerticalScroll className="lg:min-h-0 lg:flex-1">
978 <div className="divide-y divide-border/50">
979 {rows.map((row) => renderProviderInstance(row, "list"))}
980 </div>
981 </ScrollArea>
982 </div>
999983
1000 <div className="min-w-0 lg:min-h-0">
1001 {selectedRow ? (
1002 renderProviderInstance(selectedRow, "editor")
1003 ) : (
1004 <div className="p-6 text-sm text-muted-foreground">
1005 {targetInstanceMissing
1006 ? "This provider instance is no longer available on this device."
1007 : "No providers configured."}
1008 </div>
1009 )}
1010 </div>
984 <div className="min-w-0 lg:min-h-0">
985 {selectedRow ? (
986 <ScrollArea scrollFade chainVerticalScroll className="lg:h-full">
987 <div className="space-y-6 p-4">{renderProviderInstance(selectedRow, "editor")}</div>
988 </ScrollArea>
989 ) : (
990 <div className="p-6 text-sm text-muted-foreground">
991 {targetInstanceMissing
992 ? "This provider instance is no longer available on this device."
993 : "No providers configured."}
994 </div>
995 )}
1011996 </div>
997 </div>
998 </SettingsSection>
1012999
1013 <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen} className="mt-1">
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">
1015 <ChevronDownIcon
1016 className={cn("size-3 transition-transform", advancedOpen && "rotate-180")}
1017 />
1018 Advanced
1019 </CollapsibleTrigger>
1020 <CollapsibleContent>
1021 {/* Only the write controls go inert; the title and its policy tooltip stay readable. */}
1022 <SettingsRow
1023 id={searchableSetting("provider-health-check-interval").id}
1024 title={
1025 <span className="inline-flex items-center gap-1.5">
1026 {searchableSetting("provider-health-check-interval").title}
1027 <PolicyTooltip>
1028 This interval is configured here, then the shared Background activity policy
1029 decides whether provider probes may run when the timer fires. Custom intervals
1030 appear as Advanced in General settings.
1031 </PolicyTooltip>
1032 </span>
1033 }
1034 description="Refresh availability, versions, auth state, and models in the background. 0 seconds turns background checks off."
1035 resetAction={
1036 providerHealthRefreshIntervalSeconds !==
1037 defaultProviderHealthRefreshIntervalSeconds ? (
1038 <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}>
1039 <SettingResetButton
1040 label="provider health check interval"
1041 onClick={() =>
1042 updateSettings(
1043 backgroundActivityOverrideSettings(
1044 settings.backgroundActivity,
1045 resolvedBackgroundActivity,
1046 { providerHealthRefreshInterval: undefined },
1047 ),
1048 )
1049 }
1050 />
1051 </span>
1052 ) : null
1053 }
1054 control={
1055 <div
1056 inert={readOnly}
1057 aria-disabled={readOnly || undefined}
1058 className={cn(
1059 "flex shrink-0 items-center gap-2",
1060 readOnly && "opacity-50 select-none",
1061 )}
1062 >
1063 <NumberField
1064 value={providerHealthRefreshIntervalSeconds}
1065 min={0}
1066 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
1067 size="sm"
1068 className="w-32"
1069 onValueChange={(value) =>
1070 updateSettings(
1071 backgroundActivityOverrideSettings(
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>
1000 <SettingsSection title="Advanced">
1001 <SettingsRow
1002 id={searchableSetting("provider-health-check-interval").id}
1003 title={
1004 <span className="inline-flex items-center gap-1.5">
1005 {searchableSetting("provider-health-check-interval").title}
1006 <PolicyTooltip>
1007 This interval is configured here, then the shared Background activity policy decides
1008 whether provider probes may run when the timer fires. Custom intervals appear as
1009 Advanced in General settings.
1010 </PolicyTooltip>
1011 </span>
1012 }
1013 description="Refresh provider status, versions, and models in the background. Set to 0 to disable."
1014 resetAction={
1015 providerHealthRefreshIntervalSeconds !== defaultProviderHealthRefreshIntervalSeconds ? (
1016 <span inert={readOnly} className={readOnly ? "opacity-50" : undefined}>
1017 <SettingResetButton
1018 label="provider health check interval"
1019 onClick={() =>
1020 updateSettings(
1021 backgroundActivityOverrideSettings(
1022 settings.backgroundActivity,
1023 resolvedBackgroundActivity,
1024 { providerHealthRefreshInterval: undefined },
1025 ),
1026 )
1027 }
1028 />
1029 </span>
1030 ) : null
1031 }
1032 control={
1033 <div
1034 inert={readOnly}
1035 aria-disabled={readOnly || undefined}
1036 className={cn(
1037 "flex shrink-0 items-center gap-2",
1038 readOnly && "opacity-50 select-none",
1039 )}
1040 >
1041 <NumberField
1042 value={providerHealthRefreshIntervalSeconds}
1043 min={0}
1044 step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS}
1045 size="sm"
1046 className="w-32"
1047 onValueChange={(value) =>
1048 updateSettings(
1049 backgroundActivityOverrideSettings(
1050 settings.backgroundActivity,
1051 resolvedBackgroundActivity,
1052 {
1053 providerHealthRefreshInterval: Duration.seconds(
1054 normalizeIntervalSeconds(value),
1055 ),
1056 },
1057 ),
1058 )
10911059 }
1092 />
1093 </CollapsibleContent>
1094 </Collapsible>
1095 </div>
1060 >
1061 <NumberFieldGroup>
1062 <NumberFieldDecrement aria-label="Decrease provider health check interval" />
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 />
10961071 </SettingsSection>
10971072
10981073 {isAddInstanceDialogOpen ? (
apps/web/src/components/settings/SettingsPanels.tsx+30-22
......@@ -154,6 +154,7 @@ import {
154154 SettingsPageContainer,
155155 SettingsRow,
156156 SettingsSection,
157 useSettingsSearchTarget,
157158 useSettingsSearchTargetId,
158159} from "./settingsLayout";
159160import { searchableSetting } from "./settingsSearch";
......@@ -203,14 +204,12 @@ const BACKGROUND_ACTIVITY_PROFILE_OPTION_LABELS: Record<BackgroundActivityProfil
203204};
204205
205206const BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS: Record<BackgroundActivityProfile, string> = {
206 balanced:
207 "Pauses background probes when clients are idle, the host is locked, or low power mode is active.",
207 balanced: "Pauses probes for idle clients, locked hosts, or low power mode.",
208208 performance: "Allows scoped background probes while any subscribed client remains connected.",
209209 "battery-saver": "Also pauses background probes when the host or client is on battery.",
210210};
211211
212const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION =
213 "Uses custom background intervals with the selected shared power policy.";
212const ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION = "Uses custom intervals.";
214213
215214const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
216215const BACKGROUND_ACTIVITY_BOOLEAN_OVERRIDES: ReadonlyArray<{
......@@ -422,7 +421,7 @@ function AboutVersionSection() {
422421 {hasDesktopBridge ? (
423422 <SettingsRow
424423 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."
426425 control={
427426 <Select
428427 value={selectedUpdateChannel}
......@@ -1091,7 +1090,7 @@ export function AppearanceSettingsPanel() {
10911090
10921091 return (
10931092 <SettingsPageContainer>
1094 <SettingsSection id="appearance" title="Appearance">
1093 <SettingsSection id="appearance" title="Colors & themes" variant="plain" hideTitle>
10951094 <div id={searchableSetting("theme").id}>
10961095 <ThemeLibrary
10971096 appearanceMode={appearanceMode}
......@@ -1107,7 +1106,9 @@ export function AppearanceSettingsPanel() {
11071106 onImportOpenChange={setIsImportThemeOpen}
11081107 />
11091108 </div>
1109 </SettingsSection>
11101110
1111 <SettingsSection id="appearance-interface" title="Interface">
11111112 <SettingsRow
11121113 {...searchableSetting("setting-appearance-contrast")}
11131114 description="Adjust the contrast of colors and borders across the interface."
......@@ -1158,7 +1159,7 @@ export function AppearanceSettingsPanel() {
11581159
11591160 <SettingsRow
11601161 {...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."
11621163 resetAction={
11631164 settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? (
11641165 <SettingResetButton
......@@ -1479,7 +1480,7 @@ function FontSmoothingRow() {
14791480 return (
14801481 <SettingsRow
14811482 {...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."
14831484 resetAction={
14841485 settings.fontSmoothing !== DEFAULT_UNIFIED_SETTINGS.fontSmoothing ? (
14851486 <SettingResetButton
......@@ -1610,6 +1611,7 @@ function TypographySection() {
16101611 }, [searchTargetId, setAdvanced]);
16111612 return (
16121613 <SettingsSection
1614 id="typography"
16131615 title="Typography"
16141616 headerAction={
16151617 <label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground">
......@@ -1889,6 +1891,7 @@ function LegacyFeaturesSection() {
18891891 const updateSettings = useUpdatePrimarySettings();
18901892 const [open, setOpen] = useState(false);
18911893 const searchTargetId = useSettingsSearchTargetId();
1894 const targetRef = useSettingsSearchTarget<HTMLElement>("legacy-features");
18921895 // Unfold once per search jump; tracking the handled id lets the user fold
18931896 // the section back up without the still-set target immediately reopening it.
18941897 const lastExpandedTargetRef = useRef<string | null>(null);
......@@ -1906,19 +1909,19 @@ function LegacyFeaturesSection() {
19061909 }, [searchTargetId]);
19071910
19081911 return (
1909 <section className="space-y-3">
1912 <section id="legacy-features" ref={targetRef} tabIndex={-1} className="space-y-2.5">
19101913 <Collapsible open={open} onOpenChange={setOpen}>
19111914 <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">
19131916 Legacy features
19141917 </h2>
19151918 <ChevronRightIcon className="size-4 text-muted-foreground transition-transform duration-200 group-data-panel-open:rotate-90" />
19161919 </CollapsibleTrigger>
19171920 <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">
19191922 <SettingsRow
19201923 {...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."
19221925 control={
19231926 <Switch
19241927 checked={settings.planModeEnabled}
......@@ -1967,7 +1970,7 @@ function LegacyFeaturesSection() {
19671970 <SettingsRow
19681971 serverScoped
19691972 {...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."
19711974 control={
19721975 <Switch
19731976 checked={settings.enableLegacyTokenStreaming}
......@@ -1993,7 +1996,7 @@ function LegacyFeaturesSection() {
19931996 />
19941997 <SettingsRow
19951998 {...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."
19972000 control={
19982001 <Switch
19992002 checked={settings.legacySidebarEnabled}
......@@ -2075,7 +2078,7 @@ export function GeneralSettingsPanel() {
20752078 const backgroundActivityProfileOption = resolveBackgroundActivityProfileOption(settings);
20762079 const backgroundActivityDescription =
20772080 backgroundActivityProfileOption === "advanced"
2078 ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Current shared policy: ${
2081 ? `${ADVANCED_BACKGROUND_ACTIVITY_DESCRIPTION} Shared policy: ${
20792082 BACKGROUND_ACTIVITY_PROFILE_LABELS[activeBackgroundActivityProfile]
20802083 }.`
20812084 : BACKGROUND_ACTIVITY_PROFILE_DESCRIPTIONS[resolvedBackgroundActivity.profile];
......@@ -2087,7 +2090,7 @@ export function GeneralSettingsPanel() {
20872090 return (
20882091 <SettingsPageContainer>
20892092 <SharedSettingsMismatchAlert />
2090 <SettingsSection title="General">
2093 <SettingsSection id="organization" title="Organization">
20912094 <SettingsRow
20922095 {...searchableSetting("project-grouping")}
20932096 description="Combine matching repositories across environments."
......@@ -2199,7 +2202,9 @@ export function GeneralSettingsPanel() {
21992202 ) : null}
22002203 </>
22012204 ) : null}
2205 </SettingsSection>
22022206
2207 <SettingsSection id="behavior" title="Behavior">
22032208 <SettingsRow
22042209 {...searchableSetting("time-format")}
22052210 description="System default follows your browser or OS clock preference."
......@@ -2241,7 +2246,6 @@ export function GeneralSettingsPanel() {
22412246 </Select>
22422247 }
22432248 />
2244
22452249 <SettingsRow
22462250 {...searchableSetting("hide-whitespace-changes")}
22472251 description="Set whether the diff panel ignores whitespace-only edits by default."
......@@ -2267,7 +2271,6 @@ export function GeneralSettingsPanel() {
22672271 />
22682272 }
22692273 />
2270
22712274 <SettingsRow
22722275 {...searchableSetting("diff-layout")}
22732276 description="Show diffs stacked or side by side. The toggle in the diff toolbar changes this too."
......@@ -2305,7 +2308,7 @@ export function GeneralSettingsPanel() {
23052308
23062309 <SettingsRow
23072310 {...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."
23092312 resetAction={
23102313 settings.proactivePanelsEnabled !== DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled ? (
23112314 <SettingResetButton
......@@ -2548,7 +2551,9 @@ export function GeneralSettingsPanel() {
25482551 </>
25492552 }
25502553 />
2554 </SettingsSection>
25512555
2556 <SettingsSection id="projects-and-threads" title="Projects & threads">
25522557 <SettingsRow
25532558 serverScoped
25542559 {...searchableSetting("new-threads")}
......@@ -2626,7 +2631,6 @@ export function GeneralSettingsPanel() {
26262631 }
26272632 />
26282633 ) : null}
2629
26302634 <SettingsRow
26312635 serverScoped
26322636 {...searchableSetting("add-project-starts-in")}
......@@ -2656,7 +2660,9 @@ export function GeneralSettingsPanel() {
26562660 />
26572661 }
26582662 />
2663 </SettingsSection>
26592664
2665 <SettingsSection id="confirmations" title="Confirmations">
26602666 <SettingsRow
26612667 {...searchableSetting("unpin-confirmation")}
26622668 description="Ask before unpinning a thread from the pinned section."
......@@ -2776,11 +2782,13 @@ export function GeneralSettingsPanel() {
27762782 }
27772783 />
27782784 ) : null}
2785 </SettingsSection>
27792786
2787 <SettingsSection id="text-generation" title="Text generation">
27802788 <SettingsRow
27812789 serverScoped
27822790 {...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."
27842792 resetAction={
27852793 isTextGenerationModelDirty ? (
27862794 <SettingResetButton
......@@ -2872,7 +2880,7 @@ export function GeneralSettingsPanel() {
28722880 />
28732881 </SettingsSection>
28742882
2875 <SettingsSection title="About">
2883 <SettingsSection id="about" title="About">
28762884 {isElectron || HOSTED_APP_CHANNEL ? (
28772885 <AboutVersionSection />
28782886 ) : (
apps/web/src/components/settings/SettingsSidebarNav.tsx+126-46
......@@ -31,6 +31,9 @@ import {
3131 SidebarMenu,
3232 SidebarMenuButton,
3333 SidebarMenuItem,
34 SidebarMenuSub,
35 SidebarMenuSubButton,
36 SidebarMenuSubItem,
3437 useSidebar,
3538} from "../ui/sidebar";
3639import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
......@@ -67,6 +70,33 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
6770 icon: SETTINGS_SECTION_ICONS[to],
6871}));
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
70100function SettingsSectionIcon({ to }: { to: SettingsPath }) {
71101 const Icon = SETTINGS_SECTION_ICONS[to];
72102 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 }) {
133163 if (isMobile) {
134164 setOpenMobile(false);
135165 }
136 void navigate({ to, hash: "", replace: true, hashScrollIntoView: false });
166 void navigate({
167 to,
168 hash: "",
169 replace: true,
170 hashScrollIntoView: false,
171 });
137172 },
138173 [isMobile, navigate, setOpenMobile],
139174 );
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 );
140193 const clearSearch = useCallback(() => {
141194 setQuery("");
142195 setActiveResultIndex(0);
......@@ -152,7 +205,13 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
152205 scrollToSettingsTarget(targetId);
153206 return;
154207 }
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 });
156215 },
157216 [clearSearch, currentHash, isMobile, navigate, pathname, setOpenMobile],
158217 );
......@@ -239,54 +298,75 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
239298 No settings found
240299 </p>
241300 ) : null}
242 <SidebarMenu
243 className="ps-px"
244 id={isSearching && hasResults ? "settings-search-results" : undefined}
245 role={isSearching && hasResults ? "listbox" : undefined}
246 aria-label={isSearching && hasResults ? "Settings search results" : undefined}
247 >
248 {isSearching
249 ? results.map((item, index) => (
250 <SidebarMenuItem key={item.id} role="presentation">
301 {isSearching ? (
302 <SidebarMenu
303 className="ps-px"
304 id={hasResults ? "settings-search-results" : undefined}
305 role={hasResults ? "listbox" : undefined}
306 aria-label={hasResults ? "Settings search results" : undefined}
307 >
308 {results.map((item, index) => (
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}>
251342 <SidebarMenuButton
252 id={`settings-search-result-${item.id}`}
253 role="option"
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)}
343 isActive={isActive}
344 onClick={() => handleSectionClick(item.to)}
261345 >
262 <SettingsSectionIcon to={item.to} />
263 <span className="min-w-0 flex-1">
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>
346 <Icon />
347 <span className="truncate">{item.label}</span>
271348 </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}
272365 </SidebarMenuItem>
273 ))
274 : SETTINGS_NAV_ITEMS.map((item) => {
275 const Icon = item.icon;
276 const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
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>
366 );
367 })}
368 </SidebarMenu>
369 )}
290370 </SidebarGroup>
291371 </SidebarContent>
292372 <SidebarFooter className="p-[var(--sidebar-content-inset)]">
apps/web/src/components/settings/SourceControlSettings.tsx+1-2
......@@ -390,8 +390,7 @@ function GitFetchIntervalSettings() {
390390 </span>
391391 </div>
392392 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
393 Refresh remote branch status in the background. Set this to 0 seconds if Git credentials
394 or security keys should only be prompted by explicit Git actions.
393 Refresh remote branches in the background. Set to 0 to avoid automatic Git prompts.
395394 </p>
396395 </div>
397396 <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
3838 },
3939 conventional_commits: {
4040 label: "Conventional Commits",
41 description:
42 "Uses Conventional Commit prefixes for change descriptions; change request titles and descriptions stay concise.",
41 description: "Use Conventional Commit prefixes and keep change request text concise.",
4342 },
4443 custom: {
4544 label: "Custom instructions",
4645 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.",
4847 },
4948 };
5049
......@@ -90,7 +89,7 @@ export function SourceControlWritingSettingsSection() {
9089 );
9190
9291 return (
93 <SettingsSection title="Text generation">
92 <SettingsSection id="source-control-text-generation" title="Text generation">
9493 <SettingsRow
9594 serverScoped
9695 {...searchableSetting("source-control-writing-style")}
......@@ -163,7 +162,7 @@ export function SourceControlWritingSettingsSection() {
163162 <SettingsRow
164163 serverScoped
165164 {...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."
167166 resetAction={
168167 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
169168 <SettingResetButton
......@@ -196,7 +195,7 @@ export function SourceControlWritingSettingsSection() {
196195 <SettingsRow
197196 serverScoped
198197 {...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."
200199 control={
201200 <div className="flex flex-wrap items-center justify-end gap-2">
202201 {usesDedicatedModel && !canEnableDedicatedModel ? (
apps/web/src/components/settings/ThemeSettings.tsx+4-11
......@@ -719,11 +719,7 @@ export function ThemeLibrary({
719719 );
720720
721721 const renderModeTiles = () => (
722 <div
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 >
722 <div aria-label="Appearance mode" className="grid w-full grid-cols-3 gap-3" role="group">
727723 {(["system", "light", "dark"] as const).map((mode) => {
728724 const isActive = appearanceMode === mode;
729725 return (
......@@ -777,7 +773,7 @@ export function ThemeLibrary({
777773 // accepted — scoping the group tighter makes the handoffs feel sluggish.
778774 <TooltipProvider>
779775 <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"
781777 style={{ gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 16rem), 1fr))" }}
782778 >
783779 {STANDARD_THEME_CARDS.map((standardTheme) => (
......@@ -889,15 +885,12 @@ export function ThemeLibrary({
889885
890886 return (
891887 <div className="space-y-3">
892 <p className="px-3 text-[13px] leading-[1.45] text-muted-foreground/80 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">
888 <h3 className="px-3 text-sm font-normal tracking-[-0.005em] text-foreground/70 sm:px-4">
896889 {searchableSetting("color-scheme").title}
897890 </h3>
898891 {renderModeTiles()}
899892 <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">
901894 {searchableSetting("theme").title}
902895 </h3>
903896 <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
2020import { Button } from "../ui/button";
2121import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2222
23declare module "@tanstack/react-router" {
24 interface HistoryState {
25 settingsTargetHighlight?: boolean;
26 }
27}
28
2329interface SettingsSearchTargetContextValue {
2430 readonly targetId: string | null;
31 readonly highlightTarget: boolean;
2532 readonly onTargetHandled: () => void;
2633}
2734
2835const noop = () => undefined;
2936const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({
3037 targetId: null,
38 highlightTarget: true,
3139 onTargetHandled: noop,
3240});
3341
3442export function SettingsSearchTargetProvider({
3543 targetId,
44 highlightTarget = true,
3645 onTargetHandled = noop,
3746 children,
3847}: {
3948 targetId: string | null;
49 highlightTarget?: boolean;
4050 onTargetHandled?: () => void;
4151 children: ReactNode;
4252}) {
43 const value = useMemo(() => ({ targetId, onTargetHandled }), [onTargetHandled, targetId]);
53 const value = useMemo(
54 () => ({ targetId, highlightTarget, onTargetHandled }),
55 [highlightTarget, onTargetHandled, targetId],
56 );
4457 return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>;
4558}
4659
47function scrollAndFocusSettingsTarget(target: HTMLElement): void {
60function scrollAndFocusSettingsTarget(target: HTMLElement, highlight = true): void {
4861 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;
4966 const scrollTarget =
50 target.tagName === "SECTION" && target.firstElementChild
67 markedScrollTarget ??
68 (target.tagName === "SECTION" && target.firstElementChild
5169 ? (target.firstElementChild as HTMLElement)
52 : target;
70 : target);
5371
5472 scrollTarget.scrollIntoView({
5573 behavior: prefersReducedMotion ? "auto" : "smooth",
......@@ -57,7 +75,7 @@ function scrollAndFocusSettingsTarget(target: HTMLElement): void {
5775 });
5876 target.focus({ preventScroll: true });
5977 target.classList.remove("settings-search-target-pulse");
60 if (prefersReducedMotion) return;
78 if (!highlight || prefersReducedMotion) return;
6179 void target.offsetWidth;
6280 target.classList.add("settings-search-target-pulse");
6381 // The class also suppresses the focus outline (the pulse is the destination
......@@ -72,17 +90,17 @@ export function useSettingsSearchTargetId(): string | null {
7290 return useContext(SettingsSearchTargetContext).targetId;
7391}
7492
75function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {
76 const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext);
93export function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {
94 const { targetId, highlightTarget, onTargetHandled } = useContext(SettingsSearchTargetContext);
7795 const isSearchTarget = id !== undefined && id === targetId;
7896 const targetRef = useCallback(
7997 (target: T | null) => {
8098 if (target && isSearchTarget) {
81 scrollAndFocusSettingsTarget(target);
99 scrollAndFocusSettingsTarget(target, highlightTarget);
82100 onTargetHandled();
83101 }
84102 },
85 [isSearchTarget, onTargetHandled],
103 [highlightTarget, isSearchTarget, onTargetHandled],
86104 );
87105
88106 return targetRef;
......@@ -139,15 +157,21 @@ export function useRelativeTimeTick(intervalMs = 1_000) {
139157
140158export function SettingsSection({
141159 title,
160 description,
161 hideTitle = false,
142162 icon,
143163 headerAction,
164 variant = "grouped",
144165 children,
145166 className,
146167 ...sectionProps
147168}: ComponentPropsWithoutRef<"section"> & {
148169 title: string;
170 description?: ReactNode;
171 hideTitle?: boolean;
149172 icon?: ReactNode;
150173 headerAction?: ReactNode;
174 variant?: "grouped" | "plain";
151175 children: ReactNode;
152176}) {
153177 const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id);
......@@ -157,16 +181,40 @@ export function SettingsSection({
157181 {...sectionProps}
158182 ref={targetRef}
159183 tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex}
160 className={cn("space-y-3", className)}
184 className={cn(!hideTitle && "space-y-2.5", className)}
161185 >
162 <div className="flex min-h-8 items-center justify-between gap-4 px-3 sm:px-4">
163 <h2 className="flex items-center gap-2 text-lg font-semibold tracking-[-0.025em] text-foreground">
164 {icon}
165 {title}
166 </h2>
167 <div className="flex min-h-7 min-w-7 items-center justify-end">{headerAction}</div>
186 {hideTitle ? (
187 <h2 className="sr-only">{title}</h2>
188 ) : (
189 <div
190 data-settings-scroll-target
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}
168217 </div>
169 <div className="relative space-y-1 overflow-visible text-foreground">{children}</div>
170218 </section>
171219 );
172220}
......@@ -235,6 +283,7 @@ export function SettingsRow({
235283 {...rowProps}
236284 ref={targetRef}
237285 tabIndex={rowProps.id ? -1 : rowProps.tabIndex}
286 data-slot="settings-row"
238287 className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)}
239288 >
240289 <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({
312361}) {
313362 const navigate = useNavigate();
314363 const hash = useLocation({ select: (location) => location.hash });
364 const highlightTarget = useLocation({
365 select: (location) => location.state.settingsTargetHighlight !== false,
366 });
315367 const targetId = hash.replace(/^#/, "") || null;
316368 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 });
318376 }, [navigate]);
319377
320378 return (
321 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>
379 <SettingsSearchTargetProvider
380 targetId={targetId}
381 highlightTarget={highlightTarget}
382 onTargetHandled={clearTargetHash}
383 >
322384 <div
323385 className="topbar-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto"
324386 data-settings-page-scroll
325387 >
326 <WorkspacePageContainer width={width} className={cn("gap-12", className)}>
388 <WorkspacePageContainer width={width} className={cn("gap-8", className)}>
327389 {children}
328390 </WorkspacePageContainer>
329391 </div>
......@@ -331,9 +393,12 @@ export function SettingsPageContainer({
331393 );
332394}
333395
334export function scrollToSettingsTarget(targetId: string): boolean {
396export function scrollToSettingsTarget(
397 targetId: string,
398 { highlight = true }: { readonly highlight?: boolean } = {},
399): boolean {
335400 const target = document.getElementById(targetId);
336401 if (!target) return false;
337 scrollAndFocusSettingsTarget(target);
402 scrollAndFocusSettingsTarget(target, highlight);
338403 return true;
339404}
apps/web/src/components/settings/settingsSearch.test.ts+1-1
......@@ -197,7 +197,7 @@ describe("searchSettings", () => {
197197 expect(searchSettings("environment identification")[0]).toMatchObject({
198198 id: "environment-identification",
199199 to: "/settings/appearance",
200 targetId: "appearance",
200 targetId: "appearance-interface",
201201 });
202202 });
203203
apps/web/src/components/settings/settingsSearch.ts+1-1
......@@ -105,7 +105,7 @@ export const SETTINGS_SEARCH_ITEMS = [
105105 to: "/settings/appearance",
106106 searchTerms: ["dev nightly artwork pill label hide none"],
107107 // The setting is stage-dependent, so its parent section is the stable destination.
108 targetId: "appearance",
108 targetId: "appearance-interface",
109109 },
110110 {
111111 id: "interface-font",