From bffb33ed32bf090ae8b8b476472844b7e822ea43 Mon Sep 17 00:00:00 2001 From: Christoph Leiter Date: Tue, 25 Aug 2026 19:58:41 +0200 Subject: [PATCH] feat(app): match and rank Command Center results by query tokens (#2971) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Command Center tested the whole trimmed query as one substring against one concatenated haystack, so a query only matched when its words appeared consecutively, in that order, with that spacing. Typing `shortcuts` found "Keyboard shortcuts"; typing `short key` found nothing. Survivors were then ordered by a fixed group rank, so the best match was rarely first. Route both through `scoreTextFields`, the matcher the combobox, provider selection, directory suggestions and the daemon's history search already share. It splits on whitespace, scores each token against each field on its own, and requires every token to land somewhere. `short key`, `key short`, `workspace new` and `project add` all find their row now. The score is the filter: `scoreSearchFields` returns null when a token finds no home, so filtering and ranking are one pass rather than a boolean predicate that throws the scan away. Workspaces and agents are scored too, not just filtered, with each row's own ordering kept as the tiebreak. Fields are split into visible and hidden, so a match on an agent's `cwd` — which is never rendered — cannot outrank a match on its title. Subsequence matching is deliberately off here, unlike the composer's slash-command list where it makes `/pasbab` find `/paseo-babysit`. It widens far enough that three-letter typos hit several rows each, and this list preselects its first row, so a query that should find nothing would put an unrelated action under Enter. `MatchOptions.subsequence` defaults to on, leaving every other caller unchanged. The tier has to be suppressed during scoring rather than filtered afterwards, because a two-token total of tier 5 is either 0+5 or 4+1. Sections carry a band so the ones that reorder by relevance are compared before the pinned ones, which keeps the comparator total: file rows are matched by the daemon and carry no client-side score, so they cannot be relevance-compared against rows that do. Contiguous per-token matching is a strict widening of the old test. Tokens hold no whitespace and the old haystack joined fields with a space, so any token found there lay entirely within one field and is still found. Verified across a query set at zero rows lost, and pinned by a test. --- .../command-center-agent-controls.spec.ts | 104 +++++ .../helpers/command-center-agent-controls.ts | 17 +- .../app/src/command-center/command-center.tsx | 172 +++++--- .../app/src/command-center/results.test.ts | 407 +++++++++++++++++- packages/app/src/command-center/results.ts | 198 ++++++++- .../protocol/src/search/text-match.test.ts | 17 + packages/protocol/src/search/text-match.ts | 19 +- 7 files changed, 840 insertions(+), 94 deletions(-) diff --git a/packages/app/e2e/browser/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts index 08597285fc..0748250302 100644 --- a/packages/app/e2e/browser/command-center-agent-controls.spec.ts +++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts @@ -4,6 +4,7 @@ import { closeCommandCenter, openCommandCenter } from "../support/helpers/comman import { applyCommandCenterAgentControls, chooseCommandCenterAgentControl, + openCommandCenterForAgent, expectCommandCenterAgentControlSelected, expectFocusedAgentControls, expectWorkspaceAgentConfiguration, @@ -12,7 +13,9 @@ import { } from "../support/helpers/command-center-agent-controls"; import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { expectAppRoute } from "../support/helpers/route-assertions"; import { seedWorkspace } from "../support/helpers/seed-client"; +import { buildSchedulesRoute } from "@/utils/host-routes"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; @@ -76,6 +79,107 @@ test.describe("Command Center agent controls", () => { } }); + test("activates the best-matching row, not the highest-ranked group", async ({ page }) => { + // "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row + // matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a + // model. Asserting both model and mode catches either row winning. + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "command-center-relevance-", + title: "Command Center relevance", + model: "five-minute-stream", + }); + try { + await workspace.client.setAgentMode(workspace.agentId, "approval-test"); + const input = await openCommandCenterForAgent(page, workspace); + await input.fill("load"); + await page.keyboard.press("Enter"); + + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); + } finally { + await workspace.cleanup(); + } + }); + + test("matches query tokens in any order", async ({ page }) => { + // "Mode › Load test" flattens to "mode load test", so the reversed query "test load" is never + // a substring of it. Only per-token matching finds this row. + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "command-center-tokenized-", + title: "Command Center tokenized search", + model: "five-minute-stream", + }); + try { + await workspace.client.setAgentMode(workspace.agentId, "legacy-mode"); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + await openCommandCenter(page); + await chooseCommandCenterAgentControl({ + page, + query: "test load", + choice: "Mode › Load test", + }); + + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); + } finally { + await workspace.cleanup(); + } + }); + + test("keeps an action first when the action is the best match", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "command-center-action-priority-", + title: "Command Center action priority", + model: "five-minute-stream", + }); + try { + const input = await openCommandCenterForAgent(page, workspace); + await input.fill("sched"); + await page.keyboard.press("Enter"); + + await expectAppRoute(page, buildSchedulesRoute(), { timeout: 30_000 }); + } finally { + await workspace.cleanup(); + } + }); + + test("drops an arrow-key selection once the query changes", async ({ page }) => { + const workspace = await seedMockAgentWorkspace({ + repoPrefix: "command-center-active-reset-", + title: "Command Center active reset", + model: "five-minute-stream", + }); + try { + await workspace.client.setAgentMode(workspace.agentId, "approval-test"); + const input = await openCommandCenterForAgent(page, workspace); + await input.fill("loa"); + // Moves the highlight onto a model row, which still matches after the next keystroke. + await page.keyboard.press("ArrowDown"); + await input.fill("load"); + await page.keyboard.press("Enter"); + + await expectWorkspaceAgentConfiguration(workspace, { + id: workspace.agentId, + provider: "mock", + model: "five-minute-stream", + modeId: "load-test", + }); + } finally { + await workspace.cleanup(); + } + }); + test("applies draft model and setting choices to the created agent", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "command-center-draft-controls-" }); await seedMockDraftPreferences(page); diff --git a/packages/app/e2e/support/helpers/command-center-agent-controls.ts b/packages/app/e2e/support/helpers/command-center-agent-controls.ts index 4b4cbfa4d9..c460154d25 100644 --- a/packages/app/e2e/support/helpers/command-center-agent-controls.ts +++ b/packages/app/e2e/support/helpers/command-center-agent-controls.ts @@ -1,12 +1,27 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Locator, type Page } from "@playwright/test"; import type { SeededWorkspace } from "./seed-client"; import { openCommandCenter } from "./command-center"; +import { openAgentRoute, type MockAgentWorkspace } from "./mock-agent"; export interface CommandCenterAgentControlChoice { query: string; choice: string; } +/** + * Focuses a seeded mock agent — which is what makes its model and mode contributions register — + * opens the Command Center over it, and hands back the query input. Seeding stays in the test so + * the workspace lifecycle is owned by the `try`/`finally` that cleans it up. + */ +export async function openCommandCenterForAgent( + page: Page, + workspace: Pick, +): Promise { + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId }); + const panel = await openCommandCenter(page); + return panel.getByTestId("command-center-input"); +} + export async function chooseCommandCenterAgentControl(input: { page: Page; query: string; diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 504729f8fd..956c47b385 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -58,8 +58,10 @@ import { shortenPath } from "@/utils/shorten-path"; import { useCommandCenterContributions } from "./provider"; import { buildContributionSections, + filterAndRankBuiltInResults, joinSubtitleParts, moveActiveResultId, + PINNED_SECTION_BAND, preserveActiveResultId, projectCommandCenterRows, type CommandCenterAgentResult, @@ -67,6 +69,7 @@ import { type CommandCenterListRow, type CommandCenterResult, type CommandCenterResultSection, + type CommandCenterSearchFields, type CommandCenterWorkspaceResult, } from "./results"; import { useWorkspaceFileSearch } from "./workspace-file-search"; @@ -88,7 +91,6 @@ const ThemedLoadingSpinner = withUnistyles(LoadingSpinner, (theme) => ({ })); const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"]; const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const; -const DEFAULT_CATEGORY_RESULT_LIMIT = 5; function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; @@ -103,41 +105,59 @@ function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); } -function matchesQuery(searchText: string, query: string): boolean { - const normalized = query.trim().toLowerCase(); - return !normalized || searchText.includes(normalized); +function compareWorkspacesByTitle( + left: CommandCenterWorkspaceResult, + right: CommandCenterWorkspaceResult, +): number { + const titleDelta = left.title.localeCompare(right.title, undefined, { + numeric: true, + sensitivity: "base", + }); + return titleDelta || left.subtitle.localeCompare(right.subtitle); } -function limitDefaultCategoryResults(results: Result[], query: string): Result[] { - return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); +/** `cwd` is not rendered, so a path match must never outrank a match on text the user can see. */ +function agentSearchFields(result: CommandCenterAgentResult): CommandCenterSearchFields { + return { visible: [result.title, result.subtitle], hidden: [result.agent.cwd] }; } -function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] { +function workspaceSearchFields(result: CommandCenterWorkspaceResult): CommandCenterSearchFields { + return { visible: [result.title, result.subtitle], hidden: [] }; +} + +/** + * Build every pinned row, in its default order. Deliberately not keyed on `query`: none of this + * work depends on what was typed, and rebuilding it per keystroke means re-running an Intl + * collation sort over every workspace. + * + * The cost is that `formatTimeAgo` is baked into the agent subtitle here, so relative timestamps + * now refresh when agents or projects change rather than on every keystroke. + */ +function useBuiltInRows(open: boolean): { + workspaces: CommandCenterWorkspaceResult[]; + agents: CommandCenterAgentResult[]; +} { const { t } = useTranslation(); const { agents } = useAggregatedAgents(); const { projects } = useProjects({ enabled: open }); const showHost = useHosts().length > 1; return useMemo(() => { - if (!open) return []; + if (!open) return { workspaces: [], agents: [] }; const allWorkspaces: CommandCenterWorkspaceResult[] = []; for (const project of projects) { for (const host of project.hosts) { for (const workspace of host.workspaces) { if (workspace.archivingAt) continue; - const title = workspace.title ?? workspace.name; - const subtitle = joinSubtitleParts([ - showHost ? host.serverName : null, - project.projectName, - workspace.currentBranch, - ]); - const searchText = `${title} ${subtitle}`.toLowerCase(); allWorkspaces.push({ kind: "workspace", id: `workspace:${host.serverId}:${workspace.id}`, - title, - subtitle, - searchText, + title: workspace.title ?? workspace.name, + subtitle: joinSubtitleParts([ + showHost ? host.serverName : null, + project.projectName, + workspace.currentBranch, + ]), run: () => { clearCommandCenterFocusRestoreElement(); navigateToWorkspace({ serverId: host.serverId, workspaceId: workspace.id }); @@ -146,60 +166,66 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe } } } - allWorkspaces.sort((left, right) => { - const titleDelta = left.title.localeCompare(right.title, undefined, { - numeric: true, - sensitivity: "base", - }); - return titleDelta || left.subtitle.localeCompare(right.subtitle); - }); + allWorkspaces.sort(compareWorkspacesByTitle); const workspaceTitleByKey = new Map( allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]), ); - const workspaces = limitDefaultCategoryResults( - allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)), - query, - ); - const agentResults = limitDefaultCategoryResults( - agents - .map((agent) => { - const title = agent.title || t("shell.commandCenter.newAgent"); - const workspaceTitle = agent.workspaceId - ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) - : undefined; - const location = workspaceTitle ?? shortenPath(agent.cwd); - const subtitle = joinSubtitleParts([ + const agentRows = agents + .map((agent) => { + const workspaceTitle = agent.workspaceId + ? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`) + : undefined; + return { + kind: "agent", + id: `agent:${agent.serverId}:${agent.id}`, + agent, + title: agent.title || t("shell.commandCenter.newAgent"), + subtitle: joinSubtitleParts([ showHost ? agent.serverLabel : null, - location, + workspaceTitle ?? shortenPath(agent.cwd), formatTimeAgo(agent.lastActivityAt), - ]); - return { - kind: "agent", - id: `agent:${agent.serverId}:${agent.id}`, - agent, - title, - subtitle, - searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(), - run: () => { - clearCommandCenterFocusRestoreElement(); - navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); - }, - }; - }) - .filter((agent) => matchesQuery(agent.searchText, query)) - .sort((left, right) => sortAgents(left.agent, right.agent)), - query, - ); + ]), + run: () => { + clearCommandCenterFocusRestoreElement(); + navigateToAgent({ serverId: agent.serverId, agentId: agent.id }); + }, + }; + }) + .sort((left, right) => sortAgents(left.agent, right.agent)); + return { workspaces: allWorkspaces, agents: agentRows }; + }, [agents, open, projects, showHost, t]); +} + +function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] { + const { t } = useTranslation(); + const rows = useBuiltInRows(open); + + return useMemo(() => { + if (!open) return []; return [ { id: "workspaces", + band: PINNED_SECTION_BAND, rank: 2, title: t("shell.commandCenter.workspaces"), - results: workspaces, + results: filterAndRankBuiltInResults( + rows.workspaces, + query, + workspaceSearchFields, + compareWorkspacesByTitle, + ), + }, + { + id: "agents", + band: PINNED_SECTION_BAND, + rank: 3, + title: t("shell.commandCenter.agents"), + results: filterAndRankBuiltInResults(rows.agents, query, agentSearchFields, (left, right) => + sortAgents(left.agent, right.agent), + ), }, - { id: "agents", rank: 3, title: t("shell.commandCenter.agents"), results: agentResults }, ]; - }, [agents, open, projects, query, showHost, t]); + }, [open, query, rows, t]); } interface CommandCenterState { @@ -231,7 +257,7 @@ function useCommandCenterState(): CommandCenterState { const snapshot = useCommandCenterContributions(); const inputRef = useRef(null); const previousOpenRef = useRef(open); - const [query, setQuery] = useState(""); + const [query, setQueryState] = useState(""); const [activeId, setActiveId] = useState(null); const builtInSections = useBuiltInSections(open, query); const { @@ -250,10 +276,20 @@ function useCommandCenterState(): CommandCenterState { filePath: entry.path, title: entry.name, subtitle: entry.directory, - searchText: entry.path.toLowerCase(), run: () => openFile(entry.path), })); - return [{ id: "files", rank: 4, title: t("shell.commandCenter.files"), results }]; + // File rows are matched by the daemon, so they carry no client-side score and cannot be + // relevance-compared against the rows that do. Pinning them keeps the section comparator + // total: band is decided before any score is read. + return [ + { + id: "files", + band: PINNED_SECTION_BAND, + rank: 4, + title: t("shell.commandCenter.files"), + results, + }, + ]; }, [fileSearchEntries, openFile, t]); const contributionSections = useMemo( () => buildContributionSections(snapshot.contributions, query), @@ -270,6 +306,14 @@ function useCommandCenterState(): CommandCenterState { ); const resolvedActiveId = preserveActiveResultId(activeId, projection.selectableResults); + // Editing the query re-ranks everything, so an arrow-key selection made under the previous + // query is no longer meaningful. Reset it in the same update as the query rather than in an + // effect, so there is no intermediate render holding the stale selection. + const setQuery = useCallback((next: string) => { + setQueryState(next); + setActiveId(null); + }, []); + const close = useCallback(() => setOpen(false), [setOpen]); const select = useCallback( (result: CommandCenterResult) => { @@ -312,7 +356,7 @@ function useCommandCenterState(): CommandCenterState { const timer = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(timer); } - setQuery(""); + setQueryState(""); setActiveId(null); if (!wasOpen) return; const element = takeCommandCenterFocusRestoreElement(); diff --git a/packages/app/src/command-center/results.test.ts b/packages/app/src/command-center/results.test.ts index f54abe744a..6e37de1fed 100644 --- a/packages/app/src/command-center/results.test.ts +++ b/packages/app/src/command-center/results.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import type { CommandCenterContribution } from "./contributions"; import { buildContributionSections, + filterAndRankBuiltInResults, joinSubtitleParts, moveActiveResultId, + PINNED_SECTION_BAND, preserveActiveResultId, projectCommandCenterRows, type CommandCenterWorkspaceResult, @@ -29,17 +31,80 @@ function contribution(input: { }; } -function workspace(id: string): CommandCenterWorkspaceResult { +function action(input: { + id: string; + group: string; + groupRank: number; + rank?: number; + title: string; + subtitle?: string; + keywords?: readonly string[]; + visibility?: "always" | "query"; +}): CommandCenterContribution { return { - kind: "workspace", - id, - title: id, - subtitle: "host", - searchText: id, + id: input.id, + group: input.group, + groupRank: input.groupRank, + rank: input.rank ?? 0, + keywords: input.keywords ?? [], + visibility: input.visibility ?? "always", run: () => undefined, + presentation: { + kind: "action", + title: input.title, + subtitle: input.subtitle, + sectionTitle: input.group, + }, }; } +function choice(input: { + id: string; + group: string; + groupRank: number; + rank?: number; + path: readonly [string, ...string[]]; + keywords?: readonly string[]; +}): CommandCenterContribution { + return { + id: input.id, + group: input.group, + groupRank: input.groupRank, + rank: input.rank ?? 0, + keywords: input.keywords ?? [], + visibility: "query", + run: () => undefined, + presentation: { kind: "choice", path: input.path, selected: false }, + }; +} + +/** The real Actions › Schedules contribution, shape-for-shape (root-registration.tsx). */ +function schedulesAction(): CommandCenterContribution { + return action({ + id: "schedules", + group: "actions", + groupRank: 0, + rank: 4, + title: "Schedules", + keywords: ["schedules", "scheduled", "automation", "recurring"], + }); +} + +/** The real Mode › Auto mode contribution, shape-for-shape (agent-control-contributions.ts). */ +function autoModeChoice(): CommandCenterContribution { + return choice({ + id: "mode:auto", + group: "modes", + groupRank: 1.2, + path: ["Mode", "Auto mode"], + keywords: ["access", "permission", "approval", "mode"], + }); +} + +function workspace(id: string, title = id, subtitle = "host"): CommandCenterWorkspaceResult { + return { kind: "workspace", id, title, subtitle, run: () => undefined }; +} + function sectionResultIds(sections: ReturnType): string[] { const ids: string[] = []; for (const section of sections) { @@ -60,7 +125,13 @@ describe("Command Center result projection", () => { const sections = buildContributionSections(contributions, "o"); const projection = projectCommandCenterRows([ ...sections, - { id: "workspaces", rank: 2, title: "Workspaces", results: [workspace("workspace:1")] }, + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 2, + title: "Workspaces", + results: [workspace("workspace:1")], + }, ]); expect(projection.rows.map((row) => row.key)).toEqual([ "section:models", @@ -100,7 +171,13 @@ describe("Command Center result projection", () => { it("keeps keyboard selection aligned with rows beyond the render window", () => { const workspaces = Array.from({ length: 200 }, (_, index) => workspace(`workspace:${index}`)); const projection = projectCommandCenterRows([ - { id: "workspaces", rank: 0, title: "Workspaces", results: workspaces }, + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 0, + title: "Workspaces", + results: workspaces, + }, ]); let activeId: string | null = workspaces[0].id; @@ -115,6 +192,320 @@ describe("Command Center result projection", () => { }); }); +describe("Command Center relevance ordering", () => { + it("ranks a label match above a hidden-keyword match at every prefix", () => { + // Reported bug: "Schedules" matched only via its hidden keyword "automation" yet outranked + // "Auto mode", so Enter navigated to Schedules instead of switching permission mode. + // Every prefix counts, not just the full word — you read the list while you type, and "aut" + // is a string-prefix of both "Auto mode" and "automation", so match quality alone ties them. + for (const query of ["a", "au", "aut", "auto"]) { + const sections = buildContributionSections([schedulesAction(), autoModeChoice()], query); + expect( + sections.map((section) => section.id), + `query ${query}`, + ).toEqual(["modes", "actions"]); + expect(sectionResultIds(sections), `query ${query}`).toEqual(["mode:auto", "schedules"]); + } + }); + + it("prefers a visible label over an exact hidden keyword", () => { + const sections = buildContributionSections( + [ + action({ + id: "schedules", + group: "actions", + groupRank: 0, + rank: 0, + title: "Schedules", + keywords: ["automation"], + }), + action({ + id: "automation-hub", + group: "actions", + groupRank: 0, + rank: 1, + title: "Automation hub", + }), + ], + "automation", + ); + + // "automation" is an exact keyword on Schedules and only a prefix of the other label. + // The visible match still wins — the deliberate trade-off of ranking by field role first. + expect(sectionResultIds(sections)).toEqual(["automation-hub", "schedules"]); + }); + + it("keeps registry order when the query is empty", () => { + const shuffled = [ + choice({ id: "mode:auto", group: "modes", groupRank: 1.2, path: ["Mode", "Auto mode"] }), + schedulesAction(), + action({ id: "new-agent", group: "workspace", groupRank: -1, title: "New agent" }), + ]; + const projection = projectCommandCenterRows(buildContributionSections(shuffled, "")); + + // Query-gated choices stay hidden; the rest fall back to groupRank, unchanged by this feature. + expect(projection.selectableResults.map((result) => result.id)).toEqual([ + "new-agent", + "schedules", + ]); + }); + + it("still ranks Schedules first for 'sched'", () => { + const sections = buildContributionSections( + [ + schedulesAction(), + choice({ + id: "mode:auto", + group: "modes", + groupRank: 1.2, + path: ["Mode", "Auto mode"], + keywords: ["rescheduled"], + }), + ], + "sched", + ); + + expect(sectionResultIds(sections)).toEqual(["schedules", "mode:auto"]); + }); + + it("orders rows within a section by match quality", () => { + const sections = buildContributionSections( + [ + action({ id: "reset", group: "actions", groupRank: 0, rank: 0, title: "Reset device" }), + action({ id: "settings", group: "actions", groupRank: 0, rank: 1, title: "Settings" }), + ], + "set", + ); + + // "Settings" is a prefix match; "Reset device" only matches mid-word. + expect(sectionResultIds(sections)).toEqual(["settings", "reset"]); + }); + + it("breaks an equal-score tie with registry order, not alphabetically", () => { + const sections = buildContributionSections( + [ + action({ id: "z-first", group: "actions", groupRank: 0, rank: 0, title: "Auto one" }), + action({ id: "a-second", group: "actions", groupRank: 0, rank: 1, title: "Auto two" }), + ], + "auto", + ); + + expect(sectionResultIds(sections)).toEqual(["z-first", "a-second"]); + }); + + it("keeps Workspaces below contributions even on a perfect match", () => { + const projection = projectCommandCenterRows([ + ...buildContributionSections([schedulesAction()], "auto"), + { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 2, + title: "Workspaces", + results: [workspace("auto")], + }, + ]); + + expect(projection.selectableResults.map((result) => result.id)).toEqual(["schedules", "auto"]); + }); + + it("sorts sections handed to it out of order", () => { + const sections = buildContributionSections([schedulesAction(), autoModeChoice()], "auto"); + const workspaces = { + id: "workspaces", + band: PINNED_SECTION_BAND, + rank: 2, + title: "Workspaces", + results: [workspace("auto")], + }; + const projection = projectCommandCenterRows([workspaces, ...sections.toReversed()]); + + expect(projection.selectableResults.map((result) => result.id)).toEqual([ + "mode:auto", + "schedules", + "auto", + ]); + }); + + it("still matches a query spanning two fields", () => { + // Each token is scored against each field on its own, so "mode auto" lands one token in + // path[0] and one in path[1]. Order is not significant — the reverse matches too. + for (const query of ["mode auto", "auto mode"]) { + expect(sectionResultIds(buildContributionSections([autoModeChoice()], query)), query).toEqual( + ["mode:auto"], + ); + } + }); +}); + +describe("Command Center query tokenization", () => { + const labelAsDesign = () => + action({ + id: "label:design", + group: "workspace", + groupRank: 0, + title: "Label as Design", + }); + + it("matches a multi-token query in any order", () => { + // The reported bug: "design" found this row and "lab des" found nothing, because the whole + // query was tested as one substring. + for (const query of ["design", "lab des", "des lab", "Label as Design"]) { + expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual([ + "label:design", + ]); + } + }); + + it("requires each token to be adjacent characters, not a subsequence", () => { + // Unlike the composer's slash-command list, where "/pasbab" finds "/paseo-babysit". Dropping + // the subsequence tier is what keeps "No matches" meaning no matches: this list preselects + // its first row, so a query that should find nothing must not put an action under Enter. + for (const query of ["labdes", "lbl", "lasdsgn"]) { + expect(sectionResultIds(buildContributionSections([labelAsDesign()], query)), query).toEqual( + [], + ); + } + }); + + it("rejects the row when any single token matches nothing", () => { + expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab zzz"))).toEqual([]); + }); + + it("treats a repeated token as idempotent", () => { + // Each token is scored independently, so both find the same occurrence. Documented, not a bug. + expect(sectionResultIds(buildContributionSections([labelAsDesign()], "lab lab"))).toEqual([ + "label:design", + ]); + }); + + it("never drops a row that the old substring filter would have matched", () => { + // Tokens hold no whitespace and the old haystack joined fields with a space, so any token + // found in that haystack lay entirely within one field. Tokenizing can only widen. + const rows = [labelAsDesign(), schedulesAction(), autoModeChoice()]; + const haystack = (row: CommandCenterContribution): string => + [ + ...(row.presentation.kind === "action" + ? [row.presentation.title, row.presentation.subtitle ?? ""] + : row.presentation.path), + ...row.keywords, + ] + .join(" ") + .toLowerCase(); + + for (const query of ["design", "sched", "auto mode", "label as", "recurring", "mode"]) { + const substringMatches = rows + .filter((row) => haystack(row).includes(query.toLowerCase())) + .map((row) => row.id); + const tokenMatches = new Set(sectionResultIds(buildContributionSections(rows, query))); + for (const id of substringMatches) { + expect(tokenMatches.has(id), `${query} -> ${id}`).toBe(true); + } + } + }); + + it("matches one token against the title and another against the subtitle", () => { + const sections = buildContributionSections( + [ + action({ + id: "open-changes", + group: "workspace", + groupRank: 0, + title: "Open Changes", + subtitle: "Review the working tree", + }), + ], + "changes tree", + ); + + expect(sectionResultIds(sections)).toEqual(["open-changes"]); + }); + + it("ranks a visible match above one that only a hidden keyword reached", () => { + const sections = buildContributionSections( + [ + action({ + id: "hidden-only", + group: "actions", + groupRank: 0, + rank: 0, + title: "Unrelated", + keywords: ["label", "design"], + }), + labelAsDesign(), + ], + "lab des", + ); + + expect(sectionResultIds(sections)).toEqual(["label:design", "hidden-only"]); + }); + + it("returns everything for an empty or whitespace-only query", () => { + for (const query of ["", " "]) { + expect( + sectionResultIds(buildContributionSections([labelAsDesign(), schedulesAction()], query)), + JSON.stringify(query), + ).toEqual(["label:design", "schedules"]); + } + }); +}); + +describe("filterAndRankBuiltInResults", () => { + const visibleOnly = (row: CommandCenterWorkspaceResult) => ({ + visible: [row.title, row.subtitle], + hidden: [], + }); + const byTitle = (left: CommandCenterWorkspaceResult, right: CommandCenterWorkspaceResult) => + left.title.localeCompare(right.title); + + it("caps an empty query and preserves the caller's order", () => { + const rows = Array.from({ length: 8 }, (_, index) => workspace(`w${index}`)); + + expect( + filterAndRankBuiltInResults(rows, "", visibleOnly, byTitle).map((row) => row.id), + ).toEqual(["w0", "w1", "w2", "w3", "w4"]); + }); + + it("puts the best match first even when it sorts last alphabetically", () => { + const rows = [ + workspace("a", "Zebra design system"), + workspace("b", "Alpha lab notes"), + workspace("c", "Label as Design"), + ]; + + // Alphabetically this is Alpha, Label, Zebra. "lab des" only matches Label as Design. + expect( + filterAndRankBuiltInResults(rows, "lab des", visibleOnly, byTitle).map((row) => row.id), + ).toEqual(["c"]); + }); + + it("ranks a title match above a match that only the hidden field reached", () => { + const rows = [workspace("hidden-hit", "Unrelated"), workspace("title-hit", "Deploy")]; + const fields = (row: CommandCenterWorkspaceResult) => + row.id === "hidden-hit" + ? { visible: [row.title], hidden: ["deploy"] } + : { visible: [row.title], hidden: [] }; + + expect( + filterAndRankBuiltInResults(rows, "deploy", fields, byTitle).map((row) => row.id), + ).toEqual(["title-hit", "hidden-hit"]); + }); + + it("breaks an equal score with the caller's tiebreak, not with match order", () => { + // Both titles are an exact prefix match for "task", so only the tiebreak separates them. + const rows = [workspace("second", "Task zebra"), workspace("first", "Task alpha")]; + + expect( + filterAndRankBuiltInResults(rows, "task", visibleOnly, byTitle).map((row) => row.id), + ).toEqual(["first", "second"]); + }); + + it("drops a row when one token matches nothing", () => { + const rows = [workspace("a", "Label as Design")]; + + expect(filterAndRankBuiltInResults(rows, "lab zzz", visibleOnly, byTitle)).toEqual([]); + }); +}); + describe("joinSubtitleParts", () => { it("joins all present parts with a middle dot", () => { expect(joinSubtitleParts(["a", "b", "c"])).toBe("a · b · c"); diff --git a/packages/app/src/command-center/results.ts b/packages/app/src/command-center/results.ts index e0c6107958..3d50020ccc 100644 --- a/packages/app/src/command-center/results.ts +++ b/packages/app/src/command-center/results.ts @@ -1,12 +1,53 @@ import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { + compareMatchScores, + type MatchScore, + scoreTextFields, +} from "@getpaseo/protocol/search/text-match"; import type { CommandCenterContribution } from "./contributions"; +/** Sections that reorder by how well they match the query. */ +export const CONTRIBUTION_SECTION_BAND = 0; +/** Sections pinned below every contribution section regardless of match quality. */ +export const PINNED_SECTION_BAND = 1; + +/** Every query token matched something the user can read on the row. */ +const VISIBLE_FIELD_RANK = 0; +/** At least one token only matched a hidden keyword. */ +const KEYWORD_FIELD_RANK = 1; + +export interface CommandCenterScore { + /** VISIBLE_FIELD_RANK or KEYWORD_FIELD_RANK. Compared before match quality. */ + fieldRank: number; + match: MatchScore; +} + +/** + * Rank by which fields matched before ranking by how well they matched. A row whose visible + * label matches always outranks one that only matches through a hidden keyword, however good + * the keyword match is. + * + * Scoring purely by tier is not enough: "aut" is a string-prefix of both "Auto mode" and + * Schedules' hidden keyword "automation", so the tiers tie and the order falls back to group + * rank, which puts Schedules first. It only came out right at the full word "auto". Order that + * flips mid-word as you type is worse than order that is merely wrong. + * + * The cost is deliberate: an exact hidden-keyword match now loses to any visible match. No + * query over the contributions registered today hits that case — hidden keywords here are + * synonyms nothing else displays ("automation", "recurring", "repo", "shell", "hotkeys"). + */ +export function compareCommandCenterScores( + left: CommandCenterScore, + right: CommandCenterScore, +): number { + return left.fieldRank - right.fieldRank || compareMatchScores(left.match, right.match); +} + export interface CommandCenterWorkspaceResult { kind: "workspace"; id: string; title: string; subtitle: string; - searchText: string; run(): void; } @@ -16,7 +57,6 @@ export interface CommandCenterAgentResult { agent: AggregatedAgent; title: string; subtitle: string; - searchText: string; run(): void; } @@ -26,7 +66,6 @@ export interface CommandCenterFileResult { filePath: string; title: string; subtitle: string; - searchText: string; run(): void; } @@ -34,7 +73,8 @@ export interface CommandCenterContributionResult { kind: "contribution"; id: string; contribution: CommandCenterContribution; - searchText: string; + /** How well this row matches the query. Absent when the query is empty. */ + score?: CommandCenterScore; run(): void | Promise; } @@ -46,15 +86,21 @@ export type CommandCenterResult = export interface CommandCenterResultSection { id: string; + /** CONTRIBUTION_SECTION_BAND or PINNED_SECTION_BAND. Compared before anything else. */ + band: number; rank: number; title?: string; + /** Best score among this section's results. Absent when the query is empty. */ + score?: CommandCenterScore; results: readonly CommandCenterResult[]; } interface MutableCommandCenterResultSection { id: string; + band: number; rank: number; title?: string; + score?: CommandCenterScore; results: CommandCenterResult[]; } @@ -69,9 +115,41 @@ export interface CommandCenterListProjection { offsets: readonly number[]; } -function matchesQuery(searchText: string, query: string): boolean { - const normalized = query.trim().toLowerCase(); - return !normalized || searchText.includes(normalized); +/** + * Fields a row can be matched on, split by whether the user can read them. A row that matches + * only through `hidden` still matches, but always ranks below one whose `visible` text did. + */ +export interface CommandCenterSearchFields { + visible: readonly string[]; + hidden: readonly string[]; +} + +const MATCH_OPTIONS = { subsequence: false } as const; + +/** + * The one definition of "how well did this row match", shared by every scored row kind. Null + * means the row is filtered out, so scoring and filtering are the same pass — a separate boolean + * predicate would throw this result away and force the token scan a second time. + * + * Matching is per-token (`scoreTextFields` splits on whitespace and every token must land in + * some field), so token order does not matter and `lab des` finds "Label as Design". This can + * only ever widen the result set: tokens hold no whitespace, so any token that was found in the + * old space-joined haystack lies entirely within one field and is still found here. + * + * Subsequence matching is off, unlike the composer's slash-command list. Each token has to be a + * run of adjacent characters, so `lab des` matches and `labdes` does not. It widens hard enough + * that almost any typo finds something, and this list preselects its first row: a query the user + * expects to find nothing would instead put an unrelated action under Enter. + */ +export function scoreSearchFields( + query: string, + fields: CommandCenterSearchFields, +): CommandCenterScore | null { + const visible = scoreTextFields(query, [...fields.visible], MATCH_OPTIONS); + if (visible) return { fieldRank: VISIBLE_FIELD_RANK, match: visible }; + if (fields.hidden.length === 0) return null; + const match = scoreTextFields(query, [...fields.visible, ...fields.hidden], MATCH_OPTIONS); + return match ? { fieldRank: KEYWORD_FIELD_RANK, match } : null; } /** Join non-empty subtitle parts with " · ", dropping null/undefined/empty. */ @@ -79,12 +157,18 @@ export function joinSubtitleParts(parts: readonly (string | null | undefined)[]) return parts.filter((part): part is string => Boolean(part)).join(" · "); } -function contributionSearchText(contribution: CommandCenterContribution): string { - const presentationText = +/** + * Field order carries no meaning: every token is scored against each field on its own, so + * ["Mode", "Auto mode"] and its reverse both match "mode auto" and "auto mode". + */ +function contributionSearchFields( + contribution: CommandCenterContribution, +): CommandCenterSearchFields { + const visible = contribution.presentation.kind === "action" ? [contribution.presentation.title, contribution.presentation.subtitle ?? ""] - : contribution.presentation.path; - return [...presentationText, ...contribution.keywords].join(" ").toLowerCase(); + : [...contribution.presentation.path]; + return { visible, hidden: contribution.keywords }; } function resultHeight(result: CommandCenterResult): number { @@ -96,17 +180,77 @@ function resultHeight(result: CommandCenterResult): number { return 36; } +/** + * Order two sections. Total and transitive: the mixed case (one score present, one absent) + * cannot occur, because scores exist exactly on band-0 sections with a non-empty query, band is + * compared first so cross-band pairs never reach the score clause, and within band 0 either + * every section has a score or none does. + */ +function compareResultSections( + left: CommandCenterResultSection, + right: CommandCenterResultSection, +): number { + if (left.band !== right.band) return left.band - right.band; + if (left.score && right.score) { + const delta = compareCommandCenterScores(left.score, right.score); + if (delta !== 0) return delta; + } + return left.rank - right.rank || left.id.localeCompare(right.id); +} + +function resultScore(result: CommandCenterResult): CommandCenterScore | undefined { + return result.kind === "contribution" ? result.score : undefined; +} + +/** Rows shown when nothing has been typed, per pinned category. */ +const DEFAULT_CATEGORY_RESULT_LIMIT = 5; + +/** + * Filter and rank already-built rows. Rows in, rows out — building them needs translations, + * navigation callbacks and the project tree, none of which matching cares about, so that stays + * with the caller and this stays testable without mounting React. + * + * `tiebreak` is the caller's own ordering (alphabetical for workspaces, status and recency for + * agents). It decides equal-scoring rows, so an agent awaiting input still outranks an idle one + * that matched exactly as well. + */ +export function filterAndRankBuiltInResults( + rows: readonly Row[], + query: string, + fieldsOf: (row: Row) => CommandCenterSearchFields, + tiebreak: (left: Row, right: Row) => number, +): Row[] { + if (!query.trim()) return rows.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT); + + const scored: { row: Row; score: CommandCenterScore }[] = []; + for (const row of rows) { + const score = scoreSearchFields(query, fieldsOf(row)); + if (score) scored.push({ row, score }); + } + scored.sort( + (left, right) => + compareCommandCenterScores(left.score, right.score) || tiebreak(left.row, right.row), + ); + return scored.map((entry) => entry.row); +} + export function buildContributionSections( contributions: readonly CommandCenterContribution[], query: string, ): CommandCenterResultSection[] { const groups = new Map(); + const registryIndexById = new Map(); const hasQuery = Boolean(query.trim()); - for (const contribution of contributions) { + for (const [index, contribution] of contributions.entries()) { if (contribution.visibility === "query" && !hasQuery) continue; - const searchText = contributionSearchText(contribution); - if (!matchesQuery(searchText, query)) continue; + let score: CommandCenterScore | undefined; + if (hasQuery) { + // The score is the filter: null means no token found a home, so the row is out. + const matched = scoreSearchFields(query, contributionSearchFields(contribution)); + if (!matched) continue; + score = matched; + } const existing = groups.get(contribution.group); const title = contribution.presentation.kind === "action" @@ -114,23 +258,39 @@ export function buildContributionSections( : undefined; const section = existing ?? { id: contribution.group, + band: CONTRIBUTION_SECTION_BAND, rank: contribution.groupRank, title, results: [], }; + registryIndexById.set(contribution.id, index); section.results.push({ kind: "contribution", id: contribution.id, contribution, - searchText, + score, run: contribution.run, }); + if (score && (!section.score || compareCommandCenterScores(score, section.score) < 0)) { + section.score = score; + } groups.set(contribution.group, section); } - return [...groups.values()].sort( - (left, right) => left.rank - right.rank || left.id.localeCompare(right.id), - ); + const sections = [...groups.values()]; + // With no query there is nothing to rank by, so leave the registry order untouched. + if (!hasQuery) return sections; + + for (const section of sections) { + section.results.sort((left, right) => { + const leftScore = resultScore(left); + const rightScore = resultScore(right); + const delta = leftScore && rightScore ? compareCommandCenterScores(leftScore, rightScore) : 0; + if (delta !== 0) return delta; + return (registryIndexById.get(left.id) ?? 0) - (registryIndexById.get(right.id) ?? 0); + }); + } + return sections.sort(compareResultSections); } export function projectCommandCenterRows( @@ -138,7 +298,7 @@ export function projectCommandCenterRows( ): CommandCenterListProjection { const populated = sections .filter((section) => section.results.length > 0) - .sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id)); + .sort(compareResultSections); const rows: CommandCenterListRow[] = []; const selectableResults: CommandCenterResult[] = []; const rowIndexByResultId = new Map(); diff --git a/packages/protocol/src/search/text-match.test.ts b/packages/protocol/src/search/text-match.test.ts index d3454ed29e..05037fdee7 100644 --- a/packages/protocol/src/search/text-match.test.ts +++ b/packages/protocol/src/search/text-match.test.ts @@ -113,6 +113,15 @@ describe("scoreMatch typo tolerance", () => { expect(scoreMatch("confug", "revamp configuration editor", typoTolerant)?.tier).toBe(6); }); + it("drops the subsequence tier when the caller opts out", () => { + // Callers that preselect a row need a near miss to read as no match, so they turn this off. + // Only the subsequence tier goes; every contiguous tier still scores the same. + expect(scoreMatch("confg", "revamp configuration editor", { subsequence: false })).toBeNull(); + expect(scoreMatch("config", "revamp configuration editor", { subsequence: false })?.tier).toBe( + 3, + ); + }); + it("reports the offset and edit distance of the word it matched", () => { expect(scoreMatch("bulling", "add stripe billing", typoTolerant)).toEqual({ tier: 6, @@ -210,6 +219,14 @@ describe("scoreTextFields", () => { ).not.toBeNull(); expect(scoreTextFields("stipe bulling", ["add stripe billing"])).toBeNull(); }); + + it("carries the subsequence opt-out down to each token", () => { + const fields = ["Label as Design"]; + // Splitting on whitespace is what the opt-out keeps; only the run-together form goes. + expect(scoreTextFields("lab des", fields, { subsequence: false })).not.toBeNull(); + expect(scoreTextFields("labdes", fields, { subsequence: false })).toBeNull(); + expect(scoreTextFields("labdes", fields)).not.toBeNull(); + }); }); describe("compareMatchScores", () => { diff --git a/packages/protocol/src/search/text-match.ts b/packages/protocol/src/search/text-match.ts index b15789ff73..db73acb305 100644 --- a/packages/protocol/src/search/text-match.ts +++ b/packages/protocol/src/search/text-match.ts @@ -17,6 +17,14 @@ export interface MatchScore { export interface MatchOptions { /** Omit or pass null to match exactly. `fuzzyPolicyForToken` picks a policy. */ fuzzy?: FuzzyPolicy | null; + /** + * Match characters that appear in order but not adjacently, so `pasbab` finds + * `paseo-babysit`. Defaults to on. Turn it off where a near miss has to read as + * no match at all: it widens far enough that most typos hit something, and a + * list that preselects its first row would then act on that row when the user + * presses Enter expecting nothing to happen. + */ + subsequence?: boolean; } /** Exact tiers, best to worst. The fuzzy tier always sorts after all of them. */ @@ -194,7 +202,8 @@ export function scoreMatch( const t = text.toLowerCase(); if (t === q) return { tier: TIER_EXACT, offset: 0 }; - const exact = scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t); + const substring = scoreSubstringMatch(q, t); + const exact = substring ?? (options.subsequence === false ? null : scoreSubsequenceMatch(q, t)); if (exact) return exact; const fuzzy = options.fuzzy; @@ -307,6 +316,12 @@ export interface TextFieldsOptions { * query mixes long words that can absorb an edit with short ones that cannot. */ typoTolerant?: boolean; + /** + * See `MatchOptions.subsequence`. Applied per token, so turning it off means + * every token has to appear as a run of adjacent characters in some field — + * `lab des` still matches "Label as Design", `labdes` no longer does. + */ + subsequence?: boolean; } export function scoreTextFields( @@ -322,7 +337,7 @@ export function scoreTextFields( const fuzzy = options.typoTolerant ? fuzzyPolicyForToken(token) : null; let best: MatchScore | null = null; for (const field of fields) { - const score = scoreMatch(token, field, { fuzzy }); + const score = scoreMatch(token, field, { fuzzy, subsequence: options.subsequence }); if (score && (!best || compareMatchScores(score, best) < 0)) { best = score; }