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
8 enumerateCommandPaletteItems,8 enumerateCommandPaletteItems,
9 filterPinnedBrowseEntries,9 filterPinnedBrowseEntries,
10 filterCommandPaletteGroups,10 filterCommandPaletteGroups,
11 normalizeSearchText,
11 reduceCommandPaletteUiState,12 reduceCommandPaletteUiState,
12 type CommandPaletteGroup,13 type CommandPaletteGroup,
13} from "./CommandPalette.logic";14} from "./CommandPalette.logic";
@@ -272,6 +273,75 @@ describe("buildThreadActionItems", () => { · 258 unmodified lines
272 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);273 expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]);
273 });274 });
274 275
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
275 it("keeps message excerpts searchable without replacing thread metadata", () => {345 it("keeps message excerpts searchable without replacing thread metadata", () => {
276 const [item] = buildThreadActionItems({346 const [item] = buildThreadActionItems({
277 threads: [makeThread({ branch: "feat/search" })],347 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
9import * as Result from "effect/Result";9import * as Result from "effect/Result";
10import { type ReactNode } from "react";10import { type ReactNode } from "react";
11import { sortThreads } from "../lib/threadSort";11import { sortThreads } from "../lib/threadSort";
12import { normalizeSearchText } from "../lib/utils";
12import { formatRelativeTimeLabel } from "../timestampFormat";13import { formatRelativeTimeLabel } from "../timestampFormat";
13import { type Project, type SidebarThreadSummary, type Thread } from "../types";14import { type Project, type SidebarThreadSummary, type Thread } from "../types";
14 15
16export { normalizeSearchText } from "../lib/utils";
17
15export const RECENT_THREAD_LIMIT = 12;18export const RECENT_THREAD_LIMIT = 12;
16export const ITEM_ICON_CLASS = "size-4 text-icon-muted";19export const ITEM_ICON_CLASS = "size-4 text-icon-muted";
17export const ADDON_ICON_CLASS = "size-4";20export const ADDON_ICON_CLASS = "size-4";
@@ -138,10 +141,6 @@ export function enumerateCommandPaletteItems( · 120 unmodified lines
138 141
139export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";142export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
140 143
141export function normalizeSearchText(value: string): string {
142 return value.trim().toLowerCase().replace(/\s+/g, " ");
143}
144
145export function buildProjectActionItems(input: {144export function buildProjectActionItems(input: {
146 projects: ReadonlyArray<Project>;145 projects: ReadonlyArray<Project>;
147 valuePrefix: string;146 valuePrefix: string;
@@ -255,9 +254,16 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr · 107 unmodified lines
255 });254 });
256}255}
257 256
258function rankSearchFieldMatch(field: string, normalizedQuery: string): number {257function rankSearchFieldMatch(
258 field: string,
259 normalizedQuery: string,
260 queryTokens: ReadonlyArray<string>,
261): number {
259 const normalizedField = normalizeSearchText(field);262 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 ) {
261 return Number.NEGATIVE_INFINITY;267 return Number.NEGATIVE_INFINITY;
262 }268 }
263 if (normalizedField === normalizedQuery) {269 if (normalizedField === normalizedQuery) {
@@ -266,12 +272,16 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number { · 2 unmodified lines
266 if (normalizedField.startsWith(normalizedQuery)) {272 if (normalizedField.startsWith(normalizedQuery)) {
267 return 2;273 return 2;
268 }274 }
269 return 1;275 if (normalizedField.includes(normalizedQuery)) {
276 return 1;
277 }
278 return 0;
270}279}
271 280
272function rankCommandPaletteItemMatch(281function rankCommandPaletteItemMatch(
273 item: CommandPaletteActionItem | CommandPaletteSubmenuItem,282 item: CommandPaletteActionItem | CommandPaletteSubmenuItem,
274 normalizedQuery: string,283 normalizedQuery: string,
284 queryTokens: ReadonlyArray<string>,
275): number {285): number {
276 const terms = item.searchTerms.filter((term) => term.length > 0);286 const terms = item.searchTerms.filter((term) => term.length > 0);
277 if (terms.length === 0) {287 if (terms.length === 0) {
@@ -279,7 +289,7 @@ function rankCommandPaletteItemMatch( · 1 unmodified line
279 }289 }
280 290
281 for (const [index, field] of terms.entries()) {291 for (const [index, field] of terms.entries()) {
282 const fieldRank = rankSearchFieldMatch(field, normalizedQuery);292 const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens);
283 if (fieldRank !== Number.NEGATIVE_INFINITY) {293 if (fieldRank !== Number.NEGATIVE_INFINITY) {
284 return 1_000 - index * 100 + fieldRank;294 return 1_000 - index * 100 + fieldRank;
285 }295 }
@@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: { · 7 unmodified lines
293 query: string;303 query: string;
294 isInSubmenu: boolean;304 isInSubmenu: boolean;
295 projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;305 projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
306 settingsSearchItems?: ReadonlyArray<CommandPaletteActionItem>;
296 threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;307 threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
297}): CommandPaletteGroup[] {308}): CommandPaletteGroup[] {
298 const isActionsFilter = input.query.startsWith(">");309 const isActionsFilter = input.query.startsWith(">");
@@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: { · 6 unmodified lines
305 }316 }
306 return [...input.activeGroups];317 return [...input.activeGroups];
307 }318 }
319 const queryTokens = normalizedQuery.split(" ");
308 320
309 let baseGroups = [...input.activeGroups];321 let baseGroups = [...input.activeGroups];
310 if (isActionsFilter) {322 if (isActionsFilter) {
@@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: { · 11 unmodified lines
322 items: input.projectSearchItems,334 items: input.projectSearchItems,
323 });335 });
324 }336 }
337 if (input.settingsSearchItems && input.settingsSearchItems.length > 0) {
338 searchableGroups.push({
339 value: "settings-search",
340 label: "Settings",
341 items: input.settingsSearchItems,
342 });
343 }
325 if (input.threadSearchItems.length > 0) {344 if (input.threadSearchItems.length > 0) {
326 searchableGroups.push({345 searchableGroups.push({
327 value: "threads-search",346 value: "threads-search",
@@ -334,14 +353,14 @@ export function filterCommandPaletteGroups(input: { · 6 unmodified lines
334 return searchableGroups.flatMap((group) => {353 return searchableGroups.flatMap((group) => {
335 const items = Arr.filterMap(group.items, (item, index) => {354 const items = Arr.filterMap(group.items, (item, index) => {
336 const haystack = normalizeSearchText(item.searchTerms.join(" "));355 const haystack = normalizeSearchText(item.searchTerms.join(" "));
337 if (!haystack.includes(normalizedQuery)) {356 if (!queryTokens.every((token) => haystack.includes(token))) {
338 return Result.failVoid;357 return Result.failVoid;
339 }358 }
340 359
341 return Result.succeed({360 return Result.succeed({
342 item,361 item,
343 index,362 index,
344 rank: rankCommandPaletteItemMatch(item, normalizedQuery),363 rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens),
345 });364 });
346 })365 })
347 .toSorted((left, right) => right.rank - left.rank || left.index - right.index)366 .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
32 type SourceControlRepositoryInfo,32 type SourceControlRepositoryInfo,
33 PRIMARY_LOCAL_ENVIRONMENT_ID,33 PRIMARY_LOCAL_ENVIRONMENT_ID,
34} from "@t3tools/contracts";34} from "@t3tools/contracts";
35import { useNavigate, useParams } from "@tanstack/react-router";35import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
36import * as Option from "effect/Option";36import * as Option from "effect/Option";
37import {37import {
38 ArrowLeftIcon,38 ArrowLeftIcon,
@@ -104,6 +104,7 @@ import { · 65 unmodified lines
104} from "../lib/utils";104} from "../lib/utils";
105import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";105import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
106import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";106import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes";
107import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems";
107import {108import {
108 applyWslEnvironmentConfiguration,109 applyWslEnvironmentConfiguration,
109 parseWslUncPath,110 parseWslUncPath,
@@ -140,6 +141,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; · 30 unmodified lines
140import { ProjectFilePicker } from "./files/ProjectFilePicker";141import { ProjectFilePicker } from "./files/ProjectFilePicker";
141import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";142import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog";
142import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";143import { toggleThemeEditorForTheme } from "./settings/themeEditorStore";
144import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch";
143import {145import {
144 COMMAND_PALETTE_META_ICON_CLASS,146 COMMAND_PALETTE_META_ICON_CLASS,
145 CommandPaletteMetaDot,147 CommandPaletteMetaDot,
@@ -563,6 +565,7 @@ function OpenCommandPaletteDialog(props: { · 417 unmodified lines
563 readonly clearOpenIntent: () => void;565 readonly clearOpenIntent: () => void;
564}) {566}) {
565 const navigate = useNavigate();567 const navigate = useNavigate();
568 const pathname = useLocation({ select: (location) => location.pathname });
566 const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;569 const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;
567 const [query, setQuery] = useState("");570 const [query, setQuery] = useState("");
568 const deferredQuery = useDeferredValue(query);571 const deferredQuery = useDeferredValue(query);
@@ -585,6 +588,7 @@ function OpenCommandPaletteDialog(props: { · 16 unmodified lines
585 const { environments } = useEnvironments();588 const { environments } = useEnvironments();
586 const desktopLocalBootstraps = useDesktopLocalBootstraps();589 const desktopLocalBootstraps = useDesktopLocalBootstraps();
587 const primaryEnvironmentId = usePrimaryEnvironmentId();590 const primaryEnvironmentId = usePrimaryEnvironmentId();
591 const availableSettingsSearchItems = useAvailableSettingsSearchItems();
588 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =592 const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } =
589 useHandleNewThread();593 useHandleNewThread();
590 const projects = useProjects();594 const projects = useProjects();
@@ -1637,7 +1641,19 @@ function OpenCommandPaletteDialog(props: { · 1046 unmodified lines
1637 actionItems.push({1641 actionItems.push({
1638 kind: "action",1642 kind: "action",
1639 value: "action:project-settings",1643 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 ],
1641 title: "Project settings",1657 title: "Project settings",
1642 description: contextualProjectGroup.displayName,1658 description: contextualProjectGroup.displayName,
1643 icon: <FolderIcon className={ITEM_ICON_CLASS} />,1659 icon: <FolderIcon className={ITEM_ICON_CLASS} />,
@@ -1651,6 +1667,25 @@ function OpenCommandPaletteDialog(props: { · 7 unmodified lines
1651 }1667 }
1652 1668
1653 const rootGroups = buildRootGroups({ actionItems, recentThreadItems });1669 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 }));
1654 const sourceSelectionViewValue =1689 const sourceSelectionViewValue =
1655 addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`;1690 addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`;
1656 const activeGroups =1691 const activeGroups =
@@ -1668,6 +1703,7 @@ function OpenCommandPaletteDialog(props: { · 11 unmodified lines
1668 query: deferredQuery,1703 query: deferredQuery,
1669 isInSubmenu: currentView !== null,1704 isInSubmenu: currentView !== null,
1670 projectSearchItems: projectSearchItems,1705 projectSearchItems: projectSearchItems,
1706 settingsSearchItems,
1671 threadSearchItems: allThreadItems,1707 threadSearchItems: allThreadItems,
1672 });1708 });
1673 1709
apps/web/src/components/settings/ConnectionsSettings.tsx+7−7
@@ -1670,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b · 1669 unmodified lines
1670 <>1670 <>
1671 {window.desktopBridge ? (1671 {window.desktopBridge ? (
1672 <SettingsRow1672 <SettingsRow
1673 title="T3 Connect"1673 title={searchableSetting("t3-connect").title}
1674 description={1674 description={
1675 managedTunnelActive1675 managedTunnelActive
1676 ? "This environment is available to your other devices through T3 Connect."1676 ? "This environment is available to your other devices through T3 Connect."
@@ -1688,7 +1688,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b · 11 unmodified lines
1688 />1688 />
1689 ) : null}1689 ) : null}
1690 <SettingsRow1690 <SettingsRow
1691 title="Publish agent activity"1691 title={searchableSetting("publish-agent-activity").title}
1692 description="Send activity from this environment to your mobile clients for push notifications and Live Activities. Works without a T3 Connect tunnel."1692 description="Send activity from this environment to your mobile clients for push notifications and Live Activities. Works without a T3 Connect tunnel."
1693 control={1693 control={
1694 <CloudLinkSwitch1694 <CloudLinkSwitch
@@ -2908,7 +2908,7 @@ export function ConnectionsSettings() { · 1213 unmodified lines
2908 2908
2909 const renderTailscaleRow = () => (2909 const renderTailscaleRow = () => (
2910 <SettingsRow2910 <SettingsRow
2911 title="Tailscale HTTPS"2911 title={searchableSetting("tailscale-https").title}
2912 description={2912 description={
2913 tailscaleHttpsEndpoint2913 tailscaleHttpsEndpoint
2914 ? tailscaleHttpsEndpoint.status === "available"2914 ? tailscaleHttpsEndpoint.status === "available"
@@ -2958,7 +2958,7 @@ export function ConnectionsSettings() { · 43 unmodified lines
2958 );2958 );
2959 const renderNetworkAccessRow = () => (2959 const renderNetworkAccessRow = () => (
2960 <SettingsRow2960 <SettingsRow
2961 title="Network access"2961 title={searchableSetting("network-access").title}
2962 description={2962 description={
2963 isLocalBackendNetworkAccessible ? (2963 isLocalBackendNetworkAccessible ? (
2964 <NetworkAccessDescription2964 <NetworkAccessDescription
@@ -2990,7 +2990,7 @@ export function ConnectionsSettings() { · 25 unmodified lines
2990 );2990 );
2991 const renderDisabledNetworkAccessRow = () => (2991 const renderDisabledNetworkAccessRow = () => (
2992 <SettingsRow2992 <SettingsRow
2993 title="Network access"2993 title={searchableSetting("network-access").title}
2994 description={2994 description={
2995 currentAuthPolicy === "remote-reachable"2995 currentAuthPolicy === "remote-reachable"
2996 ? "This backend is already configured for remote access. Network exposure changes must be made where the server is launched."2996 ? "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
3022 <SettingsPageContainer>3022 <SettingsPageContainer>
3023 {canManageLocalBackend ? (3023 {canManageLocalBackend ? (
3024 <>3024 <>
3025 <SettingsSection title="This environment">3025 <SettingsSection {...searchableSetting("connections-environment")}>
3026 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (3026 {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? (
3027 <SettingsRow3027 <SettingsRow
3028 title={3028 title={
@@ -3377,7 +3377,7 @@ export function ConnectionsSettings() { · 348 unmodified lines
3377 </Dialog>3377 </Dialog>
3378 </>3378 </>
3379 ) : (3379 ) : (
3380 <SettingsSection title="This environment">3380 <SettingsSection {...searchableSetting("connections-environment")}>
3381 <SettingsRow3381 <SettingsRow
3382 title="Administrative access"3382 title="Administrative access"
3383 description="Pairing links and client-session management require the access:write scope for this backend."3383 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
31 updateSettings: vi.fn(),31 updateSettings: vi.fn(),
32}));32}));
33 33
34const settingsSearchState = vi.hoisted(() => ({
35 targetId: null as string | null,
36 effects: [] as Array<() => void>,
37}));
38
34vi.mock("react", async (importOriginal) => {39vi.mock("react", async (importOriginal) => {
35 const actual = await importOriginal<typeof import("react")>();40 const actual = await importOriginal<typeof import("react")>();
36 const { reactHookHarness } = await import("../../test/reactHookHarness");41 const { reactHookHarness } = await import("../../test/reactHookHarness");
37 return {42 return {
38 ...actual,43 ...actual,
39 useCallback: reactHookHarness.useCallback,44 useCallback: reactHookHarness.useCallback,
45 useEffect: (effect: () => void) => settingsSearchState.effects.push(effect),
40 useMemo: reactHookHarness.useMemo,46 useMemo: reactHookHarness.useMemo,
41 useRef: reactHookHarness.useRef,47 useRef: reactHookHarness.useRef,
42 useState: reactHookHarness.useState,48 useState: reactHookHarness.useState,
43 };49 };
44});50});
45 51
52vi.mock("./settingsLayout", async (importOriginal) => {
53 const actual = await importOriginal<typeof import("./settingsLayout")>();
54 return {
55 ...actual,
56 useSettingsSearchTargetId: () => settingsSearchState.targetId,
57 };
58});
59
46vi.mock("react/compiler-runtime", async () => {60vi.mock("react/compiler-runtime", async () => {
47 const { reactHookHarness } = await import("../../test/reactHookHarness");61 const { reactHookHarness } = await import("../../test/reactHookHarness");
48 return { c: reactHookHarness.useMemoCache };62 return { c: reactHookHarness.useMemoCache };
@@ -131,6 +145,17 @@ function isAddProviderButton(element: ReactElement<Record<string, unknown>>): bo · 82 unmodified lines
131 return element.props["aria-label"] === "Add provider";145 return element.props["aria-label"] === "Add provider";
132}146}
133 147
148function 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
155function flushEffects(): void {
156 for (const effect of settingsSearchState.effects.splice(0)) effect();
157}
158
134async function flushPromises(): Promise<void> {159async function flushPromises(): Promise<void> {
135 await Promise.resolve();160 await Promise.resolve();
136 await Promise.resolve();161 await Promise.resolve();
@@ -144,6 +169,8 @@ describe("EnvironmentProviderSettings routing", () => { · 7 unmodified lines
144 settingsState.readEnvironmentIds = [];169 settingsState.readEnvironmentIds = [];
145 settingsState.updateEnvironmentIds = [];170 settingsState.updateEnvironmentIds = [];
146 settingsState.updateSettings.mockReset();171 settingsState.updateSettings.mockReset();
172 settingsSearchState.targetId = null;
173 settingsSearchState.effects = [];
147 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });174 commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" });
148 commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" });175 commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" });
149 });176 });
@@ -235,6 +262,17 @@ describe("EnvironmentProviderSettings routing", () => { · 85 unmodified lines
235 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();262 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
236 });263 });
237 264
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
238 it("deletes and resets provider configuration without erasing shared preferences", () => {276 it("deletes and resets provider configuration without erasing shared preferences", () => {
239 settingsState.value = {277 settingsState.value = {
240 ...DEFAULT_UNIFIED_SETTINGS,278 ...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
4import {4import {
5 buildProviderEnvironmentOptions,5 buildProviderEnvironmentOptions,
6 classifyProviderEnvironmentAccess,6 classifyProviderEnvironmentAccess,
7 isProviderSettingsEnvironmentAvailable,
7 resolvePrimaryOperateAccess,8 resolvePrimaryOperateAccess,
8 resolveRemoteOperateAccess,9 resolveRemoteOperateAccess,
9 resolveSelectedProviderEnvironmentId,10 resolveSelectedProviderEnvironmentId,
@@ -20,6 +21,27 @@ const environments = [ · 10 unmodified lines
20] as const;21] as const;
21 22
22describe("provider environment selection", () => {23describe("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
23 it("sorts the primary environment first and the rest by label", () => {45 it("sorts the primary environment first and the rest by label", () => {
24 expect(46 expect(
25 buildProviderEnvironmentOptions(environments, primaryId).map(47 buildProviderEnvironmentOptions(environments, primaryId).map(
apps/web/src/components/settings/ProviderSettingsPanel.logic.ts+7−0
@@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike { · 9 unmodified lines
10 readonly label: string;10 readonly label: string;
11}11}
12 12
13export function isProviderSettingsEnvironmentAvailable(input: {
14 readonly connectionPhase: EnvironmentConnectionPhase;
15 readonly hasServerConfig: boolean;
16}): boolean {
17 return input.connectionPhase === "connected" && input.hasServerConfig;
18}
19
13export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>(20export function buildProviderEnvironmentOptions<T extends ProviderEnvironmentOptionLike>(
14 environments: ReadonlyArray<T>,21 environments: ReadonlyArray<T>,
15 primaryEnvironmentId: EnvironmentId | null,22 primaryEnvironmentId: EnvironmentId | null,
apps/web/src/components/settings/ProviderSettingsPanel.tsx+46−6
@@ -33,7 +33,7 @@ import { · 32 unmodified lines
33 RefreshCwIcon,33 RefreshCwIcon,
34 TerminalIcon,34 TerminalIcon,
35} from "lucide-react";35} from "lucide-react";
36import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";36import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
37 37
38import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";38import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
39import { isElectron } from "../../env";39import { isElectron } from "../../env";
@@ -93,10 +93,12 @@ import { · 53 unmodified lines
93 SettingsRow,93 SettingsRow,
94 SettingsSection,94 SettingsSection,
95 useRelativeTimeTick,95 useRelativeTimeTick,
96 useSettingsSearchTargetId,
96} from "./settingsLayout";97} from "./settingsLayout";
97import {98import {
98 buildProviderEnvironmentOptions,99 buildProviderEnvironmentOptions,
99 classifyProviderEnvironmentAccess,100 classifyProviderEnvironmentAccess,
101 isProviderSettingsEnvironmentAvailable,
100 type ProviderEnvironmentAccess,102 type ProviderEnvironmentAccess,
101 type ProviderOperateAccess,103 type ProviderOperateAccess,
102 resolvePrimaryOperateAccess,104 resolvePrimaryOperateAccess,
@@ -189,7 +191,7 @@ function EnvironmentUnavailableRow({ · 86 unmodified lines
189 // No spinner: this state can persist indefinitely for a wedged device, and a191 // No spinner: this state can persist indefinitely for a wedged device, and a
190 // continuously repainting animation would run the whole time.192 // continuously repainting animation would run the whole time.
191 return (193 return (
192 <SettingsSection title="Providers">194 <SettingsSection {...searchableSetting("providers")}>
193 {deviceTabs}195 {deviceTabs}
194 <SettingsRow title={title} description={description} />196 <SettingsRow title={title} description={description} />
195 </SettingsSection>197 </SettingsSection>
@@ -197,8 +199,17 @@ function EnvironmentUnavailableRow({ · 1 unmodified line
197}199}
198 200
199export function ProviderSettingsPanel() {201export function ProviderSettingsPanel() {
202 return (
203 <SettingsPageContainer className="gap-8">
204 <ProviderSettingsPanelContent />
205 </SettingsPageContainer>
206 );
207}
208
209function ProviderSettingsPanelContent() {
200 const { environments, isReady } = useEnvironments();210 const { environments, isReady } = useEnvironments();
201 const primaryEnvironmentId = usePrimaryEnvironmentId();211 const primaryEnvironmentId = usePrimaryEnvironmentId();
212 const searchTargetId = useSettingsSearchTargetId();
202 const options = useMemo(213 const options = useMemo(
203 () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId),214 () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId),
204 [environments, primaryEnvironmentId],215 [environments, primaryEnvironmentId],
@@ -216,6 +227,27 @@ export function ProviderSettingsPanel() { · 11 unmodified lines
216 );227 );
217 const selectedEnvironment =228 const selectedEnvironment =
218 options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null;229 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]);
219 const onlyPrimaryDevice =251 const onlyPrimaryDevice =
220 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";252 options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget";
221 const deviceTabs =253 const deviceTabs =
@@ -266,9 +298,9 @@ export function ProviderSettingsPanel() { · 44 unmodified lines
266 ) : null;298 ) : null;
267 299
268 return (300 return (
269 <SettingsPageContainer className="gap-8">301 <>
270 {options.length === 0 ? (302 {options.length === 0 ? (
271 <SettingsSection title="Providers">303 <SettingsSection {...searchableSetting("providers")}>
272 <SettingsRow304 <SettingsRow
273 title={isReady ? "No connected devices" : "Loading devices"}305 title={isReady ? "No connected devices" : "Loading devices"}
274 description={306 description={
@@ -287,7 +319,7 @@ export function ProviderSettingsPanel() { · 12 unmodified lines
287 deviceTabs={deviceTabs}319 deviceTabs={deviceTabs}
288 />320 />
289 ) : null}321 ) : null}
290 </SettingsPageContainer>322 </>
291 );323 );
292}324}
293 325
@@ -429,12 +461,19 @@ export function EnvironmentProviderSettings({ · 135 unmodified lines
429 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);461 const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false);
430 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);462 const [selectedInstanceId, setSelectedInstanceId] = useState<ProviderInstanceId | null>(null);
431 const [advancedOpen, setAdvancedOpen] = useState(false);463 const [advancedOpen, setAdvancedOpen] = useState(false);
464 const searchTargetId = useSettingsSearchTargetId();
432 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<465 const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState<
433 ReadonlySet<ProviderDriverKind>466 ReadonlySet<ProviderDriverKind>
434 >(() => new Set());467 >(() => new Set());
435 const refreshingRef = useRef(false);468 const refreshingRef = useRef(false);
436 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());469 const updatingDriversRef = useRef<Set<ProviderDriverKind>>(new Set());
437 470
471 useEffect(() => {
472 if (searchTargetId === searchableSetting("provider-health-check-interval").id) {
473 setAdvancedOpen(true);
474 }
475 }, [searchTargetId]);
476
438 const providerUpdateCandidates = useMemo(477 const providerUpdateCandidates = useMemo(
439 () => collectProviderUpdateCandidates(serverProviders),478 () => collectProviderUpdateCandidates(serverProviders),
440 [serverProviders],479 [serverProviders],
@@ -915,9 +954,10 @@ export function EnvironmentProviderSettings({ · 474 unmodified lines
915 className={readOnly ? "opacity-50 select-none" : undefined}954 className={readOnly ? "opacity-50 select-none" : undefined}
916 >955 >
917 <SettingsRow956 <SettingsRow
957 id={searchableSetting("provider-health-check-interval").id}
918 title={958 title={
919 <span className="inline-flex items-center gap-1.5">959 <span className="inline-flex items-center gap-1.5">
920 Health check interval960 {searchableSetting("provider-health-check-interval").title}
921 <PolicyTooltip>961 <PolicyTooltip>
922 This interval is configured here, then the shared Background activity policy962 This interval is configured here, then the shared Background activity policy
923 decides whether provider probes may run when the timer fires. Custom963 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
790 790
791 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">791 <div className="flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
792 <div className="min-w-0 space-y-1">792 <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>
794 <p className="text-xs leading-relaxed text-muted-foreground">796 <p className="text-xs leading-relaxed text-muted-foreground">
795 Refresh remote branch status in the background.797 Refresh remote branch status in the background.
796 </p>798 </p>
@@ -2009,7 +2011,7 @@ export function GeneralSettingsPanel() { · 1212 unmodified lines
2009 />2011 />
2010 {settings.sidebarAutoSettleAfterDays !== null ? (2012 {settings.sidebarAutoSettleAfterDays !== null ? (
2011 <SettingsRow2013 <SettingsRow
2012 title="Days of inactivity before auto-settle"2014 title={searchableSetting("days-before-auto-settle").title}
2013 description="Any new activity un-settles a thread automatically."2015 description="Any new activity un-settles a thread automatically."
2014 control={2016 control={
2015 <AutoSettleDaysInput2017 <AutoSettleDaysInput
@@ -2142,9 +2144,10 @@ export function GeneralSettingsPanel() { · 126 unmodified lines
2142 />2144 />
2143 2145
2144 <SettingsRow2146 <SettingsRow
2147 id={searchableSetting("background-activity").id}
2145 title={2148 title={
2146 <span className="inline-flex items-center gap-1.5">2149 <span className="inline-flex items-center gap-1.5">
2147 Background activity2150 {searchableSetting("background-activity").title}
2148 <PolicyTooltip>2151 <PolicyTooltip>
2149 This shared policy gates background work such as Git refreshes and provider health2152 This shared policy gates background work such as Git refreshes and provider health
2150 probes after their individual intervals elapse.2153 probes after their individual intervals elapse.
apps/web/src/components/settings/SettingsSidebarNav.tsx+2−17
@@ -7,8 +7,6 @@ import { · 6 unmodified lines
7 type ComponentType,7 type ComponentType,
8 type KeyboardEvent,8 type KeyboardEvent,
9} from "react";9} from "react";
10import { useEnvironmentQuery } from "~/state/query";
11import { desktopWslStateAtom } from "~/state/desktopWslState";
12import {10import {
13 ArchiveIcon,11 ArchiveIcon,
14 BlocksIcon,12 BlocksIcon,
@@ -23,8 +21,6 @@ import { · 8 unmodified lines
23} from "lucide-react";21} from "lucide-react";
24import { useLocation, useNavigate } from "@tanstack/react-router";22import { useLocation, useNavigate } from "@tanstack/react-router";
25 23
26import { isElectron } from "~/env";
27import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
28import { Button } from "../ui/button";24import { Button } from "../ui/button";
29import { Input } from "../ui/input";25import { Input } from "../ui/input";
30import { Kbd } from "../ui/kbd";26import { Kbd } from "../ui/kbd";
@@ -42,11 +38,11 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; · 11 unmodified lines
42import { scrollToSettingsTarget } from "./settingsLayout";38import { scrollToSettingsTarget } from "./settingsLayout";
43import {39import {
44 searchSettings,40 searchSettings,
45 SETTINGS_SEARCH_ITEMS,
46 SETTINGS_SECTION_LABELS,41 SETTINGS_SECTION_LABELS,
47 type SettingsPath,42 type SettingsPath,
48 type SettingsSearchItem,43 type SettingsSearchItem,
49} from "./settingsSearch";44} from "./settingsSearch";
45import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
50 46
51const SETTINGS_SECTION_ICONS: Readonly<47const SETTINGS_SECTION_ICONS: Readonly<
52 Record<SettingsPath, ComponentType<{ className?: string }>>48 Record<SettingsPath, ComponentType<{ className?: string }>>
@@ -83,18 +79,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { · 30 unmodified lines
83 const searchInputRef = useRef<HTMLInputElement>(null);79 const searchInputRef = useRef<HTMLInputElement>(null);
84 const [query, setQuery] = useState("");80 const [query, setQuery] = useState("");
85 const [activeResultIndex, setActiveResultIndex] = useState(0);81 const [activeResultIndex, setActiveResultIndex] = useState(0);
86 const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null);82 const searchableItems = useAvailableSettingsSearchItems();
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]);
98 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);83 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
99 const isSearching = query.trim().length > 0;84 const isSearching = query.trim().length > 0;
100 const hasResults = results.length > 0;85 const hasResults = results.length > 0;
apps/web/src/components/settings/SourceControlSettings.tsx+14−4
@@ -1,7 +1,7 @@
1import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";1import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
2import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
3import * as Option from "effect/Option";3import * as Option from "effect/Option";
4import { useState, type ReactNode } from "react";4import { useEffect, useState, type ReactNode } from "react";
5import type {5import type {
6 BackgroundActivitySettings,6 BackgroundActivitySettings,
7 SourceControlProviderKind,7 SourceControlProviderKind,
@@ -59,7 +59,9 @@ import { · 51 unmodified lines
59 PolicyTooltip,59 PolicyTooltip,
60 SettingResetButton,60 SettingResetButton,
61 SettingsPageContainer,61 SettingsPageContainer,
62 SettingsSearchTarget,
62 SettingsSection,63 SettingsSection,
64 useSettingsSearchTargetId,
63} from "./settingsLayout";65} from "./settingsLayout";
64import { searchableSetting } from "./settingsSearch";66import { searchableSetting } from "./settingsSearch";
65 67
@@ -267,6 +269,13 @@ function DiscoveryItemRow({ · 201 unmodified lines
267 const authAccount = auth ? optionLabel(auth.account) : null;269 const authAccount = auth ? optionLabel(auth.account) : null;
268 const [isExpanded, setIsExpanded] = useState(false);270 const [isExpanded, setIsExpanded] = useState(false);
269 const hasDetails = children !== undefined;271 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]);
270 279
271 return (280 return (
272 <div281 <div
@@ -345,13 +354,14 @@ function GitFetchIntervalSettings() { · 72 unmodified lines
345 );354 );
346 const canResetFetchInterval =355 const canResetFetchInterval =
347 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;356 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;
357 const setting = searchableSetting("git-fetch-interval");
348 358
349 return (359 return (
350 <div className="grid gap-3">360 <SettingsSearchTarget id={setting.id} className="grid gap-3">
351 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">361 <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
352 <div className="min-w-0 space-y-1">362 <div className="min-w-0 space-y-1">
353 <div className="flex min-w-0 items-center gap-1">363 <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>
355 <PolicyTooltip>365 <PolicyTooltip>
356 This interval is configured for Git only. The shared Background activity policy still366 This interval is configured for Git only. The shared Background activity policy still
357 decides whether Git refreshes may run when the timer fires. Custom intervals appear as367 decides whether Git refreshes may run when the timer fires. Custom intervals appear as
@@ -407,7 +417,7 @@ function GitFetchIntervalSettings() { · 49 unmodified lines
407 <span className="text-xs text-muted-foreground">seconds</span>417 <span className="text-xs text-muted-foreground">seconds</span>
408 </div>418 </div>
409 </div>419 </div>
410 </div>420 </SettingsSearchTarget>
411 );421 );
412}422}
413 423
apps/web/src/components/settings/SourceControlWritingSettings.tsx+4−3
@@ -21,6 +21,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ · 20 unmodified lines
21import { Switch } from "../ui/switch";21import { Switch } from "../ui/switch";
22import { Textarea } from "../ui/textarea";22import { Textarea } from "../ui/textarea";
23import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";23import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout";
24import { searchableSetting } from "./settingsSearch";
24 25
25const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; description: string }> =26const MODE_OPTIONS: Record<SourceControlWritingStyleMode, { label: string; description: string }> =
26 {27 {
@@ -73,7 +74,7 @@ export function SourceControlWritingSettingsSection() { · 46 unmodified lines
73 return (74 return (
74 <SettingsSection title="Text generation">75 <SettingsSection title="Text generation">
75 <SettingsRow76 <SettingsRow
76 title="Source control writing style"77 {...searchableSetting("source-control-writing-style")}
77 description={MODE_OPTIONS[style.mode].description}78 description={MODE_OPTIONS[style.mode].description}
78 resetAction={79 resetAction={
79 isSourceControlWritingStyleDirty ? (80 isSourceControlWritingStyleDirty ? (
@@ -137,7 +138,7 @@ export function SourceControlWritingSettingsSection() { · 57 unmodified lines
137 </SettingsRow>138 </SettingsRow>
138 139
139 <SettingsRow140 <SettingsRow
140 title="Follow change request templates"141 {...searchableSetting("follow-change-request-templates")}
141 description="Structures change request descriptions using the current repository's template when one is available."142 description="Structures change request descriptions using the current repository's template when one is available."
142 resetAction={143 resetAction={
143 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (144 style.followChangeRequestTemplates !== defaults.followChangeRequestTemplates ? (
@@ -169,7 +170,7 @@ export function SourceControlWritingSettingsSection() { · 25 unmodified lines
169 />170 />
170 171
171 <SettingsRow172 <SettingsRow
172 title="Source control writer model"173 {...searchableSetting("source-control-writer-model")}
173 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."174 description="Optional model override for change descriptions, change request titles and descriptions, and branch or bookmark names. Off uses the global text generation model."
174 control={175 control={
175 <div className="flex flex-wrap items-center justify-end gap-2">176 <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
41import { stackedThreadToast, toastManager } from "../ui/toast";41import { stackedThreadToast, toastManager } from "../ui/toast";
42import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";42import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
43import { ThemeImportDialog } from "./ThemeImportDialog";43import { ThemeImportDialog } from "./ThemeImportDialog";
44import { searchableSetting } from "./settingsSearch";
44import { useThemeEditorStore } from "./themeEditorStore";45import { useThemeEditorStore } from "./themeEditorStore";
45import {46import {
46 STANDARD_THEME_CARDS,47 STANDARD_THEME_CARDS,
@@ -892,11 +893,13 @@ export function ThemeLibrary({ · 845 unmodified lines
892 Choose how T3 Code looks. Use a built-in theme or make your own.893 Choose how T3 Code looks. Use a built-in theme or make your own.
893 </p>894 </p>
894 <h3 className="px-3 text-sm font-medium tracking-[-0.005em] text-foreground sm:px-4">895 <h3 className="px-3 text-sm font-medium tracking-[-0.005em] text-foreground sm:px-4">
895 Color scheme896 {searchableSetting("color-scheme").title}
896 </h3>897 </h3>
897 {renderModeTiles()}898 {renderModeTiles()}
898 <div className="flex min-h-8 flex-wrap items-center justify-between gap-3 px-3 pt-2 sm:px-4">899 <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>
900 <div className="flex flex-wrap items-center justify-end gap-2">903 <div className="flex flex-wrap items-center justify-end gap-2">
901 <Button904 <Button
902 size="xs"905 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
84 return targetRef;84 return targetRef;
85}85}
86 86
87export 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
87/** Info affordance explaining how a setting interacts with the shared background policy. */99/** Info affordance explaining how a setting interacts with the shared background policy. */
88export function PolicyTooltip({ children }: { readonly children: string }) {100export function PolicyTooltip({ children }: { readonly children: string }) {
89 return (101 return (
apps/web/src/components/settings/settingsSearch.test.ts+75−7
@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2 2
3import {3import {
4 filterAvailableSettingsSearchItems,
4 searchableSetting,5 searchableSetting,
5 searchSettings,6 searchSettings,
6 SETTINGS_SEARCH_ITEMS,7 SETTINGS_SEARCH_ITEMS,
@@ -12,16 +13,19 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ · 5 unmodified lines
12 id: "word-wrap",13 id: "word-wrap",
13 title: "Word wrap",14 title: "Word wrap",
14 to: "/settings/general",15 to: "/settings/general",
16 searchTerms: ["long lines in code previews"],
15 },17 },
16 {18 {
17 id: "network-access",19 id: "network-access",
18 title: "Network access",20 title: "Network access",
19 to: "/settings/connections",21 to: "/settings/connections",
22 searchTerms: ["remote pairing backend"],
20 },23 },
21 {24 {
22 id: "providers",25 id: "providers",
23 title: "Providers",26 title: "Providers",
24 to: "/settings/providers",27 to: "/settings/providers",
28 searchTerms: ["claude codex agents"],
25 },29 },
26 {30 {
27 id: "provider-updates",31 id: "provider-updates",
@@ -36,16 +40,25 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [ · 8 unmodified lines
36];40];
37 41
38describe("searchSettings", () => {42describe("searchSettings", () => {
39 it("matches only setting titles", () => {43 it("matches titles, sections, and remembered setting details", () => {
40 expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);44 expect(searchSettings("word", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
41 expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]);45 expect(searchSettings("network", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
42 expect(searchSettings("connections", ITEMS)).toEqual([]);46 expect(searchSettings("connections", ITEMS).map((item) => item.id)).toEqual(["network-access"]);
43 expect(searchSettings("claude", ITEMS)).toEqual([]);47 expect(searchSettings("claude", ITEMS).map((item) => item.id)).toEqual(["providers"]);
48 expect(searchSettings("long lines", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
44 });49 });
45 50
46 it("matches normalized title substrings", () => {51 it("matches normalized title substrings", () => {
47 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);52 expect(searchSettings(" WORD WRAP ", ITEMS).map((item) => item.id)).toEqual(["word-wrap"]);
48 expect(searchSettings("glass").map((item) => item.id)).toEqual(["setting-glass-opacity"]);53 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 }
49 expect(searchSettings("xyzzy")).toEqual([]);62 expect(searchSettings("xyzzy")).toEqual([]);
50 });63 });
51 64
@@ -56,6 +69,27 @@ describe("searchSettings", () => { · 4 unmodified lines
56 ]);69 ]);
57 });70 });
58 71
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
59 it("lists thread confirmations in panel order", () => {93 it("lists thread confirmations in panel order", () => {
60 expect(searchSettings("confirmation").map((item) => item.id)).toEqual([94 expect(searchSettings("confirmation").map((item) => item.id)).toEqual([
61 "unpin-confirmation",95 "unpin-confirmation",
@@ -74,8 +108,17 @@ describe("searchSettings", () => { · 12 unmodified lines
74 expect(searchSettings("wsl")).toEqual([]);108 expect(searchSettings("wsl")).toEqual([]);
75 });109 });
76 110
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
77 it("registers the WSL backend as a desktop-only setting", () => {120 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({
79 id: "wsl-backend",122 id: "wsl-backend",
80 title: "WSL backend",123 title: "WSL backend",
81 to: "/settings/connections",124 to: "/settings/connections",
@@ -84,6 +127,30 @@ describe("searchSettings", () => { · 2 unmodified lines
84 });127 });
85 });128 });
86 129
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
87 it("keeps catalog result ids unique", () => {154 it("keeps catalog result ids unique", () => {
88 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);155 const ids = SETTINGS_SEARCH_ITEMS.map((item) => item.id);
89 expect(new Set(ids).size).toBe(ids.length);156 expect(new Set(ids).size).toBe(ids.length);
@@ -111,10 +178,11 @@ describe("searchSettings", () => { · 21 unmodified lines
111 });178 });
112 179
113 it("routes browser recording quality to integrations", () => {180 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({
115 id: "browser-recording-frame-rate",183 id: "browser-recording-frame-rate",
116 to: "/settings/integrations",184 to: "/settings/integrations",
117 targetId: "browser",
118 });185 });
186 expect(result).not.toHaveProperty("targetId");
119 });187 });
120});188});
apps/web/src/components/settings/settingsSearch.ts+217−31
@@ -1,5 +1,5 @@
1import { isElectron } from "~/env";1import { isElectron } from "~/env";
2import { isWindowsPlatform } from "~/lib/utils";2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
3 3
4export type SettingsPath =4export type SettingsPath =
5 | "/settings/general"5 | "/settings/general"
@@ -16,12 +16,28 @@ export interface SettingsSearchItem { · 10 unmodified lines
16 readonly title: string;16 readonly title: string;
17 readonly to: SettingsPath;17 readonly to: SettingsPath;
18 readonly targetId?: string;18 readonly targetId?: string;
19 /** Descriptions, option labels, and aliases people may remember instead of the title. */
20 readonly searchTerms?: ReadonlyArray<string>;
19 // Its row only renders in the desktop app, so a browser result would land on21 // Its row only renders in the desktop app, so a browser result would land on
20 // an anchor that isn't there.22 // an anchor that isn't there.
21 readonly desktopOnly?: boolean;23 readonly desktopOnly?: boolean;
24 readonly macOnly?: boolean;
22 // Its row only renders on Windows desktop, so other desktop platforms must25 // Its row only renders on Windows desktop, so other desktop platforms must
23 // not expose a result that points to a missing anchor.26 // not expose a result that points to a missing anchor.
24 readonly windowsOnly?: boolean;27 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
35export interface SettingsSearchAvailability {
36 readonly hasCloudPublicConfig: boolean;
37 readonly hasPrimaryEnvironment: boolean;
38 readonly hasProviderSettingsEnvironment: boolean;
39 readonly canManageLocalBackend: boolean;
40 readonly isWslSettingsRowVisible: boolean;
25}41}
26 42
27/**43/**
@@ -40,16 +56,16 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = { · 12 unmodified lines
40};56};
41 57
42/**58/**
43 * Every searchable setting, in result order. This catalog is the single59 * Searchable settings and stable destinations, in result order. Rows with a
44 * source of truth for anchor ids and visible titles: panels render both via60 * dedicated anchor render their id and title via `searchableSetting`; items
45 * `searchableSetting`, so a retitle (or, later, a translation pass) happens61 * that may not be mounted point at their nearest stable section instead.
46 * here once instead of separately in the panel and the index.
47 */62 */
48export const SETTINGS_SEARCH_ITEMS = [63export const SETTINGS_SEARCH_ITEMS = [
49 {64 {
50 id: "color-scheme",65 id: "color-scheme",
51 title: "Color scheme",66 title: "Color scheme",
52 to: "/settings/appearance",67 to: "/settings/appearance",
68 searchTerms: ["appearance light dark system mode"],
53 // The scheme tiles sit at the top of the Appearance section.69 // The scheme tiles sit at the top of the Appearance section.
54 targetId: "appearance",70 targetId: "appearance",
55 },71 },
@@ -57,6 +73,7 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line
57 id: "theme",73 id: "theme",
58 title: "Themes",74 title: "Themes",
59 to: "/settings/appearance",75 to: "/settings/appearance",
76 searchTerms: ["appearance colors palette custom import"],
60 // Theme cards live directly under the scheme tiles; the section is the77 // Theme cards live directly under the scheme tiles; the section is the
61 // stable scroll destination for both.78 // stable scroll destination for both.
62 targetId: "appearance",79 targetId: "appearance",
@@ -66,17 +83,20 @@ export const SETTINGS_SEARCH_ITEMS = [ · 3 unmodified lines
66 id: "setting-appearance-contrast",83 id: "setting-appearance-contrast",
67 title: "Contrast",84 title: "Contrast",
68 to: "/settings/appearance",85 to: "/settings/appearance",
86 searchTerms: ["colors borders interface"],
69 },87 },
70 {88 {
71 // Prefixed because the slider control already owns the `glass-opacity` id.89 // Prefixed because the slider control already owns the `glass-opacity` id.
72 id: "setting-glass-opacity",90 id: "setting-glass-opacity",
73 title: "Glass opacity",91 title: "Glass opacity",
74 to: "/settings/appearance",92 to: "/settings/appearance",
93 searchTerms: ["transparent transparency solid menus dialogs composer"],
75 },94 },
76 {95 {
77 id: "environment-identification",96 id: "environment-identification",
78 title: "Environment identification",97 title: "Environment identification",
79 to: "/settings/appearance",98 to: "/settings/appearance",
99 searchTerms: ["dev nightly artwork pill label hide none"],
80 // The setting is stage-dependent, so its parent section is the stable destination.100 // The setting is stage-dependent, so its parent section is the stable destination.
81 targetId: "appearance",101 targetId: "appearance",
82 },102 },
@@ -84,204 +104,339 @@ export const SETTINGS_SEARCH_ITEMS = [ · 1 unmodified line
84 id: "interface-font",104 id: "interface-font",
85 title: "Interface font",105 title: "Interface font",
86 to: "/settings/appearance",106 to: "/settings/appearance",
107 searchTerms: ["typography family size system sans"],
87 },108 },
88 {109 {
89 id: "prompt-font",110 id: "prompt-font",
90 title: "Prompt font",111 title: "Prompt font",
91 to: "/settings/appearance",112 to: "/settings/appearance",
113 searchTerms: ["typography family size composer input"],
92 },114 },
93 {115 {
94 id: "code-font",116 id: "code-font",
95 title: "Code font",117 title: "Code font",
96 to: "/settings/appearance",118 to: "/settings/appearance",
119 searchTerms: ["typography family size monospace code blocks diffs file previews"],
97 },120 },
98 {121 {
99 id: "terminal-font",122 id: "terminal-font",
100 title: "Terminal font",123 title: "Terminal font",
101 to: "/settings/appearance",124 to: "/settings/appearance",
125 searchTerms: ["typography family size monospace output"],
102 },126 },
103 {127 {
104 id: "font-smoothing",128 id: "font-smoothing",
105 title: "Font smoothing",129 title: "Font smoothing",
106 to: "/settings/appearance",130 to: "/settings/appearance",
131 searchTerms: ["typography text grayscale anti aliasing macos thin"],
132 macOnly: true,
107 },133 },
108 {134 {
109 id: "word-wrap",135 id: "word-wrap",
110 title: "Word wrap",136 title: "Word wrap",
111 to: "/settings/appearance",137 to: "/settings/appearance",
138 searchTerms: ["long lines code blocks tables diffs file previews"],
112 },139 },
113 {140 {
114 id: "project-grouping",141 id: "project-grouping",
115 title: "Project grouping",142 title: "Project grouping",
116 to: "/settings/general",143 to: "/settings/general",
144 searchTerms: ["combine matching repositories environments sidebar"],
117 },145 },
118 {146 {
119 id: "auto-settle-inactive-threads",147 id: "auto-settle-inactive-threads",
120 title: "Auto-settle inactive threads",148 title: "Auto-settle inactive threads",
121 to: "/settings/general",149 to: "/settings/general",
150 searchTerms: ["sidebar inactivity days no activity automatically"],
122 },151 },
123 {152 {
124 id: "auto-settle-merged-threads",153 id: "auto-settle-merged-threads",
125 title: "Auto-settle merged threads",154 title: "Auto-settle merged threads",
126 to: "/settings/general",155 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"],
127 },164 },
128 {165 {
129 id: "time-format",166 id: "time-format",
130 title: "Time format",167 title: "Time format",
131 to: "/settings/general",168 to: "/settings/general",
169 searchTerms: ["timestamp clock locale system browser os 12 hour 24 hour"],
132 },170 },
133 {171 {
134 id: "hide-whitespace-changes",172 id: "hide-whitespace-changes",
135 title: "Hide whitespace changes",173 title: "Hide whitespace changes",
136 to: "/settings/general",174 to: "/settings/general",
175 searchTerms: ["diff ignore spaces edits default"],
137 },176 },
138 {177 {
139 id: "skills-in-slash-menu",178 id: "skills-in-slash-menu",
140 title: "Show skills in slash menu",179 title: "Show skills in slash menu",
141 to: "/settings/general",180 to: "/settings/general",
181 searchTerms: ["command menu dollar $ slash /"],
142 },182 },
143 {183 {
144 id: "provider-update-checks",184 id: "provider-update-checks",
145 title: "Provider update checks",185 title: "Provider update checks",
146 to: "/settings/general",186 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 ],
147 },196 },
148 {197 {
149 id: "new-threads",198 id: "new-threads",
150 title: "New threads",199 title: "New threads",
151 to: "/settings/general",200 to: "/settings/general",
201 searchTerms: ["default workspace mode draft local worktree"],
152 },202 },
153 {203 {
154 id: "start-from-origin",204 id: "start-from-origin",
155 title: "Start from origin",205 title: "Start from origin",
156 to: "/settings/general",206 to: "/settings/general",
157 targetId: "new-threads",207 targetId: "new-threads",
208 searchTerms: ["new worktrees latest matching remote branch local"],
158 },209 },
159 {210 {
160 id: "add-project-starts-in",211 id: "add-project-starts-in",
161 title: "Add project starts in",212 title: "Add project starts in",
162 to: "/settings/general",213 to: "/settings/general",
214 searchTerms: ["base directory folder browser path home"],
163 },215 },
164 {216 {
165 id: "unpin-confirmation",217 id: "unpin-confirmation",
166 title: "Unpin confirmation",218 title: "Unpin confirmation",
167 to: "/settings/general",219 to: "/settings/general",
220 searchTerms: ["ask before thread pinned section"],
168 },221 },
169 {222 {
170 id: "archive-confirmation",223 id: "archive-confirmation",
171 title: "Archive confirmation",224 title: "Archive confirmation",
172 to: "/settings/general",225 to: "/settings/general",
226 searchTerms: ["ask before thread second click inline action"],
173 },227 },
174 {228 {
175 id: "delete-confirmation",229 id: "delete-confirmation",
176 title: "Delete confirmation",230 title: "Delete confirmation",
177 to: "/settings/general",231 to: "/settings/general",
232 searchTerms: ["ask before thread chat history"],
178 },233 },
179 {234 {
180 id: "quit-confirmation",235 id: "quit-confirmation",
181 title: "Hold to quit",236 title: "Hold to quit",
182 to: "/settings/general",237 to: "/settings/general",
238 searchTerms: ["confirmation shortcut desktop app exit"],
183 desktopOnly: true,239 desktopOnly: true,
184 },240 },
185 {241 {
186 id: "text-generation-model",242 id: "text-generation-model",
187 title: "Text generation model",243 title: "Text generation model",
188 to: "/settings/general",244 to: "/settings/general",
245 searchTerms: ["generated thread titles source control content default provider"],
189 },246 },
190 {247 {
191 id: "diagnostics",248 id: "diagnostics",
192 title: "Diagnostics",249 title: "Diagnostics",
193 to: "/settings/general",250 to: "/settings/general",
251 searchTerms: ["logs traces processes resource history failures spans cpu memory"],
194 },252 },
195 {253 {
196 id: "legacy-plan-mode",254 id: "legacy-plan-mode",
197 title: "Plan mode (legacy)",255 title: "Plan mode (legacy)",
198 to: "/settings/general",256 to: "/settings/general",
257 searchTerms: ["build plan composer old"],
199 },258 },
200 {259 {
201 id: "legacy-token-streaming",260 id: "legacy-token-streaming",
202 title: "Stream token by token (legacy)",261 title: "Stream token by token (legacy)",
203 to: "/settings/general",262 to: "/settings/general",
263 searchTerms: ["response output old compatibility"],
204 },264 },
205 {265 {
206 id: "legacy-sidebar",266 id: "legacy-sidebar",
207 title: "Sidebar (legacy)",267 title: "Sidebar (legacy)",
208 to: "/settings/general",268 to: "/settings/general",
269 searchTerms: ["project thread tree old flat list"],
209 },270 },
210 {271 {
211 id: "keybindings",272 id: "keybindings",
212 title: "Keybindings",273 title: "Keybindings",
213 to: "/settings/keybindings",274 to: "/settings/keybindings",
275 searchTerms: ["keyboard shortcuts hotkeys commands bindings json"],
214 },276 },
215 {277 {
216 id: "providers",278 id: "providers",
217 title: "Providers",279 title: "Providers",
218 to: "/settings/providers",280 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,
219 },291 },
220 {292 {
221 id: "agent-browser-access",293 id: "agent-browser-access",
222 title: "Agent browser access",294 title: "Agent browser access",
223 to: "/settings/integrations",295 to: "/settings/integrations",
224 targetId: "browser",296 searchTerms: ["allow open drive preview tools sessions"],
225 },297 },
226 {298 {
227 id: "browser-default-viewport",299 id: "browser-default-viewport",
228 title: "Default browser viewport",300 title: "Default browser viewport",
229 to: "/settings/integrations",301 to: "/settings/integrations",
230 targetId: "browser",302 searchTerms: ["preview size width height device desktop mobile rotate"],
231 },303 },
232 {304 {
233 id: "browser-default-zoom",305 id: "browser-default-zoom",
234 title: "Default browser zoom",306 title: "Default browser zoom",
235 to: "/settings/integrations",307 to: "/settings/integrations",
236 targetId: "browser",308 searchTerms: ["preview page scale tabs percent"],
237 },309 },
238 {310 {
239 id: "browser-default-appearance",311 id: "browser-default-appearance",
240 title: "Default browser appearance",312 title: "Default browser appearance",
241 to: "/settings/integrations",313 to: "/settings/integrations",
242 targetId: "browser",314 searchTerms: ["preview color scheme light dark system os"],
243 },315 },
244 {316 {
245 id: "browser-recording-frame-rate",317 id: "browser-recording-frame-rate",
246 title: "Browser recording frame rate",318 title: "Browser recording frame rate",
247 to: "/settings/integrations",319 to: "/settings/integrations",
248 targetId: "browser",
249 },320 },
250 {321 {
251 id: "browser-auto-show-floating-preview",322 id: "browser-auto-show-floating-preview",
252 title: "Auto-show floating preview",323 title: "Auto-show floating preview",
253 to: "/settings/integrations",324 to: "/settings/integrations",
254 targetId: "browser",325 searchTerms: ["agent opens browser pop into view hide"],
255 },326 },
256 {327 {
257 id: "source-control",328 id: "source-control",
258 title: "Source control",329 title: "Source control",
259 to: "/settings/source-control",330 to: "/settings/source-control",
331 searchTerms: [
332 "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",
333 ],
260 },334 },
261 {335 {
262 id: "remote-environments",336 id: "git-fetch-interval",
263 title: "Remote environments",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",
264 to: "/settings/connections",372 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,
265 },385 },
266 {386 {
267 id: "wsl-backend",387 id: "wsl-backend",
268 title: "WSL backend",388 title: "WSL backend",
269 to: "/settings/connections",389 to: "/settings/connections",
390 searchTerms: [
391 "windows subsystem linux distro second server projects stop windows backend restart",
392 ],
270 desktopOnly: true,393 desktopOnly: true,
271 windowsOnly: true,394 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"],
272 },428 },
273 {429 {
274 id: "archive",430 id: "archive",
275 title: "Archived threads",431 title: "Archived threads",
276 to: "/settings/archived",432 to: "/settings/archived",
433 searchTerms: ["restore reopen deleted history projects"],
277 },434 },
278] as const satisfies ReadonlyArray<SettingsSearchItem>;435] as const satisfies ReadonlyArray<SettingsSearchItem>;
279 436
280export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];437export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
281 438
282const SEARCH_ITEMS_BY_ID = Object.fromEntries(439const SEARCH_ITEMS_BY_ID = new Map(SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item] as const));
283 SETTINGS_SEARCH_ITEMS.map((item) => [item.id, item]),
284) as Readonly<Record<SettingsSearchItemId, SettingsSearchItem>>;
285 440
286/**441/**
287 * `id` and `title` props for the element a search item anchors to. Panels442 * `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
292 readonly id: string;447 readonly id: string;
293 readonly title: string;448 readonly title: string;
294} {449} {
295 const { id: anchorId, title } = SEARCH_ITEMS_BY_ID[id];450 const { id: anchorId, title } = SEARCH_ITEMS_BY_ID.get(id)!;
296 return { id: anchorId, title };451 return { id: anchorId, title };
297}452}
298 453
299function normalizeSearchText(value: string): string {454export function filterAvailableSettingsSearchItems(
300 return value455 availability: SettingsSearchAvailability,
301 .normalize("NFKD")456): ReadonlyArray<SettingsSearchItem> {
302 .replace(/[\u0300-\u036f]/g, "")457 const items: ReadonlyArray<SettingsSearchItem> = SETTINGS_SEARCH_ITEMS;
303 .toLocaleLowerCase()458 return items.filter(
304 .replace(/\s+/g, " ")459 (item) =>
305 .trim();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 );
306}466}
307 467
308export function searchSettings(468export function searchSettings(
@@ -311,12 +471,38 @@ export function searchSettings( · 2 unmodified lines
311): ReadonlyArray<SettingsSearchItem> {471): ReadonlyArray<SettingsSearchItem> {
312 const normalizedQuery = normalizeSearchText(query);472 const normalizedQuery = normalizeSearchText(query);
313 if (normalizedQuery.length === 0) return [];473 if (normalizedQuery.length === 0) return [];
474 const queryTokens = normalizedQuery.split(" ");
475 const platform = typeof navigator === "undefined" ? "" : navigator.platform;
314 476
315 return items.filter(477 return items
316 (item) =>478 .flatMap((item, index) => {
317 (isElectron || item.desktopOnly !== true) &&479 if (!isElectron && item.desktopOnly === true) return [];
318 (!item.windowsOnly ||480 if (item.macOnly && !isMacPlatform(platform)) return [];
319 isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) &&481 if (item.windowsOnly && !isWindowsPlatform(platform)) return [];
320 normalizeSearchText(item.title).includes(normalizedQuery),482
321 );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);
322}508}
apps/web/src/components/settings/useAvailableSettingsSearchItems.ts+44−0
@@ -0,0 +1,44 @@
1import { useMemo } from "react";
2import { AuthAccessWriteScope } from "@t3tools/contracts";
3
4import { hasCloudPublicConfig } from "~/cloud/publicConfig";
5import { isElectron } from "~/env";
6import { desktopWslStateAtom } from "~/state/desktopWslState";
7import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
8import { useEnvironmentQuery } from "~/state/query";
9import { usePrimarySessionState } from "~/environments/primary";
10import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic";
11import { isProviderSettingsEnvironmentAvailable } from "./ProviderSettingsPanel.logic";
12import { filterAvailableSettingsSearchItems } from "./settingsSearch";
13
14export 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
20 return /linux/i.test(platform);20 return /linux/i.test(platform);
21}21}
22 22
23export function normalizeSearchText(value: string): string {
24 return value.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase().replace(/\s+/g, " ").trim();
25}
26
23export function getLocalFileManagerName(platform: string): string {27export function getLocalFileManagerName(platform: string): string {
24 if (isMacPlatform(platform)) {28 if (isMacPlatform(platform)) {
25 return "Finder";29 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
59already pinned. Its default shortcut is `mod+shift+p`, and it does not run while the terminal has59already pinned. Its default shortcut is `mod+shift+p`, and it does not run while the terminal has
60focus. See [Organizing threads](./thread-sidebar.md) for how pinned threads are ordered.60focus. See [Organizing threads](./thread-sidebar.md) for how pinned threads are ordered.
61 61
62The command palette searches active thread titles, projects, branches, user messages, and final62The command palette searches settings, active thread titles, projects, branches, user messages, and
63agent responses across connected environments. Message matches show one labeled excerpt while63final agent responses across connected environments. A setting result opens its exact control or
64keeping the thread's project, branch, and machine context visible. Message search begins after two64section. Message matches show one labeled excerpt while keeping the thread's project, branch, and
65characters and uses SQLite's ASCII case-insensitive matching.65machine context visible. Message search begins after two characters and uses SQLite's ASCII
66case-insensitive matching.
66 67
67The full command list and the current defaults are shown in **Settings** → **Keybindings**, which68The full command list and the current defaults are shown in **Settings** → **Keybindings**, which
68always matches the build you are running. Use that rather than a copied list.69always matches the build you are running. Use that rather than a copied list.