Files
PaperClipAI/ui/src/lib/searchable-select.ts
T
DottaandPaperclip 50ae8fc657 [codex] Improve reusable workspace selector search (#8597)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The new issue dialog lets operators create follow-up tasks and
optionally reuse an existing execution workspace
> - Reusing a workspace depends on a searchable selector that can
include workspace names, branches, and local paths
> - The selector previously treated all matched text equally, so hidden
path text could outrank the visible workspace label and unrelated fuzzy
letter matches could leak into results
> - The reusable workspace popover also needed to stay inside the modal
so scrolling and layering behave like the rest of the dialog
> - This pull request improves the shared searchable select scoring and
applies it to reusable execution workspace choices
> - The benefit is a more predictable workspace reuse flow when an
operator searches by branch, task name, or workspace label

## Linked Issues or Issue Description

No public GitHub issue found for this selector bug.

### Pre-submission checklist

- [x] I have searched existing open and closed issues and this is not a
duplicate.
- [x] I am on the latest released version of Paperclip (or can reproduce
on `master`).
- [x] I have confirmed the error originates in Paperclip itself — not in
my agent adapter, API provider, or local configuration.

### What happened?

Workspace searches could rank hidden path matches ahead of direct
visible label matches, and broad fuzzy matching could match letters
spread across unrelated workspace metadata.

### Expected behavior

Direct label/name matches should sort ahead of weaker hidden metadata
matches, and fuzzy matching should stay constrained enough to avoid
unrelated workspace results.

### Steps to reproduce

1. Open the new issue dialog.
2. Choose reuse existing execution workspace.
3. Search for a term that appears in one workspace label and only in
another workspace path.
4. Observe that the path-only match can rank ahead of the direct visible
label match.

### Paperclip version or commit

Current `master` before this change.

### Deployment mode

Local dev (`pnpm dev`).

### Installation method

Built from source (`pnpm dev` / `pnpm build`).

### Agent adapter(s) involved

- [x] Not adapter-specific (core bug)

### Database mode

Not database-related.

### Access context

Board (human operator).

### Node.js version

Not version-specific.

### Operating system

Not OS-specific.

### Relevant logs or output

No logs; this is client-side selector behavior.

### Relevant config (if applicable)

None.

### Additional context

This PR also keeps the reusable workspace selector popover inside the
modal and contains command-list scroll events to keep the dialog
interaction stable.

### Privacy checklist

- [x] I have reviewed all pasted output for PII (usernames, file paths,
API keys, tokens, company names) and redacted where necessary.

## What Changed

- Added fuzzy scoring helpers for searchable text fields, including
field weights for visible labels versus secondary search metadata.
- Updated `SearchableSelect` to sort filtered results by score while
preserving original order for ties and custom filters.
- Updated reusable execution workspace matching to prefer visible
labels, then descriptions, then hidden search text.
- Kept the reusable workspace selector popover inside the new issue
modal and contained wheel/touch scrolling in the command list.
- Added unit/component coverage for selector ranking, reusable workspace
matching, modal popover containment, and scroll containment classes.

## Verification

- `pnpm exec vitest run ui/src/lib/searchable-select.ts
ui/src/lib/reusable-execution-workspaces.test.ts
ui/src/components/SearchableSelect.test.tsx
ui/src/components/NewIssueDialog.test.tsx`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

Low risk. The change is scoped to client-side searchable selector
ranking and modal popover behavior. The main behavior shift is that
searches are intentionally less permissive for unrelated fuzzy letter
spreads, which should reduce noisy results but could hide a result
someone previously reached through very loose matching.

## Model Used

OpenAI Codex, GPT-5-based coding agent with repository file access,
shell/tool execution, and medium reasoning effort. Exact hosted model
build and context window were not surfaced in this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-06-24 13:21:32 -05:00

120 lines
3.4 KiB
TypeScript

export function normalizeSearchText(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, " ");
}
export interface FuzzySearchField {
text: string | null | undefined;
weight?: number;
}
function searchWords(value: string): string[] {
return normalizeSearchText(value).match(/[\p{L}\p{N}]+/gu) ?? [];
}
function fuzzyWordSubsequenceScore(word: string, token: string): number | null {
if (token.length < 3) return null;
let tokenIndex = 0;
let firstIndex = -1;
let lastIndex = -1;
let gaps = 0;
for (let wordIndex = 0; wordIndex < word.length && tokenIndex < token.length; wordIndex += 1) {
if (word[wordIndex] !== token[tokenIndex]) continue;
if (firstIndex === -1) {
firstIndex = wordIndex;
} else {
gaps += wordIndex - lastIndex - 1;
}
lastIndex = wordIndex;
tokenIndex += 1;
}
if (tokenIndex !== token.length) return null;
const spread = lastIndex - firstIndex + 1;
if (spread > token.length * 2) return null;
return 32 + gaps * 2 + firstIndex + Math.max(0, word.length - token.length) * 0.5;
}
function scoreTokenAgainstText(text: string, token: string): number | null {
if (!token) return 0;
const words = searchWords(text);
if (words.length === 0) return null;
let bestScore = Number.POSITIVE_INFINITY;
words.forEach((word, wordIndex) => {
let score: number | null = null;
if (word === token) {
score = 0;
} else if (word.startsWith(token)) {
score = 8 + (word.length - token.length) * 0.25;
} else {
const substringIndex = word.indexOf(token);
if (substringIndex >= 0) {
score = 16 + substringIndex + (word.length - token.length) * 0.1;
} else {
score = fuzzyWordSubsequenceScore(word, token);
}
}
if (score !== null) {
bestScore = Math.min(bestScore, score + wordIndex * 0.05);
}
});
const compactText = words.join("");
const compactIndex = compactText.indexOf(token);
if (compactIndex >= 0) {
bestScore = Math.min(bestScore, 24 + compactIndex * 0.05);
}
const initials = words.map((word) => word[0]).join("");
if (token.length <= 4) {
if (initials.startsWith(token)) {
bestScore = Math.min(bestScore, 28 + (initials.length - token.length) * 0.25);
} else {
const initialsIndex = initials.indexOf(token);
if (initialsIndex >= 0) {
bestScore = Math.min(bestScore, 36 + initialsIndex);
}
}
}
return Number.isFinite(bestScore) ? bestScore : null;
}
export function scoreFuzzyTextFields(fields: readonly FuzzySearchField[], query: string): number | null {
const queryTokens = searchWords(query);
if (queryTokens.length === 0) return 0;
let totalScore = 0;
for (const token of queryTokens) {
let bestTokenScore = Number.POSITIVE_INFINITY;
fields.forEach((field, fieldIndex) => {
const text = field.text ?? "";
const score = scoreTokenAgainstText(text, token);
if (score === null) return;
bestTokenScore = Math.min(bestTokenScore, score + (field.weight ?? fieldIndex * 20));
});
if (!Number.isFinite(bestTokenScore)) return null;
totalScore += bestTokenScore;
}
return totalScore;
}
export function scoreFuzzyTextMatch(text: string, query: string): number | null {
return scoreFuzzyTextFields([{ text }], query);
}
export function fuzzyTextMatchesQuery(text: string, query: string): boolean {
return scoreFuzzyTextMatch(text, query) !== null;
}