[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>
This commit is contained in:
Dotta 2026-06-24 13:21:32 -05:00 committed by GitHub
parent 51ffbb380f
commit 50ae8fc657
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1625 additions and 46 deletions

View File

@ -124,14 +124,22 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
describe("IssueWorkspaceCard", () => {
let container: HTMLDivElement;
let originalResizeObserver: typeof ResizeObserver | undefined;
beforeEach(() => {
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
container = document.createElement("div");
document.body.appendChild(container);
useQueryMock.mockReset();
});
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver!;
container.remove();
});
@ -180,7 +188,8 @@ describe("IssueWorkspaceCard", () => {
});
const selects = container.querySelectorAll("select");
expect(selects).toHaveLength(2);
expect(selects).toHaveLength(1);
expect(container.querySelector("button[role='combobox']")?.textContent).toContain("Issue sandbox");
const saveButton = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Save"));
expect(saveButton).not.toBeUndefined();
@ -243,7 +252,8 @@ describe("IssueWorkspaceCard", () => {
});
const selects = container.querySelectorAll("select");
expect(selects).toHaveLength(2);
expect(selects).toHaveLength(1);
expect(container.querySelector("button[role='combobox']")?.textContent).toContain("Issue sandbox");
expect(container.textContent).not.toContain("Project default environment");
act(() => {

View File

@ -7,10 +7,10 @@ import { environmentsApi } from "../api/environments";
import { instanceSettingsApi } from "../api/instanceSettings";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
import { cn, projectWorkspaceUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Check, Copy, FileSearch, FolderOpen, FolderSearch, GitBranch, Pencil, X } from "lucide-react";
import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSelect";
/* -------------------------------------------------------------------------- */
/* Utility helpers (mirrored from IssueProperties for self-containment) */
@ -228,7 +228,11 @@ export function IssueWorkspaceCard({
enabled: Boolean(companyId) && environmentsEnabled,
});
const { data: reusableExecutionWorkspaces } = useQuery({
const {
data: reusableExecutionWorkspaces,
isLoading: reusableExecutionWorkspacesLoading,
isError: reusableExecutionWorkspacesError,
} = useQuery({
queryKey: queryKeys.executionWorkspaces.list(companyId!, {
projectId: issue.projectId ?? undefined,
projectWorkspaceId: issue.projectWorkspaceId ?? undefined,
@ -243,12 +247,10 @@ export function IssueWorkspaceCard({
enabled: Boolean(companyId) && Boolean(issue.projectId) && editing,
});
const deduplicatedReusableWorkspaces = useMemo(() => {
return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []);
}, [reusableExecutionWorkspaces]);
const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? [];
const selectedReusableExecutionWorkspace =
deduplicatedReusableWorkspaces.find((w) => w.id === issue.executionWorkspaceId)
selectableReusableWorkspaces.find((w) => w.id === issue.executionWorkspaceId)
?? workspace
?? null;
@ -286,7 +288,7 @@ export function IssueWorkspaceCard({
const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace");
const configuredReusableWorkspace =
deduplicatedReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId)
selectableReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId)
?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null);
const selectedReusableWorkspaceLink = workspaceDetailLink({
@ -488,20 +490,13 @@ export function IssueWorkspaceCard({
</select>
{draftSelection === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
<ReusableExecutionWorkspaceSelect
value={draftExecutionWorkspaceId}
onChange={(e) => {
setDraftExecutionWorkspaceId(e.target.value);
}}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((w) => (
<option key={w.id} value={w.id}>
{w.name} · {w.status} · {w.branchName ?? w.cwd ?? w.id.slice(0, 8)}
</option>
))}
</select>
workspaces={selectableReusableWorkspaces}
onValueChange={(workspaceId) => setDraftExecutionWorkspaceId(workspaceId)}
loading={reusableExecutionWorkspacesLoading}
error={reusableExecutionWorkspacesError}
/>
)}
{/* Current workspace summary when editing */}

View File

@ -1454,8 +1454,9 @@ describe("IssuesList", () => {
await waitForAssertion(() => {
expect(container.querySelectorAll('[data-testid="issue-row"]')).toHaveLength(100);
});
await flush();
expect(onLoadMoreIssues).toHaveBeenCalledTimes(1);
await waitForAssertion(() => {
expect(onLoadMoreIssues).toHaveBeenCalledTimes(1);
});
await flush();
expect(onLoadMoreIssues).toHaveBeenCalledTimes(1);

View File

@ -221,7 +221,9 @@ vi.mock("@/components/ui/toggle-switch", () => ({
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverContent: ({ children, disablePortal }: { children: ReactNode; disablePortal?: boolean }) => (
<div data-disable-portal={String(Boolean(disablePortal))}>{children}</div>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -298,9 +300,16 @@ function renderDialog(container: HTMLDivElement) {
describe("NewIssueDialog", () => {
let container: HTMLDivElement;
let originalResizeObserver: typeof ResizeObserver | undefined;
beforeEach(() => {
vi.useRealTimers();
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
container = document.createElement("div");
document.body.appendChild(container);
dialogState.newIssueOpen = true;
@ -337,6 +346,7 @@ describe("NewIssueDialog", () => {
});
afterEach(() => {
globalThis.ResizeObserver = originalResizeObserver!;
document.body.innerHTML = "";
});
@ -598,6 +608,57 @@ describe("NewIssueDialog", () => {
act(() => root.unmount());
});
it("keeps the reusable workspace search popover inside the modal", async () => {
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
name: "Alpha",
description: null,
archivedAt: null,
color: "#445566",
workspaces: [
{
id: "project-workspace-1",
name: "Primary",
isPrimary: true,
},
],
executionWorkspacePolicy: {
enabled: true,
defaultMode: "shared_workspace",
},
},
]);
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "PAP-11446-on-mobile-the-agent-chat",
mode: "isolated_workspace",
status: "active",
branchName: "PAP-11446-on-mobile-the-agent-chat",
cwd: "/tmp/workspace-1",
projectWorkspaceId: "project-workspace-1",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
dialogState.newIssueDefaults = {
title: "Follow-up issue",
projectId: "project-1",
executionWorkspaceId: "workspace-1",
};
const { root } = renderDialog(container);
await flush();
await waitForAssertion(() => {
const workspaceInput = container.querySelector('input[placeholder="Search workspaces..."]');
expect(workspaceInput?.closest("[data-disable-portal]")?.getAttribute("data-disable-portal")).toBe("true");
});
act(() => root.unmount());
});
it("submits the latest locally typed title and description", async () => {
let resolveProjects: (projects: Array<{
id: string;

View File

@ -15,7 +15,6 @@ import { authApi } from "../api/auth";
import { assetsApi } from "../api/assets";
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
@ -72,6 +71,7 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { AgentIcon } from "./AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { getTrustPreset } from "../lib/trust-policy-ui";
import { ReusableExecutionWorkspaceSelect } from "./ReusableExecutionWorkspaceSelect";
const DRAFT_KEY = "paperclip:issue-draft";
const DEBOUNCE_MS = 800;
@ -477,7 +477,11 @@ export function NewIssueDialog() {
queryFn: () => projectsApi.list(effectiveCompanyId!),
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: reusableExecutionWorkspaces } = useQuery({
const {
data: reusableExecutionWorkspaces,
isLoading: reusableExecutionWorkspacesLoading,
isError: reusableExecutionWorkspacesError,
} = useQuery({
queryKey: queryKeys.executionWorkspaces.summaryList(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
@ -991,7 +995,7 @@ export function NewIssueDialog() {
experimentalSettings?.enableIsolatedWorkspaces === true
? selectedProject?.executionWorkspacePolicy ?? null
: null;
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
const selectedReusableExecutionWorkspace = selectableReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const requestedExecutionWorkspaceMode =
@ -1121,10 +1125,8 @@ export function NewIssueDialog() {
: null;
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
const deduplicatedReusableWorkspaces = useMemo(() => {
return orderReusableExecutionWorkspaces(reusableExecutionWorkspaces ?? []);
}, [reusableExecutionWorkspaces]);
const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find(
const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? [];
const selectedReusableExecutionWorkspace = selectableReusableWorkspaces.find(
(workspace) => workspace.id === selectedExecutionWorkspaceId,
);
const isUsingParentExecutionWorkspace = isSubIssueMode && parentExecutionWorkspaceId
@ -1778,18 +1780,14 @@ export function NewIssueDialog() {
))}
</select>
{executionWorkspaceMode === "reuse_existing" && (
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
<ReusableExecutionWorkspaceSelect
value={selectedExecutionWorkspaceId}
onChange={(e) => setSelectedExecutionWorkspaceId(e.target.value)}
>
<option value="">Choose an existing workspace</option>
{deduplicatedReusableWorkspaces.map((workspace) => (
<option key={workspace.id} value={workspace.id}>
{workspace.name} · {workspace.status} · {workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8)}
</option>
))}
</select>
workspaces={selectableReusableWorkspaces}
onValueChange={(workspaceId) => setSelectedExecutionWorkspaceId(workspaceId)}
loading={reusableExecutionWorkspacesLoading}
error={reusableExecutionWorkspacesError}
disablePortal
/>
)}
{executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
<div className="text-[11px] text-muted-foreground">

View File

@ -0,0 +1,68 @@
import { useMemo } from "react";
import { SearchableSelect } from "@/components/SearchableSelect";
import {
buildReusableExecutionWorkspaceOptionGroups,
reusableWorkspaceOptionMatches,
scoreReusableWorkspaceOptionMatch,
type ReusableExecutionWorkspaceLike,
type ReusableWorkspaceOption,
} from "@/lib/reusable-execution-workspaces";
import { cn } from "@/lib/utils";
const COMPACT_TRIGGER_CLASS = "h-8 px-2 py-1.5 text-xs font-normal";
interface ReusableExecutionWorkspaceSelectProps<TWorkspace extends ReusableExecutionWorkspaceLike> {
value: string;
workspaces: readonly TWorkspace[];
onValueChange: (workspaceId: string, option: ReusableWorkspaceOption<TWorkspace>) => void;
placeholder?: string;
loading?: boolean;
error?: boolean;
disabled?: boolean;
className?: string;
triggerClassName?: string;
disablePortal?: boolean;
}
export function ReusableExecutionWorkspaceSelect<TWorkspace extends ReusableExecutionWorkspaceLike>({
value,
workspaces,
onValueChange,
placeholder = "Choose an existing workspace",
loading = false,
error = false,
disabled = false,
className,
triggerClassName,
disablePortal,
}: ReusableExecutionWorkspaceSelectProps<TWorkspace>) {
const groups = useMemo(() => buildReusableExecutionWorkspaceOptionGroups(workspaces), [workspaces]);
return (
<SearchableSelect<string, ReusableWorkspaceOption<TWorkspace>>
value={value}
groups={groups}
onValueChange={onValueChange}
placeholder={placeholder}
searchPlaceholder="Search workspaces..."
emptyMessage={error ? "Workspaces failed to load." : "No matching workspaces."}
loadingMessage="Loading workspaces..."
loading={loading}
disabled={disabled}
className={className}
triggerClassName={cn(COMPACT_TRIGGER_CLASS, triggerClassName)}
filterOption={reusableWorkspaceOptionMatches}
scoreOption={scoreReusableWorkspaceOptionMatch}
disablePortal={disablePortal}
renderOption={(option, { selected }) => (
<span className="flex min-w-0 flex-col">
<span className={cn("truncate", selected && "font-medium")}>{option.label}</span>
<span className="truncate text-[11px] text-muted-foreground">
{option.workspace.status ? `${option.workspace.status} - ` : ""}
{option.description}
</span>
</span>
)}
/>
);
}

View File

@ -0,0 +1,486 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SearchableSelect, type SearchableSelectGroup, type SearchableSelectOption } from "./SearchableSelect";
import {
buildReusableExecutionWorkspaceOptionGroups,
reusableWorkspaceOptionMatches,
type ReusableExecutionWorkspaceLike,
type ReusableWorkspaceOption,
} from "@/lib/reusable-execution-workspaces";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => {
result = callback();
});
return result;
}
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
function render(node: ReactNode, container: HTMLElement) {
const root = createRoot(container);
act(() => {
root.render(node);
});
return root;
}
function setInputValue(input: HTMLInputElement, value: string) {
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
});
}
function keyDown(target: Element, key: string) {
act(() => {
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
});
}
function workspace(overrides: Partial<ReusableExecutionWorkspaceLike>): ReusableExecutionWorkspaceLike {
return {
id: overrides.id ?? "workspace-id",
name: overrides.name ?? "Workspace",
cwd: overrides.cwd ?? null,
lastUsedAt: overrides.lastUsedAt ?? "2026-06-24T00:00:00.000Z",
status: overrides.status,
branchName: overrides.branchName,
};
}
function buildWorkspaceSelectGroups(workspaces: readonly ReusableExecutionWorkspaceLike[]) {
return buildReusableExecutionWorkspaceOptionGroups(workspaces, {
now: "2026-06-24T12:00:00.000Z",
}).map((group) => ({
id: group.id,
label: group.label,
options: group.options,
})) satisfies SearchableSelectGroup<string, ReusableWorkspaceOption>[];
}
describe("SearchableSelect", () => {
let container: HTMLDivElement;
let root: Root | null;
let originalResizeObserver: typeof ResizeObserver | undefined;
beforeEach(() => {
originalResizeObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
container = document.createElement("div");
document.body.appendChild(container);
root = null;
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
globalThis.ResizeObserver = originalResizeObserver!;
container.remove();
document.body.innerHTML = "";
});
it("renders grouped duplicate options while keeping selection by value", async () => {
const onValueChange = vi.fn();
const alpha: SearchableSelectOption = { key: "recent:alpha", value: "alpha", label: "Alpha" };
const groups: SearchableSelectGroup[] = [
{ id: "recent", label: "Recent", options: [alpha] },
{ id: "all", label: "All", options: [{ ...alpha, key: "all:alpha" }] },
];
root = render(
<SearchableSelect
value="alpha"
groups={groups}
onValueChange={onValueChange}
placeholder="Pick one"
disablePortal
renderOption={(option) => <span data-option-key={option.key}>{option.label}</span>}
/>,
container,
);
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
expect(trigger?.textContent).toContain("Alpha");
act(() => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await flush();
expect(container.querySelector("[data-option-key='recent:alpha']")).not.toBeNull();
expect(container.querySelector("[data-option-key='all:alpha']")).not.toBeNull();
});
it("filters options and returns the selected option object", async () => {
const onValueChange = vi.fn();
const bravo = { key: "all:bravo", value: "bravo", label: "Bravo", searchText: "secondary branch" };
const groups: SearchableSelectGroup[] = [
{
id: "all",
label: "All",
options: [
{ key: "all:alpha", value: "alpha", label: "Alpha", searchText: "primary branch" },
bravo,
],
},
];
root = render(
<SearchableSelect
value=""
groups={groups}
onValueChange={onValueChange}
placeholder="Pick one"
searchPlaceholder="Search options..."
disablePortal
/>,
container,
);
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
act(() => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await flush();
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
expect(input).not.toBeNull();
setInputValue(input!, "sec br");
await flush();
expect(container.textContent).not.toContain("Alpha");
expect(container.textContent).toContain("Bravo");
const bravoItem = Array.from(container.querySelectorAll("[cmdk-item]")).find((item) => item.textContent?.includes("Bravo"));
expect(bravoItem).not.toBeUndefined();
act(() => {
bravoItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
expect(onValueChange).toHaveBeenCalledWith("bravo", bravo);
});
it("ranks visible label matches ahead of lower-quality search text matches", async () => {
const onValueChange = vi.fn();
const groups: SearchableSelectGroup[] = [
{
id: "all",
label: "All",
options: [
{
key: "all:path-only",
value: "path-only",
label: "Paperclip app",
searchText: "/srv/paperclip/mobile-checkout",
},
{
key: "all:mobile",
value: "mobile",
label: "Mobile agent chat",
searchText: "/srv/paperclip/agent-chat",
},
],
},
];
root = render(
<SearchableSelect
value=""
groups={groups}
onValueChange={onValueChange}
placeholder="Pick one"
searchPlaceholder="Search options..."
disablePortal
renderOption={(option) => <span data-option-key={option.key}>{option.label}</span>}
/>,
container,
);
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
act(() => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await flush();
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
expect(input).not.toBeNull();
setInputValue(input!, "mobile");
await flush();
const renderedKeys = Array.from(container.querySelectorAll("[data-option-key]")).map((item) =>
item.getAttribute("data-option-key"),
);
expect(renderedKeys).toEqual(["all:mobile", "all:path-only"]);
const commandList = container.querySelector("[data-slot='command-list']");
expect(commandList?.className).toContain("overscroll-contain");
expect(commandList?.className).toContain("touch-pan-y");
});
it("applies custom filtering before custom scoring", async () => {
const groups: SearchableSelectGroup[] = [
{
id: "all",
options: [
{ key: "all:alpha", value: "alpha", label: "Alpha", searchText: "visible" },
{ key: "all:hidden", value: "hidden", label: "Hidden", searchText: "visible" },
],
},
];
root = render(
<SearchableSelect
value=""
groups={groups}
onValueChange={vi.fn()}
placeholder="Pick one"
searchPlaceholder="Search options..."
disablePortal
filterOption={(option, query) => option.value !== "hidden" && option.searchText === query}
scoreOption={() => 0}
renderOption={(option) => <span data-option-key={option.key}>{option.label}</span>}
/>,
container,
);
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
act(() => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await flush();
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
expect(input).not.toBeNull();
setInputValue(input!, "visible");
await flush();
const renderedKeys = Array.from(container.querySelectorAll("[data-option-key]")).map((item) =>
item.getAttribute("data-option-key"),
);
expect(renderedKeys).toEqual(["all:alpha"]);
});
it("shows loading, empty, and disabled states", async () => {
const onValueChange = vi.fn();
root = render(
<SearchableSelect
value=""
groups={[{ id: "all", options: [{ key: "all:alpha", value: "alpha", label: "Alpha" }] }]}
onValueChange={onValueChange}
placeholder="Pick one"
loading
loadingMessage="Loading choices..."
disablePortal
/>,
container,
);
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
act(() => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await flush();
expect(container.textContent).toContain("Loading choices...");
act(() => {
root?.render(
<SearchableSelect
value=""
groups={[{ id: "all", options: [{ key: "all:alpha", value: "alpha", label: "Alpha" }] }]}
onValueChange={onValueChange}
placeholder="Pick one"
searchPlaceholder="Search options..."
emptyMessage="Nothing matched."
disablePortal
/>,
);
});
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
expect(input).not.toBeNull();
setInputValue(input!, "zzz");
await flush();
expect(container.textContent).toContain("Nothing matched.");
act(() => {
root?.render(
<SearchableSelect
value=""
groups={[]}
onValueChange={onValueChange}
placeholder="Pick one"
disabled
disablePortal
/>,
);
});
expect(container.querySelector("button[role='combobox']")?.hasAttribute("disabled")).toBe(true);
});
it("opens on focus and closes with Escape", async () => {
root = render(
<SearchableSelect
value=""
groups={[{ id: "all", options: [{ key: "all:alpha", value: "alpha", label: "Alpha" }] }]}
onValueChange={vi.fn()}
placeholder="Pick one"
searchPlaceholder="Search options..."
disablePortal
/>,
container,
);
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
expect(trigger).not.toBeNull();
act(() => {
trigger?.focus();
});
await flush();
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
expect(input).not.toBeNull();
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
keyDown(input!, "Escape");
await flush();
expect(container.querySelector("input[placeholder='Search options...']")).toBeNull();
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
act(() => {
trigger?.dispatchEvent(new Event("pointerdown", { bubbles: true, cancelable: true }));
trigger?.focus();
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await flush();
expect(container.querySelector("input[placeholder='Search options...']")).not.toBeNull();
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
const reopenedInput = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
expect(reopenedInput).not.toBeNull();
setInputValue(reopenedInput!, "alp");
await flush();
keyDown(reopenedInput!, "Escape");
await flush();
expect(container.querySelector("input[placeholder='Search options...']")).toBeNull();
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
});
it("filters workspace options, moves with arrows, and selects the workspace id with Enter", async () => {
const onValueChange = vi.fn();
const groups = buildWorkspaceSelectGroups([
workspace({
id: "workspace-paperclip",
name: "Paperclip app",
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11722-new-existing-workspace-selector",
branchName: "feature/reusable-workspaces",
status: "running",
lastUsedAt: "2026-06-24T10:00:00.000Z",
}),
workspace({
id: "workspace-marketing",
name: "Marketing site",
cwd: "/srv/paperclip/home/marketing-site",
branchName: "landing-refresh",
status: "idle",
lastUsedAt: "2026-06-20T10:00:00.000Z",
}),
]);
root = render(
<SearchableSelect<string, ReusableWorkspaceOption>
value=""
groups={groups}
onValueChange={onValueChange}
placeholder="Choose an existing workspace"
searchPlaceholder="Search workspaces..."
filterOption={(option, query) => reusableWorkspaceOptionMatches(option, query)}
disablePortal
renderOption={(option, { selected }) => (
<span data-option-key={option.key} data-selected={String(selected)}>
{option.label}
</span>
)}
/>,
container,
);
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
act(() => {
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
});
await flush();
const input = container.querySelector("input[placeholder='Search workspaces...']") as HTMLInputElement | null;
expect(input).not.toBeNull();
expect(container.textContent).toContain("Recent");
expect(container.textContent).toContain("All workspaces");
setInputValue(input!, "pclip reusable");
await flush();
expect(container.textContent).toContain("Paperclip app");
expect(container.textContent).not.toContain("Marketing site");
const selectedOptionKey = () => (
container.querySelector("[cmdk-item][aria-selected='true'] [data-option-key]")?.getAttribute("data-option-key")
);
expect(selectedOptionKey()).toBe("recent:workspace-paperclip");
keyDown(input!, "ArrowDown");
await flush();
expect(selectedOptionKey()).toBe("all:workspace-paperclip");
keyDown(input!, "ArrowUp");
await flush();
expect(selectedOptionKey()).toBe("recent:workspace-paperclip");
keyDown(input!, "ArrowDown");
await flush();
keyDown(input!, "Enter");
await flush();
expect(onValueChange).toHaveBeenCalledWith(
"workspace-paperclip",
expect.objectContaining({
key: "all:workspace-paperclip",
value: "workspace-paperclip",
workspaceId: "workspace-paperclip",
}),
);
expect(container.querySelector("input[placeholder='Search workspaces...']")).toBeNull();
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
act(() => {
trigger?.focus();
});
await flush();
expect(container.querySelector("input[placeholder='Search workspaces...']")).toBeNull();
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
});
});

View File

@ -0,0 +1,265 @@
import { Check, ChevronsUpDown } from "lucide-react";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { fuzzyTextMatchesQuery, normalizeSearchText, scoreFuzzyTextFields } from "@/lib/searchable-select";
import { cn } from "@/lib/utils";
export interface SearchableSelectOption<TValue extends string = string> {
key: string;
value: TValue;
label: string;
searchText?: string;
disabled?: boolean;
}
export interface SearchableSelectGroup<TValue extends string = string, TOption extends SearchableSelectOption<TValue> = SearchableSelectOption<TValue>> {
id: string;
label?: string;
options: readonly TOption[];
}
interface SearchableSelectRenderState {
selected: boolean;
}
export interface SearchableSelectProps<
TValue extends string = string,
TOption extends SearchableSelectOption<TValue> = SearchableSelectOption<TValue>,
> {
value: TValue | "";
groups: readonly SearchableSelectGroup<TValue, TOption>[];
onValueChange: (value: TValue, option: TOption) => void;
placeholder: string;
searchPlaceholder?: string;
emptyMessage?: string;
loadingMessage?: string;
loading?: boolean;
disabled?: boolean;
className?: string;
triggerClassName?: string;
contentClassName?: string;
align?: "start" | "center" | "end";
contentWidth?: "trigger" | "auto";
renderValue?: (option: TOption | null) => ReactNode;
renderOption?: (option: TOption, state: SearchableSelectRenderState) => ReactNode;
filterOption?: (option: TOption, query: string) => boolean;
scoreOption?: (option: TOption, query: string) => number | null;
disablePortal?: boolean;
}
function defaultFilterOption(option: SearchableSelectOption, query: string) {
return fuzzyTextMatchesQuery(`${option.label} ${option.searchText ?? ""}`, query);
}
function defaultScoreOption(option: SearchableSelectOption, query: string) {
return scoreFuzzyTextFields([
{ text: option.label, weight: 0 },
{ text: option.searchText, weight: 20 },
], query);
}
export function SearchableSelect<
TValue extends string = string,
TOption extends SearchableSelectOption<TValue> = SearchableSelectOption<TValue>,
>({
value,
groups,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found.",
loadingMessage = "Loading...",
loading = false,
disabled = false,
className,
triggerClassName,
contentClassName,
align = "start",
contentWidth = "trigger",
renderValue,
renderOption,
filterOption = defaultFilterOption,
scoreOption,
disablePortal,
}: SearchableSelectProps<TValue, TOption>) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const pointerFocusRef = useRef(false);
const suppressNextTriggerFocusRef = useRef(false);
const selectedOption = useMemo(() => {
for (const group of groups) {
const option = group.options.find((candidate) => candidate.value === value);
if (option) return option;
}
return null;
}, [groups, value]);
const filteredGroups = useMemo(() => {
if (loading) return [];
const normalizedQuery = normalizeSearchText(query);
return groups
.map((group) => {
const options = group.options
.map((option, index) => {
if (!normalizedQuery) return { option, index, score: 0 };
if (!filterOption(option, query)) return null;
if (scoreOption) {
const score = scoreOption(option, query);
return score === null ? null : { option, index, score };
}
return {
option,
index,
score: defaultScoreOption(option, query) ?? Number.MAX_SAFE_INTEGER,
};
})
.filter((entry): entry is { option: TOption; index: number; score: number } => entry !== null);
if (normalizedQuery) {
options.sort((a, b) => a.score - b.score || a.index - b.index);
}
return {
...group,
options: options.map((entry) => entry.option),
};
})
.filter((group) => group.options.length > 0);
}, [filterOption, groups, loading, query, scoreOption]);
const hasOptions = filteredGroups.some((group) => group.options.length > 0);
function closePopover({ suppressTriggerFocus = false }: { suppressTriggerFocus?: boolean } = {}) {
if (suppressTriggerFocus) {
suppressNextTriggerFocusRef.current = true;
}
setOpen(false);
setQuery("");
}
function selectOption(option: TOption) {
if (option.disabled) return;
suppressNextTriggerFocusRef.current = true;
onValueChange(option.value, option);
closePopover();
}
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
onPointerDown={() => {
pointerFocusRef.current = true;
}}
onFocus={() => {
const shouldIgnoreFocus = pointerFocusRef.current || suppressNextTriggerFocusRef.current;
pointerFocusRef.current = false;
suppressNextTriggerFocusRef.current = false;
if (shouldIgnoreFocus) {
return;
}
setOpen(true);
}}
onKeyDown={(event) => {
if (event.key === "Escape" && open) {
event.preventDefault();
closePopover();
}
}}
aria-expanded={open}
role="combobox"
className={cn("w-full justify-between overflow-hidden", className, triggerClassName)}
>
<span className={cn("min-w-0 truncate", !selectedOption && "text-muted-foreground")}>
{renderValue ? renderValue(selectedOption) : selectedOption?.label ?? placeholder}
</span>
<ChevronsUpDown className="ml-2 size-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
align={align}
collisionPadding={16}
disablePortal={disablePortal}
className={cn(
"p-0",
contentWidth === "trigger"
? "w-[var(--radix-popover-trigger-width)] min-w-56 max-w-[min(32rem,calc(100vw-2rem))]"
: "w-72 max-w-[min(32rem,calc(100vw-2rem))]",
contentClassName,
)}
onKeyDownCapture={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
closePopover({ suppressTriggerFocus: true });
}
}}
>
<Command shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={searchPlaceholder}
/>
<CommandList
className="overscroll-contain touch-pan-y"
onWheelCapture={(event) => {
if (event.currentTarget.scrollHeight > event.currentTarget.clientHeight) {
event.stopPropagation();
}
}}
>
{loading ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">{loadingMessage}</div>
) : !hasOptions ? (
<CommandEmpty>{emptyMessage}</CommandEmpty>
) : (
filteredGroups.map((group) => (
<CommandGroup key={group.id} heading={group.label}>
{group.options.map((option) => {
const selected = option.value === value;
return (
<CommandItem
key={option.key}
value={option.key}
disabled={option.disabled}
onSelect={() => selectOption(option)}
>
{renderOption
? renderOption(option, { selected })
: <span className="min-w-0 truncate">{option.label}</span>}
<Check className={cn("ml-auto size-4", selected ? "opacity-100" : "opacity-0")} />
</CommandItem>
);
})}
</CommandGroup>
))
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@ -1,5 +1,11 @@
import { describe, expect, it } from "vitest";
import { orderReusableExecutionWorkspaces, type ReusableExecutionWorkspaceLike } from "./reusable-execution-workspaces";
import {
buildReusableExecutionWorkspaceOptionGroups,
orderReusableExecutionWorkspaces,
reusableWorkspaceOptionMatches,
scoreReusableWorkspaceOptionMatch,
type ReusableExecutionWorkspaceLike,
} from "./reusable-execution-workspaces";
function workspace(overrides: Partial<ReusableExecutionWorkspaceLike>): ReusableExecutionWorkspaceLike {
return {
@ -7,6 +13,8 @@ function workspace(overrides: Partial<ReusableExecutionWorkspaceLike>): Reusable
name: overrides.name ?? "Workspace",
cwd: overrides.cwd ?? null,
lastUsedAt: overrides.lastUsedAt ?? "2026-01-01T00:00:00.000Z",
status: overrides.status,
branchName: overrides.branchName,
};
}
@ -80,3 +88,195 @@ describe("orderReusableExecutionWorkspaces", () => {
]);
});
});
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")!,
);
});
});

View File

@ -1,8 +1,33 @@
import { scoreFuzzyTextFields } from "./searchable-select";
export interface ReusableExecutionWorkspaceLike {
id: string;
name: string;
cwd: string | null;
lastUsedAt: Date | string;
status?: string;
branchName?: string | null;
}
const RECENT_WORKSPACE_CUTOFF_DAYS = 3;
export type ReusableWorkspaceOptionGroupId = "recent" | "all";
export interface ReusableWorkspaceOption<TWorkspace extends ReusableExecutionWorkspaceLike = ReusableExecutionWorkspaceLike> {
key: string;
value: string;
workspaceId: string;
groupId: ReusableWorkspaceOptionGroupId;
label: string;
description: string;
searchText: string;
workspace: TWorkspace;
}
export interface ReusableWorkspaceOptionGroup<TWorkspace extends ReusableExecutionWorkspaceLike = ReusableExecutionWorkspaceLike> {
id: ReusableWorkspaceOptionGroupId;
label: string;
options: ReusableWorkspaceOption<TWorkspace>[];
}
function workspaceLastUsedTime(workspace: Pick<ReusableExecutionWorkspaceLike, "lastUsedAt">) {
@ -19,7 +44,27 @@ function compareWorkspaceNames(a: ReusableExecutionWorkspaceLike, b: ReusableExe
return a.id.localeCompare(b.id);
}
export function orderReusableExecutionWorkspaces<T extends ReusableExecutionWorkspaceLike>(
function compareWorkspaceLastUsedDesc(a: ReusableExecutionWorkspaceLike, b: ReusableExecutionWorkspaceLike) {
const timeCompare = workspaceLastUsedTime(b) - workspaceLastUsedTime(a);
if (timeCompare !== 0) return timeCompare;
return compareWorkspaceNames(a, b);
}
function workspaceDescription(workspace: ReusableExecutionWorkspaceLike) {
return workspace.branchName ?? workspace.cwd ?? workspace.id.slice(0, 8);
}
function workspaceSearchText(workspace: ReusableExecutionWorkspaceLike) {
return [
workspace.name,
workspace.status,
workspace.branchName,
workspace.cwd,
workspace.id,
].filter(Boolean).join(" ");
}
export function dedupeReusableExecutionWorkspaces<T extends ReusableExecutionWorkspaceLike>(
workspaces: readonly T[],
): T[] {
const deduplicatedByPath = new Map<string, T>();
@ -32,7 +77,13 @@ export function orderReusableExecutionWorkspaces<T extends ReusableExecutionWork
}
}
const alphabetized = Array.from(deduplicatedByPath.values()).sort(compareWorkspaceNames);
return Array.from(deduplicatedByPath.values());
}
export function orderReusableExecutionWorkspaces<T extends ReusableExecutionWorkspaceLike>(
workspaces: readonly T[],
): T[] {
const alphabetized = dedupeReusableExecutionWorkspaces(workspaces).sort(compareWorkspaceNames);
if (alphabetized.length <= 1) return alphabetized;
let mostRecentlyUsed = alphabetized[0]!;
@ -47,3 +98,59 @@ export function orderReusableExecutionWorkspaces<T extends ReusableExecutionWork
...alphabetized.filter((workspace) => workspace.id !== mostRecentlyUsed.id),
];
}
export function buildReusableExecutionWorkspaceOptionGroups<T extends ReusableExecutionWorkspaceLike>(
workspaces: readonly T[],
options: { now?: Date | string; recentCutoffDays?: number } = {},
): ReusableWorkspaceOptionGroup<T>[] {
const nowTime = options.now ? new Date(options.now).getTime() : Date.now();
const cutoffDays = options.recentCutoffDays ?? RECENT_WORKSPACE_CUTOFF_DAYS;
const cutoffTime = nowTime - cutoffDays * 24 * 60 * 60 * 1000;
const deduplicated = dedupeReusableExecutionWorkspaces(workspaces);
const toOption = (
workspace: T,
groupId: ReusableWorkspaceOptionGroupId,
): ReusableWorkspaceOption<T> => ({
key: `${groupId}:${workspace.id}`,
value: workspace.id,
workspaceId: workspace.id,
groupId,
label: workspace.name,
description: workspaceDescription(workspace),
searchText: workspaceSearchText(workspace),
workspace,
});
const recent = deduplicated
.filter((workspace) => workspaceLastUsedTime(workspace) >= cutoffTime)
.sort(compareWorkspaceLastUsedDesc)
.map((workspace) => toOption(workspace, "recent"));
const all = [...deduplicated]
.sort(compareWorkspaceNames)
.map((workspace) => toOption(workspace, "all"));
return [
...(recent.length > 0 ? [{ id: "recent" as const, label: "Recent", options: recent }] : []),
{ id: "all", label: "All workspaces", options: all },
];
}
export function reusableWorkspaceOptionMatches(
option: Pick<ReusableWorkspaceOption, "label" | "description" | "searchText">,
query: string,
) {
return scoreReusableWorkspaceOptionMatch(option, query) !== null;
}
export function scoreReusableWorkspaceOptionMatch(
option: Pick<ReusableWorkspaceOption, "label" | "description" | "searchText">,
query: string,
) {
return scoreFuzzyTextFields([
{ text: option.label, weight: 0 },
{ text: option.description, weight: 20 },
{ text: option.searchText, weight: 40 },
], query);
}

View File

@ -0,0 +1,119 @@
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;
}

View File

@ -0,0 +1,269 @@
import { useEffect, useRef, useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
import {
buildReusableExecutionWorkspaceOptionGroups,
reusableWorkspaceOptionMatches,
type ReusableExecutionWorkspaceLike,
type ReusableWorkspaceOption,
} from "@/lib/reusable-execution-workspaces";
const NOW = new Date("2026-06-24T12:00:00.000Z");
const DAY = 24 * 60 * 60 * 1000;
const WORKSPACES: ReusableExecutionWorkspaceLike[] = [
{
id: "ws-auth-refresh",
name: "auth-token-refresh",
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11502-auth-token-refresh",
branchName: "PAP-11502-auth-token-refresh",
status: "running",
lastUsedAt: new Date(NOW.getTime() - 2 * 60 * 60 * 1000),
},
{
id: "ws-billing",
name: "billing-webhooks",
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11380-billing-webhooks",
branchName: "PAP-11380-billing-webhooks",
status: "idle",
lastUsedAt: new Date(NOW.getTime() - 1 * DAY),
},
{
id: "ws-search",
name: "workspace-selector",
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11722-new-existing-workspace-selector",
branchName: "PAP-11722-new-existing-workspace-selector",
status: "idle",
lastUsedAt: new Date(NOW.getTime() - 2 * DAY),
},
{
id: "ws-docs",
name: "docs-trust-presets",
cwd: "/srv/paperclip/home/docs/.paperclip/worktrees/docs-trust-presets",
branchName: "docs/trust-presets",
status: "archived",
lastUsedAt: new Date(NOW.getTime() - 9 * DAY),
},
{
id: "ws-pipeline",
name: "pipeline-body-doc",
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11567-body-document-ui",
branchName: "PAP-11567-body-document-ui",
status: "idle",
lastUsedAt: new Date(NOW.getTime() - 14 * DAY),
},
{
id: "ws-watchdog",
name: "task-watchdog",
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11275-task-watchdog",
branchName: "PAP-11275-task-watchdog",
status: "idle",
lastUsedAt: new Date(NOW.getTime() - 21 * DAY),
},
];
const LONG_WORKSPACES: ReusableExecutionWorkspaceLike[] = [
{
id: "ws-long-name",
name: "paperclip-control-plane-existing-workspace-selector-long-running-validation-branch",
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11722-existing-workspace-selector-with-a-very-long-path-segment-for-review",
branchName: "feature/existing-workspace-selector-long-path-validation",
status: "running",
lastUsedAt: new Date(NOW.getTime() - 90 * 60 * 1000),
},
{
id: "ws-long-cwd",
name: "adapter-plugin-registry-regression-suite",
cwd: "/srv/paperclip/home/paperclipai/paperclip/packages/adapters/external-plugin-fixtures/hermes-droid-regression-workspace-with-long-directory-name",
branchName: null,
status: "idle",
lastUsedAt: new Date(NOW.getTime() - 1 * DAY),
},
...WORKSPACES.slice(0, 2),
];
const GROUPS = buildReusableExecutionWorkspaceOptionGroups(WORKSPACES, { now: NOW });
const SELECT_GROUPS: SearchableSelectGroup<string, ReusableWorkspaceOption>[] = GROUPS.map((group) => ({
id: group.id,
label: group.label,
options: group.options,
}));
const COMPACT_TRIGGER = "h-8 px-2 py-1.5 text-xs font-normal";
function WorkspaceSelect({
triggerClassName,
loading = false,
disabled = false,
groups = SELECT_GROUPS,
initialValue = "",
autoOpen = false,
autoQuery = "",
}: {
triggerClassName?: string;
loading?: boolean;
disabled?: boolean;
groups?: SearchableSelectGroup<string, ReusableWorkspaceOption>[];
initialValue?: string;
autoOpen?: boolean;
autoQuery?: string;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [value, setValue] = useState(initialValue);
useEffect(() => {
if (!autoOpen && !autoQuery) return;
let queryTimer: number | undefined;
const openTimer = window.setTimeout(() => {
rootRef.current?.querySelector<HTMLButtonElement>("button[role='combobox']")?.click();
if (!autoQuery) return;
queryTimer = window.setTimeout(() => {
const input = rootRef.current?.querySelector<HTMLInputElement>("input[cmdk-input]");
if (!input) return;
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
valueSetter?.call(input, autoQuery);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: autoQuery, inputType: "insertText" }));
}, 0);
}, 0);
return () => {
window.clearTimeout(openTimer);
if (queryTimer !== undefined) window.clearTimeout(queryTimer);
};
}, [autoOpen, autoQuery]);
return (
<div ref={rootRef}>
<SearchableSelect<string, ReusableWorkspaceOption>
value={value}
groups={groups}
onValueChange={(next) => setValue(next)}
placeholder="Choose an existing workspace"
searchPlaceholder="Search workspaces..."
emptyMessage="No matching workspaces."
loadingMessage="Loading workspaces..."
loading={loading}
disabled={disabled}
triggerClassName={triggerClassName}
filterOption={(option, query) => reusableWorkspaceOptionMatches(option, query)}
renderOption={(option, { selected }) => (
<span className="flex min-w-0 flex-col">
<span className={`truncate ${selected ? "font-medium" : ""}`}>{option.label}</span>
<span className="truncate text-[11px] text-muted-foreground">
{option.workspace.status} - {option.description}
</span>
</span>
)}
/>
</div>
);
}
function FormContext({ triggerClassName }: { triggerClassName?: string }) {
return (
<div className="w-full max-w-sm rounded-md border border-border bg-card p-0">
<div className="px-4 py-3 space-y-2">
<div className="space-y-1.5">
<div className="text-xs font-medium">Execution workspace</div>
<div className="text-[11px] text-muted-foreground">
Control whether this task runs in the shared workspace, a new isolated workspace, or an existing one.
</div>
{/* Neighbouring native select (mode picker): the row the combobox must match. */}
<select
className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none"
defaultValue="reuse_existing"
>
<option value="shared_workspace">Project default</option>
<option value="isolated_workspace">New isolated workspace</option>
<option value="reuse_existing">Reuse existing workspace</option>
</select>
<WorkspaceSelect triggerClassName={triggerClassName} />
</div>
</div>
</div>
);
}
const meta = {
title: "Components/SearchableSelect/Workspace picker",
parameters: { layout: "centered" },
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const EmptyQueryWithRecentAndAllGroups: Story = {
render: () => (
<div className="w-80">
<WorkspaceSelect triggerClassName={COMPACT_TRIGGER} autoOpen />
</div>
),
};
export const FuzzyQueryMatches: Story = {
render: () => (
<div className="w-80">
<WorkspaceSelect triggerClassName={COMPACT_TRIGGER} autoOpen autoQuery="pclip selector" />
</div>
),
};
export const LongNamesAndPaths: Story = {
render: () => (
<div className="w-[280px]">
<WorkspaceSelect
triggerClassName={COMPACT_TRIGGER}
groups={buildReusableExecutionWorkspaceOptionGroups(LONG_WORKSPACES, { now: NOW })}
autoOpen
/>
</div>
),
};
export const NoMatches: Story = {
render: () => (
<div className="w-80">
<WorkspaceSelect triggerClassName={COMPACT_TRIGGER} autoOpen autoQuery="not a workspace" />
</div>
),
};
export const Loading: Story = {
render: () => (
<div className="w-80">
<WorkspaceSelect triggerClassName={COMPACT_TRIGGER} loading autoOpen />
</div>
),
};
export const Disabled: Story = {
render: () => (
<div className="w-80">
<WorkspaceSelect triggerClassName={COMPACT_TRIGGER} disabled />
</div>
),
};
export const SelectedRecentWorkspaceDuplicatedInAllGroup: Story = {
render: () => (
<div className="w-80">
<WorkspaceSelect triggerClassName={COMPACT_TRIGGER} initialValue="ws-auth-refresh" autoOpen />
</div>
),
};
export const DefaultAndCompactSizeComparison: Story = {
render: () => (
<div className="flex w-80 flex-col gap-2">
<select className="w-full rounded border border-border bg-transparent px-2 py-1.5 text-xs outline-none" defaultValue="reuse_existing">
<option value="reuse_existing">Reuse existing workspace</option>
</select>
<WorkspaceSelect />
<WorkspaceSelect triggerClassName={COMPACT_TRIGGER} />
</div>
),
};
export const InNewIssueContextCompact: Story = {
render: () => <FormContext triggerClassName={COMPACT_TRIGGER} />,
};