authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-08-01 03:41:27+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 18:11:27-04:00
loge5c75470678e33ca2bd1001d9d93493fe437972a
treee4768f2d3faf9a39060682731490b2613e258706
parentbfc31507f8483b0ba22cf10f5862947b49b20a53
signature Signed by PGP key B5690EEEBB952194

feat(web): add settings sidebar search (#4682)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

12 files changed, 807 insertions(+), 83 deletions(-)

apps/web/src/components/Sidebar.tsx-3
...@@ -165,7 +165,6 @@ import {...@@ -165,7 +165,6 @@ import {
165 SidebarMenuSub,165 SidebarMenuSub,
166 SidebarMenuSubButton,166 SidebarMenuSubButton,
167 SidebarMenuSubItem,167 SidebarMenuSubItem,
168 SidebarSeparator,
169 useSidebar,168 useSidebar,
170} from "./ui/sidebar";169} from "./ui/sidebar";
171import { useThreadSelectionStore } from "../threadSelectionStore";170import { useThreadSelectionStore } from "../threadSelectionStore";
...@@ -3631,8 +3630,6 @@ export default function Sidebar() {...@@ -3631,8 +3630,6 @@ export default function Sidebar() {
3631 attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}3630 attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef}
3632 projectsLength={projects.length}3631 projectsLength={projects.length}
3633 />3632 />
3634
3635 <SidebarSeparator />
3636 <SidebarChromeFooter />3633 <SidebarChromeFooter />
3637 </>3634 </>
3638 )}3635 )}
apps/web/src/components/settings/BetaSettingsPanel.tsx+3-2
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8import { Input } from "../ui/input";8import { Input } from "../ui/input";
9import { Switch } from "../ui/switch";9import { Switch } from "../ui/switch";
10import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";10import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
11import { searchableSetting } from "./settingsSearch";
1112
12const AUTO_SETTLE_MIN_DAYS = 1;13const AUTO_SETTLE_MIN_DAYS = 1;
13const AUTO_SETTLE_MAX_DAYS = 90;14const AUTO_SETTLE_MAX_DAYS = 90;
...@@ -65,7 +66,7 @@ export function BetaSettingsPanel() {...@@ -65,7 +66,7 @@ export function BetaSettingsPanel() {
65 <SettingsPageContainer>66 <SettingsPageContainer>
66 <SettingsSection title="Beta features">67 <SettingsSection title="Beta features">
67 <SettingsRow68 <SettingsRow
68 title="Sidebar v2"69 {...searchableSetting("sidebar-v2")}
69 description="One flat thread list in creation order. Active work renders as rich cards; settled threads collapse to compact rows. Settling requires an up-to-date server — on older servers threads simply stay active. Switch back any time."70 description="One flat thread list in creation order. Active work renders as rich cards; settled threads collapse to compact rows. Settling requires an up-to-date server — on older servers threads simply stay active. Switch back any time."
70 control={71 control={
71 <Switch72 <Switch
...@@ -85,7 +86,7 @@ export function BetaSettingsPanel() {...@@ -85,7 +86,7 @@ export function BetaSettingsPanel() {
85 {sidebarV2Enabled ? (86 {sidebarV2Enabled ? (
86 <>87 <>
87 <SettingsRow88 <SettingsRow
88 title="Auto-settle inactive threads"89 title={searchableSetting("auto-settle-inactive-threads").title}
89 description="Threads with no activity for this long settle automatically. Threads on merged or closed PRs always settle."90 description="Threads with no activity for this long settle automatically. Threads on merged or closed PRs always settle."
90 control={91 control={
91 <Switch92 <Switch
apps/web/src/components/settings/ConnectionsSettings.tsx+2-1
...@@ -49,6 +49,7 @@ import {...@@ -49,6 +49,7 @@ import {
49 SettingsSection,49 SettingsSection,
50 useRelativeTimeTick,50 useRelativeTimeTick,
51} from "./settingsLayout";51} from "./settingsLayout";
52import { searchableSetting } from "./settingsSearch";
52import { Input } from "../ui/input";53import { Input } from "../ui/input";
53import { Checkbox } from "../ui/checkbox";54import { Checkbox } from "../ui/checkbox";
54import {55import {
...@@ -3351,7 +3352,7 @@ export function ConnectionsSettings() {...@@ -3351,7 +3352,7 @@ export function ConnectionsSettings() {
3351 )}3352 )}
33523353
3353 <SettingsSection3354 <SettingsSection
3354 title="Remote environments"3355 {...searchableSetting("remote-environments")}
3355 headerAction={3356 headerAction={
3356 <Dialog3357 <Dialog
3357 open={addBackendDialogOpen}3358 open={addBackendDialogOpen}
apps/web/src/components/settings/KeybindingsSettings.tsx+2-1
...@@ -70,6 +70,7 @@ import {...@@ -70,6 +70,7 @@ import {
70 whenAstToExpression,70 whenAstToExpression,
71} from "./KeybindingsSettings.logic";71} from "./KeybindingsSettings.logic";
72import { SettingsPageContainer, SettingsSection } from "./settingsLayout";72import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
73import { searchableSetting } from "./settingsSearch";
73import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";74import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
74import { useAtomCommand } from "../../state/use-atom-command";75import { useAtomCommand } from "../../state/use-atom-command";
7576
...@@ -1230,7 +1231,7 @@ export function KeybindingsSettingsPanel() {...@@ -1230,7 +1231,7 @@ export function KeybindingsSettingsPanel() {
1230 return (1231 return (
1231 <SettingsPageContainer className="max-w-5xl">1232 <SettingsPageContainer className="max-w-5xl">
1232 <SettingsSection1233 <SettingsSection
1233 title="Keybindings"1234 {...searchableSetting("keybindings")}
1234 headerAction={1235 headerAction={
1235 <div className="flex items-center gap-1.5">1236 <div className="flex items-center gap-1.5">
1236 <ExpandableHeaderSearch1237 <ExpandableHeaderSearch
apps/web/src/components/settings/SettingsPanels.tsx+27-22
...@@ -136,6 +136,7 @@ import {...@@ -136,6 +136,7 @@ import {
136 SettingsSection,136 SettingsSection,
137 useRelativeTimeTick,137 useRelativeTimeTick,
138} from "./settingsLayout";138} from "./settingsLayout";
139import { searchableSetting } from "./settingsSearch";
139import { ProjectFavicon } from "../ProjectFavicon";140import { ProjectFavicon } from "../ProjectFavicon";
140import { useAtomCommand } from "../../state/use-atom-command";141import { useAtomCommand } from "../../state/use-atom-command";
141142
...@@ -963,9 +964,9 @@ export function AppearanceSettingsPanel() {...@@ -963,9 +964,9 @@ export function AppearanceSettingsPanel() {
963964
964 return (965 return (
965 <SettingsPageContainer>966 <SettingsPageContainer>
966 <SettingsSection title="Appearance">967 <SettingsSection id="appearance" title="Appearance">
967 <SettingsRow968 <SettingsRow
968 title="Theme"969 {...searchableSetting("theme")}
969 description="Choose how T3 Code looks across the app."970 description="Choose how T3 Code looks across the app."
970 resetAction={971 resetAction={
971 theme !== "system" ? (972 theme !== "system" ? (
...@@ -998,7 +999,7 @@ export function AppearanceSettingsPanel() {...@@ -998,7 +999,7 @@ export function AppearanceSettingsPanel() {
998 />999 />
9991000
1000 <SettingsRow1001 <SettingsRow
1001 title="Glass opacity"1002 {...searchableSetting("setting-glass-opacity")}
1002 description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid."1003 description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid."
1003 resetAction={1004 resetAction={
1004 settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? (1005 settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? (
...@@ -1045,7 +1046,7 @@ export function AppearanceSettingsPanel() {...@@ -1045,7 +1046,7 @@ export function AppearanceSettingsPanel() {
10451046
1046 {showEnvironmentIdentification ? (1047 {showEnvironmentIdentification ? (
1047 <SettingsRow1048 <SettingsRow
1048 title="Environment identification"1049 {...searchableSetting("environment-identification")}
1049 description="Choose how Dev and Nightly environments are identified."1050 description="Choose how Dev and Nightly environments are identified."
1050 resetAction={1051 resetAction={
1051 settings.environmentIdentificationMode !== DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE ? (1052 settings.environmentIdentificationMode !== DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE ? (
...@@ -1086,7 +1087,7 @@ export function AppearanceSettingsPanel() {...@@ -1086,7 +1087,7 @@ export function AppearanceSettingsPanel() {
1086 ) : null}1087 ) : null}
10871088
1088 <SettingsRow1089 <SettingsRow
1089 title="Word wrap"1090 {...searchableSetting("word-wrap")}
1090 description="Wrap long lines in code blocks, tables, diffs, and file previews by default."1091 description="Wrap long lines in code blocks, tables, diffs, and file previews by default."
1091 resetAction={1092 resetAction={
1092 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (1093 settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? (
...@@ -1170,7 +1171,7 @@ export function GeneralSettingsPanel() {...@@ -1170,7 +1171,7 @@ export function GeneralSettingsPanel() {
1170 <SettingsPageContainer>1171 <SettingsPageContainer>
1171 <SettingsSection title="General">1172 <SettingsSection title="General">
1172 <SettingsRow1173 <SettingsRow
1173 title="Project Grouping"1174 {...searchableSetting("project-grouping")}
1174 description="Combine matching repositories across environments."1175 description="Combine matching repositories across environments."
1175 resetAction={1176 resetAction={
1176 settings.sidebarProjectGroupingMode !==1177 settings.sidebarProjectGroupingMode !==
...@@ -1200,13 +1201,13 @@ export function GeneralSettingsPanel() {...@@ -1200,13 +1201,13 @@ export function GeneralSettingsPanel() {
1200 ),1201 ),
1201 });1202 });
1202 }}1203 }}
1203 aria-label="Project Grouping"1204 aria-label="Project grouping"
1204 />1205 />
1205 }1206 }
1206 />1207 />
12071208
1208 <SettingsRow1209 <SettingsRow
1209 title="Time format"1210 {...searchableSetting("time-format")}
1210 description="System default follows your browser or OS clock preference."1211 description="System default follows your browser or OS clock preference."
1211 resetAction={1212 resetAction={
1212 settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? (1213 settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? (
...@@ -1248,7 +1249,7 @@ export function GeneralSettingsPanel() {...@@ -1248,7 +1249,7 @@ export function GeneralSettingsPanel() {
1248 />1249 />
12491250
1250 <SettingsRow1251 <SettingsRow
1251 title="Hide whitespace changes"1252 {...searchableSetting("hide-whitespace-changes")}
1252 description="Set whether the diff panel ignores whitespace-only edits by default."1253 description="Set whether the diff panel ignores whitespace-only edits by default."
1253 resetAction={1254 resetAction={
1254 settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? (1255 settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? (
...@@ -1274,7 +1275,7 @@ export function GeneralSettingsPanel() {...@@ -1274,7 +1275,7 @@ export function GeneralSettingsPanel() {
1274 />1275 />
12751276
1276 <SettingsRow1277 <SettingsRow
1277 title="Assistant output"1278 {...searchableSetting("assistant-output")}
1278 description="Show token-by-token output while a response is in progress."1279 description="Show token-by-token output while a response is in progress."
1279 resetAction={1280 resetAction={
1280 settings.enableAssistantStreaming !==1281 settings.enableAssistantStreaming !==
...@@ -1301,7 +1302,7 @@ export function GeneralSettingsPanel() {...@@ -1301,7 +1302,7 @@ export function GeneralSettingsPanel() {
1301 />1302 />
13021303
1303 <SettingsRow1304 <SettingsRow
1304 title="Provider update checks"1305 {...searchableSetting("provider-update-checks")}
1305 description="Check installed provider CLIs for newer available versions."1306 description="Check installed provider CLIs for newer available versions."
1306 resetAction={1307 resetAction={
1307 settings.enableProviderUpdateChecks !==1308 settings.enableProviderUpdateChecks !==
...@@ -1410,7 +1411,7 @@ export function GeneralSettingsPanel() {...@@ -1410,7 +1411,7 @@ export function GeneralSettingsPanel() {
1410 />1411 />
14111412
1412 <SettingsRow1413 <SettingsRow
1413 title="Auto-open task panel"1414 {...searchableSetting("auto-open-task-panel")}
1414 description="Open the right-side plan and task panel automatically when steps appear."1415 description="Open the right-side plan and task panel automatically when steps appear."
1415 resetAction={1416 resetAction={
1416 settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? (1417 settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? (
...@@ -1436,7 +1437,7 @@ export function GeneralSettingsPanel() {...@@ -1436,7 +1437,7 @@ export function GeneralSettingsPanel() {
1436 />1437 />
14371438
1438 <SettingsRow1439 <SettingsRow
1439 title="New threads"1440 {...searchableSetting("new-threads")}
1440 description="Pick the default workspace mode for newly created draft threads."1441 description="Pick the default workspace mode for newly created draft threads."
1441 resetAction={1442 resetAction={
1442 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ||1443 settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode ||
...@@ -1483,7 +1484,7 @@ export function GeneralSettingsPanel() {...@@ -1483,7 +1484,7 @@ export function GeneralSettingsPanel() {
1483 {settings.defaultThreadEnvMode === "worktree" ? (1484 {settings.defaultThreadEnvMode === "worktree" ? (
1484 <SettingsRow1485 <SettingsRow
1485 className="bg-muted/20 sm:pl-9"1486 className="bg-muted/20 sm:pl-9"
1486 title="Start from origin"1487 title={searchableSetting("start-from-origin").title}
1487 description="Creates the worktree from the latest matching branch on origin instead of your local branch."1488 description="Creates the worktree from the latest matching branch on origin instead of your local branch."
1488 resetAction={1489 resetAction={
1489 settings.newWorktreesStartFromOrigin !==1490 settings.newWorktreesStartFromOrigin !==
...@@ -1512,7 +1513,7 @@ export function GeneralSettingsPanel() {...@@ -1512,7 +1513,7 @@ export function GeneralSettingsPanel() {
1512 ) : null}1513 ) : null}
15131514
1514 <SettingsRow1515 <SettingsRow
1515 title="Add project starts in"1516 {...searchableSetting("add-project-starts-in")}
1516 description='Leave empty to use "~/" when the Add Project browser opens.'1517 description='Leave empty to use "~/" when the Add Project browser opens.'
1517 resetAction={1518 resetAction={
1518 settings.addProjectBaseDirectory !==1519 settings.addProjectBaseDirectory !==
...@@ -1540,7 +1541,7 @@ export function GeneralSettingsPanel() {...@@ -1540,7 +1541,7 @@ export function GeneralSettingsPanel() {
1540 />1541 />
15411542
1542 <SettingsRow1543 <SettingsRow
1543 title="Archive confirmation"1544 {...searchableSetting("archive-confirmation")}
1544 description="Require a second click on the inline archive action before a thread is archived."1545 description="Require a second click on the inline archive action before a thread is archived."
1545 resetAction={1546 resetAction={
1546 settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? (1547 settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? (
...@@ -1566,7 +1567,7 @@ export function GeneralSettingsPanel() {...@@ -1566,7 +1567,7 @@ export function GeneralSettingsPanel() {
1566 />1567 />
15671568
1568 <SettingsRow1569 <SettingsRow
1569 title="Delete confirmation"1570 {...searchableSetting("delete-confirmation")}
1570 description="Ask before deleting a thread and its chat history."1571 description="Ask before deleting a thread and its chat history."
1571 resetAction={1572 resetAction={
1572 settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? (1573 settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? (
...@@ -1592,7 +1593,7 @@ export function GeneralSettingsPanel() {...@@ -1592,7 +1593,7 @@ export function GeneralSettingsPanel() {
1592 />1593 />
15931594
1594 <SettingsRow1595 <SettingsRow
1595 title="Text generation model"1596 {...searchableSetting("text-generation-model")}
1596 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."1597 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."
1597 resetAction={1598 resetAction={
1598 isTextGenerationModelDirty ? (1599 isTextGenerationModelDirty ? (
...@@ -1676,7 +1677,7 @@ export function GeneralSettingsPanel() {...@@ -1676,7 +1677,7 @@ export function GeneralSettingsPanel() {
1676 />1677 />
1677 )}1678 )}
1678 <SettingsRow1679 <SettingsRow
1679 title="Diagnostics"1680 {...searchableSetting("diagnostics")}
1680 description={diagnosticsDescription}1681 description={diagnosticsDescription}
1681 control={1682 control={
1682 <Button render={<Link to="/settings/diagnostics" />} size="xs" variant="outline">1683 <Button render={<Link to="/settings/diagnostics" />} size="xs" variant="outline">
...@@ -1991,7 +1992,7 @@ export function ProviderSettingsPanel() {...@@ -1991,7 +1992,7 @@ export function ProviderSettingsPanel() {
1991 return (1992 return (
1992 <SettingsPageContainer>1993 <SettingsPageContainer>
1993 <SettingsSection1994 <SettingsSection
1994 title="Providers"1995 {...searchableSetting("providers")}
1995 headerAction={1996 headerAction={
1996 <div className="flex items-center gap-1.5">1997 <div className="flex items-center gap-1.5">
1997 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />1998 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
...@@ -2320,7 +2321,10 @@ export function ArchivedThreadsPanel() {...@@ -2320,7 +2321,10 @@ export function ArchivedThreadsPanel() {
2320 return (2321 return (
2321 <SettingsPageContainer>2322 <SettingsPageContainer>
2322 {archivedGroups.length === 0 ? (2323 {archivedGroups.length === 0 ? (
2323 <SettingsSection title="Archived threads">2324 <SettingsSection
2325 id={isLoadingArchive ? undefined : searchableSetting("archive").id}
2326 title={searchableSetting("archive").title}
2327 >
2324 <SettingsRow2328 <SettingsRow
2325 title={2329 title={
2326 <span className="inline-flex items-center gap-2">2330 <span className="inline-flex items-center gap-2">
...@@ -2344,9 +2348,10 @@ export function ArchivedThreadsPanel() {...@@ -2344,9 +2348,10 @@ export function ArchivedThreadsPanel() {
2344 />2348 />
2345 </SettingsSection>2349 </SettingsSection>
2346 ) : (2350 ) : (
2347 archivedGroups.map(({ project, threads: projectThreads }) => (2351 archivedGroups.map(({ project, threads: projectThreads }, index) => (
2348 <SettingsSection2352 <SettingsSection
2349 key={project.id}2353 key={project.id}
2354 id={index === 0 ? searchableSetting("archive").id : undefined}
2350 title={project.name}2355 title={project.name}
2351 icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />}2356 icon={<ProjectFavicon environmentId={project.environmentId} cwd={project.cwd} />}
2352 >2357 >
apps/web/src/components/settings/SettingsSidebarNav.tsx+243-42
...@@ -1,4 +1,12 @@...@@ -1,4 +1,12 @@
1import { useCallback, type ComponentType } from "react";1import {
2 useCallback,
3 useEffect,
4 useMemo,
5 useRef,
6 useState,
7 type ComponentType,
8 type KeyboardEvent,
9} from "react";
2import {10import {
3 ArchiveIcon,11 ArchiveIcon,
4 ArrowLeftIcon,12 ArrowLeftIcon,
...@@ -8,10 +16,15 @@ import {...@@ -8,10 +16,15 @@ import {
8 KeyboardIcon,16 KeyboardIcon,
9 Link2Icon,17 Link2Icon,
10 PaletteIcon,18 PaletteIcon,
19 SearchIcon,
11 Settings2Icon,20 Settings2Icon,
21 XIcon,
12} from "lucide-react";22} from "lucide-react";
13import { useCanGoBack, useNavigate } from "@tanstack/react-router";23import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
1424
25import { Button } from "../ui/button";
26import { Input } from "../ui/input";
27import { Kbd } from "../ui/kbd";
15import {28import {
16 SidebarContent,29 SidebarContent,
17 SidebarFooter,30 SidebarFooter,
...@@ -22,45 +35,149 @@ import {...@@ -22,45 +35,149 @@ import {
22 useSidebar,35 useSidebar,
23} from "../ui/sidebar";36} from "../ui/sidebar";
24import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";37import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
38import { scrollToSettingsTarget } from "./settingsLayout";
39import {
40 searchSettings,
41 SETTINGS_SECTION_LABELS,
42 type SettingsPath,
43 type SettingsSearchItem,
44} from "./settingsSearch";
2545
26export type SettingsSectionPath =46const SETTINGS_SECTION_ICONS: Readonly<
27 | "/settings/general"47 Record<SettingsPath, ComponentType<{ className?: string }>>
28 | "/settings/appearance"48> = {
29 | "/settings/keybindings"49 "/settings/general": Settings2Icon,
30 | "/settings/providers"50 "/settings/appearance": PaletteIcon,
31 | "/settings/source-control"51 "/settings/keybindings": KeyboardIcon,
32 | "/settings/connections"52 "/settings/providers": BotIcon,
33 | "/settings/beta"53 "/settings/source-control": GitBranchIcon,
34 | "/settings/archived";54 "/settings/connections": Link2Icon,
55 "/settings/beta": FlaskConicalIcon,
56 "/settings/archived": ArchiveIcon,
57};
3558
36export const SETTINGS_NAV_ITEMS: ReadonlyArray<{59export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
37 label: string;60 label: string;
38 to: SettingsSectionPath;61 to: SettingsPath;
39 icon: ComponentType<{ className?: string }>;62 icon: ComponentType<{ className?: string }>;
40}> = [63}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({
41 { label: "General", to: "/settings/general", icon: Settings2Icon },64 to,
42 { label: "Appearance", to: "/settings/appearance", icon: PaletteIcon },65 label: SETTINGS_SECTION_LABELS[to],
43 { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon },66 icon: SETTINGS_SECTION_ICONS[to],
44 { label: "Providers", to: "/settings/providers", icon: BotIcon },67}));
45 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },68
46 { label: "Connections", to: "/settings/connections", icon: Link2Icon },69function SettingsSectionIcon({ to }: { to: SettingsPath }) {
47 { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon },70 const Icon = SETTINGS_SECTION_ICONS[to];
48 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },71 return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;
49];72}
5073
51export function SettingsSidebarNav({ pathname }: { pathname: string }) {74export function SettingsSidebarNav({ pathname }: { pathname: string }) {
52 const navigate = useNavigate();75 const navigate = useNavigate();
76 const currentHash = useLocation({ select: (location) => location.hash });
53 const canGoBack = useCanGoBack();77 const canGoBack = useCanGoBack();
54 const { isMobile, setOpenMobile } = useSidebar();78 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
79 const searchInputRef = useRef<HTMLInputElement>(null);
80 const [query, setQuery] = useState("");
81 const [activeResultIndex, setActiveResultIndex] = useState(0);
82 const results = useMemo(() => searchSettings(query), [query]);
83 const isSearching = query.trim().length > 0;
84 const hasResults = results.length > 0;
85
86 useEffect(() => {
87 const result = results[activeResultIndex];
88 if (!result) return;
89 document
90 .getElementById(`settings-search-result-${result.id}`)
91 ?.scrollIntoView({ block: "nearest" });
92 }, [activeResultIndex, results]);
93
94 useEffect(() => {
95 const handleKeyDown = (event: globalThis.KeyboardEvent) => {
96 if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
97
98 const target = event.target;
99 if (
100 target instanceof HTMLElement &&
101 (target.tagName === "INPUT" ||
102 target.tagName === "TEXTAREA" ||
103 target.isContentEditable ||
104 // Keep focus inside open dialogs and popups instead of escaping
105 // their focus trap into the sidebar search.
106 target.closest('[role="dialog"], [aria-modal="true"], [data-slot$="popup"]') !== null)
107 ) {
108 return;
109 }
110
111 event.preventDefault();
112 if (isMobile) {
113 setOpenMobile(true);
114 } else if (!open) {
115 setOpen(true);
116 }
117 requestAnimationFrame(() => {
118 searchInputRef.current?.focus();
119 searchInputRef.current?.select();
120 });
121 };
122 window.addEventListener("keydown", handleKeyDown);
123 return () => window.removeEventListener("keydown", handleKeyDown);
124 }, [isMobile, open, setOpen, setOpenMobile]);
125
55 const handleSectionClick = useCallback(126 const handleSectionClick = useCallback(
56 (to: SettingsSectionPath) => {127 (to: SettingsPath) => {
57 if (isMobile) {128 if (isMobile) {
58 setOpenMobile(false);129 setOpenMobile(false);
59 }130 }
60 void navigate({ to, replace: true });131 void navigate({ to, hash: "", replace: true, hashScrollIntoView: false });
61 },132 },
62 [isMobile, navigate, setOpenMobile],133 [isMobile, navigate, setOpenMobile],
63 );134 );
135 const clearSearch = useCallback(() => {
136 setQuery("");
137 setActiveResultIndex(0);
138 }, []);
139 const handleSearchResultClick = useCallback(
140 (item: SettingsSearchItem) => {
141 clearSearch();
142 if (isMobile) {
143 setOpenMobile(false);
144 }
145 const targetId = item.targetId ?? item.id;
146 if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
147 scrollToSettingsTarget(targetId);
148 return;
149 }
150 void navigate({ to: item.to, hash: targetId, replace: true, hashScrollIntoView: false });
151 },
152 [clearSearch, currentHash, isMobile, navigate, pathname, setOpenMobile],
153 );
154 const handleSearchKeyDown = useCallback(
155 (event: KeyboardEvent<HTMLInputElement>) => {
156 if (event.key === "Escape" && isSearching) {
157 event.preventDefault();
158 event.stopPropagation();
159 clearSearch();
160 return;
161 }
162 if (results.length === 0) return;
163 if (event.key === "ArrowDown") {
164 event.preventDefault();
165 setActiveResultIndex((index) => (index + 1) % results.length);
166 return;
167 }
168 if (event.key === "ArrowUp") {
169 event.preventDefault();
170 setActiveResultIndex((index) => (index - 1 + results.length) % results.length);
171 return;
172 }
173 if (event.key === "Enter") {
174 event.preventDefault();
175 const result = results[activeResultIndex];
176 if (result) handleSearchResultClick(result);
177 }
178 },
179 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],
180 );
64 const handleBackClick = useCallback(() => {181 const handleBackClick = useCallback(() => {
65 if (isMobile) {182 if (isMobile) {
66 setOpenMobile(false);183 setOpenMobile(false);
...@@ -75,23 +192,107 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {...@@ -75,23 +192,107 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
75 return (192 return (
76 <>193 <>
77 <SidebarContent className="overflow-x-hidden">194 <SidebarContent className="overflow-x-hidden">
78 <SidebarGroup className="p-[var(--sidebar-content-inset)]">195 <SidebarGroup className="gap-2 p-[var(--sidebar-content-inset)]">
79 <SidebarMenu>196 <div className="flex h-8 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground">
80 {SETTINGS_NAV_ITEMS.map((item) => {197 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
81 const Icon = item.icon;198 <Input
82 const isActive = pathname === item.to;199 ref={searchInputRef}
83 return (200 nativeInput
84 <SidebarMenuItem key={item.to}>201 unstyled
85 <SidebarMenuButton202 type="search"
86 isActive={isActive}203 value={query}
87 onClick={() => handleSectionClick(item.to)}204 onChange={(event) => {
88 >205 setQuery(event.currentTarget.value);
89 <Icon />206 setActiveResultIndex(0);
90 <span className="truncate">{item.label}</span>207 }}
91 </SidebarMenuButton>208 onKeyDown={handleSearchKeyDown}
92 </SidebarMenuItem>209 placeholder="Search"
93 );210 aria-label="Search settings"
94 })}211 role="combobox"
212 aria-autocomplete="list"
213 aria-expanded={isSearching && hasResults}
214 aria-controls={isSearching && hasResults ? "settings-search-results" : undefined}
215 aria-activedescendant={
216 isSearching && results[activeResultIndex]
217 ? `settings-search-result-${results[activeResultIndex].id}`
218 : undefined
219 }
220 className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground"
221 />
222 {isSearching ? (
223 <Button
224 type="button"
225 size="icon-xs"
226 variant="ghost"
227 className="size-5 shrink-0 rounded-sm text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
228 aria-label="Clear settings search"
229 onClick={() => {
230 clearSearch();
231 searchInputRef.current?.focus();
232 }}
233 >
234 <XIcon className="size-3" />
235 </Button>
236 ) : (
237 <Kbd className="mr-px h-4 min-w-0 rounded-sm bg-sidebar-control-surface px-1.5 text-[10px] text-sidebar-muted-foreground ring-1 ring-sidebar-border">
238 /
239 </Kbd>
240 )}
241 </div>
242 {isSearching && results.length === 0 ? (
243 <p
244 role="status"
245 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
246 >
247 No settings found
248 </p>
249 ) : null}
250 <SidebarMenu
251 id={isSearching && hasResults ? "settings-search-results" : undefined}
252 role={isSearching && hasResults ? "listbox" : undefined}
253 aria-label={isSearching && hasResults ? "Settings search results" : undefined}
254 >
255 {isSearching
256 ? results.map((item, index) => (
257 <SidebarMenuItem key={item.id} role="presentation">
258 <SidebarMenuButton
259 id={`settings-search-result-${item.id}`}
260 role="option"
261 aria-selected={index === activeResultIndex}
262 tabIndex={-1}
263 size="sm"
264 isActive={index === activeResultIndex}
265 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"
266 onMouseMove={() => setActiveResultIndex(index)}
267 onClick={() => handleSearchResultClick(item)}
268 >
269 <SettingsSectionIcon to={item.to} />
270 <span className="min-w-0 flex-1">
271 <span className="block truncate text-sm font-medium text-sidebar-foreground">
272 {item.title}
273 </span>
274 <span className="block truncate text-[11px] text-sidebar-muted-foreground/75">
275 {SETTINGS_SECTION_LABELS[item.to]}
276 </span>
277 </span>
278 </SidebarMenuButton>
279 </SidebarMenuItem>
280 ))
281 : SETTINGS_NAV_ITEMS.map((item) => {
282 const Icon = item.icon;
283 const isActive = pathname === item.to;
284 return (
285 <SidebarMenuItem key={item.to}>
286 <SidebarMenuButton
287 isActive={isActive}
288 onClick={() => handleSectionClick(item.to)}
289 >
290 <Icon />
291 <span className="truncate">{item.label}</span>
292 </SidebarMenuButton>
293 </SidebarMenuItem>
294 );
295 })}
95 </SidebarMenu>296 </SidebarMenu>
96 </SidebarGroup>297 </SidebarGroup>
97 </SidebarContent>298 </SidebarContent>
apps/web/src/components/settings/SourceControlSettings.tsx+12-6
...@@ -55,6 +55,7 @@ import {...@@ -55,6 +55,7 @@ import {
55import { RedactedSensitiveText } from "./RedactedSensitiveText";55import { RedactedSensitiveText } from "./RedactedSensitiveText";
56import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";56import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
57import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";57import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout";
58import { searchableSetting } from "./settingsSearch";
5859
59const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {60const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
60 versionControlSystems: [],61 versionControlSystems: [],
...@@ -474,7 +475,7 @@ function EmptySourceControlDiscovery({...@@ -474,7 +475,7 @@ function EmptySourceControlDiscovery({
474 const hasError = error !== null;475 const hasError = error !== null;
475476
476 return (477 return (
477 <SettingsSection title="Server environment">478 <SettingsSection id={searchableSetting("source-control").id} title="Server environment">
478 <Empty className="min-h-88">479 <Empty className="min-h-88">
479 <EmptyMedia variant="icon">480 <EmptyMedia variant="icon">
480 <GitPullRequestIcon />481 <GitPullRequestIcon />
...@@ -517,8 +518,8 @@ export function SourceControlSettingsPanel() {...@@ -517,8 +518,8 @@ export function SourceControlSettingsPanel() {
517 }),518 }),
518 );519 );
519 const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;520 const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;
520 const hasDiscoveryItems =521 const hasVersionControlSystems = result.versionControlSystems.length > 0;
521 result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0;522 const hasDiscoveryItems = hasVersionControlSystems || result.sourceControlProviders.length > 0;
522 const isInitialScanPending = discovery.isPending && discovery.data === null;523 const isInitialScanPending = discovery.isPending && discovery.data === null;
523 const handleScan = () => {524 const handleScan = () => {
524 discovery.refresh();525 discovery.refresh();
...@@ -552,8 +553,12 @@ export function SourceControlSettingsPanel() {...@@ -552,8 +553,12 @@ export function SourceControlSettingsPanel() {
552 </>553 </>
553 ) : hasDiscoveryItems ? (554 ) : hasDiscoveryItems ? (
554 <>555 <>
555 {result.versionControlSystems.length > 0 ? (556 {hasVersionControlSystems ? (
556 <SettingsSection title="Version Control" headerAction={scanButton}>557 <SettingsSection
558 id={searchableSetting("source-control").id}
559 title="Version Control"
560 headerAction={scanButton}
561 >
557 {result.versionControlSystems.map((item) => (562 {result.versionControlSystems.map((item) => (
558 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}>563 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}>
559 {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined}564 {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined}
...@@ -564,8 +569,9 @@ export function SourceControlSettingsPanel() {...@@ -564,8 +569,9 @@ export function SourceControlSettingsPanel() {
564569
565 {result.sourceControlProviders.length > 0 ? (570 {result.sourceControlProviders.length > 0 ? (
566 <SettingsSection571 <SettingsSection
572 id={hasVersionControlSystems ? undefined : searchableSetting("source-control").id}
567 title="Source Control Providers"573 title="Source Control Providers"
568 headerAction={result.versionControlSystems.length === 0 ? scanButton : null}574 headerAction={hasVersionControlSystems ? null : scanButton}
569 >575 >
570 {result.sourceControlProviders.map((item) => (576 {result.sourceControlProviders.map((item) => (
571 <DiscoveryItemRow key={`provider:${item.kind}`} item={item} />577 <DiscoveryItemRow key={`provider:${item.kind}`} item={item} />
apps/web/src/components/settings/settingsLayout.test.tsx created+100
...@@ -0,0 +1,100 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { afterEach, describe, expect, it, vi } from "vite-plus/test";
3
4import {
5 scrollToSettingsTarget,
6 SettingsRow,
7 SettingsSearchTargetProvider,
8} from "./settingsLayout";
9
10afterEach(() => {
11 vi.unstubAllGlobals();
12});
13
14describe("settings search targets", () => {
15 it("does not persist destination styling in the rendered row", () => {
16 const markup = renderToStaticMarkup(
17 <SettingsSearchTargetProvider targetId="word-wrap">
18 <SettingsRow id="word-wrap" title="Word wrap" description="Wrap long lines." />
19 <SettingsRow id="time-format" title="Time format" description="Choose a clock." />
20 </SettingsSearchTargetProvider>,
21 );
22
23 expect(markup).toContain('id="word-wrap" tabindex="-1"');
24 expect(markup).not.toContain("data-settings-search-target");
25 expect(markup).not.toContain("settings-search-target-pulse");
26 });
27
28 it("scrolls directly to a section header and restarts the destination pulse", () => {
29 const sectionScrollIntoView = vi.fn();
30 const headerScrollIntoView = vi.fn();
31 const focus = vi.fn();
32 const remove = vi.fn();
33 const add = vi.fn();
34 const addEventListener = vi.fn();
35 const target = {
36 tagName: "SECTION",
37 firstElementChild: { scrollIntoView: headerScrollIntoView },
38 scrollIntoView: sectionScrollIntoView,
39 focus,
40 classList: { remove, add },
41 addEventListener,
42 offsetWidth: 100,
43 } as unknown as HTMLElement;
44 vi.stubGlobal("document", {
45 getElementById: vi.fn(() => target),
46 });
47 vi.stubGlobal("window", {
48 matchMedia: vi.fn(() => ({ matches: false })),
49 });
50
51 expect(scrollToSettingsTarget("providers")).toBe(true);
52 expect(headerScrollIntoView).toHaveBeenCalledWith({
53 behavior: "smooth",
54 block: "center",
55 });
56 expect(sectionScrollIntoView).not.toHaveBeenCalled();
57 expect(focus).toHaveBeenCalledWith({ preventScroll: true });
58 expect(remove).toHaveBeenCalledWith("settings-search-target-pulse");
59 expect(add).toHaveBeenCalledWith("settings-search-target-pulse");
60 expect(addEventListener).toHaveBeenCalledWith("blur", expect.any(Function), { once: true });
61 });
62
63 it("does not animate the destination when reduced motion is requested", () => {
64 const scrollIntoView = vi.fn();
65 const focus = vi.fn();
66 const remove = vi.fn();
67 const add = vi.fn();
68 const target = {
69 tagName: "DIV",
70 firstElementChild: null,
71 scrollIntoView,
72 focus,
73 classList: { remove, add },
74 offsetWidth: 100,
75 } as unknown as HTMLElement;
76 vi.stubGlobal("document", {
77 getElementById: vi.fn(() => target),
78 });
79 vi.stubGlobal("window", {
80 matchMedia: vi.fn(() => ({ matches: true })),
81 });
82
83 expect(scrollToSettingsTarget("word-wrap")).toBe(true);
84 expect(scrollIntoView).toHaveBeenCalledWith({
85 behavior: "auto",
86 block: "center",
87 });
88 expect(focus).toHaveBeenCalledWith({ preventScroll: true });
89 expect(remove).toHaveBeenCalledWith("settings-search-target-pulse");
90 expect(add).not.toHaveBeenCalled();
91 });
92
93 it("leaves not-yet-mounted destinations to their mount lifecycle", () => {
94 vi.stubGlobal("document", {
95 getElementById: vi.fn(() => null),
96 });
97
98 expect(scrollToSettingsTarget("archive")).toBe(false);
99 });
100});
apps/web/src/components/settings/settingsLayout.tsx+106-6
...@@ -1,10 +1,83 @@...@@ -1,10 +1,83 @@
1import { Undo2Icon } from "lucide-react";1import { Undo2Icon } from "lucide-react";
2import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from "react";2import { useLocation, useNavigate } from "@tanstack/react-router";
3import {
4 createContext,
5 type ComponentPropsWithoutRef,
6 type ReactNode,
7 useCallback,
8 useContext,
9 useEffect,
10 useMemo,
11 useState,
12} from "react";
313
4import { cn } from "../../lib/utils";14import { cn } from "../../lib/utils";
5import { Button } from "../ui/button";15import { Button } from "../ui/button";
6import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";16import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
717
18interface SettingsSearchTargetContextValue {
19 readonly targetId: string | null;
20 readonly onTargetHandled: () => void;
21}
22
23const noop = () => undefined;
24const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({
25 targetId: null,
26 onTargetHandled: noop,
27});
28
29export function SettingsSearchTargetProvider({
30 targetId,
31 onTargetHandled = noop,
32 children,
33}: {
34 targetId: string | null;
35 onTargetHandled?: () => void;
36 children: ReactNode;
37}) {
38 const value = useMemo(() => ({ targetId, onTargetHandled }), [onTargetHandled, targetId]);
39 return <SettingsSearchTargetContext value={value}>{children}</SettingsSearchTargetContext>;
40}
41
42function scrollAndFocusSettingsTarget(target: HTMLElement): void {
43 const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
44 const scrollTarget =
45 target.tagName === "SECTION" && target.firstElementChild
46 ? (target.firstElementChild as HTMLElement)
47 : target;
48
49 scrollTarget.scrollIntoView({
50 behavior: prefersReducedMotion ? "auto" : "smooth",
51 block: "center",
52 });
53 target.focus({ preventScroll: true });
54 target.classList.remove("settings-search-target-pulse");
55 if (prefersReducedMotion) return;
56 void target.offsetWidth;
57 target.classList.add("settings-search-target-pulse");
58 // The class also suppresses the focus outline (the pulse is the destination
59 // indicator), so drop it once the element is no longer the destination.
60 target.addEventListener("blur", () => target.classList.remove("settings-search-target-pulse"), {
61 once: true,
62 });
63}
64
65function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) {
66 const { targetId, onTargetHandled } = useContext(SettingsSearchTargetContext);
67 const isSearchTarget = id !== undefined && id === targetId;
68 const targetRef = useCallback(
69 (target: T | null) => {
70 if (target && isSearchTarget) {
71 scrollAndFocusSettingsTarget(target);
72 onTargetHandled();
73 }
74 },
75 [isSearchTarget, onTargetHandled],
76 );
77
78 return targetRef;
79}
80
8/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */81/** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */
9export function useRelativeTimeTick(intervalMs = 1_000) {82export function useRelativeTimeTick(intervalMs = 1_000) {
10 const [nowMs, setNowMs] = useState(() => Date.now());83 const [nowMs, setNowMs] = useState(() => Date.now());
...@@ -28,8 +101,15 @@ export function SettingsSection({...@@ -28,8 +101,15 @@ export function SettingsSection({
28 headerAction?: ReactNode;101 headerAction?: ReactNode;
29 children: ReactNode;102 children: ReactNode;
30}) {103}) {
104 const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id);
105
31 return (106 return (
32 <section {...sectionProps} className={cn("space-y-3", className)}>107 <section
108 {...sectionProps}
109 ref={targetRef}
110 tabIndex={sectionProps.id ? -1 : sectionProps.tabIndex}
111 className={cn("space-y-3", className)}
112 >
33 <div className="flex min-h-8 items-center justify-between gap-4 px-3 sm:px-4">113 <div className="flex min-h-8 items-center justify-between gap-4 px-3 sm:px-4">
34 <h2 className="flex items-center gap-2 text-lg font-semibold tracking-[-0.025em] text-foreground">114 <h2 className="flex items-center gap-2 text-lg font-semibold tracking-[-0.025em] text-foreground">
35 {icon}115 {icon}
...@@ -59,9 +139,13 @@ export function SettingsRow({...@@ -59,9 +139,13 @@ export function SettingsRow({
59 control?: ReactNode;139 control?: ReactNode;
60 children?: ReactNode;140 children?: ReactNode;
61}) {141}) {
142 const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id);
143
62 return (144 return (
63 <div145 <div
64 {...rowProps}146 {...rowProps}
147 ref={targetRef}
148 tabIndex={rowProps.id ? -1 : rowProps.tabIndex}
65 className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)}149 className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)}
66 >150 >
67 <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8">151 <div className="flex flex-col gap-3 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(10rem,auto)] sm:items-center sm:gap-8">
...@@ -119,11 +203,27 @@ export function SettingsPageContainer({...@@ -119,11 +203,27 @@ export function SettingsPageContainer({
119 children: ReactNode;203 children: ReactNode;
120 className?: string;204 className?: string;
121}) {205}) {
206 const navigate = useNavigate();
207 const hash = useLocation({ select: (location) => location.hash });
208 const targetId = hash.replace(/^#/, "") || null;
209 const clearTargetHash = useCallback(() => {
210 void navigate({ hash: "", replace: true, resetScroll: false, hashScrollIntoView: false });
211 }, [navigate]);
212
122 return (213 return (
123 <div className="settings-page-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto px-4 pt-10 pb-7 sm:px-8 sm:pt-12 sm:pb-10">214 <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}>
124 <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}>215 <div className="settings-page-scroll-fade scrollbar-gutter-both flex-1 overflow-y-auto px-4 pt-10 pb-7 sm:px-8 sm:pt-12 sm:pb-10">
125 {children}216 <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}>
217 {children}
218 </div>
126 </div>219 </div>
127 </div>220 </SettingsSearchTargetProvider>
128 );221 );
129}222}
223
224export function scrollToSettingsTarget(targetId: string): boolean {
225 const target = document.getElementById(targetId);
226 if (!target) return false;
227 scrollAndFocusSettingsTarget(target);
228 return true;
229}
apps/web/src/components/settings/settingsSearch.test.ts created+87
...@@ -0,0 +1,87 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 searchableSetting,
5 searchSettings,
6 SETTINGS_SEARCH_ITEMS,
7 type SettingsSearchItem,
8} from "./settingsSearch";
9
10const ITEMS: ReadonlyArray<SettingsSearchItem> = [
11 {
12 id: "word-wrap",
13 title: "Word wrap",
14 to: "/settings/general",
15 },
16 {
17 id: "network-access",
18 title: "Network access",
19 to: "/settings/connections",
20 },
21 {
22 id: "providers",
23 title: "Providers",
24 to: "/settings/providers",
25 },
26 {
27 id: "provider-updates",
28 title: "Update checks",
29 to: "/settings/general",
30 },
31 {
32 id: "automatic-updates",
33 title: "Automatic updates",
34 to: "/settings/general",
35 },
36];
37
38describe("searchSettings", () => {
39 it("matches only setting titles", () => {
40 expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
41 expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
42 expect(searchSettings("connections", ITEMS)).toEqual([]);
43 expect(searchSettings("claude", ITEMS)).toEqual([]);
44 });
45
46 it("matches normalized title substrings", () => {
47 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
48 expect(searchSettings("work")).toEqual([]);
49 });
50
51 it("keeps catalog order for multiple title matches", () => {
52 expect(searchSettings("update", ITEMS).map((item) => item.id)).toEqual([
53 "provider-updates",
54 "automatic-updates",
55 ]);
56 });
57
58 it("returns no results for an empty query", () => {
59 expect(searchSettings(" ", ITEMS)).toEqual([]);
60 });
61
62 it("keeps catalog result ids unique", () => {
63 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
64 expect(new Set(ids).size).toBe(ids.length);
65 });
66
67 it("serves anchor props to panels from the catalog", () => {
68 expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" });
69 expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" });
70 });
71
72 it("routes appearance settings to their current section", () => {
73 expect(searchSettings("theme")[0]).toMatchObject({
74 id: "theme",
75 to: "/settings/appearance",
76 });
77 expect(searchSettings("word wrap")[0]).toMatchObject({
78 id: "word-wrap",
79 to: "/settings/appearance",
80 });
81 expect(searchSettings("environment identification")[0]).toMatchObject({
82 id: "environment-identification",
83 to: "/settings/appearance",
84 targetId: "appearance",
85 });
86 });
87});
apps/web/src/components/settings/settingsSearch.ts created+203
...@@ -0,0 +1,203 @@
1export type SettingsPath =
2 | "/settings/general"
3 | "/settings/appearance"
4 | "/settings/keybindings"
5 | "/settings/providers"
6 | "/settings/source-control"
7 | "/settings/connections"
8 | "/settings/beta"
9 | "/settings/archived";
10
11export interface SettingsSearchItem {
12 readonly id: string;
13 readonly title: string;
14 readonly to: SettingsPath;
15 readonly targetId?: string;
16}
17
18/**
19 * Section labels in sidebar order. The sidebar nav and the search-result
20 * subtitles both render from this record, so each label exists once.
21 */
22export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
23 "/settings/general": "General",
24 "/settings/appearance": "Appearance",
25 "/settings/keybindings": "Keybindings",
26 "/settings/providers": "Providers",
27 "/settings/source-control": "Source Control",
28 "/settings/connections": "Connections",
29 "/settings/beta": "Beta",
30 "/settings/archived": "Archive",
31};
32
33/**
34 * Every searchable setting, in result order. This catalog is the single
35 * source of truth for anchor ids and visible titles: panels render both via
36 * `searchableSetting`, so a retitle (or, later, a translation pass) happens
37 * here once instead of separately in the panel and the index.
38 */
39export const SETTINGS_SEARCH_ITEMS = [
40 {
41 id: "theme",
42 title: "Theme",
43 to: "/settings/appearance",
44 },
45 {
46 // Prefixed because the slider control already owns the `glass-opacity` id.
47 id: "setting-glass-opacity",
48 title: "Glass opacity",
49 to: "/settings/appearance",
50 },
51 {
52 id: "environment-identification",
53 title: "Environment identification",
54 to: "/settings/appearance",
55 // The setting is stage-dependent, so its parent section is the stable destination.
56 targetId: "appearance",
57 },
58 {
59 id: "word-wrap",
60 title: "Word wrap",
61 to: "/settings/appearance",
62 },
63 {
64 id: "project-grouping",
65 title: "Project grouping",
66 to: "/settings/general",
67 },
68 {
69 id: "time-format",
70 title: "Time format",
71 to: "/settings/general",
72 },
73 {
74 id: "hide-whitespace-changes",
75 title: "Hide whitespace changes",
76 to: "/settings/general",
77 },
78 {
79 id: "assistant-output",
80 title: "Assistant output",
81 to: "/settings/general",
82 },
83 {
84 id: "provider-update-checks",
85 title: "Provider update checks",
86 to: "/settings/general",
87 },
88 {
89 id: "auto-open-task-panel",
90 title: "Auto-open task panel",
91 to: "/settings/general",
92 },
93 {
94 id: "new-threads",
95 title: "New threads",
96 to: "/settings/general",
97 },
98 {
99 id: "start-from-origin",
100 title: "Start from origin",
101 to: "/settings/general",
102 targetId: "new-threads",
103 },
104 {
105 id: "add-project-starts-in",
106 title: "Add project starts in",
107 to: "/settings/general",
108 },
109 {
110 id: "archive-confirmation",
111 title: "Archive confirmation",
112 to: "/settings/general",
113 },
114 {
115 id: "delete-confirmation",
116 title: "Delete confirmation",
117 to: "/settings/general",
118 },
119 {
120 id: "text-generation-model",
121 title: "Text generation model",
122 to: "/settings/general",
123 },
124 {
125 id: "diagnostics",
126 title: "Diagnostics",
127 to: "/settings/general",
128 },
129 {
130 id: "keybindings",
131 title: "Keybindings",
132 to: "/settings/keybindings",
133 },
134 {
135 id: "providers",
136 title: "Providers",
137 to: "/settings/providers",
138 },
139 {
140 id: "source-control",
141 title: "Source control",
142 to: "/settings/source-control",
143 },
144 {
145 id: "remote-environments",
146 title: "Remote environments",
147 to: "/settings/connections",
148 },
149 {
150 id: "sidebar-v2",
151 title: "Sidebar v2",
152 to: "/settings/beta",
153 },
154 {
155 id: "auto-settle-inactive-threads",
156 title: "Auto-settle inactive threads",
157 to: "/settings/beta",
158 targetId: "sidebar-v2",
159 },
160 {
161 id: "archive",
162 title: "Archived threads",
163 to: "/settings/archived",
164 },
165] as const satisfies ReadonlyArray<SettingsSearchItem>;
166
167export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
168
169const SEARCH_ITEMS_BY_ID = Object.fromEntries(
170 SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item]),
171) as Readonly<Record<SettingsSearchItemId, SettingsSearchItem>>;
172
173/**
174 * `id` and `title` props for the element a search item anchors to. Panels
175 * spread (or pick from) this instead of restating the strings, so the catalog
176 * and the rendered settings cannot drift apart.
177 */
178export function searchableSetting(id: SettingsSearchItemId): {
179 readonly id: string;
180 readonly title: string;
181} {
182 const { id: anchorId, title } = SEARCH_ITEMS_BY_ID[id];
183 return { id: anchorId, title };
184}
185
186function normalizeSearchText(value: string): string {
187 return value
188 .normalize("NFKD")
189 .replace(/[\u0300-\u036f]/g, "")
190 .toLocaleLowerCase()
191 .replace(/\s+/g, " ")
192 .trim();
193}
194
195export function searchSettings(
196 query: string,
197 items: ReadonlyArray<SettingsSearchItem> = SETTINGS_SEARCH_ITEMS,
198): ReadonlyArray<SettingsSearchItem> {
199 const normalizedQuery = normalizeSearchText(query);
200 if (normalizedQuery.length === 0) return [];
201
202 return items.filter((item) => normalizeSearchText(item.title).includes(normalizedQuery));
203}
apps/web/src/index.css+22
...@@ -370,6 +370,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil...@@ -370,6 +370,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
370 var(--app-scrollbar-width) 100%;370 var(--app-scrollbar-width) 100%;
371 }371 }
372372
373 @keyframes settings-search-target-pulse {
374 0%,
375 100% {
376 box-shadow: 0 0 0 0 transparent;
377 }
378 50% {
379 box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 70%, transparent);
380 }
381 }
382
383 .settings-page-scroll-fade div.settings-search-target-pulse,
384 .settings-page-scroll-fade section.settings-search-target-pulse > div:first-child {
385 animation: settings-search-target-pulse 650ms ease-in-out 2;
386 border-radius: 0.75rem;
387 }
388
389 /* The pulse is the destination indicator; without it (reduced motion), the
390 focus outline takes over, so exactly one indicator shows at a time. */
391 .settings-page-scroll-fade .settings-search-target-pulse:focus {
392 outline: none;
393 }
394
373 .workspace-titlebar-controls {395 .workspace-titlebar-controls {
374 position: absolute;396 position: absolute;
375 top: var(--workspace-controls-top);397 top: var(--workspace-controls-top);