authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-01 00:56:22-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 00:56:22-04:00
logff93aba61dc1f7839713580bc8ee08da2a395f9a
treee41fe8fc68e0b5131aaf14acaf345fa3f6b30d54
parentd35c71d1b975660ec6a71bbceb6aa7dfd0e8c3d5
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

19 files changed, 645 insertions(+), 96 deletions(-)

apps/web/src/components/CommandPalette.logic.test.ts+70
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
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", () => {...@@ -272,6 +273,75 @@ describe("buildThreadActionItems", () => {
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 });
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
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";...@@ -9,9 +9,12 @@ import * as Arr from "effect/Array";
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";
1415
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(...@@ -138,10 +141,6 @@ export function enumerateCommandPaletteItems(
138141
139export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";142export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";
140143
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...@@ -255,9 +254,16 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
255 });254 });
256}255}
257256
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 {...@@ -266,12 +272,16 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number {
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}
271280
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(...@@ -279,7 +289,7 @@ function rankCommandPaletteItemMatch(
279 }289 }
280290
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: {...@@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: {
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: {...@@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: {
305 }316 }
306 return [...input.activeGroups];317 return [...input.activeGroups];
307 }318 }
319 const queryTokens = normalizedQuery.split(" ");
308320
309 let baseGroups = [...input.activeGroups];321 let baseGroups = [...input.activeGroups];
310 if (isActionsFilter) {322 if (isActionsFilter) {
...@@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: {...@@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: {
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: {...@@ -334,14 +353,14 @@ export function filterCommandPaletteGroups(input: {
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 }
340359
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 {...@@ -32,7 +32,7 @@ import {
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 {...@@ -104,6 +104,7 @@ import {
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";...@@ -140,6 +141,7 @@ import { ProjectFavicon } from "./ProjectFavicon";
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: {...@@ -563,6 +565,7 @@ function OpenCommandPaletteDialog(props: {
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: {...@@ -585,6 +588,7 @@ function OpenCommandPaletteDialog(props: {
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: {...@@ -1637,7 +1641,19 @@ function OpenCommandPaletteDialog(props: {
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: {...@@ -1651,6 +1667,25 @@ function OpenCommandPaletteDialog(props: {
1651 }1667 }
16521668
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: {...@@ -1668,6 +1703,7 @@ function OpenCommandPaletteDialog(props: {
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 });
16731709
apps/web/src/components/settings/ConnectionsSettings.tsx+7-7
...@@ -1670,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b...@@ -1670,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
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...@@ -1688,7 +1688,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b
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() {...@@ -2908,7 +2908,7 @@ export function ConnectionsSettings() {
29082908
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() {...@@ -2958,7 +2958,7 @@ export function ConnectionsSettings() {
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() {...@@ -2990,7 +2990,7 @@ export function ConnectionsSettings() {
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() {...@@ -3022,7 +3022,7 @@ export function ConnectionsSettings() {
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() {...@@ -3377,7 +3377,7 @@ export function ConnectionsSettings() {
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
...@@ -31,18 +31,32 @@ const settingsState = vi.hoisted(() => ({...@@ -31,18 +31,32 @@ const settingsState = vi.hoisted(() => ({
31 updateSettings: vi.fn(),31 updateSettings: vi.fn(),
32}));32}));
3333
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});
4551
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...@@ -131,6 +145,17 @@ function isAddProviderButton(element: ReactElement<Record<string, unknown>>): bo
131 return element.props["aria-label"] === "Add provider";145 return element.props["aria-label"] === "Add provider";
132}146}
133147
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", () => {...@@ -144,6 +169,8 @@ describe("EnvironmentProviderSettings routing", () => {
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", () => {...@@ -235,6 +262,17 @@ describe("EnvironmentProviderSettings routing", () => {
235 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();262 expect(visitElements(panel, isAddProviderButton)).not.toBeNull();
236 });263 });
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
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
...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
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 = [...@@ -20,6 +21,27 @@ const environments = [
20] as const;21] as const;
2122
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
...@@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike {...@@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike {
10 readonly label: string;10 readonly label: string;
11}11}
1212
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 {...@@ -33,7 +33,7 @@ import {
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";
3737
38import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";38import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal";
39import { isElectron } from "../../env";39import { isElectron } from "../../env";
...@@ -93,10 +93,12 @@ import {...@@ -93,10 +93,12 @@ import {
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({...@@ -189,7 +191,7 @@ function EnvironmentUnavailableRow({
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({...@@ -197,8 +199,17 @@ function EnvironmentUnavailableRow({
197}199}
198200
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() {...@@ -216,6 +227,27 @@ export function ProviderSettingsPanel() {
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() {...@@ -266,9 +298,9 @@ export function ProviderSettingsPanel() {
266 ) : null;298 ) : null;
267299
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() {...@@ -287,7 +319,7 @@ export function ProviderSettingsPanel() {
287 deviceTabs={deviceTabs}319 deviceTabs={deviceTabs}
288 />320 />
289 ) : null}321 ) : null}
290 </SettingsPageContainer>322 </>
291 );323 );
292}324}
293325
...@@ -429,12 +461,19 @@ export function EnvironmentProviderSettings({...@@ -429,12 +461,19 @@ export function EnvironmentProviderSettings({
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());
437470
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({...@@ -915,9 +954,10 @@ export function EnvironmentProviderSettings({
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({...@@ -790,7 +790,9 @@ function BackgroundActivityAdvancedDialog({
790790
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() {...@@ -2009,7 +2011,7 @@ export function GeneralSettingsPanel() {
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() {...@@ -2142,9 +2144,10 @@ export function GeneralSettingsPanel() {
2142 />2144 />
21432145
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 {...@@ -7,8 +7,6 @@ import {
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 {...@@ -23,8 +21,6 @@ import {
23} from "lucide-react";21} from "lucide-react";
24import { useLocation, useNavigate } from "@tanstack/react-router";22import { useLocation, useNavigate } from "@tanstack/react-router";
2523
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";...@@ -42,11 +38,11 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
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";
5046
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 }) {...@@ -83,18 +79,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
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 @@...@@ -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 {...@@ -59,7 +59,9 @@ import {
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";
6567
...@@ -267,6 +269,13 @@ function DiscoveryItemRow({...@@ -267,6 +269,13 @@ function DiscoveryItemRow({
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]);
270279
271 return (280 return (
272 <div281 <div
...@@ -345,13 +354,14 @@ function GitFetchIntervalSettings() {...@@ -345,13 +354,14 @@ function GitFetchIntervalSettings() {
345 );354 );
346 const canResetFetchInterval =355 const canResetFetchInterval =
347 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;356 automaticGitFetchIntervalSeconds !== defaultAutomaticGitFetchIntervalSeconds;
357 const setting = searchableSetting("git-fetch-interval");
348358
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() {...@@ -407,7 +417,7 @@ function GitFetchIntervalSettings() {
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}
413423
apps/web/src/components/settings/SourceControlWritingSettings.tsx+4-3
...@@ -21,6 +21,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../...@@ -21,6 +21,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../
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";
2425
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() {...@@ -73,7 +74,7 @@ export function SourceControlWritingSettingsSection() {
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() {...@@ -137,7 +138,7 @@ export function SourceControlWritingSettingsSection() {
137 </SettingsRow>138 </SettingsRow>
138139
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() {...@@ -169,7 +170,7 @@ export function SourceControlWritingSettingsSection() {
169 />170 />
170171
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";...@@ -41,6 +41,7 @@ import { Button } from "../ui/button";
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({...@@ -892,11 +893,13 @@ export function ThemeLibrary({
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
...@@ -84,6 +84,18 @@ function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined)...@@ -84,6 +84,18 @@ function useSettingsSearchTarget<T extends HTMLElement>(id: string | undefined)
84 return targetRef;84 return targetRef;
85}85}
8686
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 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
22
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> = [...@@ -12,16 +13,19 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [
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> = [...@@ -36,16 +40,25 @@ const ITEMS: ReadonlyArray<SettingsSearchItem> = [
36];40];
3741
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 });
4550
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 });
5164
...@@ -56,6 +69,27 @@ describe("searchSettings", () => {...@@ -56,6 +69,27 @@ describe("searchSettings", () => {
56 ]);69 ]);
57 });70 });
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
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", () => {...@@ -74,8 +108,17 @@ describe("searchSettings", () => {
74 expect(searchSettings("wsl")).toEqual([]);108 expect(searchSettings("wsl")).toEqual([]);
75 });109 });
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
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", () => {...@@ -84,6 +127,30 @@ describe("searchSettings", () => {
84 });127 });
85 });128 });
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
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", () => {...@@ -111,10 +178,11 @@ describe("searchSettings", () => {
111 });178 });
112179
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 @@...@@ -1,5 +1,5 @@
1import { isElectron } from "~/env";1import { isElectron } from "~/env";
2import { isWindowsPlatform } from "~/lib/utils";2import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils";
33
4export type SettingsPath =4export type SettingsPath =
5 | "/settings/general"5 | "/settings/general"
...@@ -16,12 +16,28 @@ export interface SettingsSearchItem {...@@ -16,12 +16,28 @@ export interface SettingsSearchItem {
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}
2642
27/**43/**
...@@ -40,16 +56,16 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {...@@ -40,16 +56,16 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
40};56};
4157
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 = [...@@ -57,6 +73,7 @@ export const SETTINGS_SEARCH_ITEMS = [
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 = [...@@ -66,17 +83,20 @@ export const SETTINGS_SEARCH_ITEMS = [
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 = [...@@ -84,204 +104,339 @@ export const SETTINGS_SEARCH_ITEMS = [
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>;
279436
280export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];437export type SettingsSearchItemId = (typeof SETTINGS_SEARCH_ITEMS)[number]["id"];
281438
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>>;
285440
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): {...@@ -292,17 +447,22 @@ export function searchableSetting(id: SettingsSearchItemId): {
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}
298453
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}
307467
308export function searchSettings(468export function searchSettings(
...@@ -311,12 +471,38 @@ export function searchSettings(...@@ -311,12 +471,38 @@ export function searchSettings(
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;
314476
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 created+44
...@@ -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
...@@ -20,6 +20,10 @@ export function isLinuxPlatform(platform: string): boolean {...@@ -20,6 +20,10 @@ export function isLinuxPlatform(platform: string): boolean {
20 return /linux/i.test(platform);20 return /linux/i.test(platform);
21}21}
2222
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....@@ -59,10 +59,11 @@ shortcut is `mod+shift+s`, and it does not run while the terminal has focus.
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.
6161
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.
6667
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.