| author | |
| committer | |
| log | e5c75470678e33ca2bd1001d9d93493fe437972a |
| tree | e4768f2d3faf9a39060682731490b2613e258706 |
| parent | bfc31507f8483b0ba22cf10f5862947b49b20a53 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 165 | SidebarMenuSub, |
| 166 | 166 | SidebarMenuSubButton, |
| 167 | 167 | SidebarMenuSubItem, |
| 168 | SidebarSeparator, | |
| 169 | 168 | useSidebar, |
| 170 | 169 | } from "./ui/sidebar"; |
| 171 | 170 | import { useThreadSelectionStore } from "../threadSelectionStore"; |
| ... | ... | @@ -3631,8 +3630,6 @@ export default function Sidebar() { |
| 3631 | 3630 | attachProjectListAutoAnimateRef={attachProjectListAutoAnimateRef} |
| 3632 | 3631 | projectsLength={projects.length} |
| 3633 | 3632 | /> |
| 3634 | ||
| 3635 | <SidebarSeparator /> | |
| 3636 | 3633 | <SidebarChromeFooter /> |
| 3637 | 3634 | </> |
| 3638 | 3635 | )} |
apps/web/src/components/settings/BetaSettingsPanel.tsx+3-2| ... | ... | @@ -8,6 +8,7 @@ import { |
| 8 | 8 | import { Input } from "../ui/input"; |
| 9 | 9 | import { Switch } from "../ui/switch"; |
| 10 | 10 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; |
| 11 | import { searchableSetting } from "./settingsSearch"; | |
| 11 | 12 | |
| 12 | 13 | const AUTO_SETTLE_MIN_DAYS = 1; |
| 13 | 14 | const AUTO_SETTLE_MAX_DAYS = 90; |
| ... | ... | @@ -65,7 +66,7 @@ export function BetaSettingsPanel() { |
| 65 | 66 | <SettingsPageContainer> |
| 66 | 67 | <SettingsSection title="Beta features"> |
| 67 | 68 | <SettingsRow |
| 68 | title="Sidebar v2" | |
| 69 | {...searchableSetting("sidebar-v2")} | |
| 69 | 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 | 71 | control={ |
| 71 | 72 | <Switch |
| ... | ... | @@ -85,7 +86,7 @@ export function BetaSettingsPanel() { |
| 85 | 86 | {sidebarV2Enabled ? ( |
| 86 | 87 | <> |
| 87 | 88 | <SettingsRow |
| 88 | title="Auto-settle inactive threads" | |
| 89 | title={searchableSetting("auto-settle-inactive-threads").title} | |
| 89 | 90 | description="Threads with no activity for this long settle automatically. Threads on merged or closed PRs always settle." |
| 90 | 91 | control={ |
| 91 | 92 | <Switch |
apps/web/src/components/settings/ConnectionsSettings.tsx+2-1| ... | ... | @@ -49,6 +49,7 @@ import { |
| 49 | 49 | SettingsSection, |
| 50 | 50 | useRelativeTimeTick, |
| 51 | 51 | } from "./settingsLayout"; |
| 52 | import { searchableSetting } from "./settingsSearch"; | |
| 52 | 53 | import { Input } from "../ui/input"; |
| 53 | 54 | import { Checkbox } from "../ui/checkbox"; |
| 54 | 55 | import { |
| ... | ... | @@ -3351,7 +3352,7 @@ export function ConnectionsSettings() { |
| 3351 | 3352 | )} |
| 3352 | 3353 | |
| 3353 | 3354 | <SettingsSection |
| 3354 | title="Remote environments" | |
| 3355 | {...searchableSetting("remote-environments")} | |
| 3355 | 3356 | headerAction={ |
| 3356 | 3357 | <Dialog |
| 3357 | 3358 | open={addBackendDialogOpen} |
apps/web/src/components/settings/KeybindingsSettings.tsx+2-1| ... | ... | @@ -70,6 +70,7 @@ import { |
| 70 | 70 | whenAstToExpression, |
| 71 | 71 | } from "./KeybindingsSettings.logic"; |
| 72 | 72 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 73 | import { searchableSetting } from "./settingsSearch"; | |
| 73 | 74 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 74 | 75 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 75 | 76 | |
| ... | ... | @@ -1230,7 +1231,7 @@ export function KeybindingsSettingsPanel() { |
| 1230 | 1231 | return ( |
| 1231 | 1232 | <SettingsPageContainer className="max-w-5xl"> |
| 1232 | 1233 | <SettingsSection |
| 1233 | title="Keybindings" | |
| 1234 | {...searchableSetting("keybindings")} | |
| 1234 | 1235 | headerAction={ |
| 1235 | 1236 | <div className="flex items-center gap-1.5"> |
| 1236 | 1237 | <ExpandableHeaderSearch |
apps/web/src/components/settings/SettingsPanels.tsx+27-22| ... | ... | @@ -136,6 +136,7 @@ import { |
| 136 | 136 | SettingsSection, |
| 137 | 137 | useRelativeTimeTick, |
| 138 | 138 | } from "./settingsLayout"; |
| 139 | import { searchableSetting } from "./settingsSearch"; | |
| 139 | 140 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 140 | 141 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 141 | 142 | |
| ... | ... | @@ -963,9 +964,9 @@ export function AppearanceSettingsPanel() { |
| 963 | 964 | |
| 964 | 965 | return ( |
| 965 | 966 | <SettingsPageContainer> |
| 966 | <SettingsSection title="Appearance"> | |
| 967 | <SettingsSection id="appearance" title="Appearance"> | |
| 967 | 968 | <SettingsRow |
| 968 | title="Theme" | |
| 969 | {...searchableSetting("theme")} | |
| 969 | 970 | description="Choose how T3 Code looks across the app." |
| 970 | 971 | resetAction={ |
| 971 | 972 | theme !== "system" ? ( |
| ... | ... | @@ -998,7 +999,7 @@ export function AppearanceSettingsPanel() { |
| 998 | 999 | /> |
| 999 | 1000 | |
| 1000 | 1001 | <SettingsRow |
| 1001 | title="Glass opacity" | |
| 1002 | {...searchableSetting("setting-glass-opacity")} | |
| 1002 | 1003 | description="Control how transparent glass surfaces are. Higher values make menus, dialogs, and the composer more solid." |
| 1003 | 1004 | resetAction={ |
| 1004 | 1005 | settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ( |
| ... | ... | @@ -1045,7 +1046,7 @@ export function AppearanceSettingsPanel() { |
| 1045 | 1046 | |
| 1046 | 1047 | {showEnvironmentIdentification ? ( |
| 1047 | 1048 | <SettingsRow |
| 1048 | title="Environment identification" | |
| 1049 | {...searchableSetting("environment-identification")} | |
| 1049 | 1050 | description="Choose how Dev and Nightly environments are identified." |
| 1050 | 1051 | resetAction={ |
| 1051 | 1052 | settings.environmentIdentificationMode !== DEFAULT_ENVIRONMENT_IDENTIFICATION_MODE ? ( |
| ... | ... | @@ -1086,7 +1087,7 @@ export function AppearanceSettingsPanel() { |
| 1086 | 1087 | ) : null} |
| 1087 | 1088 | |
| 1088 | 1089 | <SettingsRow |
| 1089 | title="Word wrap" | |
| 1090 | {...searchableSetting("word-wrap")} | |
| 1090 | 1091 | description="Wrap long lines in code blocks, tables, diffs, and file previews by default." |
| 1091 | 1092 | resetAction={ |
| 1092 | 1093 | settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ( |
| ... | ... | @@ -1170,7 +1171,7 @@ export function GeneralSettingsPanel() { |
| 1170 | 1171 | <SettingsPageContainer> |
| 1171 | 1172 | <SettingsSection title="General"> |
| 1172 | 1173 | <SettingsRow |
| 1173 | title="Project Grouping" | |
| 1174 | {...searchableSetting("project-grouping")} | |
| 1174 | 1175 | description="Combine matching repositories across environments." |
| 1175 | 1176 | resetAction={ |
| 1176 | 1177 | settings.sidebarProjectGroupingMode !== |
| ... | ... | @@ -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 | /> |
| 1207 | 1208 | |
| 1208 | 1209 | <SettingsRow |
| 1209 | title="Time format" | |
| 1210 | {...searchableSetting("time-format")} | |
| 1210 | 1211 | description="System default follows your browser or OS clock preference." |
| 1211 | 1212 | resetAction={ |
| 1212 | 1213 | settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ( |
| ... | ... | @@ -1248,7 +1249,7 @@ export function GeneralSettingsPanel() { |
| 1248 | 1249 | /> |
| 1249 | 1250 | |
| 1250 | 1251 | <SettingsRow |
| 1251 | title="Hide whitespace changes" | |
| 1252 | {...searchableSetting("hide-whitespace-changes")} | |
| 1252 | 1253 | description="Set whether the diff panel ignores whitespace-only edits by default." |
| 1253 | 1254 | resetAction={ |
| 1254 | 1255 | settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ( |
| ... | ... | @@ -1274,7 +1275,7 @@ export function GeneralSettingsPanel() { |
| 1274 | 1275 | /> |
| 1275 | 1276 | |
| 1276 | 1277 | <SettingsRow |
| 1277 | title="Assistant output" | |
| 1278 | {...searchableSetting("assistant-output")} | |
| 1278 | 1279 | description="Show token-by-token output while a response is in progress." |
| 1279 | 1280 | resetAction={ |
| 1280 | 1281 | settings.enableAssistantStreaming !== |
| ... | ... | @@ -1301,7 +1302,7 @@ export function GeneralSettingsPanel() { |
| 1301 | 1302 | /> |
| 1302 | 1303 | |
| 1303 | 1304 | <SettingsRow |
| 1304 | title="Provider update checks" | |
| 1305 | {...searchableSetting("provider-update-checks")} | |
| 1305 | 1306 | description="Check installed provider CLIs for newer available versions." |
| 1306 | 1307 | resetAction={ |
| 1307 | 1308 | settings.enableProviderUpdateChecks !== |
| ... | ... | @@ -1410,7 +1411,7 @@ export function GeneralSettingsPanel() { |
| 1410 | 1411 | /> |
| 1411 | 1412 | |
| 1412 | 1413 | <SettingsRow |
| 1413 | title="Auto-open task panel" | |
| 1414 | {...searchableSetting("auto-open-task-panel")} | |
| 1414 | 1415 | description="Open the right-side plan and task panel automatically when steps appear." |
| 1415 | 1416 | resetAction={ |
| 1416 | 1417 | settings.autoOpenPlanSidebar !== DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar ? ( |
| ... | ... | @@ -1436,7 +1437,7 @@ export function GeneralSettingsPanel() { |
| 1436 | 1437 | /> |
| 1437 | 1438 | |
| 1438 | 1439 | <SettingsRow |
| 1439 | title="New threads" | |
| 1440 | {...searchableSetting("new-threads")} | |
| 1440 | 1441 | description="Pick the default workspace mode for newly created draft threads." |
| 1441 | 1442 | resetAction={ |
| 1442 | 1443 | settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode || |
| ... | ... | @@ -1483,7 +1484,7 @@ export function GeneralSettingsPanel() { |
| 1483 | 1484 | {settings.defaultThreadEnvMode === "worktree" ? ( |
| 1484 | 1485 | <SettingsRow |
| 1485 | 1486 | className="bg-muted/20 sm:pl-9" |
| 1486 | title="Start from origin" | |
| 1487 | title={searchableSetting("start-from-origin").title} | |
| 1487 | 1488 | description="Creates the worktree from the latest matching branch on origin instead of your local branch." |
| 1488 | 1489 | resetAction={ |
| 1489 | 1490 | settings.newWorktreesStartFromOrigin !== |
| ... | ... | @@ -1512,7 +1513,7 @@ export function GeneralSettingsPanel() { |
| 1512 | 1513 | ) : null} |
| 1513 | 1514 | |
| 1514 | 1515 | <SettingsRow |
| 1515 | title="Add project starts in" | |
| 1516 | {...searchableSetting("add-project-starts-in")} | |
| 1516 | 1517 | description='Leave empty to use "~/" when the Add Project browser opens.' |
| 1517 | 1518 | resetAction={ |
| 1518 | 1519 | settings.addProjectBaseDirectory !== |
| ... | ... | @@ -1540,7 +1541,7 @@ export function GeneralSettingsPanel() { |
| 1540 | 1541 | /> |
| 1541 | 1542 | |
| 1542 | 1543 | <SettingsRow |
| 1543 | title="Archive confirmation" | |
| 1544 | {...searchableSetting("archive-confirmation")} | |
| 1544 | 1545 | description="Require a second click on the inline archive action before a thread is archived." |
| 1545 | 1546 | resetAction={ |
| 1546 | 1547 | settings.confirmThreadArchive !== DEFAULT_UNIFIED_SETTINGS.confirmThreadArchive ? ( |
| ... | ... | @@ -1566,7 +1567,7 @@ export function GeneralSettingsPanel() { |
| 1566 | 1567 | /> |
| 1567 | 1568 | |
| 1568 | 1569 | <SettingsRow |
| 1569 | title="Delete confirmation" | |
| 1570 | {...searchableSetting("delete-confirmation")} | |
| 1570 | 1571 | description="Ask before deleting a thread and its chat history." |
| 1571 | 1572 | resetAction={ |
| 1572 | 1573 | settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete ? ( |
| ... | ... | @@ -1592,7 +1593,7 @@ export function GeneralSettingsPanel() { |
| 1592 | 1593 | /> |
| 1593 | 1594 | |
| 1594 | 1595 | <SettingsRow |
| 1595 | title="Text generation model" | |
| 1596 | {...searchableSetting("text-generation-model")} | |
| 1596 | 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 | 1598 | resetAction={ |
| 1598 | 1599 | isTextGenerationModelDirty ? ( |
| ... | ... | @@ -1676,7 +1677,7 @@ export function GeneralSettingsPanel() { |
| 1676 | 1677 | /> |
| 1677 | 1678 | )} |
| 1678 | 1679 | <SettingsRow |
| 1679 | title="Diagnostics" | |
| 1680 | {...searchableSetting("diagnostics")} | |
| 1680 | 1681 | description={diagnosticsDescription} |
| 1681 | 1682 | control={ |
| 1682 | 1683 | <Button render={<Link to="/settings/diagnostics" />} size="xs" variant="outline"> |
| ... | ... | @@ -1991,7 +1992,7 @@ export function ProviderSettingsPanel() { |
| 1991 | 1992 | return ( |
| 1992 | 1993 | <SettingsPageContainer> |
| 1993 | 1994 | <SettingsSection |
| 1994 | title="Providers" | |
| 1995 | {...searchableSetting("providers")} | |
| 1995 | 1996 | headerAction={ |
| 1996 | 1997 | <div className="flex items-center gap-1.5"> |
| 1997 | 1998 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> |
| ... | ... | @@ -2320,7 +2321,10 @@ export function ArchivedThreadsPanel() { |
| 2320 | 2321 | return ( |
| 2321 | 2322 | <SettingsPageContainer> |
| 2322 | 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 | 2328 | <SettingsRow |
| 2325 | 2329 | title={ |
| 2326 | 2330 | <span className="inline-flex items-center gap-2"> |
| ... | ... | @@ -2344,9 +2348,10 @@ export function ArchivedThreadsPanel() { |
| 2344 | 2348 | /> |
| 2345 | 2349 | </SettingsSection> |
| 2346 | 2350 | ) : ( |
| 2347 | archivedGroups.map(({ project, threads: projectThreads }) => ( | |
| 2351 | archivedGroups.map(({ project, threads: projectThreads }, index) => ( | |
| 2348 | 2352 | <SettingsSection |
| 2349 | 2353 | key={project.id} |
| 2354 | id={index === 0 ? searchableSetting("archive").id : undefined} | |
| 2350 | 2355 | title={project.name} |
| 2351 | 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 | import { useCallback, type ComponentType } from "react"; | |
| 1 | import { | |
| 2 | useCallback, | |
| 3 | useEffect, | |
| 4 | useMemo, | |
| 5 | useRef, | |
| 6 | useState, | |
| 7 | type ComponentType, | |
| 8 | type KeyboardEvent, | |
| 9 | } from "react"; | |
| 2 | 10 | import { |
| 3 | 11 | ArchiveIcon, |
| 4 | 12 | ArrowLeftIcon, |
| ... | ... | @@ -8,10 +16,15 @@ import { |
| 8 | 16 | KeyboardIcon, |
| 9 | 17 | Link2Icon, |
| 10 | 18 | PaletteIcon, |
| 19 | SearchIcon, | |
| 11 | 20 | Settings2Icon, |
| 21 | XIcon, | |
| 12 | 22 | } from "lucide-react"; |
| 13 | import { useCanGoBack, useNavigate } from "@tanstack/react-router"; | |
| 23 | import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; | |
| 14 | 24 | |
| 25 | import { Button } from "../ui/button"; | |
| 26 | import { Input } from "../ui/input"; | |
| 27 | import { Kbd } from "../ui/kbd"; | |
| 15 | 28 | import { |
| 16 | 29 | SidebarContent, |
| 17 | 30 | SidebarFooter, |
| ... | ... | @@ -22,45 +35,149 @@ import { |
| 22 | 35 | useSidebar, |
| 23 | 36 | } from "../ui/sidebar"; |
| 24 | 37 | import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; |
| 38 | import { scrollToSettingsTarget } from "./settingsLayout"; | |
| 39 | import { | |
| 40 | searchSettings, | |
| 41 | SETTINGS_SECTION_LABELS, | |
| 42 | type SettingsPath, | |
| 43 | type SettingsSearchItem, | |
| 44 | } from "./settingsSearch"; | |
| 25 | 45 | |
| 26 | export type SettingsSectionPath = | |
| 27 | | "/settings/general" | |
| 28 | | "/settings/appearance" | |
| 29 | | "/settings/keybindings" | |
| 30 | | "/settings/providers" | |
| 31 | | "/settings/source-control" | |
| 32 | | "/settings/connections" | |
| 33 | | "/settings/beta" | |
| 34 | | "/settings/archived"; | |
| 46 | const SETTINGS_SECTION_ICONS: Readonly< | |
| 47 | Record<SettingsPath, ComponentType<{ className?: string }>> | |
| 48 | > = { | |
| 49 | "/settings/general": Settings2Icon, | |
| 50 | "/settings/appearance": PaletteIcon, | |
| 51 | "/settings/keybindings": KeyboardIcon, | |
| 52 | "/settings/providers": BotIcon, | |
| 53 | "/settings/source-control": GitBranchIcon, | |
| 54 | "/settings/connections": Link2Icon, | |
| 55 | "/settings/beta": FlaskConicalIcon, | |
| 56 | "/settings/archived": ArchiveIcon, | |
| 57 | }; | |
| 35 | 58 | |
| 36 | 59 | export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ |
| 37 | 60 | label: string; |
| 38 | to: SettingsSectionPath; | |
| 61 | to: SettingsPath; | |
| 39 | 62 | icon: ComponentType<{ className?: string }>; |
| 40 | }> = [ | |
| 41 | { label: "General", to: "/settings/general", icon: Settings2Icon }, | |
| 42 | { label: "Appearance", to: "/settings/appearance", icon: PaletteIcon }, | |
| 43 | { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, | |
| 44 | { label: "Providers", to: "/settings/providers", icon: BotIcon }, | |
| 45 | { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, | |
| 46 | { label: "Connections", to: "/settings/connections", icon: Link2Icon }, | |
| 47 | { label: "Beta", to: "/settings/beta", icon: FlaskConicalIcon }, | |
| 48 | { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, | |
| 49 | ]; | |
| 63 | }> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({ | |
| 64 | to, | |
| 65 | label: SETTINGS_SECTION_LABELS[to], | |
| 66 | icon: SETTINGS_SECTION_ICONS[to], | |
| 67 | })); | |
| 68 | ||
| 69 | function SettingsSectionIcon({ to }: { to: SettingsPath }) { | |
| 70 | const Icon = SETTINGS_SECTION_ICONS[to]; | |
| 71 | return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />; | |
| 72 | } | |
| 50 | 73 | |
| 51 | 74 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 52 | 75 | const navigate = useNavigate(); |
| 76 | const currentHash = useLocation({ select: (location) => location.hash }); | |
| 53 | 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 | 126 | const handleSectionClick = useCallback( |
| 56 | (to: SettingsSectionPath) => { | |
| 127 | (to: SettingsPath) => { | |
| 57 | 128 | if (isMobile) { |
| 58 | 129 | setOpenMobile(false); |
| 59 | 130 | } |
| 60 | void navigate({ to, replace: true }); | |
| 131 | void navigate({ to, hash: "", replace: true, hashScrollIntoView: false }); | |
| 61 | 132 | }, |
| 62 | 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 | 181 | const handleBackClick = useCallback(() => { |
| 65 | 182 | if (isMobile) { |
| 66 | 183 | setOpenMobile(false); |
| ... | ... | @@ -75,23 +192,107 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { |
| 75 | 192 | return ( |
| 76 | 193 | <> |
| 77 | 194 | <SidebarContent className="overflow-x-hidden"> |
| 78 | <SidebarGroup className="p-[var(--sidebar-content-inset)]"> | |
| 79 | <SidebarMenu> | |
| 80 | {SETTINGS_NAV_ITEMS.map((item) => { | |
| 81 | const Icon = item.icon; | |
| 82 | const isActive = pathname === item.to; | |
| 83 | return ( | |
| 84 | <SidebarMenuItem key={item.to}> | |
| 85 | <SidebarMenuButton | |
| 86 | isActive={isActive} | |
| 87 | onClick={() => handleSectionClick(item.to)} | |
| 88 | > | |
| 89 | <Icon /> | |
| 90 | <span className="truncate">{item.label}</span> | |
| 91 | </SidebarMenuButton> | |
| 92 | </SidebarMenuItem> | |
| 93 | ); | |
| 94 | })} | |
| 195 | <SidebarGroup className="gap-2 p-[var(--sidebar-content-inset)]"> | |
| 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"> | |
| 197 | <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> | |
| 198 | <Input | |
| 199 | ref={searchInputRef} | |
| 200 | nativeInput | |
| 201 | unstyled | |
| 202 | type="search" | |
| 203 | value={query} | |
| 204 | onChange={(event) => { | |
| 205 | setQuery(event.currentTarget.value); | |
| 206 | setActiveResultIndex(0); | |
| 207 | }} | |
| 208 | onKeyDown={handleSearchKeyDown} | |
| 209 | placeholder="Search" | |
| 210 | aria-label="Search settings" | |
| 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 | 296 | </SidebarMenu> |
| 96 | 297 | </SidebarGroup> |
| 97 | 298 | </SidebarContent> |
apps/web/src/components/settings/SourceControlSettings.tsx+12-6| ... | ... | @@ -55,6 +55,7 @@ import { |
| 55 | 55 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 56 | 56 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; |
| 57 | 57 | import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 58 | import { searchableSetting } from "./settingsSearch"; | |
| 58 | 59 | |
| 59 | 60 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { |
| 60 | 61 | versionControlSystems: [], |
| ... | ... | @@ -474,7 +475,7 @@ function EmptySourceControlDiscovery({ |
| 474 | 475 | const hasError = error !== null; |
| 475 | 476 | |
| 476 | 477 | return ( |
| 477 | <SettingsSection title="Server environment"> | |
| 478 | <SettingsSection id={searchableSetting("source-control").id} title="Server environment"> | |
| 478 | 479 | <Empty className="min-h-88"> |
| 479 | 480 | <EmptyMedia variant="icon"> |
| 480 | 481 | <GitPullRequestIcon /> |
| ... | ... | @@ -517,8 +518,8 @@ export function SourceControlSettingsPanel() { |
| 517 | 518 | }), |
| 518 | 519 | ); |
| 519 | 520 | const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; |
| 520 | const hasDiscoveryItems = | |
| 521 | result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; | |
| 521 | const hasVersionControlSystems = result.versionControlSystems.length > 0; | |
| 522 | const hasDiscoveryItems = hasVersionControlSystems || result.sourceControlProviders.length > 0; | |
| 522 | 523 | const isInitialScanPending = discovery.isPending && discovery.data === null; |
| 523 | 524 | const handleScan = () => { |
| 524 | 525 | discovery.refresh(); |
| ... | ... | @@ -552,8 +553,12 @@ export function SourceControlSettingsPanel() { |
| 552 | 553 | </> |
| 553 | 554 | ) : hasDiscoveryItems ? ( |
| 554 | 555 | <> |
| 555 | {result.versionControlSystems.length > 0 ? ( | |
| 556 | <SettingsSection title="Version Control" headerAction={scanButton}> | |
| 556 | {hasVersionControlSystems ? ( | |
| 557 | <SettingsSection | |
| 558 | id={searchableSetting("source-control").id} | |
| 559 | title="Version Control" | |
| 560 | headerAction={scanButton} | |
| 561 | > | |
| 557 | 562 | {result.versionControlSystems.map((item) => ( |
| 558 | 563 | <DiscoveryItemRow key={`vcs:${item.kind}`} item={item}> |
| 559 | 564 | {item.kind === "git" ? <GitFetchIntervalSettings /> : undefined} |
| ... | ... | @@ -564,8 +569,9 @@ export function SourceControlSettingsPanel() { |
| 564 | 569 | |
| 565 | 570 | {result.sourceControlProviders.length > 0 ? ( |
| 566 | 571 | <SettingsSection |
| 572 | id={hasVersionControlSystems ? undefined : searchableSetting("source-control").id} | |
| 567 | 573 | title="Source Control Providers" |
| 568 | headerAction={result.versionControlSystems.length === 0 ? scanButton : null} | |
| 574 | headerAction={hasVersionControlSystems ? null : scanButton} | |
| 569 | 575 | > |
| 570 | 576 | {result.sourceControlProviders.map((item) => ( |
| 571 | 577 | <DiscoveryItemRow key={`provider:${item.kind}`} item={item} /> |
apps/web/src/components/settings/settingsLayout.test.tsx created+100| ... | ... | @@ -0,0 +1,100 @@ |
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { | |
| 5 | scrollToSettingsTarget, | |
| 6 | SettingsRow, | |
| 7 | SettingsSearchTargetProvider, | |
| 8 | } from "./settingsLayout"; | |
| 9 | ||
| 10 | afterEach(() => { | |
| 11 | vi.unstubAllGlobals(); | |
| 12 | }); | |
| 13 | ||
| 14 | describe("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 | 1 | import { Undo2Icon } from "lucide-react"; |
| 2 | import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useState } from "react"; | |
| 2 | import { useLocation, useNavigate } from "@tanstack/react-router"; | |
| 3 | import { | |
| 4 | createContext, | |
| 5 | type ComponentPropsWithoutRef, | |
| 6 | type ReactNode, | |
| 7 | useCallback, | |
| 8 | useContext, | |
| 9 | useEffect, | |
| 10 | useMemo, | |
| 11 | useState, | |
| 12 | } from "react"; | |
| 3 | 13 | |
| 4 | 14 | import { cn } from "../../lib/utils"; |
| 5 | 15 | import { Button } from "../ui/button"; |
| 6 | 16 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 7 | 17 | |
| 18 | interface SettingsSearchTargetContextValue { | |
| 19 | readonly targetId: string | null; | |
| 20 | readonly onTargetHandled: () => void; | |
| 21 | } | |
| 22 | ||
| 23 | const noop = () => undefined; | |
| 24 | const SettingsSearchTargetContext = createContext<SettingsSearchTargetContextValue>({ | |
| 25 | targetId: null, | |
| 26 | onTargetHandled: noop, | |
| 27 | }); | |
| 28 | ||
| 29 | export 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 | ||
| 42 | function 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 | ||
| 65 | function 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 | 81 | /** Re-render every `intervalMs`; return a stable timestamp snapshot for render-time relative labels. */ |
| 9 | 82 | export function useRelativeTimeTick(intervalMs = 1_000) { |
| 10 | 83 | const [nowMs, setNowMs] = useState(() => Date.now()); |
| ... | ... | @@ -28,8 +101,15 @@ export function SettingsSection({ |
| 28 | 101 | headerAction?: ReactNode; |
| 29 | 102 | children: ReactNode; |
| 30 | 103 | }) { |
| 104 | const targetRef = useSettingsSearchTarget<HTMLElement>(sectionProps.id); | |
| 105 | ||
| 31 | 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 | 113 | <div className="flex min-h-8 items-center justify-between gap-4 px-3 sm:px-4"> |
| 34 | 114 | <h2 className="flex items-center gap-2 text-lg font-semibold tracking-[-0.025em] text-foreground"> |
| 35 | 115 | {icon} |
| ... | ... | @@ -59,9 +139,13 @@ export function SettingsRow({ |
| 59 | 139 | control?: ReactNode; |
| 60 | 140 | children?: ReactNode; |
| 61 | 141 | }) { |
| 142 | const targetRef = useSettingsSearchTarget<HTMLDivElement>(rowProps.id); | |
| 143 | ||
| 62 | 144 | return ( |
| 63 | 145 | <div |
| 64 | 146 | {...rowProps} |
| 147 | ref={targetRef} | |
| 148 | tabIndex={rowProps.id ? -1 : rowProps.tabIndex} | |
| 65 | 149 | className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} |
| 66 | 150 | > |
| 67 | 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 | 203 | children: ReactNode; |
| 120 | 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 | 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"> | |
| 124 | <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}> | |
| 125 | {children} | |
| 214 | <SettingsSearchTargetProvider targetId={targetId} onTargetHandled={clearTargetHash}> | |
| 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"> | |
| 216 | <div className={cn("mx-auto flex w-full max-w-4xl flex-col gap-12", className)}> | |
| 217 | {children} | |
| 218 | </div> | |
| 126 | 219 | </div> |
| 127 | </div> | |
| 220 | </SettingsSearchTargetProvider> | |
| 128 | 221 | ); |
| 129 | 222 | } |
| 223 | ||
| 224 | export 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | searchableSetting, | |
| 5 | searchSettings, | |
| 6 | SETTINGS_SEARCH_ITEMS, | |
| 7 | type SettingsSearchItem, | |
| 8 | } from "./settingsSearch"; | |
| 9 | ||
| 10 | const 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 | ||
| 38 | describe("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 @@ |
| 1 | export 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 | ||
| 11 | export 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 | */ | |
| 22 | export 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 | */ | |
| 39 | export 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 | ||
| 167 | export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"]; | |
| 168 | ||
| 169 | const 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 | */ | |
| 178 | export 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 | ||
| 186 | function 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 | ||
| 195 | export 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 | 370 | var(--app-scrollbar-width) 100%; |
| 371 | 371 | } |
| 372 | 372 | |
| 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 | 395 | .workspace-titlebar-controls { |
| 374 | 396 | position: absolute; |
| 375 | 397 | top: var(--workspace-controls-top); |