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: () => , +};