History

feat(web): search individual settings by detail (#8831)

maria committed 2 months ago

Commit ff93aba1 parent d35c71d19 files · +645 −96Browse files

Unified · Split

19 files
  1. apps/web/src/components/CommandPalette.logic.test.ts +70 −0
  2. apps/web/src/components/CommandPalette.logic.ts +29 −10
  3. apps/web/src/components/CommandPalette.tsx +38 −2
  4. apps/web/src/components/settings/ConnectionsSettings.tsx +7 −7
  5. apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx +38 −0
  6. apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +22 −0
  7. apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +7 −0
  8. apps/web/src/components/settings/ProviderSettingsPanel.tsx +46 −6
  9. apps/web/src/components/settings/SettingsPanels.tsx +6 −3
  10. apps/web/src/components/settings/SettingsSidebarNav.tsx +2 −17
  11. apps/web/src/components/settings/SourceControlSettings.tsx +14 −4
  12. apps/web/src/components/settings/SourceControlWritingSettings.tsx +4 −3
  13. apps/web/src/components/settings/ThemeSettings.tsx +5 −2
  14. apps/web/src/components/settings/settingsLayout.tsx +12 −0
  15. apps/web/src/components/settings/settingsSearch.test.ts +75 −7
  16. apps/web/src/components/settings/settingsSearch.ts +217 −31
  17. apps/web/src/components/settings/useAvailableSettingsSearchItems.ts +44 −0
  18. apps/web/src/lib/utils.ts +4 −0
  19. docs/user/keybindings.md +5 −4
apps/web/src/components/CommandPalette.logic.test.ts+70−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 enumerateCommandPaletteItems,
99 filterPinnedBrowseEntries,
1010 filterCommandPaletteGroups,
11+ normalizeSearchText,
1112 reduceCommandPaletteUiState,
1213 type CommandPaletteGroup,
1314 } from "./CommandPalette.logic";
@@ -272,6 +273,75 @@ describe("buildThreadActionItems", () => { · 258 unmodified lines
272273 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);
273274 });
274275
276+ it("ranks an order-independent setting title match above a split context match", () => {
277+ const settingsSearchItems = [
278+ {
279+ kind: "action" as const,
280+ value: "setting:context-match",
281+ searchTerms: ["Pairing settings", "remote backend"],
282+ title: "Context match",
283+ icon: null,
284+ run: async () => undefined,
285+ },
286+ {
287+ kind: "action" as const,
288+ value: "setting:remote-pairing",
289+ searchTerms: ["Remote pairing", "connections"],
290+ title: "Remote pairing",
291+ icon: null,
292+ run: async () => undefined,
293+ },
294+ ];
295+
296+ const groups = filterCommandPaletteGroups({
297+ activeGroups: [],
298+ query: "pairing remote",
299+ isInSubmenu: false,
300+ projectSearchItems: [],
301+ settingsSearchItems,
302+ threadSearchItems: [],
303+ });
304+
305+ expect(groups).toHaveLength(1);
306+ expect(groups[0]?.value).toBe("settings-search");
307+ expect(groups[0]?.items.map((item) => item.value)).toEqual([
308+ "setting:remote-pairing",
309+ "setting:context-match",
310+ ]);
311+ });
312+
313+ it("keeps accent-insensitive setting results", () => {
314+ const groups = filterCommandPaletteGroups({
315+ activeGroups: [],
316+ query: "thè\u{1ab0}mes",
317+ isInSubmenu: false,
318+ projectSearchItems: [],
319+ settingsSearchItems: [
320+ {
321+ kind: "action",
322+ value: "setting:theme",
323+ searchTerms: ["Themes", "Appearance"],
324+ title: "Themes",
325+ icon: null,
326+ run: async () => undefined,
327+ },
328+ ],
329+ threadSearchItems: [],
330+ });
331+
332+ expect(groups[0]?.items.map((item) => item.value)).toEqual(["setting:theme"]);
333+ });
334+
335+ it("normalizes case independently of the host locale", () => {
336+ const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");
337+ try {
338+ expect(normalizeSearchText("GIT")).toBe("git");
339+ expect(localeLowerCase).not.toHaveBeenCalled();
340+ } finally {
341+ localeLowerCase.mockRestore();
342+ }
343+ });
344+
275345 it("keeps message excerpts searchable without replacing thread metadata", () => {
276346 const [item] = buildThreadActionItems({
277347 threads: [makeThread({ branch: "feat/search" })],
apps/web/src/components/CommandPalette.logic.ts+29−10
@@ -9,9 +9,12 @@ import * as Arr from "effect/Array"; · 8 unmodified lines
99 import * as Result from "effect/Result";
1010 import { type ReactNode } from "react";
1111 import { sortThreads } from "../lib/threadSort";
12+import { normalizeSearchText } from "../lib/utils";
1213 import { formatRelativeTimeLabel } from "../timestampFormat";
1314 import { type Project, type SidebarThreadSummary, type Thread } from "../types";
1415
16+export { normalizeSearchText } from "../lib/utils";
17+
1518 export const RECENT_THREAD_LIMIT = 12;
1619 export const ITEM_ICON_CLASS = "size-4 text-icon-muted";
1720 export const ADDON_ICON_CLASS = "size-4";
@@ -138,10 +141,6 @@ export function enumerateCommandPaletteItems( · 120 unmodified lines
138141
139142 export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
140143
141−export function normalizeSearchText(value: string): string {
142− return value.trim().toLowerCase().replace(/\s+/g, " ");
143−}
144−
145144 export function buildProjectActionItems(input: {
146145 projects: ReadonlyArray<Project>;
147146 valuePrefix: string;
@@ -255,9 +254,16 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr · 107 unmodified lines
255254 });
256255 }
257256
258−function rankSearchFieldMatch(field: string, normalizedQuery: string): number {
257+function rankSearchFieldMatch(
258+ field: string,
259+ normalizedQuery: string,
260+ queryTokens: ReadonlyArray<string>,
261+): number {
259262 const normalizedField = normalizeSearchText(field);
260− if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) {
263+ if (
264+ normalizedField.length === 0 ||
265+ !queryTokens.every((token) => normalizedField.includes(token))
266+ ) {
261267 return Number.NEGATIVE_INFINITY;
262268 }
263269 if (normalizedField === normalizedQuery) {
@@ -266,12 +272,16 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number { · 2 unmodified lines
266272 if (normalizedField.startsWith(normalizedQuery)) {
267273 return 2;
268274 }
269− return 1;
275+ if (normalizedField.includes(normalizedQuery)) {
276+ return 1;
277+ }
278+ return 0;
270279 }
271280
272281 function rankCommandPaletteItemMatch(
273282 item: CommandPaletteActionItem | CommandPaletteSubmenuItem,
274283 normalizedQuery: string,
284+ queryTokens: ReadonlyArray<string>,
275285 ): number {
276286 const terms = item.searchTerms.filter((term) => term.length > 0);
277287 if (terms.length === 0) {
@@ -279,7 +289,7 @@ function rankCommandPaletteItemMatch( · 1 unmodified line
279289 }
280290
281291 for (const [index, field] of terms.entries()) {
282− const fieldRank = rankSearchFieldMatch(field, normalizedQuery);
292+ const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens);
283293 if (fieldRank !== Number.NEGATIVE_INFINITY) {
284294 return 1_000 - index * 100 + fieldRank;
285295 }
@@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: { · 7 unmodified lines
293303 query: string;
294304 isInSubmenu: boolean;
295305 projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
306+ settingsSearchItems?: ReadonlyArray<CommandPaletteActionItem>;
296307 threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
297308 }): CommandPaletteGroup[] {
298309 const isActionsFilter = input.query.startsWith(">");
@@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: { · 6 unmodified lines
305316 }
306317 return [...input.activeGroups];
307318 }
319+ const queryTokens = normalizedQuery.split(" ");
308320
309321 let baseGroups = [...input.activeGroups];
310322 if (isActionsFilter) {
@@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: { · 11 unmodified lines
322334 items: input.projectSearchItems,
323335 });
324336 }
337+ if (input.settingsSearchItems && input.settingsSearchItems.length > 0) {
338+ searchableGroups.push({
339+ value: "settings-search",
340+ label: "Settings",
341+ items: input.settingsSearchItems,
342+ });
343+ }
325344 if (input.threadSearchItems.length > 0) {
326345 searchableGroups.push({
327346 value: "threads-search",
@@ -334,14 +353,14 @@ export function filterCommandPaletteGroups(input: { · 6 unmodified lines
334353 return searchableGroups.flatMap((group) => {
335354 const items = Arr.filterMap(group.items, (item, index) => {
336355 const haystack = normalizeSearchText(item.searchTerms.join(" "));
337− if (!haystack.includes(normalizedQuery)) {
356+ if (!queryTokens.every((token) => haystack.includes(token))) {
338357 return Result.failVoid;
339358 }
340359
341360 return Result.succeed({
342361 item,
343362 index,
344− rank: rankCommandPaletteItemMatch(item, normalizedQuery),
363+ rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens),
345364 });
346365 })
347366 .toSorted((left, right) => right.rank - left.rank || left.index - right.index)
apps/web/src/components/CommandPalette.tsx+38−2
@@ -32,7 +32,7 @@ import { · 31 unmodified lines
3232 type SourceControlRepositoryInfo,
3333 PRIMARY_LOCAL_ENVIRONMENT_ID,
3434 } from "@t3tools/contracts";
35−import { useNavigate, useParams } from "@tanstack/react-router";
35+import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
3636 import * as Option from "effect/Option";
3737 import {
3838 ArrowLeftIcon,
@@ -104,6 +104,7 @@ import { · 65 unmodified lines
104104 } from "../lib/utils";
105105 import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
106106 import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
107+import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems";
107108 import {
108109 applyWslEnvironmentConfiguration,
109110 parseWslUncPath,
@@ -140,6 +141,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 30 unmodified lines
140141 import { ProjectFilePicker } from "./files/ProjectFilePicker";
141142 import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
142143 import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
144+import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
143145 import {
144146 COMMAND_PALETTE_META_ICON_CLASS,
145147 CommandPaletteMetaDot,
@@ -563,6 +565,7 @@ function OpenCommandPaletteDialog(props: { · 417 unmodified lines
563565 readonly clearOpenIntent: () => void;
564566 }) {
565567 const navigate = useNavigate();
568+ const pathname = useLocation({ select: (location) => location.pathname });
566569 const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;
567570 const [query, setQuery] = useState("");
568571 const deferredQuery = useDeferredValue(query);
@@ -585,6 +588,7 @@ function OpenCommandPaletteDialog(props: { · 16 unmodified lines
585588 const { environments } = useEnvironments();
586589 const desktopLocalBootstraps = useDesktopLocalBootstraps();
587590 const primaryEnvironmentId = usePrimaryEnvironmentId();
591+ const availableSettingsSearchItems = useAvailableSettingsSearchItems();
588592 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
589593 useHandleNewThread();
590594 const projects = useProjects();
@@ -1637,7 +1641,19 @@ function OpenCommandPaletteDialog(props: { · 1046 unmodified lines
16371641 actionItems.push({
16381642 kind: "action",
16391643 value: "action:project-settings",
1640− searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"],
1644+ searchTerms: [
1645+ "project",
1646+ "settings",
1647+ "name",
1648+ "icon",
1649+ "scripts",
1650+ "model",
1651+ "workspace",
1652+ "grouping",
1653+ "checkout",
1654+ "remove",
1655+ "t3.json",
1656+ ],
16411657 title: "Project settings",
16421658 description: contextualProjectGroup.displayName,
16431659 icon: <FolderIcon className={ITEM_ICON_CLASS} />,
@@ -1651,6 +1667,25 @@ function OpenCommandPaletteDialog(props: { · 7 unmodified lines
16511667 }
16521668
16531669 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });
1670+ const settingsSearchItems: CommandPaletteActionItem[] = searchSettings(
1671+ deferredQuery,
1672+ availableSettingsSearchItems,
1673+ ).map((item) => ({
1674+ kind: "action",
1675+ value: `setting:${item.id}`,
1676+ searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])],
1677+ title: item.title,
1678+ description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`,
1679+ icon: <SettingsIcon className={ITEM_ICON_CLASS} />,
1680+ run: async () => {
1681+ await navigate({
1682+ to: item.to,
1683+ hash: item.targetId ?? item.id,
1684+ replace: pathname === item.to,
1685+ hashScrollIntoView: false,
1686+ });
1687+ },
1688+ }));
16541689 const sourceSelectionViewValue =
16551690 addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`;
16561691 const activeGroups =
@@ -1668,6 +1703,7 @@ function OpenCommandPaletteDialog(props: { · 11 unmodified lines
16681703 query: deferredQuery,
16691704 isInSubmenu: currentView !== null,
16701705 projectSearchItems: projectSearchItems,
1706+ settingsSearchItems,
16711707 threadSearchItems: allThreadItems,
16721708 });
16731709
apps/web/src/components/settings/ConnectionsSettings.tsx+7−7
@@ -1670,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b · 1669 unmodified lines
16701670 <>
16711671 {window.desktopBridge ? (
16721672 <SettingsRow
1673− title="T3 Connect"
1673+ title={searchableSetting("t3-connect").title}
16741674 description={
16751675 managedTunnelActive
16761676 ? "This environment is available to your other devices through T3 Connect."
@@ -1688,7 +1688,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b · 11 unmodified lines
16881688 />
16891689 ) : null}
16901690 <SettingsRow
1691− title="Publish agent activity"
1691+ title={searchableSetting("publish-agent-activity").title}
16921692 description="Send activity from this environment to your mobile clients for push notifications and Live Activities. Works without a T3 Connect tunnel."
16931693 control={
16941694 <CloudLinkSwitch
@@ -2908,7 +2908,7 @@ export function ConnectionsSettings() { · 1213 unmodified lines
29082908
29092909 const renderTailscaleRow = () => (
29102910 <SettingsRow
2911− title="Tailscale HTTPS"
2911+ title={searchableSetting("tailscale-https").title}
29122912 description={
29132913 tailscaleHttpsEndpoint
29142914 ? tailscaleHttpsEndpoint.status === "available"
@@ -2958,7 +2958,7 @@ export function ConnectionsSettings() { · 43 unmodified lines
29582958 );
29592959 const renderNetworkAccessRow = () => (
29602960 <SettingsRow
2961− title="Network access"
2961+ title={searchableSetting("network-access").title}
29622962 description={
29632963 isLocalBackendNetworkAccessible ? (
29642964 <NetworkAccessDescription
@@ -2990,7 +2990,7 @@ export function ConnectionsSettings() { · 25 unmodified lines
29902990 );
29912991 const renderDisabledNetworkAccessRow = () => (
29922992 <SettingsRow
2993− title="Network access"
2993+ title={searchableSetting("network-access").title}
29942994 description={
29952995 currentAuthPolicy === "remote-reachable"
29962996 ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched."
@@ -3022,7 +3022,7 @@ export function ConnectionsSettings() { · 25 unmodified lines
30223022 <SettingsPageContainer>
30233023 {canManageLocalBackend ? (
30243024 <>
3025− <SettingsSection title="This environment">
3025+ <SettingsSection {...searchableSetting("connections-environment")}>
30263026 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (
30273027 <SettingsRow
30283028 title={
@@ -3377,7 +3377,7 @@ export function ConnectionsSettings() { · 348 unmodified lines
33773377 </Dialog>
33783378 </>
33793379 ) : (
3380− <SettingsSection title="This environment">
3380+ <SettingsSection {...searchableSetting("connections-environment")}>
33813381 <SettingsRow
33823382 title="Administrative access"
33833383 description="Pairing links and client-session management require the access:write scope for this backend."
apps/web/src/components/settings/ProviderSettingsPanel.environment.test.tsx+38−0
@@ -31,18 +31,32 @@ const settingsState = vi.hoisted(() => ({ · 30 unmodified lines
3131 updateSettings: vi.fn(),
3232 }));
3333
34+const settingsSearchState = vi.hoisted(() => ({
35+ targetId: null as string | null,
36+ effects: [] as Array<() => void>,
37+}));
38+
3439 vi.mock("react", async (importOriginal) => {
3540 const actual = await importOriginal<typeof import("react")>();
3641 const { reactHookHarness } = await import("../../test/reactHookHarness");
3742 return {
3843 ...actual,
3944 useCallback: reactHookHarness.useCallback,
45+ useEffect: (effect: () => void) => settingsSearchState.effects.push(effect),
4046 useMemo: reactHookHarness.useMemo,
4147 useRef: reactHookHarness.useRef,
4248 useState: reactHookHarness.useState,
4349 };
4450 });
4551
52+vi.mock("./settingsLayout", async (importOriginal) => {
53+ const actual = await importOriginal<typeof import("./settingsLayout")>();
54+ return {
55+ ...actual,
56+ useSettingsSearchTargetId: () => settingsSearchState.targetId,
57+ };
58+});
59+
4660 vi.mock("react/compiler-runtime", async () => {
4761 const { reactHookHarness } = await import("../../test/reactHookHarness");
4862 return { c: reactHookHarness.useMemoCache };
@@ -131,6 +145,17 @@ function isAddProviderButton(element: ReactElement<Record<string, unknown>>): bo · 82 unmodified lines
131145 return element.props["aria-label"] === "Add provider";
132146 }
133147
148+function findAdvancedPanel(panel: ReactElement<Record<string, unknown>>) {
149+ return visitElements(
150+ panel,
151+ (element) => element.props.className === "mt-1" && typeof element.props.open === "boolean",
152+ );
153+}
154+
155+function flushEffects(): void {
156+ for (const effect of settingsSearchState.effects.splice(0)) effect();
157+}
158+
134159 async function flushPromises(): Promise<void> {
135160 await Promise.resolve();
136161 await Promise.resolve();
@@ -144,6 +169,8 @@ describe("EnvironmentProviderSettings routing", () => { · 7 unmodified lines
144169 settingsState.readEnvironmentIds = [];
145170 settingsState.updateEnvironmentIds = [];
146171 settingsState.updateSettings.mockReset();
172+ settingsSearchState.targetId = null;
173+ settingsSearchState.effects = [];
147174 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
148175 commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" });
149176 });
@@ -235,6 +262,17 @@ describe("EnvironmentProviderSettings routing", () => { · 85 unmodified lines
235262 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
236263 });
237264
265+ it("opens Advanced when search targets the provider health interval", () => {
266+ settingsSearchState.targetId = "provider-health-check-interval";
267+ let panel = renderPanel();
268+
269+ expect(findAdvancedPanel(panel)?.props.open).toBe(false);
270+ flushEffects();
271+
272+ panel = renderPanel();
273+ expect(findAdvancedPanel(panel)?.props.open).toBe(true);
274+ });
275+
238276 it("deletes and resets provider configuration without erasing shared preferences", () => {
239277 settingsState.value = {
240278 ...DEFAULT_UNIFIED_SETTINGS,
apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts+22−0
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; · 3 unmodified lines
44 import {
55 buildProviderEnvironmentOptions,
66 classifyProviderEnvironmentAccess,
7+ isProviderSettingsEnvironmentAvailable,
78 resolvePrimaryOperateAccess,
89 resolveRemoteOperateAccess,
910 resolveSelectedProviderEnvironmentId,
@@ -20,6 +21,27 @@ const environments = [ · 10 unmodified lines
2021 ] as const;
2122
2223 describe("provider environment selection", () => {
24+ it("requires a connected environment with server config for searchable provider settings", () => {
25+ expect(
26+ isProviderSettingsEnvironmentAvailable({
27+ connectionPhase: "connected",
28+ hasServerConfig: true,
29+ }),
30+ ).toBe(true);
31+ expect(
32+ isProviderSettingsEnvironmentAvailable({
33+ connectionPhase: "reconnecting",
34+ hasServerConfig: true,
35+ }),
36+ ).toBe(false);
37+ expect(
38+ isProviderSettingsEnvironmentAvailable({
39+ connectionPhase: "connected",
40+ hasServerConfig: false,
41+ }),
42+ ).toBe(false);
43+ });
44+
2345 it("sorts the primary environment first and the rest by label", () => {
2446 expect(
2547 buildProviderEnvironmentOptions(environments, primaryId).map(
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+7−0
@@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike { · 9 unmodified lines
1010 readonly label: string;
1111 }
1212
13+export function isProviderSettingsEnvironmentAvailable(input: {
14+ readonly connectionPhase: EnvironmentConnectionPhase;
15+ readonly hasServerConfig: boolean;
16+}): boolean {
17+ return input.connectionPhase === "connected" && input.hasServerConfig;
18+}
19+
1320 export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>(
1421 environments: ReadonlyArray<T>,
1522 primaryEnvironmentId: EnvironmentId | null,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+46−6
@@ -33,7 +33,7 @@ import { · 32 unmodified lines
3333 RefreshCwIcon,
3434 TerminalIcon,
3535 } from "lucide-react";
36−import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
36+import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
3737
3838 import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
3939 import { isElectron } from "../../env";
@@ -93,10 +93,12 @@ import { · 53 unmodified lines
9393 SettingsRow,
9494 SettingsSection,
9595 useRelativeTimeTick,
96+ useSettingsSearchTargetId,
9697 } from "./settingsLayout";
9798 import {
9899 buildProviderEnvironmentOptions,
99100 classifyProviderEnvironmentAccess,
101+ isProviderSettingsEnvironmentAvailable,
100102 type ProviderEnvironmentAccess,
101103 type ProviderOperateAccess,
102104 resolvePrimaryOperateAccess,
@@ -189,7 +191,7 @@ function EnvironmentUnavailableRow({ · 86 unmodified lines
189191 // No spinner: this state can persist indefinitely for a wedged device, and a
190192 // continuously repainting animation would run the whole time.
191193 return (
192− <SettingsSection title="Providers">
194+ <SettingsSection {...searchableSetting("providers")}>
193195 {deviceTabs}
194196 <SettingsRow title={title} description={description} />
195197 </SettingsSection>
@@ -197,8 +199,17 @@ function EnvironmentUnavailableRow({ · 1 unmodified line
197199 }
198200
199201 export function ProviderSettingsPanel() {
202+ return (
203+ <SettingsPageContainer className="gap-8">
204+ <ProviderSettingsPanelContent />
205+ </SettingsPageContainer>
206+ );
207+}
208+
209+function ProviderSettingsPanelContent() {
200210 const { environments, isReady } = useEnvironments();
201211 const primaryEnvironmentId = usePrimaryEnvironmentId();
212+ const searchTargetId = useSettingsSearchTargetId();
202213 const options = useMemo(
203214 () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId),
204215 [environments, primaryEnvironmentId],
@@ -216,6 +227,27 @@ export function ProviderSettingsPanel() { · 11 unmodified lines
216227 );
217228 const selectedEnvironment =
218229 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;
230+ const selectedEnvironmentCanRenderSettings =
231+ selectedEnvironment !== null &&
232+ isProviderSettingsEnvironmentAvailable({
233+ connectionPhase: selectedEnvironment.connection.phase,
234+ hasServerConfig: selectedEnvironment.serverConfig !== null,
235+ });
236+ const searchableEnvironmentId = options.find((environment) =>
237+ isProviderSettingsEnvironmentAvailable({
238+ connectionPhase: environment.connection.phase,
239+ hasServerConfig: environment.serverConfig !== null,
240+ }),
241+ )?.environmentId;
242+ useEffect(() => {
243+ if (
244+ searchTargetId === searchableSetting("provider-health-check-interval").id &&
245+ !selectedEnvironmentCanRenderSettings &&
246+ searchableEnvironmentId !== undefined
247+ ) {
248+ setSelectedEnvironmentId(searchableEnvironmentId);
249+ }
250+ }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]);
219251 const onlyPrimaryDevice =
220252 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
221253 const deviceTabs =
@@ -266,9 +298,9 @@ export function ProviderSettingsPanel() { · 44 unmodified lines
266298 ) : null;
267299
268300 return (
269− <SettingsPageContainer className="gap-8">
301+ <>
270302 {options.length === 0 ? (
271− <SettingsSection title="Providers">
303+ <SettingsSection {...searchableSetting("providers")}>
272304 <SettingsRow
273305 title={isReady ? "No connected devices" : "Loading devices"}
274306 description={
@@ -287,7 +319,7 @@ export function ProviderSettingsPanel() { · 12 unmodified lines
287319 deviceTabs={deviceTabs}
288320 />
289321 ) : null}
290− </SettingsPageContainer>
322+ </>
291323 );
292324 }
293325
@@ -429,12 +461,19 @@ export function EnvironmentProviderSettings({ · 135 unmodified lines
429461 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
430462 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);
431463 const [advancedOpen, setAdvancedOpen] = useState(false);
464+ const searchTargetId = useSettingsSearchTargetId();
432465 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
433466 ReadonlySet<ProviderDriverKind>
434467 >(() => new Set());
435468 const refreshingRef = useRef(false);
436469 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());
437470
471+ useEffect(() => {
472+ if (searchTargetId === searchableSetting("provider-health-check-interval").id) {
473+ setAdvancedOpen(true);
474+ }
475+ }, [searchTargetId]);
476+
438477 const providerUpdateCandidates = useMemo(
439478 () => collectProviderUpdateCandidates(serverProviders),
440479 [serverProviders],
@@ -915,9 +954,10 @@ export function EnvironmentProviderSettings({ · 474 unmodified lines
915954 className={readOnly ? "opacity-50 select-none" : undefined}
916955 >
917956 <SettingsRow
957+ id={searchableSetting("provider-health-check-interval").id}
918958 title={
919959 <span className="inline-flex items-center gap-1.5">
920− Health check interval
960+ {searchableSetting("provider-health-check-interval").title}
921961 <PolicyTooltip>
922962 This interval is configured here, then the shared Background activity policy
923963 decides whether provider probes may run when the timer fires. Custom
apps/web/src/components/settings/SettingsPanels.tsx+6−3
@@ -790,7 +790,9 @@ function BackgroundActivityAdvancedDialog({ · 789 unmodified lines
790790
791791 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
792792 <div className="min-w-0 space-y-1">
793− <div className="text-sm font-medium">Git fetch interval</div>
793+ <div className="text-sm font-medium">
794+ {searchableSetting("git-fetch-interval").title}
795+ </div>
794796 <p className="text-xs leading-relaxed text-muted-foreground">
795797 Refresh remote branch status in the background.
796798 </p>
@@ -2009,7 +2011,7 @@ export function GeneralSettingsPanel() { · 1212 unmodified lines
20092011 />
20102012 {settings.sidebarAutoSettleAfterDays !== null ? (
20112013 <SettingsRow
2012− title="Days of inactivity before auto-settle"
2014+ title={searchableSetting("days-before-auto-settle").title}
20132015 description="Any new activity un-settles a thread automatically."
20142016 control={
20152017 <AutoSettleDaysInput
@@ -2142,9 +2144,10 @@ export function GeneralSettingsPanel() { · 126 unmodified lines
21422144 />
21432145
21442146 <SettingsRow
2147+ id={searchableSetting("background-activity").id}
21452148 title={
21462149 <span className="inline-flex items-center gap-1.5">
2147− Background activity
2150+ {searchableSetting("background-activity").title}
21482151 <PolicyTooltip>
21492152 This shared policy gates background work such as Git refreshes and provider health
21502153 probes after their individual intervals elapse.
apps/web/src/components/settings/SettingsSidebarNav.tsx+2−17
@@ -7,8 +7,6 @@ import { · 6 unmodified lines
77 type ComponentType,
88 type KeyboardEvent,
99 } from "react";
10−import { useEnvironmentQuery } from "~/state/query";
11−import { desktopWslStateAtom } from "~/state/desktopWslState";
1210 import {
1311 ArchiveIcon,
1412 BlocksIcon,
@@ -23,8 +21,6 @@ import { · 8 unmodified lines
2321 } from "lucide-react";
2422 import { useLocation, useNavigate } from "@tanstack/react-router";
2523
26−import { isElectron } from "~/env";
27−import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
2824 import { Button } from "../ui/button";
2925 import { Input } from "../ui/input";
3026 import { Kbd } from "../ui/kbd";
@@ -42,11 +38,11 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; · 11 unmodified lines
4238 import { scrollToSettingsTarget } from "./settingsLayout";
4339 import {
4440 searchSettings,
45− SETTINGS_SEARCH_ITEMS,
4641 SETTINGS_SECTION_LABELS,
4742 type SettingsPath,
4843 type SettingsSearchItem,
4944 } from "./settingsSearch";
45+import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
5046
5147 const SETTINGS_SECTION_ICONS: Readonly<
5248 Record<SettingsPath, ComponentType<{ className?: string }>>
@@ -83,18 +79,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { · 30 unmodified lines
8379 const searchInputRef = useRef<HTMLInputElement>(null);
8480 const [query, setQuery] = useState("");
8581 const [activeResultIndex, setActiveResultIndex] = useState(0);
86− const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
87− const searchableItems = useMemo(() => {
88− const wslState = desktopWsl.data;
89− const rowRenders = isWslSettingsRowVisible({
90− state: wslState,
91− error: desktopWsl.error,
92− });
93− if (rowRenders) {
94− return SETTINGS_SEARCH_ITEMS;
95− }
96− return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend");
97− }, [desktopWsl.data, desktopWsl.error]);
82+ const searchableItems = useAvailableSettingsSearchItems();
9883 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
9984 const isSearching = query.trim().length > 0;
10085 const hasResults = results.length > 0;
apps/web/src/components/settings/SourceControlSettings.tsx+14−4
@@ -1,7 +1,7 @@
11 import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
22 import * as Duration from "effect/Duration";
33 import * as Option from "effect/Option";
4−import { useState, type ReactNode } from "react";
4+import { useEffect, useState, type ReactNode } from "react";
55 import type {
66 BackgroundActivitySettings,
77 SourceControlProviderKind,
@@ -59,7 +59,9 @@ import { · 51 unmodified lines
5959 PolicyTooltip,
6060 SettingResetButton,
6161 SettingsPageContainer,
62+ SettingsSearchTarget,
6263 SettingsSection,
64+ useSettingsSearchTargetId,
6365 } from "./settingsLayout";
6466 import { searchableSetting } from "./settingsSearch";
6567
@@ -267,6 +269,13 @@ function DiscoveryItemRow({ · 201 unmodified lines
267269 const authAccount = auth ? optionLabel(auth.account) : null;
268270 const [isExpanded, setIsExpanded] = useState(false);
269271 const hasDetails = children !== undefined;
272+ const searchTargetId = useSettingsSearchTargetId();
273+
274+ useEffect(() => {
275+ if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) {
276+ setIsExpanded(true);
277+ }
278+ }, [item.kind, searchTargetId]);
270279
271280 return (
272281 <div
@@ -345,13 +354,14 @@ function GitFetchIntervalSettings() { · 72 unmodified lines
345354 );
346355 const canResetFetchInterval =
347356 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;
357+ const setting = searchableSetting("git-fetch-interval");
348358
349359 return (
350− <div className="grid gap-3">
360+ <SettingsSearchTarget id={setting.id} className="grid gap-3">
351361 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
352362 <div className="min-w-0 space-y-1">
353363 <div className="flex min-w-0 items-center gap-1">
354− <span className="text-xs font-medium text-foreground">Fetch interval</span>
364+ <span className="text-xs font-medium text-foreground">{setting.title}</span>
355365 <PolicyTooltip>
356366 This interval is configured for Git only. The shared Background activity policy still
357367 decides whether Git refreshes may run when the timer fires. Custom intervals appear as
@@ -407,7 +417,7 @@ function GitFetchIntervalSettings() { · 49 unmodified lines
407417 <span className="text-xs text-muted-foreground">seconds</span>
408418 </div>
409419 </div>
410− </div>
420+ </SettingsSearchTarget>
411421 );
412422 }
413423
apps/web/src/components/settings/SourceControlWritingSettings.tsx+4−3
@@ -21,6 +21,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ · 20 unmodified lines
2121 import { Switch } from "../ui/switch";
2222 import { Textarea } from "../ui/textarea";
2323 import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
24+import { searchableSetting } from "./settingsSearch";
2425
2526 const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; description: string }> =
2627 {
@@ -73,7 +74,7 @@ export function SourceControlWritingSettingsSection() { · 46 unmodified lines
7374 return (
7475 <SettingsSection title="Text generation">
7576 <SettingsRow
76− title="Source control writing style"
77+ {...searchableSetting("source-control-writing-style")}
7778 description={MODE_OPTIONS[style.mode].description}
7879 resetAction={
7980 isSourceControlWritingStyleDirty ? (
@@ -137,7 +138,7 @@ export function SourceControlWritingSettingsSection() { · 57 unmodified lines
137138 </SettingsRow>
138139
139140 <SettingsRow
140− title="Follow change request templates"
141+ {...searchableSetting("follow-change-request-templates")}
141142 description="Structures change request descriptions using the current repository's template when one is available."
142143 resetAction={
143144 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
@@ -169,7 +170,7 @@ export function SourceControlWritingSettingsSection() { · 25 unmodified lines
169170 />
170171
171172 <SettingsRow
172− title="Source control writer model"
173+ {...searchableSetting("source-control-writer-model")}
173174 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
174175 control={
175176 <div className="flex flex-wrap items-center justify-end gap-2">
apps/web/src/components/settings/ThemeSettings.tsx+5−2
@@ -41,6 +41,7 @@ import { Button } from "../ui/button"; · 40 unmodified lines
4141 import { stackedThreadToast, toastManager } from "../ui/toast";
4242 import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
4343 import { ThemeImportDialog } from "./ThemeImportDialog";
44+import { searchableSetting } from "./settingsSearch";
4445 import { useThemeEditorStore } from "./themeEditorStore";
4546 import {
4647 STANDARD_THEME_CARDS,
@@ -892,11 +893,13 @@ export function ThemeLibrary({ · 845 unmodified lines
892893 Choose how T3 Code looks. Use a built-in theme or make your own.
893894 </p>
894895 <h3 className="px-3 text-sm font-medium tracking-[-0.005em] text-foreground sm:px-4">
895− Color scheme
896+ {searchableSetting("color-scheme").title}
896897 </h3>
897898 {renderModeTiles()}
898899 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">
899− <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">Themes</h3>
900+ <h3 className="text-sm font-medium tracking-[-0.005em] text-foreground">
901+ {searchableSetting("theme").title}
902+ </h3>
900903 <div className="flex flex-wrap items-center justify-end gap-2">
901904 <Button
902905 size="xs"
apps/web/src/components/settings/settingsLayout.tsx+12−0
@@ -84,6 +84,18 @@ function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined) · 83 unmodified lines
8484 return targetRef;
8585 }
8686
87+export function SettingsSearchTarget({
88+ children,
89+ ...targetProps
90+}: ComponentPropsWithoutRef<"div">) {
91+ const targetRef = useSettingsSearchTarget<HTMLDivElement>(targetProps.id);
92+ return (
93+ <div {...targetProps} ref={targetRef} tabIndex={targetProps.id ? -1 : targetProps.tabIndex}>
94+ {children}
95+ </div>
96+ );
97+}
98+
8799 /** Info affordance explaining how a setting interacts with the shared background policy. */
88100 export function PolicyTooltip({ children }: { readonly children: string }) {
89101 return (
apps/web/src/components/settings/settingsSearch.test.ts+75−7
@@ -1,6 +1,7 @@
1−import { describe, expect, it } from "vite-plus/test";
1+import { describe, expect, it, vi } from "vite-plus/test";
22
33 import {
4+ filterAvailableSettingsSearchItems,
45 searchableSetting,
56 searchSettings,
67 SETTINGS_SEARCH_ITEMS,
@@ -12,16 +13,19 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ · 5 unmodified lines
1213 id: "word-wrap",
1314 title: "Word wrap",
1415 to: "/settings/general",
16+ searchTerms: ["long lines in code previews"],
1517 },
1618 {
1719 id: "network-access",
1820 title: "Network access",
1921 to: "/settings/connections",
22+ searchTerms: ["remote pairing backend"],
2023 },
2124 {
2225 id: "providers",
2326 title: "Providers",
2427 to: "/settings/providers",
28+ searchTerms: ["claude codex agents"],
2529 },
2630 {
2731 id: "provider-updates",
@@ -36,16 +40,25 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ · 8 unmodified lines
3640 ];
3741
3842 describe("searchSettings", () => {
39− it("matches only setting titles", () => {
43+ it("matches titles, sections, and remembered setting details", () => {
4044 expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
4145 expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
42− expect(searchSettings("connections", ITEMS)).toEqual([]);
43− expect(searchSettings("claude", ITEMS)).toEqual([]);
46+ expect(searchSettings("connections", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
47+ expect(searchSettings("claude", ITEMS).map((item) => item.id)).toEqual(["providers"]);
48+ expect(searchSettings("long lines", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
4449 });
4550
4651 it("matches normalized title substrings", () => {
4752 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
4853 expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]);
54+ expect(searchSettings("thè\u{1ab0}mes")[0]?.id).toBe("theme");
55+ const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt");
56+ try {
57+ expect(searchSettings("GIT")[0]?.id).toBe("git-fetch-interval");
58+ expect(localeLowerCase).not.toHaveBeenCalled();
59+ } finally {
60+ localeLowerCase.mockRestore();
61+ }
4962 expect(searchSettings("xyzzy")).toEqual([]);
5063 });
5164
@@ -56,6 +69,27 @@ describe("searchSettings", () => { · 4 unmodified lines
5669 ]);
5770 });
5871
72+ it("matches query words across fields and ranks the strongest result first", () => {
73+ expect(searchSettings("pairing remote", ITEMS).map((item) => item.id)).toEqual([
74+ "network-access",
75+ ]);
76+ expect(
77+ searchSettings("remote pairing")
78+ .slice(0, 2)
79+ .map((item) => item.id),
80+ ).toEqual(["network-access", "connections-environment"]);
81+ });
82+
83+ it("finds settings that used to be reachable only through their section", () => {
84+ expect(searchSettings("pull request template")[0]?.id).toBe("follow-change-request-templates");
85+ expect(searchSettings("git security keys")[0]?.id).toBe("git-fetch-interval");
86+ expect(searchSettings("push notifications")[0]?.id).toBe("publish-agent-activity");
87+ expect(searchSettings("battery saver")[0]?.id).toBe("background-activity");
88+ expect(searchSettings("binary path")[0]?.id).toBe("providers");
89+ expect(searchSettings("authorized clients")[0]?.id).toBe("connections-environment");
90+ expect(searchSettings("administrative access")[0]?.id).toBe("connections-environment");
91+ });
92+
5993 it("lists thread confirmations in panel order", () => {
6094 expect(searchSettings("confirmation").map((item) => item.id)).toEqual([
6195 "unpin-confirmation",
@@ -74,8 +108,17 @@ describe("searchSettings", () => { · 12 unmodified lines
74108 expect(searchSettings("wsl")).toEqual([]);
75109 });
76110
111+ it("hides macOS-only settings on other platforms", () => {
112+ vi.stubGlobal("navigator", { platform: "Win32" });
113+ try {
114+ expect(searchSettings("font smoothing")).toEqual([]);
115+ } finally {
116+ vi.unstubAllGlobals();
117+ }
118+ });
119+
77120 it("registers the WSL backend as a desktop-only setting", () => {
78− expect(SETTINGS_SEARCH_ITEMS).toContainEqual({
121+ expect(SETTINGS_SEARCH_ITEMS.find((item) => item.id === "wsl-backend")).toMatchObject({
79122 id: "wsl-backend",
80123 title: "WSL backend",
81124 to: "/settings/connections",
@@ -84,6 +127,30 @@ describe("searchSettings", () => { · 2 unmodified lines
84127 });
85128 });
86129
130+ it("hides settings whose controls are unavailable", () => {
131+ const available = filterAvailableSettingsSearchItems({
132+ hasCloudPublicConfig: false,
133+ hasPrimaryEnvironment: false,
134+ hasProviderSettingsEnvironment: false,
135+ canManageLocalBackend: false,
136+ isWslSettingsRowVisible: false,
137+ });
138+
139+ const gatedIds = new Set<string>([
140+ "follow-change-request-templates",
141+ "git-fetch-interval",
142+ "network-access",
143+ "publish-agent-activity",
144+ "provider-health-check-interval",
145+ "source-control-writer-model",
146+ "source-control-writing-style",
147+ "t3-connect",
148+ "tailscale-https",
149+ "wsl-backend",
150+ ]);
151+ expect(available.map((item) => item.id).filter((id) => gatedIds.has(id))).toEqual([]);
152+ });
153+
87154 it("keeps catalog result ids unique", () => {
88155 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
89156 expect(new Set(ids).size).toBe(ids.length);
@@ -111,10 +178,11 @@ describe("searchSettings", () => { · 21 unmodified lines
111178 });
112179
113180 it("routes browser recording quality to integrations", () => {
114− expect(searchSettings("recording frame rate")[0]).toMatchObject({
181+ const result = searchSettings("recording frame rate")[0];
182+ expect(result).toMatchObject({
115183 id: "browser-recording-frame-rate",
116184 to: "/settings/integrations",
117− targetId: "browser",
118185 });
186+ expect(result).not.toHaveProperty("targetId");
119187 });
120188 });
apps/web/src/components/settings/settingsSearch.ts+217−31
@@ -1,5 +1,5 @@
11 import { isElectron } from "~/env";
2−import { isWindowsPlatform } from "~/lib/utils";
2+import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
33
44 export type SettingsPath =
55 | "/settings/general"
@@ -16,12 +16,28 @@ export interface SettingsSearchItem { · 10 unmodified lines
1616 readonly title: string;
1717 readonly to: SettingsPath;
1818 readonly targetId?: string;
19+ /** Descriptions, option labels, and aliases people may remember instead of the title. */
20+ readonly searchTerms?: ReadonlyArray<string>;
1921 // Its row only renders in the desktop app, so a browser result would land on
2022 // an anchor that isn't there.
2123 readonly desktopOnly?: boolean;
24+ readonly macOnly?: boolean;
2225 // Its row only renders on Windows desktop, so other desktop platforms must
2326 // not expose a result that points to a missing anchor.
2427 readonly windowsOnly?: boolean;
28+ readonly cloudOnly?: boolean;
29+ readonly primaryOnly?: boolean;
30+ readonly providerSettingsOnly?: boolean;
31+ readonly localBackendManagementOnly?: boolean;
32+ readonly wslAvailableOnly?: boolean;
33+}
34+
35+export interface SettingsSearchAvailability {
36+ readonly hasCloudPublicConfig: boolean;
37+ readonly hasPrimaryEnvironment: boolean;
38+ readonly hasProviderSettingsEnvironment: boolean;
39+ readonly canManageLocalBackend: boolean;
40+ readonly isWslSettingsRowVisible: boolean;
2541 }
2642
2743 /**
@@ -40,16 +56,16 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { · 12 unmodified lines
4056 };
4157
4258 /**
43− * Every searchable setting, in result order. This catalog is the single
44− * source of truth for anchor ids and visible titles: panels render both via
45− * `searchableSetting`, so a retitle (or, later, a translation pass) happens
46− * here once instead of separately in the panel and the index.
59+ * Searchable settings and stable destinations, in result order. Rows with a
60+ * dedicated anchor render their id and title via `searchableSetting`; items
61+ * that may not be mounted point at their nearest stable section instead.
4762 */
4863 export const SETTINGS_SEARCH_ITEMS = [
4964 {
5065 id: "color-scheme",
5166 title: "Color scheme",
5267 to: "/settings/appearance",
68+ searchTerms: ["appearance light dark system mode"],
5369 // The scheme tiles sit at the top of the Appearance section.
5470 targetId: "appearance",
5571 },
@@ -57,6 +73,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line
5773 id: "theme",
5874 title: "Themes",
5975 to: "/settings/appearance",
76+ searchTerms: ["appearance colors palette custom import"],
6077 // Theme cards live directly under the scheme tiles; the section is the
6178 // stable scroll destination for both.
6279 targetId: "appearance",
@@ -66,17 +83,20 @@ export const SETTINGS_SEARCH_ITEMS = [ · 3 unmodified lines
6683 id: "setting-appearance-contrast",
6784 title: "Contrast",
6885 to: "/settings/appearance",
86+ searchTerms: ["colors borders interface"],
6987 },
7088 {
7189 // Prefixed because the slider control already owns the `glass-opacity` id.
7290 id: "setting-glass-opacity",
7391 title: "Glass opacity",
7492 to: "/settings/appearance",
93+ searchTerms: ["transparent transparency solid menus dialogs composer"],
7594 },
7695 {
7796 id: "environment-identification",
7897 title: "Environment identification",
7998 to: "/settings/appearance",
99+ searchTerms: ["dev nightly artwork pill label hide none"],
80100 // The setting is stage-dependent, so its parent section is the stable destination.
81101 targetId: "appearance",
82102 },
@@ -84,204 +104,339 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line
84104 id: "interface-font",
85105 title: "Interface font",
86106 to: "/settings/appearance",
107+ searchTerms: ["typography family size system sans"],
87108 },
88109 {
89110 id: "prompt-font",
90111 title: "Prompt font",
91112 to: "/settings/appearance",
113+ searchTerms: ["typography family size composer input"],
92114 },
93115 {
94116 id: "code-font",
95117 title: "Code font",
96118 to: "/settings/appearance",
119+ searchTerms: ["typography family size monospace code blocks diffs file previews"],
97120 },
98121 {
99122 id: "terminal-font",
100123 title: "Terminal font",
101124 to: "/settings/appearance",
125+ searchTerms: ["typography family size monospace output"],
102126 },
103127 {
104128 id: "font-smoothing",
105129 title: "Font smoothing",
106130 to: "/settings/appearance",
131+ searchTerms: ["typography text grayscale anti aliasing macos thin"],
132+ macOnly: true,
107133 },
108134 {
109135 id: "word-wrap",
110136 title: "Word wrap",
111137 to: "/settings/appearance",
138+ searchTerms: ["long lines code blocks tables diffs file previews"],
112139 },
113140 {
114141 id: "project-grouping",
115142 title: "Project grouping",
116143 to: "/settings/general",
144+ searchTerms: ["combine matching repositories environments sidebar"],
117145 },
118146 {
119147 id: "auto-settle-inactive-threads",
120148 title: "Auto-settle inactive threads",
121149 to: "/settings/general",
150+ searchTerms: ["sidebar inactivity days no activity automatically"],
122151 },
123152 {
124153 id: "auto-settle-merged-threads",
125154 title: "Auto-settle merged threads",
126155 to: "/settings/general",
156+ searchTerms: ["pull request merge closed automatically sidebar"],
157+ },
158+ {
159+ id: "days-before-auto-settle",
160+ title: "Days of inactivity before auto-settle",
161+ to: "/settings/general",
162+ targetId: "auto-settle-inactive-threads",
163+ searchTerms: ["thread timeout activity sidebar"],
127164 },
128165 {
129166 id: "time-format",
130167 title: "Time format",
131168 to: "/settings/general",
169+ searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
132170 },
133171 {
134172 id: "hide-whitespace-changes",
135173 title: "Hide whitespace changes",
136174 to: "/settings/general",
175+ searchTerms: ["diff ignore spaces edits default"],
137176 },
138177 {
139178 id: "skills-in-slash-menu",
140179 title: "Show skills in slash menu",
141180 to: "/settings/general",
181+ searchTerms: ["command menu dollar $ slash /"],
142182 },
143183 {
144184 id: "provider-update-checks",
145185 title: "Provider update checks",
146186 to: "/settings/general",
187+ searchTerms: ["installed cli versions newer available codex claude cursor grok opencode"],
188+ },
189+ {
190+ id: "background-activity",
191+ title: "Background activity",
192+ to: "/settings/general",
193+ searchTerms: [
194+ "balanced performance battery saver advanced git fetch provider health refresh host power monitor idle policy",
195+ ],
147196 },
148197 {
149198 id: "new-threads",
150199 title: "New threads",
151200 to: "/settings/general",
201+ searchTerms: ["default workspace mode draft local worktree"],
152202 },
153203 {
154204 id: "start-from-origin",
155205 title: "Start from origin",
156206 to: "/settings/general",
157207 targetId: "new-threads",
208+ searchTerms: ["new worktrees latest matching remote branch local"],
158209 },
159210 {
160211 id: "add-project-starts-in",
161212 title: "Add project starts in",
162213 to: "/settings/general",
214+ searchTerms: ["base directory folder browser path home"],
163215 },
164216 {
165217 id: "unpin-confirmation",
166218 title: "Unpin confirmation",
167219 to: "/settings/general",
220+ searchTerms: ["ask before thread pinned section"],
168221 },
169222 {
170223 id: "archive-confirmation",
171224 title: "Archive confirmation",
172225 to: "/settings/general",
226+ searchTerms: ["ask before thread second click inline action"],
173227 },
174228 {
175229 id: "delete-confirmation",
176230 title: "Delete confirmation",
177231 to: "/settings/general",
232+ searchTerms: ["ask before thread chat history"],
178233 },
179234 {
180235 id: "quit-confirmation",
181236 title: "Hold to quit",
182237 to: "/settings/general",
238+ searchTerms: ["confirmation shortcut desktop app exit"],
183239 desktopOnly: true,
184240 },
185241 {
186242 id: "text-generation-model",
187243 title: "Text generation model",
188244 to: "/settings/general",
245+ searchTerms: ["generated thread titles source control content default provider"],
189246 },
190247 {
191248 id: "diagnostics",
192249 title: "Diagnostics",
193250 to: "/settings/general",
251+ searchTerms: ["logs traces processes resource history failures spans cpu memory"],
194252 },
195253 {
196254 id: "legacy-plan-mode",
197255 title: "Plan mode (legacy)",
198256 to: "/settings/general",
257+ searchTerms: ["build plan composer old"],
199258 },
200259 {
201260 id: "legacy-token-streaming",
202261 title: "Stream token by token (legacy)",
203262 to: "/settings/general",
263+ searchTerms: ["response output old compatibility"],
204264 },
205265 {
206266 id: "legacy-sidebar",
207267 title: "Sidebar (legacy)",
208268 to: "/settings/general",
269+ searchTerms: ["project thread tree old flat list"],
209270 },
210271 {
211272 id: "keybindings",
212273 title: "Keybindings",
213274 to: "/settings/keybindings",
275+ searchTerms: ["keyboard shortcuts hotkeys commands bindings json"],
214276 },
215277 {
216278 id: "providers",
217279 title: "Providers",
218280 to: "/settings/providers",
281+ searchTerms: [
282+ "agents cli codex claude cursor grok opencode instances authentication api key models configuration binary path config directory endpoint arguments environment variables display name accent color custom favorite hidden auto compact",
283+ ],
284+ },
285+ {
286+ id: "provider-health-check-interval",
287+ title: "Health check interval",
288+ to: "/settings/providers",
289+ searchTerms: ["refresh availability versions auth state models background probes seconds off"],
290+ providerSettingsOnly: true,
219291 },
220292 {
221293 id: "agent-browser-access",
222294 title: "Agent browser access",
223295 to: "/settings/integrations",
224− targetId: "browser",
296+ searchTerms: ["allow open drive preview tools sessions"],
225297 },
226298 {
227299 id: "browser-default-viewport",
228300 title: "Default browser viewport",
229301 to: "/settings/integrations",
230− targetId: "browser",
302+ searchTerms: ["preview size width height device desktop mobile rotate"],
231303 },
232304 {
233305 id: "browser-default-zoom",
234306 title: "Default browser zoom",
235307 to: "/settings/integrations",
236− targetId: "browser",
308+ searchTerms: ["preview page scale tabs percent"],
237309 },
238310 {
239311 id: "browser-default-appearance",
240312 title: "Default browser appearance",
241313 to: "/settings/integrations",
242− targetId: "browser",
314+ searchTerms: ["preview color scheme light dark system os"],
243315 },
244316 {
245317 id: "browser-recording-frame-rate",
246318 title: "Browser recording frame rate",
247319 to: "/settings/integrations",
248− targetId: "browser",
249320 },
250321 {
251322 id: "browser-auto-show-floating-preview",
252323 title: "Auto-show floating preview",
253324 to: "/settings/integrations",
254− targetId: "browser",
325+ searchTerms: ["agent opens browser pop into view hide"],
255326 },
256327 {
257328 id: "source-control",
258329 title: "Source control",
259330 to: "/settings/source-control",
331+ searchTerms: [
332+ "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",
333+ ],
260334 },
261335 {
262− id: "remote-environments",
263− title: "Remote environments",
336+ id: "git-fetch-interval",
337+ title: "Git fetch interval",
338+ to: "/settings/source-control",
339+ searchTerms: [
340+ "automatic remote branch refresh background credentials security keys seconds off",
341+ ],
342+ primaryOnly: true,
343+ },
344+ {
345+ id: "source-control-writing-style",
346+ title: "Source control writing style",
347+ to: "/settings/source-control",
348+ searchTerms: [
349+ "repository conventions conventional commits custom instructions change descriptions request titles",
350+ ],
351+ primaryOnly: true,
352+ },
353+ {
354+ id: "follow-change-request-templates",
355+ title: "Follow change request templates",
356+ to: "/settings/source-control",
357+ searchTerms: ["repository pr pull request description structure"],
358+ primaryOnly: true,
359+ },
360+ {
361+ id: "source-control-writer-model",
362+ title: "Source control writer model",
363+ to: "/settings/source-control",
364+ searchTerms: [
365+ "override generated commit change request pr titles descriptions branch bookmark",
366+ ],
367+ primaryOnly: true,
368+ },
369+ {
370+ id: "network-access",
371+ title: "Network access",
264372 to: "/settings/connections",
373+ targetId: "connections-environment",
374+ searchTerms: ["expose backend remote pairing local machine interfaces host restart"],
375+ localBackendManagementOnly: true,
376+ },
377+ {
378+ id: "tailscale-https",
379+ title: "Tailscale HTTPS",
380+ to: "/settings/connections",
381+ targetId: "connections-environment",
382+ searchTerms: ["serve magicdns endpoint remote secure network"],
383+ desktopOnly: true,
384+ localBackendManagementOnly: true,
265385 },
266386 {
267387 id: "wsl-backend",
268388 title: "WSL backend",
269389 to: "/settings/connections",
390+ searchTerms: [
391+ "windows subsystem linux distro second server projects stop windows backend restart",
392+ ],
270393 desktopOnly: true,
271394 windowsOnly: true,
395+ localBackendManagementOnly: true,
396+ wslAvailableOnly: true,
397+ },
398+ {
399+ id: "t3-connect",
400+ title: "T3 Connect",
401+ to: "/settings/connections",
402+ targetId: "connections-environment",
403+ searchTerms: ["managed tunnel cloud other devices remote"],
404+ desktopOnly: true,
405+ cloudOnly: true,
406+ },
407+ {
408+ id: "publish-agent-activity",
409+ title: "Publish agent activity",
410+ to: "/settings/connections",
411+ targetId: "connections-environment",
412+ searchTerms: ["mobile push notifications live activities cloud tunnel"],
413+ cloudOnly: true,
414+ },
415+ {
416+ id: "connections-environment",
417+ title: "This environment",
418+ to: "/settings/connections",
419+ searchTerms: [
420+ "connections server backend local remote access administrative permissions scope pairing links qr code authorized clients sessions revoke endpoint",
421+ ],
422+ },
423+ {
424+ id: "remote-environments",
425+ title: "Remote environments",
426+ to: "/settings/connections",
427+ searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"],
272428 },
273429 {
274430 id: "archive",
275431 title: "Archived threads",
276432 to: "/settings/archived",
433+ searchTerms: ["restore reopen deleted history projects"],
277434 },
278435 ] as const satisfies ReadonlyArray<SettingsSearchItem>;
279436
280437 export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
281438
282−const SEARCH_ITEMS_BY_ID = Object.fromEntries(
283− SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item]),
284−) as Readonly<Record<SettingsSearchItemId, SettingsSearchItem>>;
439+const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));
285440
286441 /**
287442 * `id` and `title` props for the element a search item anchors to. Panels
@@ -292,17 +447,22 @@ export function searchableSetting(id: SettingsSearchItemId): { · 4 unmodified lines
292447 readonly id: string;
293448 readonly title: string;
294449 } {
295− const { id: anchorId, title } = SEARCH_ITEMS_BY_ID[id];
450+ const { id: anchorId, title } = SEARCH_ITEMS_BY_ID.get(id)!;
296451 return { id: anchorId, title };
297452 }
298453
299−function normalizeSearchText(value: string): string {
300− return value
301− .normalize("NFKD")
302− .replace(/[\u0300-\u036f]/g, "")
303− .toLocaleLowerCase()
304− .replace(/\s+/g, " ")
305− .trim();
454+export function filterAvailableSettingsSearchItems(
455+ availability: SettingsSearchAvailability,
456+): ReadonlyArray<SettingsSearchItem> {
457+ const items: ReadonlyArray<SettingsSearchItem> = SETTINGS_SEARCH_ITEMS;
458+ return items.filter(
459+ (item) =>
460+ (!item.cloudOnly || availability.hasCloudPublicConfig) &&
461+ (!item.primaryOnly || availability.hasPrimaryEnvironment) &&
462+ (!item.providerSettingsOnly || availability.hasProviderSettingsEnvironment) &&
463+ (!item.localBackendManagementOnly || availability.canManageLocalBackend) &&
464+ (!item.wslAvailableOnly || availability.isWslSettingsRowVisible),
465+ );
306466 }
307467
308468 export function searchSettings(
@@ -311,12 +471,38 @@ export function searchSettings( · 2 unmodified lines
311471 ): ReadonlyArray<SettingsSearchItem> {
312472 const normalizedQuery = normalizeSearchText(query);
313473 if (normalizedQuery.length === 0) return [];
474+ const queryTokens = normalizedQuery.split(" ");
475+ const platform = typeof navigator === "undefined" ? "" : navigator.platform;
314476
315− return items.filter(
316− (item) =>
317− (isElectron || item.desktopOnly !== true) &&
318− (!item.windowsOnly ||
319− isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) &&
320− normalizeSearchText(item.title).includes(normalizedQuery),
321− );
477+ return items
478+ .flatMap((item, index) => {
479+ if (!isElectron && item.desktopOnly === true) return [];
480+ if (item.macOnly && !isMacPlatform(platform)) return [];
481+ if (item.windowsOnly && !isWindowsPlatform(platform)) return [];
482+
483+ const title = normalizeSearchText(item.title);
484+ const fields = [
485+ title,
486+ normalizeSearchText(SETTINGS_SECTION_LABELS[item.to]),
487+ ...(item.searchTerms ?? []).map(normalizeSearchText),
488+ ];
489+ if (!queryTokens.every((token) => fields.some((field) => field.includes(token)))) return [];
490+
491+ const exactPhraseField = fields.findIndex((field) => field.includes(normalizedQuery));
492+ const rank =
493+ title === normalizedQuery
494+ ? 5
495+ : title.startsWith(normalizedQuery)
496+ ? 4
497+ : title.includes(normalizedQuery)
498+ ? 3
499+ : queryTokens.every((token) => title.includes(token))
500+ ? 2
501+ : exactPhraseField >= 0
502+ ? 1
503+ : 0;
504+ return [{ item, index, rank }];
505+ })
506+ .toSorted((left, right) => right.rank - left.rank || left.index - right.index)
507+ .map(({ item }) => item);
322508 }
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+44−0
@@ -0,0 +1,44 @@
1+import { useMemo } from "react";
2+import { AuthAccessWriteScope } from "@t3tools/contracts";
3+
4+import { hasCloudPublicConfig } from "~/cloud/publicConfig";
5+import { isElectron } from "~/env";
6+import { desktopWslStateAtom } from "~/state/desktopWslState";
7+import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
8+import { useEnvironmentQuery } from "~/state/query";
9+import { usePrimarySessionState } from "~/environments/primary";
10+import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
11+import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
12+import { filterAvailableSettingsSearchItems } from "./settingsSearch";
13+
14+export function useAvailableSettingsSearchItems() {
15+ const primaryEnvironmentId = usePrimaryEnvironmentId();
16+ const { environments } = useEnvironments();
17+ const primarySessionState = usePrimarySessionState();
18+ const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);
19+ const canManageLocalBackend =
20+ isElectron ||
21+ ((primarySessionState.data?.authenticated &&
22+ primarySessionState.data.scopes?.includes(AuthAccessWriteScope)) ??
23+ false);
24+
25+ return useMemo(
26+ () =>
27+ filterAvailableSettingsSearchItems({
28+ hasCloudPublicConfig: hasCloudPublicConfig(),
29+ hasPrimaryEnvironment: primaryEnvironmentId !== null,
30+ hasProviderSettingsEnvironment: environments.some((environment) =>
31+ isProviderSettingsEnvironmentAvailable({
32+ connectionPhase: environment.connection.phase,
33+ hasServerConfig: environment.serverConfig !== null,
34+ }),
35+ ),
36+ canManageLocalBackend,
37+ isWslSettingsRowVisible: isWslSettingsRowVisible({
38+ state: desktopWsl.data,
39+ error: desktopWsl.error,
40+ }),
41+ }),
42+ [canManageLocalBackend, desktopWsl.data, desktopWsl.error, environments, primaryEnvironmentId],
43+ );
44+}
apps/web/src/lib/utils.ts+4−0
@@ -20,6 +20,10 @@ export function isLinuxPlatform(platform: string): boolean { · 19 unmodified lines
2020 return /linux/i.test(platform);
2121 }
2222
23+export function normalizeSearchText(value: string): string {
24+ return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ").trim();
25+}
26+
2327 export function getLocalFileManagerName(platform: string): string {
2428 if (isMacPlatform(platform)) {
2529 return "Finder";
docs/user/keybindings.md+5−4
@@ -59,10 +59,11 @@ shortcut is `mod+shift+s`, and it does not run while the terminal has focus. · 58 unmodified lines
5959 already pinned. Its default shortcut is `mod+shift+p`, and it does not run while the terminal has
6060 focus. See [Organizing threads](./thread-sidebar.md) for how pinned threads are ordered.
6161
62−The command palette searches active thread titles, projects, branches, user messages, and final
63−agent responses across connected environments. Message matches show one labeled excerpt while
64−keeping the thread's project, branch, and machine context visible. Message search begins after two
65−characters and uses SQLite's ASCII case-insensitive matching.
62+The command palette searches settings, active thread titles, projects, branches, user messages, and
63+final agent responses across connected environments. A setting result opens its exact control or
64+section. Message matches show one labeled excerpt while keeping the thread's project, branch, and
65+machine context visible. Message search begins after two characters and uses SQLite's ASCII
66+case-insensitive matching.
6667
6768 The full command list and the current defaults are shown in **Settings** → **Keybindings**, which
6869 always matches the build you are running. Use that rather than a copied list.