Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
dedupeProviderSkillsByName,
getProviderSkillsForSlashMenu,
getProviderSlashCommandsForSlashMenu,
} from "@t3tools/client-runtime/providerSkills";
Expand Down Expand Up @@ -158,7 +159,9 @@ export function buildComposerCommandItems({
}

if (trigger.kind === "skill") {
const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
const enabledSkills = dedupeProviderSkillsByName(
(selectedProviderStatus?.skills ?? []).filter((s) => s.enabled),
);
const normalizedQuery = normalizeSearchQuery(trigger.query, {
trimLeadingPattern: /^\$+/,
});
Expand Down
70 changes: 70 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
enumerateCommandPaletteItems,
filterPinnedBrowseEntries,
filterCommandPaletteGroups,
normalizeSearchText,
reduceCommandPaletteUiState,
type CommandPaletteGroup,
} from "./CommandPalette.logic";
Expand Down Expand Up @@ -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" })],
Expand Down
39 changes: 29 additions & 10 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Project>;
valuePrefix: string;
Expand Down Expand Up @@ -255,9 +254,16 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
});
}

function rankSearchFieldMatch(field: string, normalizedQuery: string): number {
function rankSearchFieldMatch(
field: string,
normalizedQuery: string,
queryTokens: ReadonlyArray<string>,
): 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) {
Expand All @@ -266,20 +272,24 @@ 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<string>,
): number {
const terms = item.searchTerms.filter((term) => term.length > 0);
if (terms.length === 0) {
return 0;
}

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;
}
Expand All @@ -293,6 +303,7 @@ export function filterCommandPaletteGroups(input: {
query: string;
isInSubmenu: boolean;
projectSearchItems: ReadonlyArray<CommandPaletteActionItem>;
settingsSearchItems?: ReadonlyArray<CommandPaletteActionItem>;
threadSearchItems: ReadonlyArray<CommandPaletteActionItem>;
}): CommandPaletteGroup[] {
const isActionsFilter = input.query.startsWith(">");
Expand All @@ -305,6 +316,7 @@ export function filterCommandPaletteGroups(input: {
}
return [...input.activeGroups];
}
const queryTokens = normalizedQuery.split(" ");

let baseGroups = [...input.activeGroups];
if (isActionsFilter) {
Expand All @@ -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",
Expand All @@ -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)
Expand Down
40 changes: 38 additions & 2 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand Down Expand Up @@ -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: <FolderIcon className={ITEM_ICON_CLASS} />,
Expand All @@ -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: <SettingsIcon className={ITEM_ICON_CLASS} />,
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 =
Expand All @@ -1709,6 +1744,7 @@ function OpenCommandPaletteDialog(props: {
query: deferredQuery,
isInSubmenu: currentView !== null,
projectSearchItems: projectSearchItems,
settingsSearchItems,
threadSearchItems: allThreadItems,
});

Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/settings/ConnectionsSettings.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vite-plus/test";
import {
applyWslEnableSelection,
isQrShareableEndpoint,
isWslSettingsRowVisible,
selectQrEndpointOption,
} from "./ConnectionsSettings.logic";

Expand All @@ -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<string> = [];
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/settings/ConnectionsSettings.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading