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