diff --git a/packages/junior-dashboard/e2e/command-palette.spec.ts b/packages/junior-dashboard/e2e/command-palette.spec.ts index 9dc14d9041..3580708d49 100644 --- a/packages/junior-dashboard/e2e/command-palette.spec.ts +++ b/packages/junior-dashboard/e2e/command-palette.spec.ts @@ -35,7 +35,8 @@ test("switches conversations from the command palette without a server search", await expect( palette.getByRole("option", { name: /Checkout latency triage/ }), ).toHaveCount(0); - await input.fill("checkout"); + // A typo still matches. + await input.fill("chekcout"); await expect( palette.getByRole("option", { name: /Investigate checkout latency/ }), ).toHaveAttribute("aria-selected", "true"); diff --git a/packages/junior-dashboard/package.json b/packages/junior-dashboard/package.json index 427073da40..9fdbc0b91e 100644 --- a/packages/junior-dashboard/package.json +++ b/packages/junior-dashboard/package.json @@ -45,7 +45,6 @@ "class-variance-authority": "^0.7.1", "cmdk": "^1.1.1", "cn": "^0.5.0", - "fuzzysort": "^4.0.2", "hono": "^4.13.11", "lucide-react": "^1.17.0", "react": "^19.2.6", diff --git a/packages/junior-dashboard/src/client/components/CommandPalette.tsx b/packages/junior-dashboard/src/client/components/CommandPalette.tsx index e9b1170acb..94648dd8b0 100644 --- a/packages/junior-dashboard/src/client/components/CommandPalette.tsx +++ b/packages/junior-dashboard/src/client/components/CommandPalette.tsx @@ -4,7 +4,6 @@ import { SquarePen, type LucideIcon, } from "lucide-react"; -import fuzzysort from "fuzzysort"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; @@ -20,6 +19,7 @@ import { isPlatformShortcut, platformShortcut } from "../keyboard"; import type { Conversation } from "../types"; import type { DashboardHeaderNavItem } from "./layout/DashboardHeader"; import { navItemIcon } from "./layout/navIcons"; +import { quickOpenMatch } from "./quickOpenMatch"; import { Command, CommandDialog, @@ -271,10 +271,10 @@ function paletteItems(input: { ); const matchedPages = ranked( pages.map((page) => { - const match = fuzzysort.single(input.query, page.label); + const match = quickOpenMatch(page.label, input.query); return { ...pageItem(page), - matches: match ? [...match.indexes] : undefined, + matches: match?.indices, score: match?.score ?? 0, }; }), @@ -299,13 +299,13 @@ function conversationItem( query?: string, ): PaletteItem { const label = conversationDisplayTitle(conversation); - const match = query ? fuzzysort.single(query, label) : undefined; + const match = query ? quickOpenMatch(label, query) : undefined; return { group, icon: conversation.visibility === "private" ? LockKeyhole : MessageSquare, id: `conversation-${conversation.id}`, label, - matches: match ? [...match.indexes] : undefined, + matches: match?.indices, meta: conversation.archivedAt ? "Archived" : formatCompactAge(conversation.lastSeenAt), diff --git a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts new file mode 100644 index 0000000000..44726f1a65 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts @@ -0,0 +1,163 @@ +export type QuickOpenMatch = { + /** UTF-16 indices in the label that the query matched, ascending. */ + indices: number[]; + score: number; +}; + +// Each term match falls in a tier: a word-start substring beats a substring, +// which beats a scattered subsequence or a typo. Scores inside a tier stay +// below one tier step. +const TIER = 1_000; +const WORD_START_BONUS = 10; +// Each character a scatter spreads over costs this much, so "clt" in +// "Checkout latency triage" ranks far above letters strewn across a title. +const SPAN_PENALTY = 20; +// A one-edit typo beats a loose scatter but not a tight one. +const TYPO_SCORE = 600; +// Shorter terms are mostly noise when edited, and need an exact match. +const TYPO_MIN_LENGTH = 4; + +/** + * Match `query` against `label` the way an editor's quick open does: every + * space-separated term must appear in order, contiguously or scattered, or + * within a typo of a word's start, with no case. Higher scores rank first. + * Returns undefined when a term misses. + */ +export function quickOpenMatch( + label: string, + query: string, +): QuickOpenMatch | undefined { + const haystack = foldCase(label); + const terms = foldCase(query).split(/\s+/).filter(Boolean); + const indices = new Set(); + let score = 0; + for (const term of terms) { + const match = matchTerm(haystack, term); + if (!match) return undefined; + score += match.score; + for (const index of match.indices) indices.add(index); + } + return { indices: [...indices].sort((a, b) => a - b), score }; +} + +function matchTerm(haystack: string, term: string): QuickOpenMatch | undefined { + let substringAt = -1; + for ( + let at = haystack.indexOf(term); + at !== -1; + at = haystack.indexOf(term, at + 1) + ) { + if (isWordStart(haystack, at)) { + return { indices: range(at, term.length), score: 3 * TIER - at }; + } + if (substringAt === -1) substringAt = at; + } + if (substringAt !== -1) { + return { + indices: range(substringAt, term.length), + score: 2 * TIER - substringAt, + }; + } + const scatter = matchScatter(haystack, term); + // No typo can beat a scatter this tight, so skip the edit distances. + if (scatter && scatter.score >= TYPO_SCORE) return scatter; + const typo = matchTypo(haystack, term); + if (!scatter || !typo) return scatter ?? typo; + return scatter.score >= typo.score ? scatter : typo; +} + +function matchScatter( + haystack: string, + term: string, +): QuickOpenMatch | undefined { + const indices: number[] = []; + let wordStarts = 0; + let from = 0; + for (const char of term) { + const at = haystack.indexOf(char, from); + if (at === -1) return undefined; + if (isWordStart(haystack, at)) wordStarts += 1; + indices.push(...range(at, char.length)); + from = at + char.length; + } + const span = indices.at(-1)! - indices[0]!; + const score = TIER - span * SPAN_PENALTY + wordStarts * WORD_START_BONUS; + return { indices, score: Math.min(Math.max(score, 1), TIER - 1) }; +} + +/** + * Compare `term` with the text from each word start, so an unfinished word + * still counts. Allows one edit for short terms and two for longer ones. + */ +function matchTypo(haystack: string, term: string): QuickOpenMatch | undefined { + if (term.length < TYPO_MIN_LENGTH) return undefined; + const allowed = term.length < 6 ? 1 : 2; + let best: { at: number; length: number; distance: number } | undefined; + // Compare against the rest of the title, not only the word, so + // `slef-update` still finds "self-update". + for (const word of haystack.matchAll(/[\p{L}\p{N}]+/gu)) { + const distances = prefixDistances( + term, + haystack.slice(word.index, word.index + term.length + allowed), + ); + // Each extra or missing letter costs an edit, so lengths further than + // `allowed` from the term can't match. A tie keeps the length closest + // to the term's, so the highlight covers the whole term. + for (let offset = 0; offset <= 2 * allowed; offset++) { + const length = + term.length + (offset % 2 ? -1 : 1) * Math.ceil(offset / 2); + const distance = distances[length]; + if (distance === undefined) continue; + if (distance <= allowed && (!best || distance < best.distance)) { + best = { at: word.index, length, distance }; + } + } + } + if (!best) return undefined; + return { + indices: range(best.at, best.length), + score: Math.max(TYPO_SCORE / best.distance - best.at, 1), + }; +} + +// Levenshtein distance from `a` to each prefix of `b`, where swapping two +// neighbors costs one edit. Keeps three rows: the swap looks two rows back. +function prefixDistances(a: string, b: string): number[] { + let before = Array.from({ length: b.length + 1 }, () => 0); + let previous = Array.from({ length: b.length + 1 }, (_, j) => j); + let current = Array.from({ length: b.length + 1 }, () => 0); + for (let i = 1; i <= a.length; i++) { + current[0] = i; + for (let j = 1; j <= b.length; j++) { + current[j] = Math.min( + previous[j]! + 1, + current[j - 1]! + 1, + previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { + current[j] = Math.min(current[j]!, before[j - 2]! + 1); + } + } + [before, previous, current] = [previous, current, before]; + } + return previous; +} + +// Lowercases one character at a time and keeps each one's UTF-16 length, so +// indices in the folded text stay valid in the label. "İ" lowercases to "i" +// plus a combining dot; only the "i" is kept. +function foldCase(text: string): string { + let folded = ""; + for (const char of text) { + folded += char.toLowerCase().slice(0, char.length); + } + return folded; +} + +function isWordStart(haystack: string, at: number): boolean { + return at === 0 || !/[\p{L}\p{N}]/u.test(haystack[at - 1]!); +} + +function range(start: number, length: number): number[] { + return Array.from({ length }, (_, offset) => start + offset); +} diff --git a/packages/junior-dashboard/tests/quickOpenMatch.test.ts b/packages/junior-dashboard/tests/quickOpenMatch.test.ts new file mode 100644 index 0000000000..bdb8a4ea17 --- /dev/null +++ b/packages/junior-dashboard/tests/quickOpenMatch.test.ts @@ -0,0 +1,68 @@ +import { expect, it } from "vitest"; + +import { quickOpenMatch } from "../src/client/components/quickOpenMatch"; + +// No describe block: CI parses the JUnit report, and each " > " in a test +// name adds an XML entity toward the parser's limit. +it("matches scattered letters in order, with no case", () => { + expect(quickOpenMatch("Checkout latency triage", "CLT")?.indices).toEqual([ + 0, 9, 11, + ]); + expect(quickOpenMatch("Checkout latency triage", "gc")).toBeUndefined(); +}); + +it("needs every space-separated term", () => { + expect(quickOpenMatch("Checkout latency triage", "chk tri")?.indices).toEqual( + [0, 1, 4, 17, 18, 19], + ); + expect(quickOpenMatch("Investigate checkout latency", "chk tri")).toBe( + undefined, + ); +}); + +it("ranks a word-start run over a mid-word run over a scatter", () => { + const score = (label: string) => quickOpenMatch(label, "deploy")!.score; + expect(score("Deploy notes")).toBeGreaterThan(score("Redeploy notes")); + expect(score("Redeploy notes")).toBeGreaterThan( + score("Debug the payments loy"), + ); +}); + +it("matches a word start within one typo, two for longer terms", () => { + expect(quickOpenMatch("Issue triage", "isseu")?.indices).toEqual([ + 0, 1, 2, 3, 4, + ]); + expect(quickOpenMatch("Customer escalation", "escelation")).toBeDefined(); + expect(quickOpenMatch("Checkout latency triage", "chekcout")).toBeDefined(); + expect( + quickOpenMatch("Checkout latency triage", "chheckkout")?.indices, + ).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + expect(quickOpenMatch("Package self-update", "slef-update")).toBeDefined(); + expect(quickOpenMatch("Checkout latency triage", "cehkcuot")).toBeUndefined(); + // Short terms need their letters in order. + expect(quickOpenMatch("Uptime alerts", "tpi")).toBeUndefined(); +}); + +it("ranks a typo below a tight scatter and above a loose one", () => { + const score = (label: string, query: string) => + quickOpenMatch(label, query)!.score; + expect(score("Who is betegon on GitHub", "bategon")).toBeGreaterThan( + score("Snuba timeout investigation", "bategon"), + ); + expect(score("Checkout latency triage", "chklat")).toBeGreaterThan( + score("Checkout latency triage", "chekcout"), + ); +}); + +it("keeps label indices when lowercasing adds characters", () => { + expect(quickOpenMatch("İstanbul latency", "latency")?.indices).toEqual([ + 9, 10, 11, 12, 13, 14, 15, + ]); + expect(quickOpenMatch("İstanbul latency", "ist")).toEqual( + quickOpenMatch("Istanbul latency", "ist"), + ); +}); + +it("keeps surrogate pairs whole", () => { + expect(quickOpenMatch("Ship 🚀 now", "🚀")?.indices).toEqual([5, 6]); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60da25f1a3..fd6d220fb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,9 +416,6 @@ importers: cn: specifier: ^0.5.0 version: 0.5.0 - fuzzysort: - specifier: ^4.0.2 - version: 4.0.2 hono: specifier: ^4.13.11 version: 4.13.11 @@ -6830,9 +6827,6 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} - fuzzysort@4.0.2: - resolution: {integrity: sha512-3c7yD6rK1EXIsqpcu1ZVzXJugODWNMzvUij6yTiziaAHrpNS8kzFOI9mJLE8pmG4K9JOaVgzQRgklfOEk8f4Tg==} - gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} @@ -16913,8 +16907,6 @@ snapshots: fuzzysort@3.1.0: {} - fuzzysort@4.0.2: {} - gaxios@7.1.4: dependencies: extend: 3.0.2