| 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,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"; |
| 171 | import { useThreadSelectionStore } from "../threadSelectionStore"; | 170 | import { 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 { |
| 8 | import { Input } from "../ui/input"; | 8 | import { Input } from "../ui/input"; |
| 9 | import { Switch } from "../ui/switch"; | 9 | import { Switch } from "../ui/switch"; |
| 10 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | 10 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; |
| 11 | import { searchableSetting } from "./settingsSearch"; | ||
| 11 | 12 | ||
| 12 | const AUTO_SETTLE_MIN_DAYS = 1; | 13 | const AUTO_SETTLE_MIN_DAYS = 1; |
| 13 | const AUTO_SETTLE_MAX_DAYS = 90; | 14 | const 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 | <SettingsRow | 68 | <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 | <Switch | 72 | <Switch |
| ... | @@ -85,7 +86,7 @@ export function BetaSettingsPanel() { | ... | @@ -85,7 +86,7 @@ export function BetaSettingsPanel() { |
| 85 | {sidebarV2Enabled ? ( | 86 | {sidebarV2Enabled ? ( |
| 86 | <> | 87 | <> |
| 87 | <SettingsRow | 88 | <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 | <Switch | 92 | <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"; |
| 52 | import { searchableSetting } from "./settingsSearch"; | ||
| 52 | import { Input } from "../ui/input"; | 53 | import { Input } from "../ui/input"; |
| 53 | import { Checkbox } from "../ui/checkbox"; | 54 | import { Checkbox } from "../ui/checkbox"; |
| 54 | import { | 55 | import { |
| ... | @@ -3351,7 +3352,7 @@ export function ConnectionsSettings() { | ... | @@ -3351,7 +3352,7 @@ export function ConnectionsSettings() { |
| 3351 | )} | 3352 | )} |
| 3352 | 3353 | ||
| 3353 | <SettingsSection | 3354 | <SettingsSection |
| 3354 | title="Remote environments" | 3355 | {...searchableSetting("remote-environments")} |
| 3355 | headerAction={ | 3356 | headerAction={ |
| 3356 | <Dialog | 3357 | <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"; |
| 72 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; | 72 | import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 73 | import { searchableSetting } from "./settingsSearch"; | ||
| 73 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 74 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 74 | import { useAtomCommand } from "../../state/use-atom-command"; | 75 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 75 | 76 | ||
| ... | @@ -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 | <SettingsSection | 1233 | <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 | <ExpandableHeaderSearch | 1237 | <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"; |
| 139 | import { searchableSetting } from "./settingsSearch"; | ||
| 139 | import { ProjectFavicon } from "../ProjectFavicon"; | 140 | import { ProjectFavicon } from "../ProjectFavicon"; |
| 140 | import { useAtomCommand } from "../../state/use-atom-command"; | 141 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 141 | 142 | ||
| ... | @@ -963,9 +964,9 @@ export function AppearanceSettingsPanel() { | ... | @@ -963,9 +964,9 @@ export function AppearanceSettingsPanel() { |
| 963 | 964 | ||
| 964 | return ( | 965 | return ( |
| 965 | <SettingsPageContainer> | 966 | <SettingsPageContainer> |
| 966 | <SettingsSection title="Appearance"> | 967 | <SettingsSection id="appearance" title="Appearance"> |
| 967 | <SettingsRow | 968 | <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 | /> |
| 999 | 1000 | ||
| 1000 | <SettingsRow | 1001 | <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() { |
| 1045 | 1046 | ||
| 1046 | {showEnvironmentIdentification ? ( | 1047 | {showEnvironmentIdentification ? ( |
| 1047 | <SettingsRow | 1048 | <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} |
| 1087 | 1088 | ||
| 1088 | <SettingsRow | 1089 | <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 | <SettingsRow | 1173 | <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 | /> |
| 1207 | 1208 | ||
| 1208 | <SettingsRow | 1209 | <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 | /> |
| 1249 | 1250 | ||
| 1250 | <SettingsRow | 1251 | <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 | /> |
| 1275 | 1276 | ||
| 1276 | <SettingsRow | 1277 | <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 | /> |
| 1302 | 1303 | ||
| 1303 | <SettingsRow | 1304 | <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 | /> |
| 1411 | 1412 | ||
| 1412 | <SettingsRow | 1413 | <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 | /> |
| 1437 | 1438 | ||
| 1438 | <SettingsRow | 1439 | <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 | <SettingsRow | 1485 | <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} |
| 1513 | 1514 | ||
| 1514 | <SettingsRow | 1515 | <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 | /> |
| 1541 | 1542 | ||
| 1542 | <SettingsRow | 1543 | <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 | /> |
| 1567 | 1568 | ||
| 1568 | <SettingsRow | 1569 | <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 | /> |
| 1593 | 1594 | ||
| 1594 | <SettingsRow | 1595 | <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 | <SettingsRow | 1679 | <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 | <SettingsSection | 1994 | <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 | <SettingsRow | 2328 | <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 | <SettingsSection | 2352 | <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 @@ |
| 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 | import { | 10 | import { |
| 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"; |
| 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 | import { | 28 | import { |
| 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"; |
| 24 | import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn"; | 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 = | 46 | const 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 | }; | ||
| 35 | 58 | ||
| 36 | export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ | 59 | export 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 }, | 69 | function 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 | } |
| 50 | 73 | ||
| 51 | export function SettingsSidebarNav({ pathname }: { pathname: string }) { | 74 | export 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 | <SidebarMenuButton | 202 | 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 { |
| 55 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; | 55 | import { RedactedSensitiveText } from "./RedactedSensitiveText"; |
| 56 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; | 56 | import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings"; |
| 57 | import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; | 57 | import { SettingResetButton, SettingsPageContainer, SettingsSection } from "./settingsLayout"; |
| 58 | import { searchableSetting } from "./settingsSearch"; | ||
| 58 | 59 | ||
| 59 | const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { | 60 | const 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; |
| 475 | 476 | ||
| 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() { |
| 564 | 569 | ||
| 565 | {result.sourceControlProviders.length > 0 ? ( | 570 | {result.sourceControlProviders.length > 0 ? ( |
| 566 | <SettingsSection | 571 | <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 @@ | ||
| 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,10 +1,83 @@ |
| 1 | import { Undo2Icon } from "lucide-react"; | 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 | import { cn } from "../../lib/utils"; | 14 | import { cn } from "../../lib/utils"; |
| 5 | import { Button } from "../ui/button"; | 15 | import { Button } from "../ui/button"; |
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 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 | /** 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. */ |
| 9 | export function useRelativeTimeTick(intervalMs = 1_000) { | 82 | export 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 | <div | 145 | <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 | |||
| 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,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 | } |
| 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 | .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); |