From 0383ca80aa1f32b9f91ec40e41f3b9931b60a674 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 1 Sep 2026 05:19:16 +0530 Subject: [PATCH 1/3] fix(web): make WSL settings searchable (#8881) --- .../ConnectionsSettings.logic.test.ts | 20 ++++++++++++++++ .../settings/ConnectionsSettings.logic.ts | 8 +++++++ .../settings/ConnectionsSettings.tsx | 16 +++++++++---- .../settings/SettingsSidebarNav.tsx | 23 ++++++++++++++++++- .../settings/settingsSearch.test.ts | 11 +++++++++ .../src/components/settings/settingsSearch.ts | 13 +++++++++++ 6 files changed, 85 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 290e2daa1..74283796a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { applyWslEnableSelection, isQrShareableEndpoint, + isWslSettingsRowVisible, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; @@ -15,6 +16,25 @@ const baseWslState: DesktopWslState = { preflightError: null, }; +describe("isWslSettingsRowVisible", () => { + it("shows the retry row when the WSL state failed to load", () => { + expect(isWslSettingsRowVisible({ state: null, error: "load failed" })).toBe(true); + }); + + it("hides an unavailable and unused WSL snapshot", () => { + expect( + isWslSettingsRowVisible({ + state: { ...baseWslState, available: false, wslOnly: false }, + error: null, + }), + ).toBe(false); + }); + + it("shows an available WSL snapshot", () => { + expect(isWslSettingsRowVisible({ state: baseWslState, error: null })).toBe(true); + }); +}); + describe("applyWslEnableSelection", () => { it("clears WSL-only and updates the distro before enabling both backends", async () => { const calls: Array = []; diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index faa0cb6c7..d683efab3 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -11,6 +11,14 @@ export function isQrShareableEndpoint(endpoint: AdvertisedEndpoint): boolean { return endpoint.status !== "unavailable" && endpoint.reachability !== "loopback"; } +export function isWslSettingsRowVisible(input: { + readonly state: DesktopWslState | null; + readonly error: string | null; +}): boolean { + const { state, error } = input; + return state ? state.available || state.enabled || state.wslOnly : error !== null; +} + export type QrEndpointOption = { /** Unique per endpoint instance (AdvertisedEndpoint.id); safe as a React key. */ readonly id: string; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 4e23f904e..5f9fad749 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -43,6 +43,7 @@ import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls import { applyWslEnableSelection, isQrShareableEndpoint, + isWslSettingsRowVisible, selectQrEndpointOption, } from "./ConnectionsSettings.logic"; import { @@ -2766,10 +2767,13 @@ export function ConnectionsSettings() { // retry so the row doesn't flicker away, and the button reflects the // loading state. With no error we simply haven't loaded yet (or WSL // management isn't available), so render nothing. - if (desktopWslError && canManageLocalBackend) { + if ( + isWslSettingsRowVisible({ state: null, error: desktopWslError }) && + canManageLocalBackend + ) { return ( {desktopWslError}} control={ @@ -2794,11 +2798,13 @@ export function ConnectionsSettings() { // be stranded on a WSL preference they can't clear, so render a recovery // row that switches back to Windows. When WSL is unavailable AND unused, // there's nothing to recover — keep the section hidden as before. + if (!isWslSettingsRowVisible({ state: desktopWslState, error: desktopWslError })) { + return null; + } if (!desktopWslState.available) { - if (!desktopWslState.enabled && !desktopWslState.wslOnly) return null; return ( (null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const results = useMemo(() => searchSettings(query), [query]); + const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); + const searchableItems = useMemo(() => { + const wslState = desktopWsl.data; + const rowRenders = isWslSettingsRowVisible({ + state: wslState, + error: desktopWsl.error, + }); + if (rowRenders) { + return SETTINGS_SEARCH_ITEMS; + } + return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend"); + }, [desktopWsl.data, desktopWsl.error]); + const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; + useEffect(() => { + setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0))); + }, [results.length]); + useEffect(() => { const result = results[activeResultIndex]; if (!result) return; diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index c560d0b43..5bf4b4f70 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -71,6 +71,17 @@ describe("searchSettings", () => { it("hides desktop-only settings from browser search", () => { expect(SETTINGS_SEARCH_ITEMS.some((item) => item.id === "quit-confirmation")).toBe(true); expect(searchSettings("quit confirmation")).toEqual([]); + expect(searchSettings("wsl")).toEqual([]); + }); + + it("registers the WSL backend as a desktop-only setting", () => { + expect(SETTINGS_SEARCH_ITEMS).toContainEqual({ + id: "wsl-backend", + title: "WSL backend", + to: "/settings/connections", + desktopOnly: true, + windowsOnly: true, + }); }); it("keeps catalog result ids unique", () => { diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c249ebf80..b907ba411 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -1,4 +1,5 @@ import { isElectron } from "~/env"; +import { isWindowsPlatform } from "~/lib/utils"; export type SettingsPath = | "/settings/general" @@ -18,6 +19,9 @@ export interface SettingsSearchItem { // Its row only renders in the desktop app, so a browser result would land on // an anchor that isn't there. readonly desktopOnly?: boolean; + // Its row only renders on Windows desktop, so other desktop platforms must + // not expose a result that points to a missing anchor. + readonly windowsOnly?: boolean; } /** @@ -259,6 +263,13 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Remote environments", to: "/settings/connections", }, + { + id: "wsl-backend", + title: "WSL backend", + to: "/settings/connections", + desktopOnly: true, + windowsOnly: true, + }, { id: "archive", title: "Archived threads", @@ -304,6 +315,8 @@ export function searchSettings( return items.filter( (item) => (isElectron || item.desktopOnly !== true) && + (!item.windowsOnly || + isWindowsPlatform(typeof navigator === "undefined" ? "" : navigator.platform)) && normalizeSearchText(item.title).includes(normalizedQuery), ); } From a205434f873a40e9909b34fe5a7fe72bd5726962 Mon Sep 17 00:00:00 2001 From: maria Date: Tue, 1 Sep 2026 00:56:22 -0400 Subject: [PATCH 2/3] feat(web): search individual settings by detail (#8831) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../components/CommandPalette.logic.test.ts | 70 +++++ .../src/components/CommandPalette.logic.ts | 39 ++- apps/web/src/components/CommandPalette.tsx | 40 ++- .../settings/ConnectionsSettings.tsx | 14 +- ...ProviderSettingsPanel.environment.test.tsx | 38 +++ .../ProviderSettingsPanel.logic.test.ts | 22 ++ .../settings/ProviderSettingsPanel.logic.ts | 7 + .../settings/ProviderSettingsPanel.tsx | 52 +++- .../components/settings/SettingsPanels.tsx | 9 +- .../settings/SettingsSidebarNav.tsx | 19 +- .../settings/SourceControlSettings.tsx | 18 +- .../settings/SourceControlWritingSettings.tsx | 7 +- .../src/components/settings/ThemeSettings.tsx | 7 +- .../components/settings/settingsLayout.tsx | 12 + .../settings/settingsSearch.test.ts | 82 +++++- .../src/components/settings/settingsSearch.ts | 248 +++++++++++++++--- .../useAvailableSettingsSearchItems.ts | 44 ++++ apps/web/src/lib/utils.ts | 4 + docs/user/keybindings.md | 9 +- 19 files changed, 645 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/components/settings/useAvailableSettingsSearchItems.ts diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index ef3e66fba..033648726 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -8,6 +8,7 @@ import { enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + normalizeSearchText, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -272,6 +273,75 @@ describe("buildThreadActionItems", () => { expect(groups[0]?.items.map((item) => item.value)).toEqual(["thread:project-context-only"]); }); + it("ranks an order-independent setting title match above a split context match", () => { + const settingsSearchItems = [ + { + kind: "action" as const, + value: "setting:context-match", + searchTerms: ["Pairing settings", "remote backend"], + title: "Context match", + icon: null, + run: async () => undefined, + }, + { + kind: "action" as const, + value: "setting:remote-pairing", + searchTerms: ["Remote pairing", "connections"], + title: "Remote pairing", + icon: null, + run: async () => undefined, + }, + ]; + + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "pairing remote", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems, + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.value).toBe("settings-search"); + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "setting:remote-pairing", + "setting:context-match", + ]); + }); + + it("keeps accent-insensitive setting results", () => { + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "thè\u{1ab0}mes", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [ + { + kind: "action", + value: "setting:theme", + searchTerms: ["Themes", "Appearance"], + title: "Themes", + icon: null, + run: async () => undefined, + }, + ], + threadSearchItems: [], + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual(["setting:theme"]); + }); + + it("normalizes case independently of the host locale", () => { + const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); + try { + expect(normalizeSearchText("GIT")).toBe("git"); + expect(localeLowerCase).not.toHaveBeenCalled(); + } finally { + localeLowerCase.mockRestore(); + } + }); + it("keeps message excerpts searchable without replacing thread metadata", () => { const [item] = buildThreadActionItems({ threads: [makeThread({ branch: "feat/search" })], diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 688a8a8ea..a0af1be04 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -9,9 +9,12 @@ import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; +import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; +export { normalizeSearchText } from "../lib/utils"; + export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; @@ -138,10 +141,6 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; -export function normalizeSearchText(value: string): string { - return value.trim().toLowerCase().replace(/\s+/g, " "); -} - export function buildProjectActionItems(input: { projects: ReadonlyArray; valuePrefix: string; @@ -255,9 +254,16 @@ export function buildThreadActionItems, +): number { const normalizedField = normalizeSearchText(field); - if (normalizedField.length === 0 || !normalizedField.includes(normalizedQuery)) { + if ( + normalizedField.length === 0 || + !queryTokens.every((token) => normalizedField.includes(token)) + ) { return Number.NEGATIVE_INFINITY; } if (normalizedField === normalizedQuery) { @@ -266,12 +272,16 @@ function rankSearchFieldMatch(field: string, normalizedQuery: string): number { if (normalizedField.startsWith(normalizedQuery)) { return 2; } - return 1; + if (normalizedField.includes(normalizedQuery)) { + return 1; + } + return 0; } function rankCommandPaletteItemMatch( item: CommandPaletteActionItem | CommandPaletteSubmenuItem, normalizedQuery: string, + queryTokens: ReadonlyArray, ): number { const terms = item.searchTerms.filter((term) => term.length > 0); if (terms.length === 0) { @@ -279,7 +289,7 @@ function rankCommandPaletteItemMatch( } for (const [index, field] of terms.entries()) { - const fieldRank = rankSearchFieldMatch(field, normalizedQuery); + const fieldRank = rankSearchFieldMatch(field, normalizedQuery, queryTokens); if (fieldRank !== Number.NEGATIVE_INFINITY) { return 1_000 - index * 100 + fieldRank; } @@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: { query: string; isInSubmenu: boolean; projectSearchItems: ReadonlyArray; + settingsSearchItems?: ReadonlyArray; threadSearchItems: ReadonlyArray; }): CommandPaletteGroup[] { const isActionsFilter = input.query.startsWith(">"); @@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: { } return [...input.activeGroups]; } + const queryTokens = normalizedQuery.split(" "); let baseGroups = [...input.activeGroups]; if (isActionsFilter) { @@ -322,6 +334,13 @@ export function filterCommandPaletteGroups(input: { items: input.projectSearchItems, }); } + if (input.settingsSearchItems && input.settingsSearchItems.length > 0) { + searchableGroups.push({ + value: "settings-search", + label: "Settings", + items: input.settingsSearchItems, + }); + } if (input.threadSearchItems.length > 0) { searchableGroups.push({ value: "threads-search", @@ -334,14 +353,14 @@ export function filterCommandPaletteGroups(input: { return searchableGroups.flatMap((group) => { const items = Arr.filterMap(group.items, (item, index) => { const haystack = normalizeSearchText(item.searchTerms.join(" ")); - if (!haystack.includes(normalizedQuery)) { + if (!queryTokens.every((token) => haystack.includes(token))) { return Result.failVoid; } return Result.succeed({ item, index, - rank: rankCommandPaletteItemMatch(item, normalizedQuery), + rank: rankCommandPaletteItemMatch(item, normalizedQuery, queryTokens), }); }) .toSorted((left, right) => right.rank - left.rank || left.index - right.index) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 3a86a930e..13dfb539a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -32,7 +32,7 @@ import { type SourceControlRepositoryInfo, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, @@ -106,6 +106,7 @@ import { } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; +import { useAvailableSettingsSearchItems } from "./settings/useAvailableSettingsSearchItems"; import { applyWslEnvironmentConfiguration, parseWslUncPath, @@ -142,6 +143,7 @@ import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; import { ProjectContentSearchDialog } from "./search/ProjectContentSearchDialog"; import { toggleThemeEditorForTheme } from "./settings/themeEditorStore"; +import { searchSettings, SETTINGS_SECTION_LABELS } from "./settings/settingsSearch"; import { COMMAND_PALETTE_META_ICON_CLASS, CommandPaletteMetaDot, @@ -566,6 +568,7 @@ function OpenCommandPaletteDialog(props: { }) { const navigate = useNavigate(); const composerHandleRef = useComposerHandleContext(); + const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); @@ -588,6 +591,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const availableSettingsSearchItems = useAvailableSettingsSearchItems(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -1678,7 +1682,19 @@ function OpenCommandPaletteDialog(props: { actionItems.push({ kind: "action", value: "action:project-settings", - searchTerms: ["project", "settings", "scripts", "model", "grouping", "checkout"], + searchTerms: [ + "project", + "settings", + "name", + "icon", + "scripts", + "model", + "workspace", + "grouping", + "checkout", + "remove", + "t3.json", + ], title: "Project settings", description: contextualProjectGroup.displayName, icon: , @@ -1692,6 +1708,25 @@ function OpenCommandPaletteDialog(props: { } const rootGroups = buildRootGroups({ actionItems, recentThreadItems }); + const settingsSearchItems: CommandPaletteActionItem[] = searchSettings( + deferredQuery, + availableSettingsSearchItems, + ).map((item) => ({ + kind: "action", + value: `setting:${item.id}`, + searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])], + title: item.title, + description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`, + icon: , + run: async () => { + await navigate({ + to: item.to, + hash: item.targetId ?? item.id, + replace: pathname === item.to, + hashScrollIntoView: false, + }); + }, + })); const sourceSelectionViewValue = addProjectEnvironmentId === null ? null : `sources:${addProjectEnvironmentId}`; const activeGroups = @@ -1709,6 +1744,7 @@ function OpenCommandPaletteDialog(props: { query: deferredQuery, isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, + settingsSearchItems, threadSearchItems: allThreadItems, }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5f9fad749..d12a1076d 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1670,7 +1670,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b <> {window.desktopBridge ? ( ) : null} ( ( ( {canManageLocalBackend ? ( <> - + {primaryVersionMismatch || primaryServerUpdateState.status !== "idle" ? ( ) : ( - + ({ updateSettings: vi.fn(), })); +const settingsSearchState = vi.hoisted(() => ({ + targetId: null as string | null, + effects: [] as Array<() => void>, +})); + vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); const { reactHookHarness } = await import("../../test/reactHookHarness"); return { ...actual, useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void) => settingsSearchState.effects.push(effect), useMemo: reactHookHarness.useMemo, useRef: reactHookHarness.useRef, useState: reactHookHarness.useState, }; }); +vi.mock("./settingsLayout", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useSettingsSearchTargetId: () => settingsSearchState.targetId, + }; +}); + vi.mock("react/compiler-runtime", async () => { const { reactHookHarness } = await import("../../test/reactHookHarness"); return { c: reactHookHarness.useMemoCache }; @@ -136,6 +150,17 @@ function isAdvancedTrigger(element: ReactElement>): bool return element.type === CollapsibleTrigger; } +function findAdvancedPanel(panel: ReactElement>) { + return visitElements( + panel, + (element) => element.props.className === "mt-1" && typeof element.props.open === "boolean", + ); +} + +function flushEffects(): void { + for (const effect of settingsSearchState.effects.splice(0)) effect(); +} + async function flushPromises(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -149,6 +174,8 @@ describe("EnvironmentProviderSettings routing", () => { settingsState.readEnvironmentIds = []; settingsState.updateEnvironmentIds = []; settingsState.updateSettings.mockReset(); + settingsSearchState.targetId = null; + settingsSearchState.effects = []; commands.refresh.mockReset().mockResolvedValue({ _tag: "Success" }); commands.updateProvider.mockReset().mockResolvedValue({ _tag: "Success" }); }); @@ -340,6 +367,17 @@ describe("EnvironmentProviderSettings routing", () => { expect(swappedEditor).toBeNull(); }); + it("opens Advanced when search targets the provider health interval", () => { + settingsSearchState.targetId = "provider-health-check-interval"; + let panel = renderPanel(); + + expect(findAdvancedPanel(panel)?.props.open).toBe(false); + flushEffects(); + + panel = renderPanel(); + expect(findAdvancedPanel(panel)?.props.open).toBe(true); + }); + it("deletes and resets provider configuration without erasing shared preferences", () => { settingsState.value = { ...DEFAULT_UNIFIED_SETTINGS, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts index bf558f5a4..c04db646a 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, resolvePrimaryOperateAccess, resolveRemoteOperateAccess, resolveSelectedProviderEnvironmentId, @@ -20,6 +21,27 @@ const environments = [ ] as const; describe("provider environment selection", () => { + it("requires a connected environment with server config for searchable provider settings", () => { + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: true, + }), + ).toBe(true); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "reconnecting", + hasServerConfig: true, + }), + ).toBe(false); + expect( + isProviderSettingsEnvironmentAvailable({ + connectionPhase: "connected", + hasServerConfig: false, + }), + ).toBe(false); + }); + it("sorts the primary environment first and the rest by label", () => { expect( buildProviderEnvironmentOptions(environments, primaryId).map( diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts index 1c7dac391..b415b5f69 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts +++ b/apps/web/src/components/settings/ProviderSettingsPanel.logic.ts @@ -10,6 +10,13 @@ export interface ProviderEnvironmentOptionLike { readonly label: string; } +export function isProviderSettingsEnvironmentAvailable(input: { + readonly connectionPhase: EnvironmentConnectionPhase; + readonly hasServerConfig: boolean; +}): boolean { + return input.connectionPhase === "connected" && input.hasServerConfig; +} + export function buildProviderEnvironmentOptions( environments: ReadonlyArray, primaryEnvironmentId: EnvironmentId | null, diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 3642fa260..dc9c849b4 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -34,7 +34,7 @@ import { RefreshCwIcon, TerminalIcon, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; @@ -95,10 +95,12 @@ import { SettingsRow, SettingsSection, useRelativeTimeTick, + useSettingsSearchTargetId, } from "./settingsLayout"; import { buildProviderEnvironmentOptions, classifyProviderEnvironmentAccess, + isProviderSettingsEnvironmentAvailable, type ProviderEnvironmentAccess, type ProviderOperateAccess, resolvePrimaryOperateAccess, @@ -191,7 +193,7 @@ function EnvironmentUnavailableRow({ // No spinner: this state can persist indefinitely for a wedged device, and a // continuously repainting animation would run the whole time. return ( - + {deviceTabs} @@ -199,8 +201,17 @@ function EnvironmentUnavailableRow({ } export function ProviderSettingsPanel() { + return ( + + + + ); +} + +function ProviderSettingsPanelContent() { const { environments, isReady } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const searchTargetId = useSettingsSearchTargetId(); const options = useMemo( () => buildProviderEnvironmentOptions(environments, primaryEnvironmentId), [environments, primaryEnvironmentId], @@ -218,6 +229,27 @@ export function ProviderSettingsPanel() { ); const selectedEnvironment = options.find((environment) => environment.environmentId === effectiveEnvironmentId) ?? null; + const selectedEnvironmentCanRenderSettings = + selectedEnvironment !== null && + isProviderSettingsEnvironmentAvailable({ + connectionPhase: selectedEnvironment.connection.phase, + hasServerConfig: selectedEnvironment.serverConfig !== null, + }); + const searchableEnvironmentId = options.find((environment) => + isProviderSettingsEnvironmentAvailable({ + connectionPhase: environment.connection.phase, + hasServerConfig: environment.serverConfig !== null, + }), + )?.environmentId; + useEffect(() => { + if ( + searchTargetId === searchableSetting("provider-health-check-interval").id && + !selectedEnvironmentCanRenderSettings && + searchableEnvironmentId !== undefined + ) { + setSelectedEnvironmentId(searchableEnvironmentId); + } + }, [searchTargetId, searchableEnvironmentId, selectedEnvironmentCanRenderSettings]); const onlyPrimaryDevice = options.length === 1 && options[0]?.entry.target._tag === "PrimaryConnectionTarget"; const deviceTabs = @@ -268,9 +300,9 @@ export function ProviderSettingsPanel() { ) : null; return ( - + <> {options.length === 0 ? ( - + ) : null} - + ); } @@ -431,12 +463,19 @@ export function EnvironmentProviderSettings({ const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); const [selectedInstanceId, setSelectedInstanceId] = useState(null); const [advancedOpen, setAdvancedOpen] = useState(false); + const searchTargetId = useSettingsSearchTargetId(); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet >(() => new Set()); const refreshingRef = useRef(false); const updatingDriversRef = useRef>(new Set()); + useEffect(() => { + if (searchTargetId === searchableSetting("provider-health-check-interval").id) { + setAdvancedOpen(true); + } + }, [searchTargetId]); + const providerUpdateCandidates = useMemo( () => collectProviderUpdateCandidates(serverProviders), [serverProviders], @@ -974,9 +1013,10 @@ export function EnvironmentProviderSettings({ className={readOnly ? "opacity-50 select-none" : undefined} > - Health check interval + {searchableSetting("provider-health-check-interval").title} This interval is configured here, then the shared Background activity policy decides whether provider probes may run when the timer fires. Custom diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3725294c0..22be22f7a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -803,7 +803,9 @@ function BackgroundActivityAdvancedDialog({
-
Git fetch interval
+
+ {searchableSetting("git-fetch-interval").title} +

Refresh remote branch status in the background.

@@ -2040,7 +2042,7 @@ export function GeneralSettingsPanel() { /> {settings.sidebarAutoSettleAfterDays !== null ? ( - Background activity + {searchableSetting("background-activity").title} This shared policy gates background work such as Git refreshes and provider health probes after their individual intervals elapse. diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index d336bde5e..daf1724a3 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -7,8 +7,6 @@ import { type ComponentType, type KeyboardEvent, } from "react"; -import { useEnvironmentQuery } from "~/state/query"; -import { desktopWslStateAtom } from "~/state/desktopWslState"; import { ArchiveIcon, BlocksIcon, @@ -23,8 +21,6 @@ import { } from "lucide-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { isElectron } from "~/env"; -import { isWslSettingsRowVisible } from "./ConnectionsSettings.logic"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd } from "../ui/kbd"; @@ -42,11 +38,11 @@ import { SidebarUtilityMenu } from "../sidebar/SidebarChrome"; import { scrollToSettingsTarget } from "./settingsLayout"; import { searchSettings, - SETTINGS_SEARCH_ITEMS, SETTINGS_SECTION_LABELS, type SettingsPath, type SettingsSearchItem, } from "./settingsSearch"; +import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; const SETTINGS_SECTION_ICONS: Readonly< Record> @@ -83,18 +79,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { const searchInputRef = useRef(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); - const desktopWsl = useEnvironmentQuery(isElectron ? desktopWslStateAtom : null); - const searchableItems = useMemo(() => { - const wslState = desktopWsl.data; - const rowRenders = isWslSettingsRowVisible({ - state: wslState, - error: desktopWsl.error, - }); - if (rowRenders) { - return SETTINGS_SEARCH_ITEMS; - } - return SETTINGS_SEARCH_ITEMS.filter((item) => item.id !== "wsl-backend"); - }, [desktopWsl.data, desktopWsl.error]); + const searchableItems = useAvailableSettingsSearchItems(); const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index a43c11646..559a90f4b 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,7 +1,7 @@ import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; -import { useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import type { BackgroundActivitySettings, SourceControlProviderKind, @@ -59,7 +59,9 @@ import { PolicyTooltip, SettingResetButton, SettingsPageContainer, + SettingsSearchTarget, SettingsSection, + useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -267,6 +269,13 @@ function DiscoveryItemRow({ const authAccount = auth ? optionLabel(auth.account) : null; const [isExpanded, setIsExpanded] = useState(false); const hasDetails = children !== undefined; + const searchTargetId = useSettingsSearchTargetId(); + + useEffect(() => { + if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + setIsExpanded(true); + } + }, [item.kind, searchTargetId]); return (
+
- Fetch interval + {setting.title} This interval is configured for Git only. The shared Background activity policy still decides whether Git refreshes may run when the timer fires. Custom intervals appear as @@ -407,7 +417,7 @@ function GitFetchIntervalSettings() { seconds
-
+
); } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index fd501ea3e..5726990d1 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -23,6 +23,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; const MODE_OPTIONS: Record = { @@ -101,7 +102,7 @@ export function SourceControlWritingSettingsSection() { return ( diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index 7b67e512c..ec9e983cd 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -41,6 +41,7 @@ import { Button } from "../ui/button"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { ThemeImportDialog } from "./ThemeImportDialog"; +import { searchableSetting } from "./settingsSearch"; import { useThemeEditorStore } from "./themeEditorStore"; import { STANDARD_THEME_CARDS, @@ -884,11 +885,13 @@ export function ThemeLibrary({ Choose how Pylon looks. Use a built-in theme or make your own.

- Color scheme + {searchableSetting("color-scheme").title}

{renderModeTiles()}
-

Themes

+

+ {searchableSetting("theme").title} +