Skip to content

Commit 1a0b6d8

Browse files
refactor: enhance workspace entry search ranking and add tests
- Introduced a new scoring mechanism for workspace entries that prioritizes exact basename matches over broader path matches. - Updated the search logic to utilize shared ranking functions for improved performance and maintainability. - Added a test case to validate the new ranking behavior for exact matches in workspace entries. - Removed deprecated functions related to scoring and ranking to streamline the codebase.
1 parent 2d6e8fb commit 1a0b6d8

8 files changed

Lines changed: 517 additions & 143 deletions

File tree

apps/server/src/workspace/Layers/WorkspaceEntries.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,18 @@ it.layer(TestLayer)("WorkspaceEntriesLive", (it) => {
129129
}),
130130
);
131131

132+
it.effect("prioritizes exact basename matches ahead of broader path matches", () =>
133+
Effect.gen(function* () {
134+
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-exact-ranking-" });
135+
yield* writeTextFile(cwd, "src/components/Composer.tsx");
136+
yield* writeTextFile(cwd, "docs/composer.tsx-notes.md");
137+
138+
const result = yield* searchWorkspaceEntries({ cwd, query: "Composer.tsx", limit: 5 });
139+
140+
expect(result.entries[0]?.path).toBe("src/components/Composer.tsx");
141+
}),
142+
);
143+
132144
it.effect("tracks truncation without sorting every fuzzy match", () =>
133145
Effect.gen(function* () {
134146
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-fuzzy-limit-" });

apps/server/src/workspace/Layers/WorkspaceEntries.ts

Lines changed: 40 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import type { Dirent } from "node:fs";
44
import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";
55

66
import { type ProjectEntry } from "@t3tools/contracts";
7+
import {
8+
insertRankedSearchResult,
9+
normalizeSearchQuery,
10+
scoreQueryMatch,
11+
type RankedSearchResult,
12+
} from "@t3tools/shared/searchRanking";
713

814
import { GitCore } from "../../git/Services/GitCore.ts";
915
import {
@@ -40,10 +46,7 @@ interface SearchableWorkspaceEntry extends ProjectEntry {
4046
normalizedName: string;
4147
}
4248

43-
interface RankedWorkspaceEntry {
44-
entry: SearchableWorkspaceEntry;
45-
score: number;
46-
}
49+
type RankedWorkspaceEntry = RankedSearchResult<SearchableWorkspaceEntry>;
4750

4851
function toPosixPath(input: string): string {
4952
return input.replaceAll("\\", "/");
@@ -74,127 +77,39 @@ function toSearchableWorkspaceEntry(entry: ProjectEntry): SearchableWorkspaceEnt
7477
};
7578
}
7679

77-
function normalizeQuery(input: string): string {
78-
return input
79-
.trim()
80-
.replace(/^[@./]+/, "")
81-
.toLowerCase();
82-
}
83-
84-
function scoreSubsequenceMatch(value: string, query: string): number | null {
85-
if (!query) return 0;
86-
87-
let queryIndex = 0;
88-
let firstMatchIndex = -1;
89-
let previousMatchIndex = -1;
90-
let gapPenalty = 0;
91-
92-
for (let valueIndex = 0; valueIndex < value.length; valueIndex += 1) {
93-
if (value[valueIndex] !== query[queryIndex]) {
94-
continue;
95-
}
96-
97-
if (firstMatchIndex === -1) {
98-
firstMatchIndex = valueIndex;
99-
}
100-
if (previousMatchIndex !== -1) {
101-
gapPenalty += valueIndex - previousMatchIndex - 1;
102-
}
103-
104-
previousMatchIndex = valueIndex;
105-
queryIndex += 1;
106-
if (queryIndex === query.length) {
107-
const spanPenalty = valueIndex - firstMatchIndex + 1 - query.length;
108-
const lengthPenalty = Math.min(64, value.length - query.length);
109-
return firstMatchIndex * 2 + gapPenalty * 3 + spanPenalty + lengthPenalty;
110-
}
111-
}
112-
113-
return null;
114-
}
115-
11680
function scoreEntry(entry: SearchableWorkspaceEntry, query: string): number | null {
11781
if (!query) {
11882
return entry.kind === "directory" ? 0 : 1;
11983
}
12084

12185
const { normalizedPath, normalizedName } = entry;
12286

123-
if (normalizedName === query) return 0;
124-
if (normalizedPath === query) return 1;
125-
if (normalizedName.startsWith(query)) return 2;
126-
if (normalizedPath.startsWith(query)) return 3;
127-
if (normalizedPath.includes(`/${query}`)) return 4;
128-
if (normalizedName.includes(query)) return 5;
129-
if (normalizedPath.includes(query)) return 6;
130-
131-
const nameFuzzyScore = scoreSubsequenceMatch(normalizedName, query);
132-
if (nameFuzzyScore !== null) {
133-
return 100 + nameFuzzyScore;
134-
}
135-
136-
const pathFuzzyScore = scoreSubsequenceMatch(normalizedPath, query);
137-
if (pathFuzzyScore !== null) {
138-
return 200 + pathFuzzyScore;
87+
const scores = [
88+
scoreQueryMatch({
89+
value: normalizedName,
90+
query,
91+
exactBase: 0,
92+
prefixBase: 2,
93+
includesBase: 5,
94+
fuzzyBase: 100,
95+
}),
96+
scoreQueryMatch({
97+
value: normalizedPath,
98+
query,
99+
exactBase: 1,
100+
prefixBase: 3,
101+
boundaryBase: 4,
102+
includesBase: 6,
103+
fuzzyBase: 200,
104+
boundaryMarkers: ["/"],
105+
}),
106+
].filter((score): score is number => score !== null);
107+
108+
if (scores.length === 0) {
109+
return null;
139110
}
140111

141-
return null;
142-
}
143-
144-
function compareRankedWorkspaceEntries(
145-
left: RankedWorkspaceEntry,
146-
right: RankedWorkspaceEntry,
147-
): number {
148-
const scoreDelta = left.score - right.score;
149-
if (scoreDelta !== 0) return scoreDelta;
150-
return left.entry.path.localeCompare(right.entry.path);
151-
}
152-
153-
function findInsertionIndex(
154-
rankedEntries: RankedWorkspaceEntry[],
155-
candidate: RankedWorkspaceEntry,
156-
): number {
157-
let low = 0;
158-
let high = rankedEntries.length;
159-
160-
while (low < high) {
161-
const middle = low + Math.floor((high - low) / 2);
162-
const current = rankedEntries[middle];
163-
if (!current) {
164-
break;
165-
}
166-
167-
if (compareRankedWorkspaceEntries(candidate, current) < 0) {
168-
high = middle;
169-
} else {
170-
low = middle + 1;
171-
}
172-
}
173-
174-
return low;
175-
}
176-
177-
function insertRankedEntry(
178-
rankedEntries: RankedWorkspaceEntry[],
179-
candidate: RankedWorkspaceEntry,
180-
limit: number,
181-
): void {
182-
if (limit <= 0) {
183-
return;
184-
}
185-
186-
const insertionIndex = findInsertionIndex(rankedEntries, candidate);
187-
if (rankedEntries.length < limit) {
188-
rankedEntries.splice(insertionIndex, 0, candidate);
189-
return;
190-
}
191-
192-
if (insertionIndex >= limit) {
193-
return;
194-
}
195-
196-
rankedEntries.splice(insertionIndex, 0, candidate);
197-
rankedEntries.pop();
112+
return Math.min(...scores);
198113
}
199114

200115
function isPathInIgnoredDirectory(relativePath: string): boolean {
@@ -469,7 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
469384
const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd);
470385
return yield* Cache.get(workspaceIndexCache, normalizedCwd).pipe(
471386
Effect.map((index) => {
472-
const normalizedQuery = normalizeQuery(input.query);
387+
const normalizedQuery = normalizeSearchQuery(input.query, {
388+
trimLeadingPattern: /^[@./]+/,
389+
});
473390
const limit = Math.max(0, Math.floor(input.limit));
474391
const rankedEntries: RankedWorkspaceEntry[] = [];
475392
let matchedEntryCount = 0;
@@ -481,11 +398,15 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
481398
}
482399

483400
matchedEntryCount += 1;
484-
insertRankedEntry(rankedEntries, { entry, score }, limit);
401+
insertRankedSearchResult(
402+
rankedEntries,
403+
{ item: entry, score, tieBreaker: entry.path },
404+
limit,
405+
);
485406
}
486407

487408
return {
488-
entries: rankedEntries.map((candidate) => candidate.entry),
409+
entries: rankedEntries.map((candidate) => candidate.item),
489410
truncated: index.truncated || matchedEntryCount > limit,
490411
};
491412
}),

apps/web/src/components/chat/ChatComposer.tsx

Lines changed: 15 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ import type { PendingUserInputDraftAnswer } from "../../pendingUserInput";
101101
import type { PendingApproval, PendingUserInput } from "../../session-logic";
102102
import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
103103
import { formatProviderSkillDisplayName } from "../../providerSkillPresentation";
104+
import { searchProviderSkills } from "../../providerSkillSearch";
104105

105106
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
106107

@@ -757,30 +758,20 @@ export const ChatComposer = memo(
757758
);
758759
}
759760
if (composerTrigger.kind === "skill") {
760-
const query = composerTrigger.query.trim().toLowerCase();
761-
return (selectedProviderStatus?.skills ?? [])
762-
.filter((skill) => skill.enabled)
763-
.filter((skill) => {
764-
if (!query) return true;
765-
return (
766-
skill.name.toLowerCase().includes(query) ||
767-
skill.displayName?.toLowerCase().includes(query) ||
768-
skill.shortDescription?.toLowerCase().includes(query) ||
769-
skill.description?.toLowerCase().includes(query) ||
770-
skill.scope?.toLowerCase().includes(query)
771-
);
772-
})
773-
.map((skill) => ({
774-
id: `skill:${selectedProvider}:${skill.name}`,
775-
type: "skill" as const,
776-
provider: selectedProvider,
777-
skill,
778-
label: formatProviderSkillDisplayName(skill),
779-
description:
780-
skill.shortDescription ??
781-
skill.description ??
782-
(skill.scope ? `${skill.scope} skill` : "Run provider skill"),
783-
}));
761+
return searchProviderSkills(
762+
selectedProviderStatus?.skills ?? [],
763+
composerTrigger.query,
764+
).map((skill) => ({
765+
id: `skill:${selectedProvider}:${skill.name}`,
766+
type: "skill" as const,
767+
provider: selectedProvider,
768+
skill,
769+
label: formatProviderSkillDisplayName(skill),
770+
description:
771+
skill.shortDescription ??
772+
skill.description ??
773+
(skill.scope ? `${skill.scope} skill` : "Run provider skill"),
774+
}));
784775
}
785776
return searchableModelOptions
786777
.filter(({ searchSlug, searchName, searchProvider }) => {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { ServerProviderSkill } from "@t3tools/contracts";
4+
5+
import { searchProviderSkills } from "./providerSkillSearch";
6+
7+
function makeSkill(input: Partial<ServerProviderSkill> & Pick<ServerProviderSkill, "name">) {
8+
return {
9+
path: `/tmp/${input.name}/SKILL.md`,
10+
enabled: true,
11+
...input,
12+
} satisfies ServerProviderSkill;
13+
}
14+
15+
describe("searchProviderSkills", () => {
16+
it("moves exact ui matches ahead of broader ui matches", () => {
17+
const skills = [
18+
makeSkill({
19+
name: "agent-browser",
20+
displayName: "Agent Browser",
21+
shortDescription: "Browser automation CLI for AI agents",
22+
}),
23+
makeSkill({
24+
name: "building-native-ui",
25+
displayName: "Building Native Ui",
26+
shortDescription: "Complete guide for building beautiful apps with Expo Router",
27+
}),
28+
makeSkill({
29+
name: "ui",
30+
displayName: "Ui",
31+
shortDescription: "Explore, build, and refine UI.",
32+
}),
33+
];
34+
35+
expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([
36+
"ui",
37+
"building-native-ui",
38+
]);
39+
});
40+
41+
it("uses fuzzy ranking for abbreviated queries", () => {
42+
const skills = [
43+
makeSkill({ name: "gh-fix-ci", displayName: "Gh Fix Ci" }),
44+
makeSkill({ name: "github", displayName: "Github" }),
45+
makeSkill({ name: "agent-browser", displayName: "Agent Browser" }),
46+
];
47+
48+
expect(searchProviderSkills(skills, "gfc").map((skill) => skill.name)).toEqual(["gh-fix-ci"]);
49+
});
50+
51+
it("omits disabled skills from results", () => {
52+
const skills = [
53+
makeSkill({ name: "ui", displayName: "Ui", enabled: false }),
54+
makeSkill({ name: "frontend-design", displayName: "Frontend Design" }),
55+
];
56+
57+
expect(searchProviderSkills(skills, "ui").map((skill) => skill.name)).toEqual([]);
58+
});
59+
});

0 commit comments

Comments
 (0)