From 7908ecf4c91e83e8ccbc4380bae92c154ba8cc4a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:36:47 +0000 Subject: [PATCH 1/4] feat(dashboard): Match typos in the quick switcher with our own matcher Bring back quickOpenMatch in place of fuzzysort and teach it typos. A term of four or more characters now matches a word start within one edit (two for longer terms), and swapping two letters counts as one edit. A stiffer span penalty keeps letters strewn across a title below a typo, while a tight scatter such as "chk tri" still ranks above it. On a 27-query test set, the right title is first for 23 queries (17 with fuzzysort) and in the top three for all of them, for 0.78 KB gzip instead of 8.4 KB. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../e2e/command-palette.spec.ts | 3 +- packages/junior-dashboard/package.json | 1 - .../src/client/components/CommandPalette.tsx | 10 +- .../src/client/components/quickOpenMatch.ts | 144 ++++++++++++++++++ .../tests/quickOpenMatch.test.ts | 55 +++++++ pnpm-lock.yaml | 8 - 6 files changed, 206 insertions(+), 15 deletions(-) create mode 100644 packages/junior-dashboard/src/client/components/quickOpenMatch.ts create mode 100644 packages/junior-dashboard/tests/quickOpenMatch.test.ts 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..22e3146a90 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts @@ -0,0 +1,144 @@ +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 = label.toLowerCase(); + const terms = query.toLowerCase().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 start of each word, 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; + for (const word of haystack.matchAll(/[\p{L}\p{N}]+/gu)) { + if (word[0].length < term.length - 1) continue; + const distances = prefixDistances(term, word[0].slice(0, term.length + 1)); + // The term's own length first, so a tie highlights the whole term. + for (const length of [term.length, term.length - 1, term.length + 1]) { + 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; +} + +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..f7d8416340 --- /dev/null +++ b/packages/junior-dashboard/tests/quickOpenMatch.test.ts @@ -0,0 +1,55 @@ +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", "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 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 From 12d80d272f353659e060555d8a6bd3d18d0076ef Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:46:42 +0000 Subject: [PATCH 2/4] fix(dashboard): Match typos that span words in the quick switcher Compare a term with the text from each word start, not only the word, so slef-update finds self-update. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../src/client/components/quickOpenMatch.ts | 12 ++++++++---- .../junior-dashboard/tests/quickOpenMatch.test.ts | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts index 22e3146a90..ada7236753 100644 --- a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts +++ b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts @@ -86,16 +86,20 @@ function matchScatter( } /** - * Compare `term` with the start of each word, so an unfinished word still - * counts. Allows one edit for short terms and two for longer ones. + * 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)) { - if (word[0].length < term.length - 1) continue; - const distances = prefixDistances(term, word[0].slice(0, term.length + 1)); + const distances = prefixDistances( + term, + haystack.slice(word.index, word.index + term.length + 1), + ); // The term's own length first, so a tie highlights the whole term. for (const length of [term.length, term.length - 1, term.length + 1]) { const distance = distances[length]; diff --git a/packages/junior-dashboard/tests/quickOpenMatch.test.ts b/packages/junior-dashboard/tests/quickOpenMatch.test.ts index f7d8416340..b312a51262 100644 --- a/packages/junior-dashboard/tests/quickOpenMatch.test.ts +++ b/packages/junior-dashboard/tests/quickOpenMatch.test.ts @@ -34,6 +34,7 @@ it("matches a word start within one typo, two for longer terms", () => { ]); expect(quickOpenMatch("Customer escalation", "escelation")).toBeDefined(); expect(quickOpenMatch("Checkout latency triage", "chekcout")).toBeDefined(); + 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(); From 20b8bf6542c660d544b6cea1d657978cff21dee9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:03:21 +0000 Subject: [PATCH 3/4] fix(dashboard): Score the full typo range at each word start Two-edit typos checked only prefixes within one letter of the input, so two extra or missing letters never matched. Check every length within the allowed edits. --- .../src/client/components/quickOpenMatch.ts | 10 +++++++--- packages/junior-dashboard/tests/quickOpenMatch.test.ts | 3 +++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts index ada7236753..b2a9813285 100644 --- a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts +++ b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts @@ -98,10 +98,14 @@ function matchTypo(haystack: string, term: string): QuickOpenMatch | undefined { for (const word of haystack.matchAll(/[\p{L}\p{N}]+/gu)) { const distances = prefixDistances( term, - haystack.slice(word.index, word.index + term.length + 1), + haystack.slice(word.index, word.index + term.length + allowed), ); - // The term's own length first, so a tie highlights the whole term. - for (const length of [term.length, term.length - 1, term.length + 1]) { + // 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)) { diff --git a/packages/junior-dashboard/tests/quickOpenMatch.test.ts b/packages/junior-dashboard/tests/quickOpenMatch.test.ts index b312a51262..0b8239173a 100644 --- a/packages/junior-dashboard/tests/quickOpenMatch.test.ts +++ b/packages/junior-dashboard/tests/quickOpenMatch.test.ts @@ -34,6 +34,9 @@ it("matches a word start within one typo, two for longer terms", () => { ]); 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. From d4b5156f587e682adf7b22a2ad61794fe905c16c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 20:35:40 +0000 Subject: [PATCH 4/4] fix(dashboard): Keep quick switcher match indices aligned when lowercasing adds characters --- .../src/client/components/quickOpenMatch.ts | 15 +++++++++++++-- .../junior-dashboard/tests/quickOpenMatch.test.ts | 9 +++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts index b2a9813285..44726f1a65 100644 --- a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts +++ b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts @@ -27,8 +27,8 @@ export function quickOpenMatch( label: string, query: string, ): QuickOpenMatch | undefined { - const haystack = label.toLowerCase(); - const terms = query.toLowerCase().split(/\s+/).filter(Boolean); + const haystack = foldCase(label); + const terms = foldCase(query).split(/\s+/).filter(Boolean); const indices = new Set(); let score = 0; for (const term of terms) { @@ -143,6 +143,17 @@ function prefixDistances(a: string, b: string): number[] { 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]!); } diff --git a/packages/junior-dashboard/tests/quickOpenMatch.test.ts b/packages/junior-dashboard/tests/quickOpenMatch.test.ts index 0b8239173a..bdb8a4ea17 100644 --- a/packages/junior-dashboard/tests/quickOpenMatch.test.ts +++ b/packages/junior-dashboard/tests/quickOpenMatch.test.ts @@ -54,6 +54,15 @@ it("ranks a typo below a tight scatter and above a loose one", () => { ); }); +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]); });