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; }