mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
## 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>
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildReusableExecutionWorkspaceOptionGroups,
|
|
orderReusableExecutionWorkspaces,
|
|
reusableWorkspaceOptionMatches,
|
|
scoreReusableWorkspaceOptionMatch,
|
|
type ReusableExecutionWorkspaceLike,
|
|
} from "./reusable-execution-workspaces";
|
|
|
|
function workspace(overrides: Partial<ReusableExecutionWorkspaceLike>): ReusableExecutionWorkspaceLike {
|
|
return {
|
|
id: overrides.id ?? "workspace-id",
|
|
name: overrides.name ?? "Workspace",
|
|
cwd: overrides.cwd ?? null,
|
|
lastUsedAt: overrides.lastUsedAt ?? "2026-01-01T00:00:00.000Z",
|
|
status: overrides.status,
|
|
branchName: overrides.branchName,
|
|
};
|
|
}
|
|
|
|
describe("orderReusableExecutionWorkspaces", () => {
|
|
it("puts the most recently used workspace first and sorts the rest alphabetically", () => {
|
|
const workspaces = [
|
|
workspace({ id: "charlie", name: "Charlie", lastUsedAt: "2026-01-03T00:00:00.000Z" }),
|
|
workspace({ id: "zulu", name: "Zulu", lastUsedAt: "2026-01-05T00:00:00.000Z" }),
|
|
workspace({ id: "alpha", name: "Alpha", lastUsedAt: "2026-01-01T00:00:00.000Z" }),
|
|
workspace({ id: "bravo", name: "Bravo", lastUsedAt: "2026-01-04T00:00:00.000Z" }),
|
|
];
|
|
|
|
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
|
|
"zulu",
|
|
"alpha",
|
|
"bravo",
|
|
"charlie",
|
|
]);
|
|
});
|
|
|
|
it("keeps only the latest used workspace for duplicate paths before sorting", () => {
|
|
const workspaces = [
|
|
workspace({
|
|
id: "older-duplicate",
|
|
name: "Older duplicate",
|
|
cwd: "/tmp/shared",
|
|
lastUsedAt: "2026-01-01T00:00:00.000Z",
|
|
}),
|
|
workspace({ id: "beta", name: "Beta", cwd: "/tmp/beta", lastUsedAt: "2026-01-02T00:00:00.000Z" }),
|
|
workspace({
|
|
id: "newer-duplicate",
|
|
name: "Newer duplicate",
|
|
cwd: "/tmp/shared",
|
|
lastUsedAt: "2026-01-04T00:00:00.000Z",
|
|
}),
|
|
workspace({ id: "alpha", name: "Alpha", cwd: "/tmp/alpha", lastUsedAt: "2026-01-03T00:00:00.000Z" }),
|
|
];
|
|
|
|
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
|
|
"newer-duplicate",
|
|
"alpha",
|
|
"beta",
|
|
]);
|
|
});
|
|
|
|
it("does not let updatedAt churn outrank the last used workspace", () => {
|
|
type WorkspaceWithUpdatedAt = ReusableExecutionWorkspaceLike & { updatedAt: Date | string };
|
|
const workspaces: WorkspaceWithUpdatedAt[] = [
|
|
{
|
|
...workspace({
|
|
id: "recently-used",
|
|
name: "Recently used",
|
|
cwd: "/tmp/shared",
|
|
lastUsedAt: "2026-01-04T00:00:00.000Z",
|
|
}),
|
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
{
|
|
...workspace({
|
|
id: "recently-updated",
|
|
name: "Recently updated",
|
|
cwd: "/tmp/shared",
|
|
lastUsedAt: "2026-01-01T00:00:00.000Z",
|
|
}),
|
|
updatedAt: "2026-01-05T00:00:00.000Z",
|
|
},
|
|
];
|
|
|
|
expect(orderReusableExecutionWorkspaces(workspaces).map((item) => item.id)).toEqual([
|
|
"recently-used",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("buildReusableExecutionWorkspaceOptionGroups", () => {
|
|
const now = "2026-01-10T12:00:00.000Z";
|
|
|
|
it("deduplicates by cwd and keeps the latest used workspace", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({
|
|
id: "older",
|
|
name: "Older",
|
|
cwd: "/repo/shared",
|
|
lastUsedAt: "2026-01-09T00:00:00.000Z",
|
|
}),
|
|
workspace({
|
|
id: "newer",
|
|
name: "Newer",
|
|
cwd: "/repo/shared",
|
|
lastUsedAt: "2026-01-10T00:00:00.000Z",
|
|
}),
|
|
workspace({
|
|
id: "other",
|
|
name: "Other",
|
|
cwd: "/repo/other",
|
|
lastUsedAt: "2026-01-08T00:00:00.000Z",
|
|
}),
|
|
], { now });
|
|
|
|
expect(groups.flatMap((group) => group.options.map((option) => option.workspaceId))).toEqual([
|
|
"newer",
|
|
"other",
|
|
"newer",
|
|
"other",
|
|
]);
|
|
});
|
|
|
|
it("orders recent by last used and all workspaces by name", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({ id: "charlie", name: "Charlie", lastUsedAt: "2026-01-09T00:00:00.000Z" }),
|
|
workspace({ id: "alpha", name: "Alpha", lastUsedAt: "2026-01-07T13:00:00.000Z" }),
|
|
workspace({ id: "bravo", name: "Bravo", lastUsedAt: "2026-01-10T00:00:00.000Z" }),
|
|
], { now });
|
|
|
|
expect(groups.find((group) => group.id === "recent")?.options.map((option) => option.workspaceId)).toEqual([
|
|
"bravo",
|
|
"charlie",
|
|
"alpha",
|
|
]);
|
|
expect(groups.find((group) => group.id === "all")?.options.map((option) => option.workspaceId)).toEqual([
|
|
"alpha",
|
|
"bravo",
|
|
"charlie",
|
|
]);
|
|
});
|
|
|
|
it("includes workspaces used exactly at the 3-day cutoff and excludes older workspaces", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({
|
|
id: "boundary",
|
|
name: "Boundary",
|
|
lastUsedAt: "2026-01-07T12:00:00.000Z",
|
|
}),
|
|
workspace({
|
|
id: "older",
|
|
name: "Older",
|
|
lastUsedAt: "2026-01-07T11:59:59.999Z",
|
|
}),
|
|
], { now });
|
|
|
|
expect(groups.find((group) => group.id === "recent")?.options.map((option) => option.workspaceId)).toEqual([
|
|
"boundary",
|
|
]);
|
|
expect(groups.find((group) => group.id === "all")?.options.map((option) => option.workspaceId)).toEqual([
|
|
"boundary",
|
|
"older",
|
|
]);
|
|
});
|
|
|
|
it("keys duplicate recent and all appearances by group while keeping the selected value as workspace id", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({
|
|
id: "workspace-1",
|
|
name: "Workspace 1",
|
|
lastUsedAt: "2026-01-10T00:00:00.000Z",
|
|
}),
|
|
], { now });
|
|
|
|
expect(groups.flatMap((group) => group.options.map((option) => [option.key, option.value]))).toEqual([
|
|
["recent:workspace-1", "workspace-1"],
|
|
["all:workspace-1", "workspace-1"],
|
|
]);
|
|
});
|
|
|
|
it("builds stable display and search metadata", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({
|
|
id: "workspace-1",
|
|
name: "Paperclip app",
|
|
cwd: "/repo/paperclip",
|
|
branchName: "feature/workspaces",
|
|
status: "active",
|
|
lastUsedAt: "2026-01-10T00:00:00.000Z",
|
|
}),
|
|
], { now });
|
|
|
|
const option = groups[0]!.options[0]!;
|
|
expect(option.label).toBe("Paperclip app");
|
|
expect(option.description).toBe("feature/workspaces");
|
|
expect(option.searchText).toBe("Paperclip app active feature/workspaces /repo/paperclip workspace-1");
|
|
});
|
|
|
|
it("matches workspace options with fuzzy query tokens", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({
|
|
id: "workspace-1",
|
|
name: "Paperclip app",
|
|
cwd: "/srv/paperclip",
|
|
branchName: "feature/reusable-workspaces",
|
|
status: "active",
|
|
lastUsedAt: "2026-01-10T00:00:00.000Z",
|
|
}),
|
|
], { now });
|
|
|
|
const option = groups[0]!.options[0]!;
|
|
expect(reusableWorkspaceOptionMatches(option, "pclip reusable")).toBe(true);
|
|
expect(reusableWorkspaceOptionMatches(option, "inactive")).toBe(false);
|
|
});
|
|
|
|
it("does not match query letters spread across unrelated workspace text", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({
|
|
id: "routine-bodies",
|
|
name: "PAP-11694-editing-routine-bodies-should-have-revision-tracking",
|
|
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11694-editing-routine-bodies",
|
|
branchName: "PAP-11694-editing-routine-bodies-should-have-revision-tracking",
|
|
status: "active",
|
|
lastUsedAt: "2026-01-10T00:00:00.000Z",
|
|
}),
|
|
workspace({
|
|
id: "mobile-agent-chat",
|
|
name: "PAP-11446-on-mobile-the-agent-chat-shouldn-t-hone-indented",
|
|
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11446-on-mobile-agent-chat",
|
|
branchName: "PAP-11446-on-mobile-the-agent-chat-shouldnt-hone-indented",
|
|
status: "active",
|
|
lastUsedAt: "2026-01-09T00:00:00.000Z",
|
|
}),
|
|
workspace({
|
|
id: "simultaneous-work",
|
|
name: "PAP-11429-why-are-these-live-simultaneously",
|
|
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11429-live-simultaneously",
|
|
branchName: "PAP-11429-why-are-these-live-simultaneously",
|
|
status: "active",
|
|
lastUsedAt: "2026-01-08T00:00:00.000Z",
|
|
}),
|
|
], { now });
|
|
|
|
const options = groups.flatMap((group) => group.options);
|
|
const unrelated = options.find((option) => option.workspaceId === "routine-bodies")!;
|
|
const mobile = options.find((option) => option.workspaceId === "mobile-agent-chat")!;
|
|
const simultaneous = options.find((option) => option.workspaceId === "simultaneous-work")!;
|
|
|
|
expect(reusableWorkspaceOptionMatches(unrelated, "mobile")).toBe(false);
|
|
expect(reusableWorkspaceOptionMatches(unrelated, "simultan")).toBe(false);
|
|
expect(reusableWorkspaceOptionMatches(mobile, "mobile")).toBe(true);
|
|
expect(reusableWorkspaceOptionMatches(simultaneous, "simultan")).toBe(true);
|
|
});
|
|
|
|
it("scores visible label matches ahead of hidden path matches", () => {
|
|
const groups = buildReusableExecutionWorkspaceOptionGroups([
|
|
workspace({
|
|
id: "path-only-mobile",
|
|
name: "Paperclip app",
|
|
cwd: "/srv/paperclip/mobile-checkout",
|
|
branchName: "feature/workspace-reuse",
|
|
lastUsedAt: "2026-01-10T00:00:00.000Z",
|
|
}),
|
|
workspace({
|
|
id: "label-mobile",
|
|
name: "Mobile agent chat",
|
|
cwd: "/srv/paperclip/agent-chat",
|
|
branchName: "feature/agent-chat",
|
|
lastUsedAt: "2026-01-09T00:00:00.000Z",
|
|
}),
|
|
], { now });
|
|
|
|
const options = groups.flatMap((group) => group.options);
|
|
const pathOnly = options.find((option) => option.workspaceId === "path-only-mobile")!;
|
|
const label = options.find((option) => option.workspaceId === "label-mobile")!;
|
|
|
|
expect(scoreReusableWorkspaceOptionMatch(label, "mobile")).toBeLessThan(
|
|
scoreReusableWorkspaceOptionMatch(pathOnly, "mobile")!,
|
|
);
|
|
});
|
|
});
|