Skip to content
Source
Sign inSign up
Open History to see the last commit

paletteSearch.test.ts

814 lines35.5 KB

1import { describe, expect, it } from "vite-plus/test";
2​
3import {
4 findSearchTokenRanges,
5 foldSearchQuery,
6 pickedQuery,
7 rankMessageMatches,
8 rankSearchGroups,
9 rankSearchItems,
10 recordThreadOpen,
11 messageMatcher,
12 scoreSearchTerms,
13 scoreThreadSearch,
14 SEARCH_RECENCY_HALF_LIFE_MS,
15 type SearchTermsMatch,
16 type ThreadSearchSubject,
17} from "./paletteSearch.ts";
18​
19const NOW = Date.UTC(2026, 9, 23);
20const scoreMessageMatch = (text: string, query: string) => messageMatcher(query)(text);
21const DAY = 24 * 60 * 60 * 1000;
22​
23function thread(title: string, overrides: Partial<ThreadSearchSubject> = {}): ThreadSearchSubject {
24 return { title, activityAt: NOW - 30 * DAY, ...overrides };
26​
27function rankTitles(query: string, threads: ReadonlyArray<ThreadSearchSubject>): string[] {
28 const normalized = foldSearchQuery(query);
29 return rankSearchItems(threads, (subject, options) =>
30 scoreThreadSearch(normalized, subject, NOW, options),
31 ).map(({ item }) => item.title);
33​
34describe("scoreThreadSearch", () => {
35 it("ranks whole words over prefixes over newer mid-word matches", () => {
36 expect(
37 rankTitles("auth", [
38 thread("Fix oauth redirect", { activityAt: NOW }),
39 thread("Authentication cleanup"),
40 thread("Share auth across worktrees"),
41 ]),
42 ).toEqual(["Share auth across worktrees", "Authentication cleanup", "Fix oauth redirect"]);
43 expect(
44 rankTitles("share auth", [
45 thread("Share authentication cleanup", {
46 activityAt: NOW,
47 opens: { openedAt: NOW, score: 12, picks: [] },
48 }),
49 thread("Auth across worktrees to share", { activityAt: NOW - 90 * DAY }),
50 ]),
51 ).toEqual(["Auth across worktrees to share", "Share authentication cleanup"]);
52 expect(
53 rankTitles("ui", [
54 thread("Parse ui tokens", { activityAt: NOW - 200 * DAY }),
55 thread("Polish UIs", { activityAt: NOW, opens: { openedAt: NOW, score: 12, picks: [] } }),
56 ]),
57 ).toEqual(["Polish UIs", "Parse ui tokens"]);
58 expect(
59 rankTitles("fix in sidebar", [
60 thread("Fix sidebar", { activityAt: NOW - 200 * DAY }),
61 thread("Fix crash in sidebar", {
62 activityAt: NOW,
63 opens: { openedAt: NOW, score: 12, picks: [] },
64 }),
65 thread("Fix sidebar input focus", {
66 activityAt: NOW,
67 opens: { openedAt: NOW, score: 6, picks: [] },
68 }),
69 ]),
70 ).toEqual(["Fix crash in sidebar", "Fix sidebar input focus", "Fix sidebar"]);
71 expect(
72 rankTitles("and", [
73 thread("Fix auth and sync", { activityAt: NOW - 200 * DAY }),
74 thread("Android build", {
75 activityAt: NOW,
76 opens: { openedAt: NOW, score: 12, picks: [] },
77 }),
78 ]),
79 ).toEqual(["Android build", "Fix auth and sync"]);
80 });
81​
82 it("treats camelCase, kebab, path, and digit boundaries as word starts", () => {
83 const starts = (title: string, query: string) =>
84 scoreThreadSearch(query, thread(title), NOW)?.ranges;
85 expect(starts("useThreadSearch hook", "search")).toEqual([{ start: 9, end: 15 }]);
86 expect(starts("fix/sidebar-perf", "perf")).toEqual([{ start: 12, end: 16 }]);
87 expect(starts("Upgrade to pnpm12", "12")).toEqual([{ start: 15, end: 17 }]);
88 expect(starts("Fix JSONSchema parsing", "sch")).toEqual([{ start: 8, end: 11 }]);
89 expect(starts("Fix HTTPServer crash", "ser")).toEqual([{ start: 8, end: 11 }]);
90 expect(starts("Rename index.ts", ".ts")).toEqual([{ start: 12, end: 15 }]);
91 // A match is whole only where a word ends: "githuba" does not fill "GitHubActions".
92 expect(scoreThreadSearch("githuba", thread("GitHubActions setup"), NOW)?.quality).toBeLessThan(
93 1,
94 );
95 expect(rankTitles(".ts", [thread("Fix parser.tsx"), thread("Rename index.ts")])).toEqual([
96 "Rename index.ts",
97 "Fix parser.tsx",
98 ]);
99 // Short tokens never match mid-word.
100 expect(scoreThreadSearch("ci", thread("Decision log"), NOW)).toBeNull();
101 });
102​
103 it("lets recent activity and frequent opens decide between equal matches", () => {
104 expect(
105 rankTitles("goldbug", [
106 thread("Goldbug epilogue", { activityAt: NOW - 20 * DAY }),
107 thread("Goldbug processes", { activityAt: NOW - DAY }),
108 ]),
109 ).toEqual(["Goldbug processes", "Goldbug epilogue"]);
110 expect(
111 rankTitles("goldbug", [
112 thread("Goldbug processes", { activityAt: NOW - DAY }),
113 thread("Goldbug notes", {
114 activityAt: NOW - 40 * DAY,
115 opens: { openedAt: NOW - DAY, score: 12, picks: [] },
116 }),
117 ]),
118 ).toEqual(["Goldbug notes", "Goldbug processes"]);
119 });
120​
121 it("puts the thread picked for a query first when that query is typed again", () => {
122 const threads = [
123 thread("Release checklist", {
124 opens: { openedAt: NOW - 5 * DAY, score: 1, picks: [{ query: "rel", at: NOW - 5 * DAY }] },
125 }),
126 thread("Relay pairing", { activityAt: NOW }),
127 ];
128 expect(rankTitles("rel", threads)).toEqual(["Release checklist", "Relay pairing"]);
129 // A pick never lifts a match inside a word above a word match.
130 expect(
131 rankTitles("auth", [
132 thread("Fix oauth redirect", {
133 opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "auth", at: NOW - DAY }] },
134 }),
135 thread("Share auth across worktrees"),
136 ]),
137 ).toEqual(["Share auth across worktrees", "Fix oauth redirect"]);
138 // Two letters are too little to go on.
139 expect(rankTitles("re", threads)).toEqual(["Relay pairing", "Release checklist"]);
140 // Once the query leaves the pick behind, the better match wins again.
141 expect(rankTitles("rela", threads)).toEqual(["Relay pairing"]);
142 // An old pick fades with the thread's last open.
143 const stale = [
144 thread("Release checklist", {
145 opens: {
146 openedAt: NOW - 90 * DAY,
147 score: 1,
148 picks: [{ query: "rel", at: NOW - 90 * DAY }],
149 },
150 }),
151 thread("Relay pairing", { activityAt: NOW }),
152 ];
153 expect(rankTitles("rel", stale)).toEqual(["Relay pairing", "Release checklist"]);
154 // Opening an old pick some other way does not revive it over a newer pick.
155 const reopened = [
156 thread("Release checklist", {
157 opens: { openedAt: NOW - DAY, score: 2, picks: [{ query: "rel", at: NOW - 30 * DAY }] },
158 }),
159 thread("Relay pairing", {
160 opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "rel", at: NOW - DAY }] },
161 }),
162 ];
163 expect(rankTitles("rel", reopened)).toEqual(["Relay pairing", "Release checklist"]);
164 // The latest pick for a query wins over an earlier one with a better title match.
165 const repicked = [
166 thread("Deploy", {
167 opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "deploy", at: NOW - DAY }] },
168 }),
169 thread("Deploy timeout on Railway", {
170 opens: { openedAt: NOW, score: 1, picks: [{ query: "deploy", at: NOW }] },
171 }),
172 ];
173 expect(rankTitles("deploy", repicked)).toEqual(["Deploy timeout on Railway", "Deploy"]);
174 // A thread's exact pick counts even after a newer pick for a longer query.
175 const exact = [
176 thread("Fix browser", {
177 opens: {
178 openedAt: NOW,
179 score: 2,
180 picks: [
181 { query: "fix browser", at: NOW },
182 { query: "fix", at: NOW - 2 * DAY },
183 ],
184 },
185 }),
186 thread("Fix sidebar", {
187 opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "fix", at: NOW - DAY }] },
188 }),
189 ];
190 expect(rankTitles("fix", exact)).toEqual(["Fix sidebar", "Fix browser"]);
191 // A pick for a longer query does not pass the thread named by the query.
192 const named = [
193 thread("Lakebed perf audit", {
194 opens: { openedAt: NOW, score: 1, picks: [{ query: "lakebed perf", at: NOW }] },
195 }),
196 thread("Lakebed"),
197 ];
198 expect(rankTitles("lakebed", named)).toEqual(["Lakebed", "Lakebed perf audit"]);
199 });
200​
201 it("finds a branch pasted with the app's worktree prefix", () => {
202 const subject = thread("Cmd+K search", { branch: "t3/cmdk-search-v2" });
203 expect(scoreThreadSearch(foldSearchQuery("t3/cmdk-search-v2"), subject, NOW)).not.toBeNull();
204 expect(scoreThreadSearch(foldSearchQuery("t3/cmdk"), subject, NOW)).not.toBeNull();
205 });
206​
207 it("ranks a title holding the words above the app's own branch prefix and project name", () => {
208 const busy = { activityAt: NOW, opens: { openedAt: NOW, score: 6, picks: [] } };
209 expect(
210 rankTitles("t3 connect", [
211 thread("Diagnose Connect provisioning failures", {
212 ...busy,
213 branch: "t3/diagnose-connect",
214 projectTitle: "t3code",
215 }),
216 thread("T3 Connect Tunnel Lifecycle Review", { projectTitle: "t3code" }),
217 ])[0],
218 ).toBe("T3 Connect Tunnel Lifecycle Review");
219 expect(
220 rankTitles("t3code", [
221 thread("Fix sidebar lag", { ...busy, projectTitle: "t3code", branch: "t3code/sidebar" }),
222 thread("T3 Code vs Gemini Token Volume", { projectTitle: "slophouse" }),
223 ])[0],
224 ).toBe("T3 Code vs Gemini Token Volume");
225 });
226​
227 it("matches pull request numbers, branches, and projects with every token somewhere", () => {
228 const subject = thread("Trim model lists", {
229 branch: "t3/trim-mcp-models",
230 projectTitle: "t3code",
231 pullRequests: [{ number: 22889, title: "Smaller MCP payloads" }],
232 });
233 for (const query of ["#22889", "22889", "trim mcp", "payloads t3code", "models trim"]) {
234 expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW), query).not.toBeNull();
235 }
236 expect(scoreThreadSearch("trim lakebed", subject, NOW)).toBeNull();
237 });
238​
239 it("forgives one typo only when nothing matches exactly", () => {
240 const threads = [thread("Hosting alternatives to Railway"), thread("Trim MCP model lists")];
241 expect(rankTitles("railwya", threads)).toEqual(["Hosting alternatives to Railway"]);
242 expect(rankTitles("trim mpc", threads)).toEqual(["Trim MCP model lists"]);
243 expect(
244 rankTitles("drga", [thread("Restore drag handles"), thread("Organize indicators")]),
245 ).toEqual(["Restore drag handles"]);
246 expect(rankTitles("22889", [thread("Stats", { pullRequests: [{ number: 22189 }] })])).toEqual(
247 [],
248 );
249 // Words with digits still forgive one.
250 expect(rankTitles("t3coed", [thread("t3code perf audit"), thread("Other")])).toEqual([
251 "t3code perf audit",
252 ]);
253 expect(rankTitles("oath2", [thread("Fix oauth2 redirect")])).toEqual(["Fix oauth2 redirect"]);
254 // A typo beats a mid-word accident: "ilde" is "idle", not the middle of "Builder".
255 expect(
256 rankTitles("ilde", [thread("Typesafe App Builder"), thread("De-risk idle tunnel cleanup")]),
257 ).toEqual(["De-risk idle tunnel cleanup", "Typesafe App Builder"]);
258 // "ing" is not "Nightly": a three-letter typo keeps the first letter.
259 expect(rankTitles("ing", [thread("Nightly build fix")])).toEqual([]);
260 // A short word may miss one letter while it is typed, but not change one.
261 expect(rankTitles("srv", [thread("Server restart"), thread("Other")])).toEqual([
262 "Server restart",
263 ]);
264 expect(rankTitles("sidb", [thread("Fix sidebar lag")])).toEqual(["Fix sidebar lag"]);
265 expect(rankTitles("cron", [thread("Config cleanup")])).toEqual([]);
266 // Filler words in a phrase-style query are not required, but alone they are.
267 expect(rankTitles("fix the sidebar", [thread("Fix Sidebar Lag")])).toEqual(["Fix Sidebar Lag"]);
268 expect(rankTitles("the", [thread("Fix sidebar lag")])).toEqual([]);
269 // A typed straight apostrophe finds a curly one.
270 expect(rankTitles("don't", [thread("Don’t merge yet")])).toEqual(["Don’t merge yet"]);
271 // Typos count across camelCase joins.
272 expect(rankTitles("githbu", [thread("GitHub API usage"), thread("Other")])).toEqual([
273 "GitHub API usage",
274 ]);
275 expect(rankTitles("macso", [thread("Fix macOS window"), thread("Other")])).toEqual([
276 "Fix macOS window",
277 ]);
278 // "enter" is not "external" when the right thread is already there.
279 expect(
280 rankTitles("cmd enter", [thread("Fix Cmd+Enter"), thread("Open links external")]),
281 ).toEqual(["Fix Cmd+Enter"]);
282 });
283});
284​
285describe("queries that fold to empty words", () => {
286 it("returns for a hash and a space, or marks that fold to nothing", () => {
287 expect(rankTitles("# fix", [thread("Fix sidebar lag")])).toEqual(["Fix sidebar lag"]);
288 expect(rankTitles("#", [thread("Fix sidebar lag")])).toEqual([]);
289 expect(rankTitles("´´´", [thread("Fix sidebar lag")])).toEqual([]);
290 expect(scoreSearchTerms("# fix", ["Fix sidebar"])).toBeNull();
291 expect(findSearchTokenRanges("Fix sidebar", "´´´")).toEqual([]);
292 expect(scoreMessageMatch("Fix sidebar", "´´´")).toBeNull();
293 });
294});
295​
296describe("scoreThreadSearch beyond Latin text and titles", () => {
297 it("leaves out a row whose words only meet across unrelated fields", () => {
298 const scattered = thread("eMudhra certificate update request", {
299 pullRequests: [
300 { number: 22645, title: "fix model picker" },
301 { number: 22649, title: "CI speedups" },
302 ],
303 });
304 expect(scoreThreadSearch("fix ci", scattered, NOW)).toBeNull();
305 // The project explains its own words; one field explains the rest.
306 const lakebed = thread("Weekly notes", {
307 projectTitle: "lakebed",
308 pullRequests: [{ number: 243, title: "perf: faster cold starts" }],
309 });
310 expect(scoreThreadSearch("lakebed perf", lakebed, NOW)?.explain?.()).toEqual({
311 label: "PR",
312 text: "#243 perf: faster cold starts",
313 ranges: [{ start: 5, end: 9 }],
314 });
315 });
316​
317 it("says which field matched when the title does not", () => {
318 const subject = thread("Trim model lists", {
319 branch: "t3/cleanup-payloads",
320 pullRequests: [{ number: 22889, title: "Smaller MCP payloads" }],
321 });
322 for (const query of ["#22889", "pr 22889", "pr22889", "pull request #22889"]) {
323 expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW)?.explain?.(), query).toEqual({
324 label: "PR",
325 text: "#22889 Smaller MCP payloads",
326 ranges: [{ start: 1, end: 6 }],
327 });
328 }
329 // The app's own worktree prefix is not part of what the branch says.
330 expect(scoreThreadSearch("cleanup", subject, NOW)?.explain?.()).toEqual({
331 label: "Branch",
332 text: "cleanup-payloads",
333 ranges: [{ start: 0, end: 7 }],
334 });
335 expect(scoreThreadSearch("trim", subject, NOW)?.explain?.()).toBeUndefined();
336 // Nor is it in the flat t3-<hex> form used when a `t3` branch blocks nested refs.
337 const flat = thread("Add search", { branch: "t3-abcd1234" });
338 expect(scoreThreadSearch("t3", flat, NOW)).toBeNull();
339 expect(scoreThreadSearch("t3 connect", flat, NOW)).toBeNull();
340 expect(scoreThreadSearch("abcd1234", flat, NOW)).not.toBeNull();
341 for (const query of ["t3-abcd", "t3-abcd1234 connect", "connect t3-abcd1234"]) {
342 expect(scoreThreadSearch(query, { ...flat, projectTitle: "connect" }, NOW)).not.toBeNull();
343 }
344 });
345​
346 it("reads letters and numbers typed together, without forgiving typos in numbers", () => {
347 const threads = [
348 thread("Upgrade to pnpm 12"),
349 thread("GPT-5 eval"),
350 thread("iOS 26 support"),
351 thread("Other"),
352 ];
353 expect(rankTitles("pnpm12", threads)).toEqual(["Upgrade to pnpm 12"]);
354 expect(rankTitles("gpt5", threads)).toEqual(["GPT-5 eval"]);
355 expect(rankTitles("ios26", threads)).toEqual(["iOS 26 support"]);
356 expect(rankTitles("pnpm13", threads)).toEqual([]);
357 });
358​
359 it("reads whole words typed together, but not scattered letters", () => {
360 const threads = [
361 thread("VS Code extension settings"),
362 thread("Fix UI bug in sidebar"),
363 thread("I plan to open source all of lakebed in the next few weeks"),
364 ];
365 expect(rankTitles("vscode", threads)).toEqual(["VS Code extension settings"]);
366 expect(rankTitles("fixuibug", threads)).toEqual(["Fix UI bug in sidebar"]);
367 expect(rankTitles("lint", threads)).toEqual([]);
368 // Short words before a mere prefix are a coincidence.
369 expect(rankTitles("inth", [thread("Show skill names in thread overview")])).toEqual([]);
370 expect(rankTitles("tos", [thread("Move Client State to Server")])).toEqual([]);
371 expect(rankTitles("t3co", [thread("T3 Code vs Gemini")])).toEqual(["T3 Code vs Gemini"]);
372 expect(rankTitles('"sidebar lag"', [thread("Fix sidebar lag")])).toEqual(["Fix sidebar lag"]);
373 // A pasted query with a separate accent mark matches the composed title.
374 expect(rankTitles("cafe\u0301", [thread("Café menu")])).toEqual(["Café menu"]);
375 // Three letters of the last word are enough, as it is typed.
376 expect(rankTitles("gomod", [thread("Migrate to Go modules")])).toEqual([
377 "Migrate to Go modules",
378 ]);
379 // Joined words beat an earlier match inside a word.
380 expect(scoreThreadSearch("vscode", thread("Fixvscode and VS Code"), NOW)?.quality).toBe(0.6);
381 // A strong match elsewhere does not hide joined words.
382 expect(
383 rankTitles("vscode", [
384 thread("VS Code remote support"),
385 thread("Old work", { branch: "vscode", activityAt: NOW - 400 * DAY }),
386 ]),
387 ).toContain("VS Code remote support");
388 });
389​
390 it("stores picked queries without splitting an emoji", () => {
391 expect(pickedQuery(`${"a".repeat(31)}🚀`)).toBe("a".repeat(31));
392 expect(pickedQuery(" Upl ")).toBe("upl");
393 // Folded before it is cut, as stored and as compared.
394 const spaced = "investigate workspace thread reset bug";
395 expect(pickedQuery(spaced)).toBe(pickedQuery(foldSearchQuery(spaced)));
396 });
397​
398 it("matches emoji in titles", () => {
399 expect(rankTitles("🚀", [thread("🚀 Launch checklist"), thread("Launch notes")])).toEqual([
400 "🚀 Launch checklist",
401 ]);
402 });
403​
404 it("treats each Chinese or Japanese character as a word start", () => {
405 expect(scoreThreadSearch("搜索", thread("实现全文搜索"), NOW)).not.toBeNull();
406 expect(scoreThreadSearch("検索", thread("新しい検索機能"), NOW)).not.toBeNull();
407 });
408});
409​
410describe("scoreThreadSearch with heavy use and loose input", () => {
411 it("keeps a whole word on a quiet thread above a mid-word hit on a thread opened daily", () => {
412 let opens = recordThreadOpen(null, NOW - 60 * DAY, undefined);
413 for (let day = 59; day >= 1; day--) opens = recordThreadOpen(opens, NOW - day * DAY, undefined);
414 expect(
415 rankTitles("auth", [
416 thread("Fix oauth redirect", { activityAt: NOW, opens }),
417 thread("Share auth across worktrees", { activityAt: NOW - 90 * DAY }),
418 ]),
419 ).toEqual(["Share auth across worktrees", "Fix oauth redirect"]);
420 });
421​
422 it("reads a word and the start of the next typed together, highlighting each part", () => {
423 const threads = [thread("Fix sidebar lag"), thread("Unrelated work")];
424 expect(rankTitles("fixsid", threads)).toEqual(["Fix sidebar lag"]);
425 // A later "F" can start the run.
426 expect(rankTitles("fixsid", [thread("Feature: Fix sidebar lag")])).toEqual([
427 "Feature: Fix sidebar lag",
428 ]);
429 expect(scoreThreadSearch("fixsid", threads[0]!, NOW)?.ranges).toEqual([
430 { start: 0, end: 3 },
431 { start: 4, end: 7 },
432 ]);
433 // Initials are not words typed together.
434 expect(rankTitles("fsl", threads)).toEqual([]);
435 });
436​
437 it("finds nothing rather than an unrelated thread when no word matches", () => {
438 const threads = [
439 thread("Projectless threads without Git"),
440 thread("Concern about connection setup"),
441 thread("Simplify worktree thread creation"),
442 thread("Start a new conversation"),
443 thread("Setting up the sentinel"),
444 thread("I plan to open source all of lakebed in the next few weeks"),
445 thread("Design notes"),
446 thread("Link pull request to thread"),
447 ];
448 for (const query of ["posthog", "cron", "stripe", "gpu", "fxsb", "lint", "deno"]) {
449 expect(rankTitles(query, threads), query).toEqual([]);
450 }
451 });
452​
453 it("matches pieces of references that hold a number, and environment names", () => {
454 const subject = thread("Trim model lists", {
455 id: "thread-5fe6f59c-4976",
456 environmentLabel: "BeeBox",
457 pullRequests: [
458 {
459 number: 22889,
460 references: ["pingdotgg/t3code#22889", "https://github.com/pingdotgg/t3code/pull/22889"],
461 },
462 ],
463 });
464 for (const query of [
465 "t3code#22889",
466 "github.com/pingdotgg/t3code/pull/22889",
467 "https://github.com/pingdotgg/t3code/pull/22889/files",
468 "5fe6f59c",
469 "beebox",
470 ]) {
471 expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW), query).not.toBeNull();
472 }
473 expect(scoreThreadSearch("github.com", subject, NOW)).toBeNull();
474 // A number is never cut short.
475 for (const query of ["github.com/pingdotgg/t3code/pull/2288", "t3code#2288"]) {
476 expect(scoreThreadSearch(foldSearchQuery(query), subject, NOW), query).toBeNull();
477 }
478 });
479});
480​
481describe("rankMessageMatches", () => {
482 it("merges environments by word match, then picks, then quality and use", () => {
483 const subjects = new Map([
484 ["a-word", thread("A word")],
485 ["a-word-2", thread("A word too")],
486 ["b-prefix", thread("B prefix")],
487 ["b-recent", thread("B recent", { activityAt: NOW })],
488 [
489 "b-picked",
490 thread("B picked", {
491 opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "needle", at: NOW - DAY }] },
492 }),
493 ],
494 ]);
495 // Environment A sent two whole words, environment B a prefix and two more.
496 const matches = [
497 { key: "a-word", quality: 1 },
498 { key: "a-word-2", quality: 1 },
499 { key: "b-prefix", quality: 0.8 },
500 { key: "b-recent", quality: 1 },
501 { key: "b-picked", quality: 0.8 },
502 ];
503 expect(
504 rankMessageMatches(matches, (match) => subjects.get(match.key), "needle", NOW).map(
505 (match) => match.key,
506 ),
507 ).toEqual(["b-picked", "b-recent", "a-word", "a-word-2", "b-prefix"]);
508 // A palette lists title matches above, so they use up no message rows.
509 const titled = new Map([...subjects, ["c-titled", thread("Needle cleanup")]]);
510 expect(
511 rankMessageMatches(
512 [{ key: "c-titled", quality: 1 }, ...matches],
513 (match) => titled.get(match.key),
514 "needle",
515 NOW,
516 { withoutTitleMatches: true },
517 ).map((match) => match.key),
518 ).not.toContain("c-titled");
519 });
520​
521 it("ranks a thread that keeps using the word above a passing mention of equal quality", () => {
522 // The real case: 21 hits seven weeks back against one this week, opened today.
523 const subjects = new Map([
524 [
525 "mention",
526 thread("Mention", {
527 activityAt: NOW - 3 * DAY,
528 opens: { openedAt: NOW, score: 1, picks: [] },
529 }),
530 ],
531 ["about", thread("About it", { activityAt: NOW - 48 * DAY })],
532 ]);
533 const matches = [
534 { key: "mention", quality: 1, hits: 1 },
535 { key: "about", quality: 1, hits: 21 },
536 ];
537 expect(
538 rankMessageMatches(matches, (match) => subjects.get(match.key), "tailscale", NOW).map(
539 (match) => match.key,
540 ),
541 ).toEqual(["about", "mention"]);
542 });
543​
544 it("keeps matches inside words below word matches, however used", () => {
545 const busy = thread("Busy", {
546 activityAt: NOW,
547 opens: { openedAt: NOW, score: 20, picks: [] },
548 });
549 const matches = [
550 { key: "busy", quality: 0.3 },
551 { key: "quiet", quality: 1 },
552 ];
553 const subjects = new Map([
554 ["busy", busy],
555 ["quiet", thread("Quiet")],
556 ]);
557 expect(
558 rankMessageMatches(matches, (match) => subjects.get(match.key), "rust", NOW).map(
559 (match) => match.key,
560 ),
561 ).toEqual(["quiet", "busy"]);
562 });
563});
564​
565describe("rankSearchGroups", () => {
566 it("orders groups by their best match and keeps the given order on ties", () => {
567 const groups = rankSearchGroups(
568 [
569 { group: "actions", items: ["Open settings", "New thread"] },
570 { group: "threads", items: ["Settings sync rewrite", "Thread settings"] },
571 { group: "projects", items: ["Settings app"] },
572 ],
573 (item, options) => scoreSearchTerms("settings", [item], options),
574 );
575 expect(groups.map(({ group, ranked }) => [group, ranked.map(({ item }) => item)])).toEqual([
576 ["actions", ["Open settings"]],
577 ["threads", ["Settings sync rewrite", "Thread settings"]],
578 ["projects", ["Settings app"]],
579 ]);
580 });
581});
582​
583describe("rankSearchGroups with keywords and picks", () => {
584 it("matches inside a word only in thread titles", () => {
585 expect(scoreSearchTerms("arch", ["Search project contents"])).toBeNull();
586 expect(
587 scoreThreadSearch("arch", thread("Notes", { branch: "research-extension-system" }), NOW),
588 ).toBeNull();
589 expect(scoreThreadSearch("arch", thread("Research notes"), NOW)?.quality).toBe(0.3);
590 });
591​
592 it("forgives typos in names but not in hidden keywords", () => {
593 const groups = rankSearchGroups(
594 [
595 { group: "actions", items: [["Open settings", "preferences", "keybindings"]] },
596 { group: "settings", items: [["Keybindings", "General"]] },
597 ],
598 (terms, options) => scoreSearchTerms("keybindngs", terms, options),
599 );
600 expect(groups.map(({ group }) => group)).toEqual(["settings"]);
601 expect(scoreSearchTerms("duckdb", ["Search engine", "duckduckgo"], { typos: true })).toBeNull();
602 expect(scoreSearchTerms("rust", ["GitHub sharing", "trusted"])).toBeNull();
603 // Filler words do not make a short query long enough for keyword prefixes.
604 expect(scoreSearchTerms("the ci", ["Context window indicator", "circle"])).toBeNull();
605 });
606​
607 it("lists a thread named for the query above a command with it as a keyword", () => {
608 const rank = (command: string[], subject: ThreadSearchSubject) =>
609 rankSearchGroups<string, string[] | ThreadSearchSubject, SearchTermsMatch>(
610 [
611 { group: "actions", items: [command] },
612 { group: "threads", items: [subject] },
613 ],
614 (item, options) => {
615 if (Array.isArray(item)) return scoreSearchTerms("mcp", item, options);
616 return scoreThreadSearch("mcp", item, NOW, options);
617 },
618 ).map(({ group }) => group);
619 const named = thread("Trim MCP model lists");
620 const picked = thread("Trim MCP model lists", {
621 opens: { openedAt: NOW, score: 1, picks: [{ query: "mcp", at: NOW }] },
622 });
623 expect(rank(["Restart agent session", "reload", "mcp"], named)).toEqual(["threads", "actions"]);
624 // A command named for the query stays first; a pick reorders threads but
625 // never moves their group, so one pick for "mcp" does not pass the command.
626 expect(rank(["Reload MCP servers"], named)).toEqual(["actions", "threads"]);
627 expect(rank(["Reload MCP servers"], picked)).toEqual(["actions", "threads"]);
628 });
629});
630​
631describe("recordThreadOpen", () => {
632 it("decays the open count, merges quick reopens, and keeps the latest picked queries", () => {
633 const first = recordThreadOpen(null, NOW, " Upl ");
634 expect(first).toEqual({ openedAt: NOW, score: 1, picks: [{ query: "upl", at: NOW }] });
635 const reopened = recordThreadOpen(first, NOW + 60_000, "uploadthing");
636 expect(reopened).toEqual({
637 openedAt: NOW,
638 score: 1,
639 picks: [
640 { query: "uploadthing", at: NOW + 60_000 },
641 { query: "upl", at: NOW },
642 ],
643 });
644 const later = recordThreadOpen(reopened, NOW + SEARCH_RECENCY_HALF_LIFE_MS, "x".repeat(50));
645 expect(later.score).toBe(1.5);
646 expect(later.picks.map((pick) => pick.query)).toEqual(["x".repeat(32), "uploadthing", "upl"]);
647 const repicked = recordThreadOpen(later, later.openedAt + DAY, "upl");
648 expect(repicked.picks).toEqual([
649 { query: "upl", at: later.openedAt + DAY },
650 { query: "x".repeat(32), at: later.openedAt },
651 { query: "uploadthing", at: NOW + 60_000 },
652 ]);
653 });
654​
655 it("drops picks that no longer count when the thread is opened again", () => {
656 const opens = {
657 openedAt: NOW - 30 * DAY,
658 score: 1,
659 picks: [{ query: "old", at: NOW - 30 * DAY }],
660 };
661 expect(recordThreadOpen(opens, NOW, "new").picks).toEqual([{ query: "new", at: NOW }]);
662 });
663​
664 it("does not renew a query's pick when the thread is picked for another one", () => {
665 const deploy = [
666 thread("Deploy notes", {
667 opens: {
668 openedAt: NOW,
669 score: 2,
670 picks: [
671 { query: "scripts", at: NOW },
672 { query: "deploy", at: NOW - 60 * DAY },
673 ],
674 },
675 }),
676 thread("Deploy timeout", {
677 opens: { openedAt: NOW - DAY, score: 1, picks: [{ query: "deploy", at: NOW - DAY }] },
678 }),
679 ];
680 expect(rankTitles("deploy", deploy)).toEqual(["Deploy timeout", "Deploy notes"]);
681 });
682});
683​
684describe("findSearchTokenRanges", () => {
685 it("maps folded matches back to the source text and skips short tokens", () => {
686 const text = "Le Café résumé ci";
687 expect(findSearchTokenRanges(text, "CAFE resume ci")).toEqual([
688 { start: 3, end: 7 },
689 { start: 8, end: 14 },
690 ]);
691 });
692​
693 it("highlights a three-letter token only where it starts a word", () => {
694 expect(findSearchTokenRanges("drafts, enableFTS", "fts")).toEqual([{ start: 14, end: 17 }]);
695 expect(findSearchTokenRanges("Open handlers.ts", ".ts")).toEqual([{ start: 13, end: 16 }]);
696 });
697});
698​
699describe("scoreMessageMatch", () => {
700 it("needs short words at a word start and ranks other words found inside words lower", () => {
701 const text = `Saved the drafts. ${"More notes. ".repeat(40)}Then enableFTS and Enable FTS.`;
702 const fts = scoreMessageMatch(text, "fts");
703 expect(fts?.quality).toBe(1);
704 expect(text.slice(fts?.offset)).toMatch(/^FTS and/);
705 expect(scoreMessageMatch("saved two drafts", "fts")).toBeNull();
706 expect(scoreMessageMatch("We trust the build", "rust")).toEqual({ quality: 0.3, offset: 4 });
707 expect(scoreMessageMatch("Rust and trust", "rust")).toEqual({ quality: 1, offset: 0 });
708 expect(scoreMessageMatch("Use the sidebar", '"sidebar"')).toEqual({ quality: 1, offset: 8 });
709 for (const [text, query, expected] of [
710 ["model mode mode", "mode", { quality: 1, offset: 6 }],
711 ["xmode model models", "mode", { quality: 0.8, offset: 6 }],
712 ["model models", "mode", { quality: 0.8, offset: 0 }],
713 ["xmode ymode", "mode", { quality: 0.3, offset: 1 }],
714 ["mode mode", "mode", { quality: 1, offset: 0 }],
715 ["fts fts", "fts", { quality: 1, offset: 0 }],
716 ["the theme", "he", null],
717 ["he theme", "he", { quality: 1, offset: 0 }],
718 ["notes", "", { quality: 1, offset: 0 }],
719 ["", "notes", null],
720 ["same same same", "same same", { quality: 1.25, offset: 0 }],
721 ["7cafe52bb8 cafe cafe", "cafe", { quality: 1, offset: 11 }],
722 ["7cafe52bb8 7cafe52bb8", "7cafe52", { quality: 1, offset: 0 }],
723 ["明天部署部署到生产环境", "部署", { quality: 1, offset: 2 }],
724 ["gannet’s gannet's", "gannet's", { quality: 1, offset: 9 }],
725 ["gannet's gannet’s", "gannet’s", { quality: 1, offset: 0 }],
726 ] as const) {
727 expect(scoreMessageMatch(text, query), `${query} in ${text}`).toEqual(expected);
728 }
729 // Words written together as typed rank above the same words apart, and
730 // the snippet starts where they are together.
731 const together = "Run the tests, then vp run dev to check";
732 expect(scoreMessageMatch(together, "vp run dev")).toEqual({
733 quality: 1.25,
734 offset: together.indexOf("vp run dev"),
735 });
736 // Near each other ranks above far apart.
737 expect(scoreMessageMatch("dev notes: vp is slow, run it", "vp run dev")?.quality).toBe(1.1);
738 const far = `run the tests. ${"More notes. ".repeat(10)}Then dev mode, then vp`;
739 expect(scoreMessageMatch(far, "vp run dev")?.quality).toBe(1);
740 expect(scoreMessageMatch("I don\u2019t know why", "don't know")?.quality).toBe(1.25);
741 expect(scoreMessageMatch("Le Café open late", "cafe open")?.quality).toBe(1.25);
742 expect(scoreMessageMatch("The cafe open late", "café open")?.quality).toBe(1.25);
743 // The snippet starts where the words meet, not at an early one.
744 const phrase = `The plan. ${"More notes. ".repeat(10)}Since the last fix, the sidebar works`;
745 expect(scoreMessageMatch(phrase, "fix the sidebar")?.offset).toBe(phrase.indexOf("fix,"));
746 const apart = `The lakebed app. ${"Notes. ".repeat(30)}Lakebed page perf is slow`;
747 expect(apart.slice(scoreMessageMatch(apart, "lakebed perf")?.offset)).toMatch(/^Lakebed page/);
748 // Only where a word counts: not "fix" in "prefix".
749 const prefix = `fix ${"unrelated words ".repeat(40)}prefix has CI`;
750 expect(scoreMessageMatch(prefix, "fix ci")?.offset).toBe(0);
751 // A commit SHA is one word: "cafe" inside one does not match, its start does.
752 expect(scoreMessageMatch("see commit 7cafe52bb8", "cafe")).toBeNull();
753 expect(scoreMessageMatch("see commit 7cafe52bb8", "7cafe52")).toEqual({
754 quality: 1,
755 offset: 11,
756 });
757 expect(scoreMessageMatch("7cafe52bb8, then the cafe", "cafe")?.quality).toBe(1);
758 // A whole word beats a word prefix: "mode" prefers "mode" over "model".
759 expect(scoreMessageMatch("model settings", "mode")?.quality).toBe(0.8);
760 expect(scoreMessageMatch("The model and the mode switch", "mode")).toEqual({
761 quality: 1,
762 offset: 18,
763 });
764 expect(scoreMessageMatch("v12rc build", "rc")?.quality).toBe(1);
765 expect(scoreMessageMatch("Fix the CLIAdapter state", "ada")?.quality).toBe(0.8);
766 // Words too short for the index must start a word too.
767 expect(scoreMessageMatch("Switch off the connection", "switch on")).toBeNull();
768 expect(scoreMessageMatch("Upgrade pnpm 512 today", "pnpm 12")).toBeNull();
769 expect(scoreMessageMatch("Upgrade to pnpm12 today", "pnpm 12")?.quality).toBe(1);
770 // A word too short to search only narrows, so typing the next word
771 // ("nvme s") does not reorder results; written together still counts.
772 expect(scoreMessageMatch("Fix circle rendering", "fix ci")?.quality).toBe(1);
773 expect(scoreMessageMatch("Fix CI failures", "fix ci")?.quality).toBe(1.25);
774 expect(scoreMessageMatch("Upgrade pnpm 123 today", "pnpm 12")?.quality).toBe(1);
775 expect(scoreMessageMatch("Rename the file to index.ts", ".ts")?.quality).toBe(1);
776 });
777​
778 it("finds words through case and accents, also after the same letters inside a word", () => {
779 expect(scoreMessageMatch("This addresses Résumé indexing", "res")).toMatchObject({
780 offset: 15,
781 });
782 expect(scoreMessageMatch("Le CAFÉ ouvert", "cafe")).toMatchObject({ quality: 1, offset: 3 });
783 // An accented whole word beats a plain prefix earlier on.
784 expect(scoreMessageMatch("The cafeteria, then the Café", "cafe")).toMatchObject({
785 quality: 1,
786 offset: 24,
787 });
788 expect(scoreMessageMatch("I presume this. Résumé indexing", "resume")).toMatchObject({
789 quality: 1,
790 offset: 16,
791 });
792 expect(scoreMessageMatch("明天部署到生产环境", "部署")).toMatchObject({ offset: 2 });
793 // Vietnamese and combining accents fold too.
794 expect(scoreMessageMatch("Properties: tiếng Việt support", "tie")).toMatchObject({
795 offset: 12,
796 });
797 expect(scoreMessageMatch("Properties: tie\u0301ng support", "tie")).toMatchObject({
798 offset: 12,
799 });
800 // A letter that grows when lowercased does not shift later offsets.
801 expect(scoreMessageMatch("İ Enable fts and model", "fts")).toMatchObject({ offset: 9 });
802 expect(scoreMessageMatch("İ Enable fts and model", "model")?.quality).toBe(1);
803 expect(scoreMessageMatch("Trip to İstanbul", "İstanbul")).toEqual({ quality: 1, offset: 8 });
804 for (const [text, query, expected] of [
805 ["İ cafeteria Café CAFÉ", "cafe", { quality: 1, offset: 12 }],
806 ["Café cafe", "cafe", { quality: 1, offset: 5 }],
807 ["İ Café Café", "café", { quality: 1, offset: 2 }],
808 ["İ Résumé Résumé", "resume", { quality: 1, offset: 2 }],
809 ["İ résuméprefix résumé", "resume", { quality: 1, offset: 15 }],
810 ] as const) {
811 expect(scoreMessageMatch(text, query), `${query} in ${text}`).toEqual(expected);
812 }
813 });
814});