mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
Use the shared composer for new tasks, including project and worktree selection, rich mentions and slash commands, and remembered task settings. Save project preferences after successful task updates. Show known agent models by name and use Default for unknown runtime defaults. Co-Authored-By: Paperclip <noreply@paperclip.ing>
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
// @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 { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
|
|
import {
|
|
buildReusableExecutionWorkspaceOptionGroups,
|
|
reusableWorkspaceOptionMatches,
|
|
type ReusableExecutionWorkspaceLike,
|
|
type ReusableWorkspaceOption,
|
|
} from "@/lib/reusable-execution-workspaces";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> | undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
}
|
|
|
|
function render(node: ReactNode, container: HTMLElement) {
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(node);
|
|
});
|
|
return root;
|
|
}
|
|
|
|
function setInputValue(input: HTMLInputElement, value: string) {
|
|
act(() => {
|
|
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
|
valueSetter?.call(input, value);
|
|
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: value, inputType: "insertText" }));
|
|
});
|
|
}
|
|
|
|
function keyDown(target: Element, key: string) {
|
|
act(() => {
|
|
target.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
|
});
|
|
}
|
|
|
|
function workspace(overrides: Partial<ReusableExecutionWorkspaceLike>): ReusableExecutionWorkspaceLike {
|
|
return {
|
|
id: overrides.id ?? "workspace-id",
|
|
name: overrides.name ?? "Workspace",
|
|
cwd: overrides.cwd ?? null,
|
|
lastUsedAt: overrides.lastUsedAt ?? "2026-06-24T00:00:00.000Z",
|
|
status: overrides.status,
|
|
branchName: overrides.branchName,
|
|
};
|
|
}
|
|
|
|
function buildWorkspaceSelectGroups(workspaces: readonly ReusableExecutionWorkspaceLike[]) {
|
|
return buildReusableExecutionWorkspaceOptionGroups(workspaces, {
|
|
now: "2026-06-24T12:00:00.000Z",
|
|
}).map((group) => ({
|
|
id: group.id,
|
|
label: group.label,
|
|
options: group.options,
|
|
})) satisfies SearchableSelectGroup<string, ReusableWorkspaceOption>[];
|
|
}
|
|
|
|
describe("SearchableSelect", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null;
|
|
let originalResizeObserver: typeof ResizeObserver | undefined;
|
|
|
|
beforeEach(() => {
|
|
originalResizeObserver = globalThis.ResizeObserver;
|
|
globalThis.ResizeObserver = class ResizeObserver {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
};
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
root = null;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (root) {
|
|
act(() => {
|
|
root?.unmount();
|
|
});
|
|
}
|
|
globalThis.ResizeObserver = originalResizeObserver!;
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("renders grouped duplicate options while keeping selection by value", async () => {
|
|
const onValueChange = vi.fn();
|
|
const alpha: SearchableSelectOption = { key: "recent:alpha", value: "alpha", label: "Alpha" };
|
|
const groups: SearchableSelectGroup[] = [
|
|
{ id: "recent", label: "Recent", options: [alpha] },
|
|
{ id: "all", label: "All", options: [{ ...alpha, key: "all:alpha" }] },
|
|
];
|
|
|
|
root = render(
|
|
<SearchableSelect
|
|
value="alpha"
|
|
groups={groups}
|
|
onValueChange={onValueChange}
|
|
placeholder="Pick one"
|
|
disablePortal
|
|
renderOption={(option) => <span data-option-key={option.key}>{option.label}</span>}
|
|
/>,
|
|
container,
|
|
);
|
|
|
|
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
|
|
expect(trigger?.textContent).toContain("Alpha");
|
|
|
|
act(() => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(container.querySelector("[data-option-key='recent:alpha']")).not.toBeNull();
|
|
expect(container.querySelector("[data-option-key='all:alpha']")).not.toBeNull();
|
|
expect(container.querySelector("[data-mobile-entity-picker]")).not.toBeNull();
|
|
expect(container.querySelector("[data-mobile-entity-picker-header]")?.textContent).toContain("Pick one");
|
|
expect(container.querySelector('button[aria-label="Close selector"]')).not.toBeNull();
|
|
});
|
|
|
|
it("filters options and returns the selected option object", async () => {
|
|
const onValueChange = vi.fn();
|
|
const bravo = { key: "all:bravo", value: "bravo", label: "Bravo", searchText: "secondary branch" };
|
|
const groups: SearchableSelectGroup[] = [
|
|
{
|
|
id: "all",
|
|
label: "All",
|
|
options: [
|
|
{ key: "all:alpha", value: "alpha", label: "Alpha", searchText: "primary branch" },
|
|
bravo,
|
|
],
|
|
},
|
|
];
|
|
|
|
root = render(
|
|
<SearchableSelect
|
|
value=""
|
|
groups={groups}
|
|
onValueChange={onValueChange}
|
|
placeholder="Pick one"
|
|
searchPlaceholder="Search options..."
|
|
disablePortal
|
|
/>,
|
|
container,
|
|
);
|
|
|
|
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
|
|
act(() => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await flush();
|
|
|
|
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
|
|
expect(input).not.toBeNull();
|
|
setInputValue(input!, "sec br");
|
|
await flush();
|
|
|
|
expect(container.textContent).not.toContain("Alpha");
|
|
expect(container.textContent).toContain("Bravo");
|
|
|
|
const bravoItem = Array.from(container.querySelectorAll("[cmdk-item]")).find((item) => item.textContent?.includes("Bravo"));
|
|
expect(bravoItem).not.toBeUndefined();
|
|
act(() => {
|
|
bravoItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
|
|
expect(onValueChange).toHaveBeenCalledWith("bravo", bravo);
|
|
});
|
|
|
|
it("ranks visible label matches ahead of lower-quality search text matches", async () => {
|
|
const onValueChange = vi.fn();
|
|
const groups: SearchableSelectGroup[] = [
|
|
{
|
|
id: "all",
|
|
label: "All",
|
|
options: [
|
|
{
|
|
key: "all:path-only",
|
|
value: "path-only",
|
|
label: "Paperclip app",
|
|
searchText: "/srv/paperclip/mobile-checkout",
|
|
},
|
|
{
|
|
key: "all:mobile",
|
|
value: "mobile",
|
|
label: "Mobile agent chat",
|
|
searchText: "/srv/paperclip/agent-chat",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
root = render(
|
|
<SearchableSelect
|
|
value=""
|
|
groups={groups}
|
|
onValueChange={onValueChange}
|
|
placeholder="Pick one"
|
|
searchPlaceholder="Search options..."
|
|
disablePortal
|
|
renderOption={(option) => <span data-option-key={option.key}>{option.label}</span>}
|
|
/>,
|
|
container,
|
|
);
|
|
|
|
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
|
|
act(() => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await flush();
|
|
|
|
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
|
|
expect(input).not.toBeNull();
|
|
setInputValue(input!, "mobile");
|
|
await flush();
|
|
|
|
const renderedKeys = Array.from(container.querySelectorAll("[data-option-key]")).map((item) =>
|
|
item.getAttribute("data-option-key"),
|
|
);
|
|
expect(renderedKeys).toEqual(["all:mobile", "all:path-only"]);
|
|
|
|
const commandList = container.querySelector("[data-slot='command-list']");
|
|
expect(commandList?.className).toContain("overscroll-contain");
|
|
expect(commandList?.className).toContain("touch-pan-y");
|
|
});
|
|
|
|
it("applies custom filtering before custom scoring", async () => {
|
|
const groups: SearchableSelectGroup[] = [
|
|
{
|
|
id: "all",
|
|
options: [
|
|
{ key: "all:alpha", value: "alpha", label: "Alpha", searchText: "visible" },
|
|
{ key: "all:hidden", value: "hidden", label: "Hidden", searchText: "visible" },
|
|
],
|
|
},
|
|
];
|
|
|
|
root = render(
|
|
<SearchableSelect
|
|
value=""
|
|
groups={groups}
|
|
onValueChange={vi.fn()}
|
|
placeholder="Pick one"
|
|
searchPlaceholder="Search options..."
|
|
disablePortal
|
|
filterOption={(option, query) => option.value !== "hidden" && option.searchText === query}
|
|
scoreOption={() => 0}
|
|
renderOption={(option) => <span data-option-key={option.key}>{option.label}</span>}
|
|
/>,
|
|
container,
|
|
);
|
|
|
|
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
|
|
act(() => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await flush();
|
|
|
|
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
|
|
expect(input).not.toBeNull();
|
|
setInputValue(input!, "visible");
|
|
await flush();
|
|
|
|
const renderedKeys = Array.from(container.querySelectorAll("[data-option-key]")).map((item) =>
|
|
item.getAttribute("data-option-key"),
|
|
);
|
|
expect(renderedKeys).toEqual(["all:alpha"]);
|
|
});
|
|
|
|
it("shows loading, empty, and disabled states", async () => {
|
|
const onValueChange = vi.fn();
|
|
|
|
root = render(
|
|
<SearchableSelect
|
|
value=""
|
|
groups={[{ id: "all", options: [{ key: "all:alpha", value: "alpha", label: "Alpha" }] }]}
|
|
onValueChange={onValueChange}
|
|
placeholder="Pick one"
|
|
loading
|
|
loadingMessage="Loading choices..."
|
|
disablePortal
|
|
/>,
|
|
container,
|
|
);
|
|
|
|
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
|
|
act(() => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await flush();
|
|
expect(container.textContent).toContain("Loading choices...");
|
|
|
|
act(() => {
|
|
root?.render(
|
|
<SearchableSelect
|
|
value=""
|
|
groups={[{ id: "all", options: [{ key: "all:alpha", value: "alpha", label: "Alpha" }] }]}
|
|
onValueChange={onValueChange}
|
|
placeholder="Pick one"
|
|
searchPlaceholder="Search options..."
|
|
emptyMessage="Nothing matched."
|
|
disablePortal
|
|
/>,
|
|
);
|
|
});
|
|
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
|
|
expect(input).not.toBeNull();
|
|
setInputValue(input!, "zzz");
|
|
await flush();
|
|
expect(container.textContent).toContain("Nothing matched.");
|
|
|
|
act(() => {
|
|
root?.render(
|
|
<SearchableSelect
|
|
value=""
|
|
groups={[]}
|
|
onValueChange={onValueChange}
|
|
placeholder="Pick one"
|
|
disabled
|
|
disablePortal
|
|
/>,
|
|
);
|
|
});
|
|
expect(container.querySelector("button[role='combobox']")?.hasAttribute("disabled")).toBe(true);
|
|
});
|
|
|
|
it("opens on focus and closes with Escape", async () => {
|
|
root = render(
|
|
<SearchableSelect
|
|
value=""
|
|
groups={[{ id: "all", options: [{ key: "all:alpha", value: "alpha", label: "Alpha" }] }]}
|
|
onValueChange={vi.fn()}
|
|
placeholder="Pick one"
|
|
searchPlaceholder="Search options..."
|
|
disablePortal
|
|
/>,
|
|
container,
|
|
);
|
|
|
|
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
|
|
expect(trigger).not.toBeNull();
|
|
act(() => {
|
|
trigger?.focus();
|
|
});
|
|
await flush();
|
|
|
|
const input = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
|
|
expect(input).not.toBeNull();
|
|
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
|
|
|
keyDown(input!, "Escape");
|
|
await flush();
|
|
|
|
expect(container.querySelector("input[placeholder='Search options...']")).toBeNull();
|
|
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
|
|
|
act(() => {
|
|
trigger?.dispatchEvent(new Event("pointerdown", { bubbles: true, cancelable: true }));
|
|
trigger?.focus();
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await flush();
|
|
|
|
expect(container.querySelector("input[placeholder='Search options...']")).not.toBeNull();
|
|
expect(trigger?.getAttribute("aria-expanded")).toBe("true");
|
|
|
|
const reopenedInput = container.querySelector("input[placeholder='Search options...']") as HTMLInputElement | null;
|
|
expect(reopenedInput).not.toBeNull();
|
|
setInputValue(reopenedInput!, "alp");
|
|
await flush();
|
|
|
|
keyDown(reopenedInput!, "Escape");
|
|
await flush();
|
|
|
|
expect(container.querySelector("input[placeholder='Search options...']")).toBeNull();
|
|
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
|
|
it.each([false, true])("dismisses outside a nested worktree picker (modal=%s) without reopening or closing the task", async (modal) => {
|
|
const onValueChange = vi.fn();
|
|
root = render(
|
|
<Dialog defaultOpen>
|
|
<DialogContent aria-describedby={undefined}>
|
|
<DialogTitle>New task</DialogTitle>
|
|
<button type="button" data-testid="outside-worktree">Task draft</button>
|
|
<SearchableSelect
|
|
value="new"
|
|
groups={[{ id: "all", options: [{ key: "new", value: "new", label: "New worktree" }] }]}
|
|
onValueChange={onValueChange}
|
|
placeholder="Worktrees"
|
|
searchPlaceholder="Search worktrees..."
|
|
modal={modal}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>,
|
|
container,
|
|
);
|
|
await flush();
|
|
const trigger = document.querySelector<HTMLButtonElement>('button[role="combobox"]')!;
|
|
const outside = document.querySelector<HTMLButtonElement>('[data-testid="outside-worktree"]')!;
|
|
act(() => trigger.click());
|
|
// Radix installs its outside pointer listener after the opening event.
|
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
|
const picker = document.querySelector<HTMLElement>("[data-mobile-entity-picker]")!;
|
|
const outsideTarget = modal ? picker.parentElement! : outside;
|
|
act(() => {
|
|
const pointerDown = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
|
|
Object.defineProperty(pointerDown, "pointerType", { value: modal ? "touch" : "mouse" });
|
|
outsideTarget.dispatchEvent(pointerDown);
|
|
if (!modal) outside.focus();
|
|
outsideTarget.click();
|
|
});
|
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
|
expect(document.querySelector("[data-mobile-entity-picker]")).toBeNull();
|
|
expect(document.querySelector("[data-slot=dialog-content]")).not.toBeNull();
|
|
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
expect(trigger.textContent).toContain("New worktree");
|
|
expect(onValueChange).not.toHaveBeenCalled();
|
|
if (modal) expect(document.activeElement).toBe(trigger);
|
|
act(() => {
|
|
outside.focus();
|
|
trigger.focus();
|
|
});
|
|
await flush();
|
|
expect(document.querySelector("[data-mobile-entity-picker]")).not.toBeNull();
|
|
});
|
|
|
|
it("filters workspace options, moves with arrows, and selects the workspace id with Enter", async () => {
|
|
const onValueChange = vi.fn();
|
|
const groups = buildWorkspaceSelectGroups([
|
|
workspace({
|
|
id: "workspace-paperclip",
|
|
name: "Paperclip app",
|
|
cwd: "/srv/paperclip/home/paperclipai/paperclip/.paperclip/worktrees/PAP-11722-new-existing-workspace-selector",
|
|
branchName: "feature/reusable-workspaces",
|
|
status: "running",
|
|
lastUsedAt: "2026-06-24T10:00:00.000Z",
|
|
}),
|
|
workspace({
|
|
id: "workspace-marketing",
|
|
name: "Marketing site",
|
|
cwd: "/srv/paperclip/home/marketing-site",
|
|
branchName: "landing-refresh",
|
|
status: "idle",
|
|
lastUsedAt: "2026-06-20T10:00:00.000Z",
|
|
}),
|
|
]);
|
|
|
|
root = render(
|
|
<SearchableSelect<string, ReusableWorkspaceOption>
|
|
value=""
|
|
groups={groups}
|
|
onValueChange={onValueChange}
|
|
placeholder="Choose an existing workspace"
|
|
searchPlaceholder="Search workspaces..."
|
|
filterOption={(option, query) => reusableWorkspaceOptionMatches(option, query)}
|
|
disablePortal
|
|
renderOption={(option, { selected }) => (
|
|
<span data-option-key={option.key} data-selected={String(selected)}>
|
|
{option.label}
|
|
</span>
|
|
)}
|
|
/>,
|
|
container,
|
|
);
|
|
|
|
const trigger = container.querySelector("button[role='combobox']") as HTMLButtonElement | null;
|
|
act(() => {
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await flush();
|
|
|
|
const input = container.querySelector("input[placeholder='Search workspaces...']") as HTMLInputElement | null;
|
|
expect(input).not.toBeNull();
|
|
expect(container.textContent).toContain("Recent");
|
|
expect(container.textContent).toContain("All workspaces");
|
|
|
|
setInputValue(input!, "pclip reusable");
|
|
await flush();
|
|
|
|
expect(container.textContent).toContain("Paperclip app");
|
|
expect(container.textContent).not.toContain("Marketing site");
|
|
|
|
const selectedOptionKey = () => (
|
|
container.querySelector("[cmdk-item][aria-selected='true'] [data-option-key]")?.getAttribute("data-option-key")
|
|
);
|
|
|
|
expect(selectedOptionKey()).toBe("recent:workspace-paperclip");
|
|
keyDown(input!, "ArrowDown");
|
|
await flush();
|
|
expect(selectedOptionKey()).toBe("all:workspace-paperclip");
|
|
|
|
keyDown(input!, "ArrowUp");
|
|
await flush();
|
|
expect(selectedOptionKey()).toBe("recent:workspace-paperclip");
|
|
|
|
keyDown(input!, "ArrowDown");
|
|
await flush();
|
|
keyDown(input!, "Enter");
|
|
await flush();
|
|
|
|
expect(onValueChange).toHaveBeenCalledWith(
|
|
"workspace-paperclip",
|
|
expect.objectContaining({
|
|
key: "all:workspace-paperclip",
|
|
value: "workspace-paperclip",
|
|
workspaceId: "workspace-paperclip",
|
|
}),
|
|
);
|
|
expect(container.querySelector("input[placeholder='Search workspaces...']")).toBeNull();
|
|
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
|
|
|
act(() => {
|
|
trigger?.focus();
|
|
});
|
|
await flush();
|
|
|
|
expect(container.querySelector("input[placeholder='Search workspaces...']")).toBeNull();
|
|
expect(trigger?.getAttribute("aria-expanded")).toBe("false");
|
|
});
|
|
});
|