From 50ae8fc657ff63b61952660908e20db9638c5eb0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:21:32 -0500 Subject: [PATCH] [codex] Improve reusable workspace selector search (#8597) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ui/src/components/IssueWorkspaceCard.test.tsx | 14 +- ui/src/components/IssueWorkspaceCard.tsx | 35 +- ui/src/components/IssuesList.test.tsx | 5 +- ui/src/components/NewIssueDialog.test.tsx | 63 ++- ui/src/components/NewIssueDialog.tsx | 34 +- .../ReusableExecutionWorkspaceSelect.tsx | 68 +++ ui/src/components/SearchableSelect.test.tsx | 486 ++++++++++++++++++ ui/src/components/SearchableSelect.tsx | 265 ++++++++++ .../lib/reusable-execution-workspaces.test.ts | 202 +++++++- ui/src/lib/reusable-execution-workspaces.ts | 111 +++- ui/src/lib/searchable-select.ts | 119 +++++ .../stories/searchable-select.stories.tsx | 269 ++++++++++ 12 files changed, 1625 insertions(+), 46 deletions(-) create mode 100644 ui/src/components/ReusableExecutionWorkspaceSelect.tsx create mode 100644 ui/src/components/SearchableSelect.test.tsx create mode 100644 ui/src/components/SearchableSelect.tsx create mode 100644 ui/src/lib/searchable-select.ts create mode 100644 ui/storybook/stories/searchable-select.stories.tsx diff --git a/ui/src/components/IssueWorkspaceCard.test.tsx b/ui/src/components/IssueWorkspaceCard.test.tsx index aeb99865df..a789f20354 100644 --- a/ui/src/components/IssueWorkspaceCard.test.tsx +++ b/ui/src/components/IssueWorkspaceCard.test.tsx @@ -124,14 +124,22 @@ function createIssue(overrides: Partial = {}): 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(() => { diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index f5a001b5a0..cd594d540e 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -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({ {draftSelection === "reuse_existing" && ( - + workspaces={selectableReusableWorkspaces} + onValueChange={(workspaceId) => setDraftExecutionWorkspaceId(workspaceId)} + loading={reusableExecutionWorkspacesLoading} + error={reusableExecutionWorkspacesError} + /> )} {/* Current workspace summary when editing */} diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index b62d8a6dda..12d57064db 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -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); diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 8da05c4304..0da91f007e 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -221,7 +221,9 @@ vi.mock("@/components/ui/toggle-switch", () => ({ vi.mock("@/components/ui/popover", () => ({ Popover: ({ children }: { children: ReactNode }) =>
{children}
, PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}, - PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverContent: ({ children, disablePortal }: { children: ReactNode; disablePortal?: boolean }) => ( +
{children}
+ ), })); // 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; diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 274de9f555..05f1c70112 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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() { ))} {executionWorkspaceMode === "reuse_existing" && ( - + workspaces={selectableReusableWorkspaces} + onValueChange={(workspaceId) => setSelectedExecutionWorkspaceId(workspaceId)} + loading={reusableExecutionWorkspacesLoading} + error={reusableExecutionWorkspacesError} + disablePortal + /> )} {executionWorkspaceMode === "reuse_existing" && selectedReusableExecutionWorkspace && (
diff --git a/ui/src/components/ReusableExecutionWorkspaceSelect.tsx b/ui/src/components/ReusableExecutionWorkspaceSelect.tsx new file mode 100644 index 0000000000..c2789ceecc --- /dev/null +++ b/ui/src/components/ReusableExecutionWorkspaceSelect.tsx @@ -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 { + value: string; + workspaces: readonly TWorkspace[]; + onValueChange: (workspaceId: string, option: ReusableWorkspaceOption) => void; + placeholder?: string; + loading?: boolean; + error?: boolean; + disabled?: boolean; + className?: string; + triggerClassName?: string; + disablePortal?: boolean; +} + +export function ReusableExecutionWorkspaceSelect({ + value, + workspaces, + onValueChange, + placeholder = "Choose an existing workspace", + loading = false, + error = false, + disabled = false, + className, + triggerClassName, + disablePortal, +}: ReusableExecutionWorkspaceSelectProps) { + const groups = useMemo(() => buildReusableExecutionWorkspaceOptionGroups(workspaces), [workspaces]); + + return ( + > + 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 }) => ( + + {option.label} + + {option.workspace.status ? `${option.workspace.status} - ` : ""} + {option.description} + + + )} + /> + ); +} diff --git a/ui/src/components/SearchableSelect.test.tsx b/ui/src/components/SearchableSelect.test.tsx new file mode 100644 index 0000000000..bed60f8ec0 --- /dev/null +++ b/ui/src/components/SearchableSelect.test.tsx @@ -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) { + let result: void | Promise | 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 { + 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[]; +} + +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( + {option.label}} + />, + 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( + , + 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( + {option.label}} + />, + 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( + option.value !== "hidden" && option.searchText === query} + scoreOption={() => 0} + renderOption={(option) => {option.label}} + />, + 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( + , + 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( + , + ); + }); + 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( + , + ); + }); + expect(container.querySelector("button[role='combobox']")?.hasAttribute("disabled")).toBe(true); + }); + + it("opens on focus and closes with Escape", async () => { + root = render( + , + 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( + + value="" + groups={groups} + onValueChange={onValueChange} + placeholder="Choose an existing workspace" + searchPlaceholder="Search workspaces..." + filterOption={(option, query) => reusableWorkspaceOptionMatches(option, query)} + disablePortal + renderOption={(option, { selected }) => ( + + {option.label} + + )} + />, + 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"); + }); +}); diff --git a/ui/src/components/SearchableSelect.tsx b/ui/src/components/SearchableSelect.tsx new file mode 100644 index 0000000000..d4a29fdcd0 --- /dev/null +++ b/ui/src/components/SearchableSelect.tsx @@ -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 { + key: string; + value: TValue; + label: string; + searchText?: string; + disabled?: boolean; +} + +export interface SearchableSelectGroup = SearchableSelectOption> { + id: string; + label?: string; + options: readonly TOption[]; +} + +interface SearchableSelectRenderState { + selected: boolean; +} + +export interface SearchableSelectProps< + TValue extends string = string, + TOption extends SearchableSelectOption = SearchableSelectOption, +> { + value: TValue | ""; + groups: readonly SearchableSelectGroup[]; + 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 = SearchableSelectOption, +>({ + 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) { + 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 ( + { + setOpen(next); + if (!next) setQuery(""); + }} + > + + + + { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closePopover({ suppressTriggerFocus: true }); + } + }} + > + + + { + if (event.currentTarget.scrollHeight > event.currentTarget.clientHeight) { + event.stopPropagation(); + } + }} + > + {loading ? ( +
{loadingMessage}
+ ) : !hasOptions ? ( + {emptyMessage} + ) : ( + filteredGroups.map((group) => ( + + {group.options.map((option) => { + const selected = option.value === value; + return ( + selectOption(option)} + > + {renderOption + ? renderOption(option, { selected }) + : {option.label}} + + + ); + })} + + )) + )} +
+
+
+
+ ); +} diff --git a/ui/src/lib/reusable-execution-workspaces.test.ts b/ui/src/lib/reusable-execution-workspaces.test.ts index a1c380a216..a856e293d9 100644 --- a/ui/src/lib/reusable-execution-workspaces.test.ts +++ b/ui/src/lib/reusable-execution-workspaces.test.ts @@ -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 { return { @@ -7,6 +13,8 @@ function workspace(overrides: Partial): 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")!, + ); + }); +}); diff --git a/ui/src/lib/reusable-execution-workspaces.ts b/ui/src/lib/reusable-execution-workspaces.ts index 633c9122f8..6ec94d3d73 100644 --- a/ui/src/lib/reusable-execution-workspaces.ts +++ b/ui/src/lib/reusable-execution-workspaces.ts @@ -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 { + key: string; + value: string; + workspaceId: string; + groupId: ReusableWorkspaceOptionGroupId; + label: string; + description: string; + searchText: string; + workspace: TWorkspace; +} + +export interface ReusableWorkspaceOptionGroup { + id: ReusableWorkspaceOptionGroupId; + label: string; + options: ReusableWorkspaceOption[]; } function workspaceLastUsedTime(workspace: Pick) { @@ -19,7 +44,27 @@ function compareWorkspaceNames(a: ReusableExecutionWorkspaceLike, b: ReusableExe return a.id.localeCompare(b.id); } -export function orderReusableExecutionWorkspaces( +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( workspaces: readonly T[], ): T[] { const deduplicatedByPath = new Map(); @@ -32,7 +77,13 @@ export function orderReusableExecutionWorkspaces( + 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 workspace.id !== mostRecentlyUsed.id), ]; } + +export function buildReusableExecutionWorkspaceOptionGroups( + workspaces: readonly T[], + options: { now?: Date | string; recentCutoffDays?: number } = {}, +): ReusableWorkspaceOptionGroup[] { + 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 => ({ + 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, + query: string, +) { + return scoreReusableWorkspaceOptionMatch(option, query) !== null; +} + +export function scoreReusableWorkspaceOptionMatch( + option: Pick, + query: string, +) { + return scoreFuzzyTextFields([ + { text: option.label, weight: 0 }, + { text: option.description, weight: 20 }, + { text: option.searchText, weight: 40 }, + ], query); +} diff --git a/ui/src/lib/searchable-select.ts b/ui/src/lib/searchable-select.ts new file mode 100644 index 0000000000..3d3ad12ce2 --- /dev/null +++ b/ui/src/lib/searchable-select.ts @@ -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; +} diff --git a/ui/storybook/stories/searchable-select.stories.tsx b/ui/storybook/stories/searchable-select.stories.tsx new file mode 100644 index 0000000000..12d657245e --- /dev/null +++ b/ui/storybook/stories/searchable-select.stories.tsx @@ -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[] = 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[]; + initialValue?: string; + autoOpen?: boolean; + autoQuery?: string; +}) { + const rootRef = useRef(null); + const [value, setValue] = useState(initialValue); + + useEffect(() => { + if (!autoOpen && !autoQuery) return; + let queryTimer: number | undefined; + const openTimer = window.setTimeout(() => { + rootRef.current?.querySelector("button[role='combobox']")?.click(); + if (!autoQuery) return; + queryTimer = window.setTimeout(() => { + const input = rootRef.current?.querySelector("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 ( +
+ + 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 }) => ( + + {option.label} + + {option.workspace.status} - {option.description} + + + )} + /> +
+ ); +} + +function FormContext({ triggerClassName }: { triggerClassName?: string }) { + return ( +
+
+
+
Execution workspace
+
+ Control whether this task runs in the shared workspace, a new isolated workspace, or an existing one. +
+ {/* Neighbouring native select (mode picker): the row the combobox must match. */} + + +
+
+
+ ); +} + +const meta = { + title: "Components/SearchableSelect/Workspace picker", + parameters: { layout: "centered" }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const EmptyQueryWithRecentAndAllGroups: Story = { + render: () => ( +
+ +
+ ), +}; + +export const FuzzyQueryMatches: Story = { + render: () => ( +
+ +
+ ), +}; + +export const LongNamesAndPaths: Story = { + render: () => ( +
+ +
+ ), +}; + +export const NoMatches: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Loading: Story = { + render: () => ( +
+ +
+ ), +}; + +export const Disabled: Story = { + render: () => ( +
+ +
+ ), +}; + +export const SelectedRecentWorkspaceDuplicatedInAllGroup: Story = { + render: () => ( +
+ +
+ ), +}; + +export const DefaultAndCompactSizeComparison: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const InNewIssueContextCompact: Story = { + render: () => , +};